diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bd484c42a4..c7f07db3fd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -86,11 +86,29 @@ jobs: name: Integration tests runs-on: ubuntu-latest needs: + - int-tests-api - int-tests-kind steps: - name: Succeed if all tests passed run: echo "Integration tests succeeded" + int-tests-api: + name: Integration tests (api) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: "**/*.sum" + - name: Run tests + run: | + make test-integration + int-tests-kind: name: Integration tests (kind) runs-on: ubuntu-latest @@ -405,6 +423,9 @@ jobs: with: fetch-depth: 0 + - name: Free up runner disk space + uses: ./.github/actions/free-disk-space + - name: Cache embedded bins uses: actions/cache@v4 with: diff --git a/Makefile b/Makefile index 7467452537..d047f16261 100644 --- a/Makefile +++ b/Makefile @@ -277,6 +277,10 @@ unit-tests: envtest $(MAKE) -C operator test $(MAKE) -C utils unit-tests +.PHONY: test-integration +test-integration: static + $(MAKE) -C api test-integration + .PHONY: vet vet: go vet -tags $(GO_BUILD_TAGS) ./... @@ -339,19 +343,31 @@ create-node%: DISTRO = debian-bookworm create-node%: NODE_PORT = 30000 create-node%: MANAGER_NODE_PORT = 30080 create-node%: K0S_DATA_DIR = /var/lib/embedded-cluster/k0s +create-node%: K0S_DATA_DIR_V3 = $(shell \ + if [ -n "$(REPLICATED_APP)" ]; then \ + echo "/var/lib/$(shell echo '$(REPLICATED_APP)' | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g')/k0s"; \ + else \ + echo "/var/lib/embedded-cluster-smoke-test-staging-app/k0s"; \ + fi) +create-node%: ENABLE_V3 = 0 create-node%: + @echo "Mounting data directories:" + @echo " v2: $(K0S_DATA_DIR)" + @echo " v3: $(K0S_DATA_DIR_V3)" @docker run -d \ --name node$* \ --hostname node$* \ --privileged \ --restart=unless-stopped \ -v $(K0S_DATA_DIR) \ + -v $(K0S_DATA_DIR_V3) \ -v $(shell pwd):/replicatedhq/embedded-cluster \ -v $(shell dirname $(shell pwd))/kots:/replicatedhq/kots \ $(if $(filter node0,node$*),-p $(NODE_PORT):$(NODE_PORT)) \ $(if $(filter node0,node$*),-p $(MANAGER_NODE_PORT):$(MANAGER_NODE_PORT)) \ $(if $(filter node0,node$*),-p 30003:30003) \ -e EC_PUBLIC_ADDRESS=localhost \ + -e ENABLE_V3=$(ENABLE_V3) \ replicated/ec-distro:$(DISTRO) @$(MAKE) ssh-node$* diff --git a/README.md b/README.md index 3d41e533e3..36cb12dbde 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,12 @@ Additionally, it includes a Registry when deployed in air gap mode, and SeaweedF make list-distros ``` + **Note:** The development environment automatically mounts both data directories to support v2 and v3: + - **v2 mode:** Uses `/var/lib/embedded-cluster/k0s` + - **v3 mode:** Uses `/var/lib/{app-slug}/k0s` (determined from `REPLICATED_APP`) + + Both directories are mounted automatically, so the embedded cluster binary can use whichever one it needs without any manual configuration. + 1. In the Vendor Portal, create and download a license that is assigned to the channel. We recommend storing this license in the `local-dev/` directory, as it is gitignored and not otherwise used by the CI. diff --git a/api/Makefile b/api/Makefile index 6b0bdc0476..ce38fe7f42 100644 --- a/api/Makefile +++ b/api/Makefile @@ -13,4 +13,8 @@ swag: .PHONY: unit-tests unit-tests: - go test -race -tags $(GO_BUILD_TAGS) -v ./... + go test -race -tags $(GO_BUILD_TAGS) -v $(shell go list ./... | grep -v '/integration') + +.PHONY: test-integration +test-integration: + go test -race -tags $(GO_BUILD_TAGS) -v ./integration diff --git a/api/README.md b/api/README.md index edc2325a97..0373e326f9 100644 --- a/api/README.md +++ b/api/README.md @@ -12,8 +12,14 @@ The root directory contains the main API setup files and request handlers. #### `/controllers` Contains the business logic for different API endpoints. Each controller package focuses on a specific domain of functionality or workflow (e.g., authentication, console, install, upgrade, join, etc.) and implements the core business logic for that domain or workflow. Controllers can utilize multiple managers with each manager handling a specific subdomain of functionality. +#### `/internal` +Contains shared utilities and helper packages that provide common functionality used across different parts of the API. This includes both general-purpose utilities and domain-specific helpers. + #### `/internal/managers` -Each manager is responsible for a specific subdomain of functionality and provides a clean, thread-safe interface for controllers to interact with. For example, the Preflight Manager manages system requirement checks and validation. +Each manager is responsible for a specific subdomain of functionality and provides a clean interface for controllers to interact with. For example, the Preflight Manager manages system requirement checks and validation. + +#### `/internal/statemachine` +The statemachine is used by controllers to capture workflow state and enforce valid transitions. #### `/types` Defines the core data structures and types used throughout the API. This includes: @@ -30,7 +36,7 @@ Contains Swagger-generated API documentation. This includes: - API operation descriptions #### `/pkg` -Contains shared utilities and helper packages that provide common functionality used across different parts of the API. This includes both general-purpose utilities and domain-specific helpers. +Contains helper packages that can be used by packages external to the API. #### `/client` Provides a client library for interacting with the API. The client package implements a clean interface for making API calls and handling responses, making it easy to integrate with the API from other parts of the system. @@ -87,6 +93,12 @@ Provides a client library for interacting with the API. The client package imple - This design choice enables better testability and easier iteration in the development environment - API components should be independently configurable and testable +2. **Kubernetes as a Subset of Linux**: + - The Kubernetes installation target should be a subset of the Linux installation target + - Linux installations include Kubernetes cluster setup (k0s, addons) plus application management + - Kubernetes installations focus on application management (deployment, upgrades, lifecycle) on an existing Kubernetes cluster + - Once Linux installation finishes setting up the Kubernetes cluster, subsequent operations should follow the same workflow as Kubernetes installations + ## Integration The API package is designed to be used as part of the larger Embedded Cluster system. It provides both HTTP endpoints for external access and a client library for internal use. diff --git a/api/api.go b/api/api.go index b8bfed8076..c943295226 100644 --- a/api/api.go +++ b/api/api.go @@ -1,27 +1,21 @@ package api import ( - "encoding/json" - "errors" "fmt" - "net/http" - "github.com/gorilla/mux" "github.com/replicatedhq/embedded-cluster/api/controllers/auth" "github.com/replicatedhq/embedded-cluster/api/controllers/console" - "github.com/replicatedhq/embedded-cluster/api/controllers/install" - "github.com/replicatedhq/embedded-cluster/api/docs" + kubernetesinstall "github.com/replicatedhq/embedded-cluster/api/controllers/kubernetes/install" + linuxinstall "github.com/replicatedhq/embedded-cluster/api/controllers/linux/install" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" "github.com/replicatedhq/embedded-cluster/api/types" - ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" - "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" "github.com/replicatedhq/embedded-cluster/pkg/metrics" - "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/sirupsen/logrus" - httpSwagger "github.com/swaggo/http-swagger/v2" ) +// API represents the main HTTP API server for the Embedded Cluster application. +// // @title Embedded Cluster API // @version 0.1 // @description This is the API for the Embedded Cluster project. @@ -41,110 +35,76 @@ import ( // @externalDocs.description OpenAPI // @externalDocs.url https://swagger.io/resources/open-api/ type API struct { - authController auth.Controller - consoleController console.Controller - installController install.Controller - rc runtimeconfig.RuntimeConfig - releaseData *release.ReleaseData - tlsConfig types.TLSConfig - licenseFile string - airgapBundle string - configValues string - endUserConfig *ecv1beta1.Config - logger logrus.FieldLogger - hostUtils hostutils.HostUtilsInterface - metricsReporter metrics.ReporterInterface + cfg types.APIConfig + + logger logrus.FieldLogger + metricsReporter metrics.ReporterInterface + + authController auth.Controller + consoleController console.Controller + linuxInstallController linuxinstall.Controller + kubernetesInstallController kubernetesinstall.Controller + + handlers handlers } -type APIOption func(*API) +// Option is a function that configures the API. +type Option func(*API) -func WithAuthController(authController auth.Controller) APIOption { +// WithAuthController configures the auth controller for the API. +func WithAuthController(authController auth.Controller) Option { return func(a *API) { a.authController = authController } } -func WithConsoleController(consoleController console.Controller) APIOption { +// WithConsoleController configures the console controller for the API. +func WithConsoleController(consoleController console.Controller) Option { return func(a *API) { a.consoleController = consoleController } } -func WithInstallController(installController install.Controller) APIOption { +// WithLinuxInstallController configures the linux install controller for the API. +func WithLinuxInstallController(linuxInstallController linuxinstall.Controller) Option { return func(a *API) { - a.installController = installController + a.linuxInstallController = linuxInstallController } } -func WithRuntimeConfig(rc runtimeconfig.RuntimeConfig) APIOption { +// WithKubernetesInstallController configures the kubernetes install controller for the API. +func WithKubernetesInstallController(kubernetesInstallController kubernetesinstall.Controller) Option { return func(a *API) { - a.rc = rc + a.kubernetesInstallController = kubernetesInstallController } } -func WithLogger(logger logrus.FieldLogger) APIOption { +// WithLogger configures the logger for the API. If not provided, a default logger will be created. +func WithLogger(logger logrus.FieldLogger) Option { return func(a *API) { a.logger = logger } } -func WithHostUtils(hostUtils hostutils.HostUtilsInterface) APIOption { - return func(a *API) { - a.hostUtils = hostUtils - } -} - -func WithMetricsReporter(metricsReporter metrics.ReporterInterface) APIOption { +// WithMetricsReporter configures the metrics reporter for the API. +func WithMetricsReporter(metricsReporter metrics.ReporterInterface) Option { return func(a *API) { a.metricsReporter = metricsReporter } } -func WithReleaseData(releaseData *release.ReleaseData) APIOption { - return func(a *API) { - a.releaseData = releaseData - } -} - -func WithTLSConfig(tlsConfig types.TLSConfig) APIOption { - return func(a *API) { - a.tlsConfig = tlsConfig - } -} - -func WithLicenseFile(licenseFile string) APIOption { - return func(a *API) { - a.licenseFile = licenseFile - } -} - -func WithAirgapBundle(airgapBundle string) APIOption { - return func(a *API) { - a.airgapBundle = airgapBundle +// New creates a new API instance. +func New(cfg types.APIConfig, opts ...Option) (*API, error) { + api := &API{ + cfg: cfg, } -} - -func WithConfigValues(configValues string) APIOption { - return func(a *API) { - a.configValues = configValues - } -} - -func WithEndUserConfig(endUserConfig *ecv1beta1.Config) APIOption { - return func(a *API) { - a.endUserConfig = endUserConfig - } -} - -func New(password string, opts ...APIOption) (*API, error) { - api := &API{} for _, opt := range opts { opt(api) } - if api.rc == nil { - api.rc = runtimeconfig.New(nil) + if api.cfg.RuntimeConfig == nil { + api.cfg.RuntimeConfig = runtimeconfig.New(nil) } if api.logger == nil { @@ -155,127 +115,9 @@ func New(password string, opts ...APIOption) (*API, error) { api.logger = l } - if api.hostUtils == nil { - api.hostUtils = hostutils.New( - hostutils.WithLogger(api.logger), - ) - } - - if api.authController == nil { - authController, err := auth.NewAuthController(password) - if err != nil { - return nil, fmt.Errorf("new auth controller: %w", err) - } - api.authController = authController - } - - if api.consoleController == nil { - consoleController, err := console.NewConsoleController() - if err != nil { - return nil, fmt.Errorf("new console controller: %w", err) - } - api.consoleController = consoleController - } - - // TODO (@team): discuss which of these should / should not be pointers - if api.installController == nil { - installController, err := install.NewInstallController( - install.WithRuntimeConfig(api.rc), - install.WithLogger(api.logger), - install.WithHostUtils(api.hostUtils), - install.WithMetricsReporter(api.metricsReporter), - install.WithReleaseData(api.releaseData), - install.WithPassword(password), - install.WithTLSConfig(api.tlsConfig), - install.WithLicenseFile(api.licenseFile), - install.WithAirgapBundle(api.airgapBundle), - install.WithConfigValues(api.configValues), - install.WithEndUserConfig(api.endUserConfig), - ) - if err != nil { - return nil, fmt.Errorf("new install controller: %w", err) - } - api.installController = installController + if err := api.initHandlers(); err != nil { + return nil, fmt.Errorf("init handlers: %w", err) } return api, nil } - -func (a *API) RegisterRoutes(router *mux.Router) { - router.HandleFunc("/health", a.getHealth).Methods("GET") - - // Hack to fix issue - // https://github.com/swaggo/swag/issues/1588#issuecomment-2797801240 - router.HandleFunc("/swagger/doc.json", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - w.Write([]byte(docs.SwaggerInfo.ReadDoc())) - }).Methods("GET") - router.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler) - - router.HandleFunc("/auth/login", a.postAuthLogin).Methods("POST") - - authenticatedRouter := router.PathPrefix("/").Subrouter() - authenticatedRouter.Use(a.authMiddleware) - - installRouter := authenticatedRouter.PathPrefix("/install").Subrouter() - installRouter.HandleFunc("/installation/config", a.getInstallInstallationConfig).Methods("GET") - installRouter.HandleFunc("/installation/configure", a.postInstallConfigureInstallation).Methods("POST") - installRouter.HandleFunc("/installation/status", a.getInstallInstallationStatus).Methods("GET") - - installRouter.HandleFunc("/host-preflights/run", a.postInstallRunHostPreflights).Methods("POST") - installRouter.HandleFunc("/host-preflights/status", a.getInstallHostPreflightsStatus).Methods("GET") - - installRouter.HandleFunc("/infra/setup", a.postInstallSetupInfra).Methods("POST") - installRouter.HandleFunc("/infra/status", a.getInstallInfraStatus).Methods("GET") - - // TODO (@salah): remove this once the cli isn't responsible for setting the install status - // and the ui isn't polling for it to know if the entire install is complete - installRouter.HandleFunc("/status", a.getInstallStatus).Methods("GET") - installRouter.HandleFunc("/status", a.setInstallStatus).Methods("POST") - - consoleRouter := authenticatedRouter.PathPrefix("/console").Subrouter() - consoleRouter.HandleFunc("/available-network-interfaces", a.getListAvailableNetworkInterfaces).Methods("GET") -} - -func (a *API) json(w http.ResponseWriter, r *http.Request, code int, payload any) { - response, err := json.Marshal(payload) - if err != nil { - a.logError(r, err, "failed to encode response") - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - w.Write(response) -} - -func (a *API) jsonError(w http.ResponseWriter, r *http.Request, err error) { - var apiErr *types.APIError - if !errors.As(err, &apiErr) { - apiErr = types.NewInternalServerError(err) - } - - response, err := json.Marshal(apiErr) - if err != nil { - a.logError(r, err, "failed to encode response") - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(apiErr.StatusCode) - w.Write(response) -} - -func (a *API) logError(r *http.Request, err error, args ...any) { - a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err).Error(args...) -} - -func logrusFieldsFromRequest(r *http.Request) logrus.Fields { - return logrus.Fields{ - "method": r.Method, - "path": r.URL.Path, - } -} diff --git a/api/auth.go b/api/auth.go deleted file mode 100644 index 89d099b257..0000000000 --- a/api/auth.go +++ /dev/null @@ -1,80 +0,0 @@ -package api - -import ( - "encoding/json" - "errors" - "net/http" - "strings" - - "github.com/replicatedhq/embedded-cluster/api/controllers/auth" - "github.com/replicatedhq/embedded-cluster/api/types" -) - -func (a *API) authMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token := r.Header.Get("Authorization") - if token == "" { - err := errors.New("authorization header is required") - a.logError(r, err, "failed to authenticate") - a.jsonError(w, r, types.NewUnauthorizedError(err)) - return - } - - if !strings.HasPrefix(token, "Bearer ") { - err := errors.New("authorization header must start with Bearer ") - a.logError(r, err, "failed to authenticate") - a.jsonError(w, r, types.NewUnauthorizedError(err)) - return - } - - token = token[len("Bearer "):] - - err := a.authController.ValidateToken(r.Context(), token) - if err != nil { - a.logError(r, err, "failed to validate token") - a.jsonError(w, r, types.NewUnauthorizedError(err)) - return - } - - next.ServeHTTP(w, r) - }) -} - -// postAuthLogin handler to authenticate a user -// -// @Summary Authenticate a user -// @Description Authenticate a user -// @Tags auth -// @Accept json -// @Produce json -// @Param request body types.AuthRequest true "Auth Request" -// @Success 200 {object} types.AuthResponse -// @Failure 401 {object} types.APIError -// @Router /auth/login [post] -func (a *API) postAuthLogin(w http.ResponseWriter, r *http.Request) { - var request types.AuthRequest - err := json.NewDecoder(r.Body).Decode(&request) - if err != nil { - a.logError(r, err, "failed to decode auth request") - a.jsonError(w, r, types.NewBadRequestError(err)) - return - } - - token, err := a.authController.Authenticate(r.Context(), request.Password) - if errors.Is(err, auth.ErrInvalidPassword) { - a.jsonError(w, r, types.NewUnauthorizedError(err)) - return - } - - if err != nil { - a.logError(r, err, "failed to authenticate") - a.jsonError(w, r, types.NewInternalServerError(err)) - return - } - - response := types.AuthResponse{ - Token: token, - } - - a.json(w, r, http.StatusOK, response) -} diff --git a/api/client/client.go b/api/client/client.go index badf282275..e5622206c2 100644 --- a/api/client/client.go +++ b/api/client/client.go @@ -11,12 +11,17 @@ import ( type Client interface { Authenticate(password string) error - GetInstallationConfig() (*types.InstallationConfig, error) - GetInstallationStatus() (*types.Status, error) - ConfigureInstallation(config *types.InstallationConfig) (*types.Status, error) - SetupInfra() (*types.Infra, error) - GetInfraStatus() (*types.Infra, error) - SetInstallStatus(status *types.Status) (*types.Status, error) + GetLinuxInstallationConfig() (types.LinuxInstallationConfig, error) + GetLinuxInstallationStatus() (types.Status, error) + ConfigureLinuxInstallation(config types.LinuxInstallationConfig) (types.Status, error) + SetupLinuxInfra(ignoreHostPreflights bool) (types.Infra, error) + GetLinuxInfraStatus() (types.Infra, error) + + GetKubernetesInstallationConfig() (types.KubernetesInstallationConfig, error) + ConfigureKubernetesInstallation(config types.KubernetesInstallationConfig) (types.Status, error) + GetKubernetesInstallationStatus() (types.Status, error) + SetupKubernetesInfra() (types.Infra, error) + GetKubernetesInfraStatus() (types.Infra, error) } type client struct { diff --git a/api/client/client_test.go b/api/client/client_test.go index 6f40fa5138..3130e98638 100644 --- a/api/client/client_test.go +++ b/api/client/client_test.go @@ -99,18 +99,18 @@ func TestLogin(t *testing.T) { assert.Equal(t, "Invalid password", apiErr.Message) } -func TestGetInstallationConfig(t *testing.T) { +func TestLinuxGetInstallationConfig(t *testing.T) { // Create a test server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "GET", r.Method) - assert.Equal(t, "/api/install/installation/config", r.URL.Path) + assert.Equal(t, "/api/linux/install/installation/config", r.URL.Path) assert.Equal(t, "application/json", r.Header.Get("Content-Type")) assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) // Return successful response w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(types.InstallationConfig{ + json.NewEncoder(w).Encode(types.LinuxInstallationConfig{ GlobalCIDR: "10.0.0.0/24", AdminConsolePort: 8080, }) @@ -119,9 +119,8 @@ func TestGetInstallationConfig(t *testing.T) { // Test successful get c := New(server.URL, WithToken("test-token")) - config, err := c.GetInstallationConfig() + config, err := c.GetLinuxInstallationConfig() assert.NoError(t, err) - assert.NotNil(t, config) assert.Equal(t, "10.0.0.0/24", config.GlobalCIDR) assert.Equal(t, 8080, config.AdminConsolePort) @@ -136,9 +135,9 @@ func TestGetInstallationConfig(t *testing.T) { defer errorServer.Close() c = New(errorServer.URL, WithToken("test-token")) - config, err = c.GetInstallationConfig() + config, err = c.GetLinuxInstallationConfig() assert.Error(t, err) - assert.Nil(t, config) + assert.Equal(t, types.LinuxInstallationConfig{}, config) apiErr, ok := err.(*types.APIError) require.True(t, ok, "Expected err to be of type *types.APIError") @@ -146,19 +145,19 @@ func TestGetInstallationConfig(t *testing.T) { assert.Equal(t, "Internal Server Error", apiErr.Message) } -func TestConfigureInstallation(t *testing.T) { +func TestLinuxConfigureInstallation(t *testing.T) { // Create a test server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Check request method and path assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/install/installation/configure", r.URL.Path) + assert.Equal(t, "/api/linux/install/installation/configure", r.URL.Path) // Check headers assert.Equal(t, "application/json", r.Header.Get("Content-Type")) assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) // Decode request body - var config types.InstallationConfig + var config types.LinuxInstallationConfig err := json.NewDecoder(r.Body).Decode(&config) require.NoError(t, err, "Failed to decode request body") @@ -173,13 +172,12 @@ func TestConfigureInstallation(t *testing.T) { // Test successful configure c := New(server.URL, WithToken("test-token")) - config := types.InstallationConfig{ + config := types.LinuxInstallationConfig{ GlobalCIDR: "20.0.0.0/24", LocalArtifactMirrorPort: 9081, } - status, err := c.ConfigureInstallation(&config) + status, err := c.ConfigureLinuxInstallation(config) assert.NoError(t, err) - assert.NotNil(t, status) assert.Equal(t, types.StateRunning, status.State) assert.Equal(t, "Configuring installation", status.Description) @@ -194,9 +192,9 @@ func TestConfigureInstallation(t *testing.T) { defer errorServer.Close() c = New(errorServer.URL, WithToken("test-token")) - status, err = c.ConfigureInstallation(&config) + status, err = c.ConfigureLinuxInstallation(config) assert.Error(t, err) - assert.Nil(t, status) + assert.Equal(t, types.Status{}, status) apiErr, ok := err.(*types.APIError) require.True(t, ok, "Expected err to be of type *types.APIError") @@ -204,19 +202,26 @@ func TestConfigureInstallation(t *testing.T) { assert.Equal(t, "Bad Request", apiErr.Message) } -func TestSetupInfra(t *testing.T) { +func TestLinuxSetupInfra(t *testing.T) { // Create a test server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/install/infra/setup", r.URL.Path) + assert.Equal(t, "/api/linux/install/infra/setup", r.URL.Path) assert.Equal(t, "application/json", r.Header.Get("Content-Type")) assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + // Decode request body + var config types.LinuxInfraSetupRequest + err := json.NewDecoder(r.Body).Decode(&config) + require.NoError(t, err, "Failed to decode request body") + + assert.True(t, config.IgnoreHostPreflights) + // Return successful response w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(types.Infra{ - Status: &types.Status{ + Status: types.Status{ State: types.StateRunning, Description: "Installing infra", }, @@ -226,10 +231,8 @@ func TestSetupInfra(t *testing.T) { // Test successful setup c := New(server.URL, WithToken("test-token")) - infra, err := c.SetupInfra() + infra, err := c.SetupLinuxInfra(true) assert.NoError(t, err) - assert.NotNil(t, infra) - assert.NotNil(t, infra.Status) assert.Equal(t, types.StateRunning, infra.Status.State) assert.Equal(t, "Installing infra", infra.Status.Description) @@ -244,9 +247,9 @@ func TestSetupInfra(t *testing.T) { defer errorServer.Close() c = New(errorServer.URL, WithToken("test-token")) - infra, err = c.SetupInfra() + infra, err = c.SetupLinuxInfra(true) assert.Error(t, err) - assert.Nil(t, infra) + assert.Equal(t, types.Infra{}, infra) apiErr, ok := err.(*types.APIError) require.True(t, ok, "Expected err to be of type *types.APIError") @@ -254,11 +257,11 @@ func TestSetupInfra(t *testing.T) { assert.Equal(t, "Internal Server Error", apiErr.Message) } -func TestGetInfraStatus(t *testing.T) { +func TestLinuxGetInfraStatus(t *testing.T) { // Create a test server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "GET", r.Method) - assert.Equal(t, "/api/install/infra/status", r.URL.Path) + assert.Equal(t, "/api/linux/install/infra/status", r.URL.Path) assert.Equal(t, "application/json", r.Header.Get("Content-Type")) assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) @@ -266,7 +269,7 @@ func TestGetInfraStatus(t *testing.T) { // Return successful response w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(types.Infra{ - Status: &types.Status{ + Status: types.Status{ State: types.StateSucceeded, Description: "Installation successful", }, @@ -276,9 +279,8 @@ func TestGetInfraStatus(t *testing.T) { // Test successful get c := New(server.URL, WithToken("test-token")) - infra, err := c.GetInfraStatus() + infra, err := c.GetLinuxInfraStatus() assert.NoError(t, err) - assert.NotNil(t, infra) assert.Equal(t, types.StateSucceeded, infra.Status.State) assert.Equal(t, "Installation successful", infra.Status.Description) @@ -293,9 +295,59 @@ func TestGetInfraStatus(t *testing.T) { defer errorServer.Close() c = New(errorServer.URL, WithToken("test-token")) - infra, err = c.GetInfraStatus() + infra, err = c.GetLinuxInfraStatus() + assert.Error(t, err) + assert.Equal(t, types.Infra{}, infra) + + apiErr, ok := err.(*types.APIError) + require.True(t, ok, "Expected err to be of type *types.APIError") + assert.Equal(t, http.StatusInternalServerError, apiErr.StatusCode) + assert.Equal(t, "Internal Server Error", apiErr.Message) +} + +func TestKubernetesGetInstallationConfig(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "/api/kubernetes/install/installation/config", r.URL.Path) + + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + // Return successful response + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.KubernetesInstallationConfig{ + HTTPProxy: "http://proxy.example.com", + HTTPSProxy: "https://proxy.example.com", + NoProxy: "localhost,127.0.0.1", + AdminConsolePort: 8080, + }) + })) + defer server.Close() + + // Test successful get + c := New(server.URL, WithToken("test-token")) + config, err := c.GetKubernetesInstallationConfig() + assert.NoError(t, err) + assert.Equal(t, "http://proxy.example.com", config.HTTPProxy) + assert.Equal(t, "https://proxy.example.com", config.HTTPSProxy) + assert.Equal(t, "localhost,127.0.0.1", config.NoProxy) + assert.Equal(t, 8080, config.AdminConsolePort) + + // Test error response + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(types.APIError{ + StatusCode: http.StatusInternalServerError, + Message: "Internal Server Error", + }) + })) + defer errorServer.Close() + + c = New(errorServer.URL, WithToken("test-token")) + config, err = c.GetKubernetesInstallationConfig() assert.Error(t, err) - assert.Nil(t, infra) + assert.Equal(t, types.KubernetesInstallationConfig{}, config) apiErr, ok := err.(*types.APIError) require.True(t, ok, "Expected err to be of type *types.APIError") @@ -303,36 +355,43 @@ func TestGetInfraStatus(t *testing.T) { assert.Equal(t, "Internal Server Error", apiErr.Message) } -func TestSetInstallStatus(t *testing.T) { +func TestKubernetesConfigureInstallation(t *testing.T) { // Create a test server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check request method and path assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/install/status", r.URL.Path) + assert.Equal(t, "/api/kubernetes/install/installation/configure", r.URL.Path) + // Check headers assert.Equal(t, "application/json", r.Header.Get("Content-Type")) assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) // Decode request body - var status types.Status - err := json.NewDecoder(r.Body).Decode(&status) + var config types.KubernetesInstallationConfig + err := json.NewDecoder(r.Body).Decode(&config) require.NoError(t, err, "Failed to decode request body") // Return successful response w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(status) + json.NewEncoder(w).Encode(types.Status{ + State: types.StateSucceeded, + Description: "Installation configured", + }) })) defer server.Close() - // Test successful set + // Test successful configure c := New(server.URL, WithToken("test-token")) - status := &types.Status{ - State: types.StateSucceeded, - Description: "Installation successful", + config := types.KubernetesInstallationConfig{ + HTTPProxy: "http://proxy.example.com", + HTTPSProxy: "https://proxy.example.com", + NoProxy: "localhost,127.0.0.1", + AdminConsolePort: 8080, } - newStatus, err := c.SetInstallStatus(status) + status, err := c.ConfigureKubernetesInstallation(config) assert.NoError(t, err) - assert.NotNil(t, newStatus) - assert.Equal(t, status, newStatus) + assert.Equal(t, types.StateSucceeded, status.State) + assert.Equal(t, "Installation configured", status.Description) // Test error response errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -345,9 +404,9 @@ func TestSetInstallStatus(t *testing.T) { defer errorServer.Close() c = New(errorServer.URL, WithToken("test-token")) - newStatus, err = c.SetInstallStatus(status) + status, err = c.ConfigureKubernetesInstallation(config) assert.Error(t, err) - assert.Nil(t, newStatus) + assert.Equal(t, types.Status{}, status) apiErr, ok := err.(*types.APIError) require.True(t, ok, "Expected err to be of type *types.APIError") @@ -355,6 +414,148 @@ func TestSetInstallStatus(t *testing.T) { assert.Equal(t, "Bad Request", apiErr.Message) } +func TestKubernetesGetInstallationStatus(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "/api/kubernetes/install/installation/status", r.URL.Path) + + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + // Return successful response + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.Status{ + State: types.StateSucceeded, + Description: "Installation successful", + }) + })) + defer server.Close() + + // Test successful get + c := New(server.URL, WithToken("test-token")) + status, err := c.GetKubernetesInstallationStatus() + assert.NoError(t, err) + assert.Equal(t, types.StateSucceeded, status.State) + assert.Equal(t, "Installation successful", status.Description) + + // Test error response + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(types.APIError{ + StatusCode: http.StatusInternalServerError, + Message: "Internal Server Error", + }) + })) + defer errorServer.Close() + + c = New(errorServer.URL, WithToken("test-token")) + status, err = c.GetKubernetesInstallationStatus() + assert.Error(t, err) + assert.Equal(t, types.Status{}, status) + + apiErr, ok := err.(*types.APIError) + require.True(t, ok, "Expected err to be of type *types.APIError") + assert.Equal(t, http.StatusInternalServerError, apiErr.StatusCode) + assert.Equal(t, "Internal Server Error", apiErr.Message) +} + +func TestKubernetesSetupInfra(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/kubernetes/install/infra/setup", r.URL.Path) + + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + // Return successful response + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.Infra{ + Status: types.Status{ + State: types.StateRunning, + Description: "Installing infra", + }, + }) + })) + defer server.Close() + + // Test successful setup + c := New(server.URL, WithToken("test-token")) + infra, err := c.SetupKubernetesInfra() + assert.NoError(t, err) + assert.Equal(t, types.StateRunning, infra.Status.State) + assert.Equal(t, "Installing infra", infra.Status.Description) + + // Test error response + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(types.APIError{ + StatusCode: http.StatusInternalServerError, + Message: "Internal Server Error", + }) + })) + defer errorServer.Close() + + c = New(errorServer.URL, WithToken("test-token")) + infra, err = c.SetupKubernetesInfra() + assert.Error(t, err) + assert.Equal(t, types.Infra{}, infra) + + apiErr, ok := err.(*types.APIError) + require.True(t, ok, "Expected err to be of type *types.APIError") + assert.Equal(t, http.StatusInternalServerError, apiErr.StatusCode) + assert.Equal(t, "Internal Server Error", apiErr.Message) +} + +func TestKubernetesGetInfraStatus(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "/api/kubernetes/install/infra/status", r.URL.Path) + + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + // Return successful response + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.Infra{ + Status: types.Status{ + State: types.StateSucceeded, + Description: "Installation successful", + }, + }) + })) + defer server.Close() + + // Test successful get + c := New(server.URL, WithToken("test-token")) + infra, err := c.GetKubernetesInfraStatus() + assert.NoError(t, err) + assert.Equal(t, types.StateSucceeded, infra.Status.State) + assert.Equal(t, "Installation successful", infra.Status.Description) + + // Test error response + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(types.APIError{ + StatusCode: http.StatusInternalServerError, + Message: "Internal Server Error", + }) + })) + defer errorServer.Close() + + c = New(errorServer.URL, WithToken("test-token")) + infra, err = c.GetKubernetesInfraStatus() + assert.Error(t, err) + assert.Equal(t, types.Infra{}, infra) + + apiErr, ok := err.(*types.APIError) + require.True(t, ok, "Expected err to be of type *types.APIError") + assert.Equal(t, http.StatusInternalServerError, apiErr.StatusCode) + assert.Equal(t, "Internal Server Error", apiErr.Message) +} + func TestErrorFromResponse(t *testing.T) { // Create a response with an error resp := &http.Response{ diff --git a/api/client/install.go b/api/client/install.go index 1c368d555e..ab70267bfd 100644 --- a/api/client/install.go +++ b/api/client/install.go @@ -8,174 +8,289 @@ import ( "github.com/replicatedhq/embedded-cluster/api/types" ) -func (c *client) GetInstallationConfig() (*types.InstallationConfig, error) { - req, err := http.NewRequest("GET", c.apiURL+"/api/install/installation/config", nil) +func (c *client) GetLinuxInstallationConfig() (types.LinuxInstallationConfig, error) { + req, err := http.NewRequest("GET", c.apiURL+"/api/linux/install/installation/config", nil) if err != nil { - return nil, err + return types.LinuxInstallationConfig{}, err } req.Header.Set("Content-Type", "application/json") setAuthorizationHeader(req, c.token) resp, err := c.httpClient.Do(req) if err != nil { - return nil, err + return types.LinuxInstallationConfig{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, errorFromResponse(resp) + return types.LinuxInstallationConfig{}, errorFromResponse(resp) } - var config types.InstallationConfig + var config types.LinuxInstallationConfig err = json.NewDecoder(resp.Body).Decode(&config) if err != nil { - return nil, err + return types.LinuxInstallationConfig{}, err } - return &config, nil + return config, nil } -func (c *client) ConfigureInstallation(cfg *types.InstallationConfig) (*types.Status, error) { - b, err := json.Marshal(cfg) +func (c *client) ConfigureLinuxInstallation(config types.LinuxInstallationConfig) (types.Status, error) { + b, err := json.Marshal(config) if err != nil { - return nil, err + return types.Status{}, err } - req, err := http.NewRequest("POST", c.apiURL+"/api/install/installation/configure", bytes.NewBuffer(b)) + req, err := http.NewRequest("POST", c.apiURL+"/api/linux/install/installation/configure", bytes.NewBuffer(b)) if err != nil { - return nil, err + return types.Status{}, err } req.Header.Set("Content-Type", "application/json") setAuthorizationHeader(req, c.token) resp, err := c.httpClient.Do(req) if err != nil { - return nil, err + return types.Status{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, errorFromResponse(resp) + return types.Status{}, errorFromResponse(resp) } var status types.Status err = json.NewDecoder(resp.Body).Decode(&status) if err != nil { - return nil, err + return types.Status{}, err } - return &status, nil + return status, nil } -func (c *client) GetInstallationStatus() (*types.Status, error) { - req, err := http.NewRequest("GET", c.apiURL+"/api/install/installation/status", nil) +func (c *client) GetLinuxInstallationStatus() (types.Status, error) { + req, err := http.NewRequest("GET", c.apiURL+"/api/linux/install/installation/status", nil) if err != nil { - return nil, err + return types.Status{}, err } req.Header.Set("Content-Type", "application/json") setAuthorizationHeader(req, c.token) resp, err := c.httpClient.Do(req) if err != nil { - return nil, err + return types.Status{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, errorFromResponse(resp) + return types.Status{}, errorFromResponse(resp) } var status types.Status err = json.NewDecoder(resp.Body).Decode(&status) if err != nil { - return nil, err + return types.Status{}, err } - return &status, nil + return status, nil } -func (c *client) SetupInfra() (*types.Infra, error) { - req, err := http.NewRequest("POST", c.apiURL+"/api/install/infra/setup", nil) +func (c *client) SetupLinuxInfra(ignoreHostPreflights bool) (types.Infra, error) { + b, err := json.Marshal(types.LinuxInfraSetupRequest{ + IgnoreHostPreflights: ignoreHostPreflights, + }) if err != nil { - return nil, err + return types.Infra{}, err + } + + req, err := http.NewRequest("POST", c.apiURL+"/api/linux/install/infra/setup", bytes.NewBuffer(b)) + if err != nil { + return types.Infra{}, err } req.Header.Set("Content-Type", "application/json") setAuthorizationHeader(req, c.token) resp, err := c.httpClient.Do(req) if err != nil { - return nil, err + return types.Infra{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, errorFromResponse(resp) + return types.Infra{}, errorFromResponse(resp) } var infra types.Infra err = json.NewDecoder(resp.Body).Decode(&infra) if err != nil { - return nil, err + return types.Infra{}, err } - return &infra, nil + return infra, nil } -func (c *client) GetInfraStatus() (*types.Infra, error) { - req, err := http.NewRequest("GET", c.apiURL+"/api/install/infra/status", nil) +func (c *client) GetLinuxInfraStatus() (types.Infra, error) { + req, err := http.NewRequest("GET", c.apiURL+"/api/linux/install/infra/status", nil) if err != nil { - return nil, err + return types.Infra{}, err } req.Header.Set("Content-Type", "application/json") setAuthorizationHeader(req, c.token) resp, err := c.httpClient.Do(req) if err != nil { - return nil, err + return types.Infra{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, errorFromResponse(resp) + return types.Infra{}, errorFromResponse(resp) } var infra types.Infra err = json.NewDecoder(resp.Body).Decode(&infra) if err != nil { - return nil, err + return types.Infra{}, err } - return &infra, nil + return infra, nil } -func (c *client) SetInstallStatus(s *types.Status) (*types.Status, error) { - b, err := json.Marshal(s) +func (c *client) GetKubernetesInstallationConfig() (types.KubernetesInstallationConfig, error) { + req, err := http.NewRequest("GET", c.apiURL+"/api/kubernetes/install/installation/config", nil) if err != nil { - return nil, err + return types.KubernetesInstallationConfig{}, err } + req.Header.Set("Content-Type", "application/json") + setAuthorizationHeader(req, c.token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return types.KubernetesInstallationConfig{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return types.KubernetesInstallationConfig{}, errorFromResponse(resp) + } + + var config types.KubernetesInstallationConfig + err = json.NewDecoder(resp.Body).Decode(&config) + if err != nil { + return types.KubernetesInstallationConfig{}, err + } + + return config, nil +} + +func (c *client) ConfigureKubernetesInstallation(config types.KubernetesInstallationConfig) (types.Status, error) { + b, err := json.Marshal(config) + if err != nil { + return types.Status{}, err + } + + req, err := http.NewRequest("POST", c.apiURL+"/api/kubernetes/install/installation/configure", bytes.NewBuffer(b)) + if err != nil { + return types.Status{}, err + } + req.Header.Set("Content-Type", "application/json") + setAuthorizationHeader(req, c.token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return types.Status{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return types.Status{}, errorFromResponse(resp) + } + + var status types.Status + err = json.NewDecoder(resp.Body).Decode(&status) + if err != nil { + return types.Status{}, err + } + + return status, nil +} - req, err := http.NewRequest("POST", c.apiURL+"/api/install/status", bytes.NewBuffer(b)) +func (c *client) GetKubernetesInstallationStatus() (types.Status, error) { + req, err := http.NewRequest("GET", c.apiURL+"/api/kubernetes/install/installation/status", nil) if err != nil { - return nil, err + return types.Status{}, err } req.Header.Set("Content-Type", "application/json") setAuthorizationHeader(req, c.token) resp, err := c.httpClient.Do(req) if err != nil { - return nil, err + return types.Status{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, errorFromResponse(resp) + return types.Status{}, errorFromResponse(resp) } var status types.Status err = json.NewDecoder(resp.Body).Decode(&status) if err != nil { - return nil, err + return types.Status{}, err + } + + return status, nil +} + +func (c *client) SetupKubernetesInfra() (types.Infra, error) { + req, err := http.NewRequest("POST", c.apiURL+"/api/kubernetes/install/infra/setup", nil) + if err != nil { + return types.Infra{}, err + } + req.Header.Set("Content-Type", "application/json") + setAuthorizationHeader(req, c.token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return types.Infra{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return types.Infra{}, errorFromResponse(resp) + } + + var infra types.Infra + err = json.NewDecoder(resp.Body).Decode(&infra) + if err != nil { + return types.Infra{}, err + } + + return infra, nil +} + +func (c *client) GetKubernetesInfraStatus() (types.Infra, error) { + req, err := http.NewRequest("GET", c.apiURL+"/api/kubernetes/install/infra/status", nil) + if err != nil { + return types.Infra{}, err + } + req.Header.Set("Content-Type", "application/json") + setAuthorizationHeader(req, c.token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return types.Infra{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return types.Infra{}, errorFromResponse(resp) + } + + var infra types.Infra + err = json.NewDecoder(resp.Body).Decode(&infra) + if err != nil { + return types.Infra{}, err } - return &status, nil + return infra, nil } diff --git a/api/console.go b/api/console.go deleted file mode 100644 index 3e7c3a2bc4..0000000000 --- a/api/console.go +++ /dev/null @@ -1,28 +0,0 @@ -package api - -import ( - "net/http" -) - -type getListAvailableNetworkInterfacesResponse struct { - NetworkInterfaces []string `json:"networkInterfaces"` -} - -func (a *API) getListAvailableNetworkInterfaces(w http.ResponseWriter, r *http.Request) { - interfaces, err := a.consoleController.ListAvailableNetworkInterfaces() - if err != nil { - a.logError(r, err, "failed to list available network interfaces") - a.jsonError(w, r, err) - return - } - - a.logger.WithFields(logrusFieldsFromRequest(r)). - WithField("interfaces", interfaces). - Info("got available network interfaces") - - response := getListAvailableNetworkInterfacesResponse{ - NetworkInterfaces: interfaces, - } - - a.json(w, r, http.StatusOK, response) -} diff --git a/api/controllers/console/controller.go b/api/controllers/console/controller.go index 66ede9f4e4..c2bea48e2e 100644 --- a/api/controllers/console/controller.go +++ b/api/controllers/console/controller.go @@ -1,7 +1,7 @@ package console import ( - "github.com/replicatedhq/embedded-cluster/api/pkg/utils" + "github.com/replicatedhq/embedded-cluster/api/internal/utils" ) type Controller interface { @@ -11,14 +11,14 @@ type Controller interface { var _ Controller = (*ConsoleController)(nil) type ConsoleController struct { - utils.NetUtils + netUtils utils.NetUtils } type ConsoleControllerOption func(*ConsoleController) func WithNetUtils(netUtils utils.NetUtils) ConsoleControllerOption { return func(c *ConsoleController) { - c.NetUtils = netUtils + c.netUtils = netUtils } } @@ -29,13 +29,13 @@ func NewConsoleController(opts ...ConsoleControllerOption) (*ConsoleController, opt(controller) } - if controller.NetUtils == nil { - controller.NetUtils = utils.NewNetUtils() + if controller.netUtils == nil { + controller.netUtils = utils.NewNetUtils() } return controller, nil } func (c *ConsoleController) ListAvailableNetworkInterfaces() ([]string, error) { - return c.NetUtils.ListValidNetworkInterfaces() + return c.netUtils.ListValidNetworkInterfaces() } diff --git a/api/controllers/install/controller_test.go b/api/controllers/install/controller_test.go deleted file mode 100644 index 6ad1bb0b4a..0000000000 --- a/api/controllers/install/controller_test.go +++ /dev/null @@ -1,859 +0,0 @@ -package install - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - "github.com/replicatedhq/embedded-cluster/api/internal/managers/infra" - "github.com/replicatedhq/embedded-cluster/api/internal/managers/installation" - "github.com/replicatedhq/embedded-cluster/api/internal/managers/preflight" - "github.com/replicatedhq/embedded-cluster/api/types" - ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" - "github.com/replicatedhq/embedded-cluster/pkg/metrics" - "github.com/replicatedhq/embedded-cluster/pkg/release" - troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" -) - -func TestGetInstallationConfig(t *testing.T) { - tests := []struct { - name string - setupMock func(*installation.MockInstallationManager) - expectedErr bool - expectedValue *types.InstallationConfig - }{ - { - name: "successful get", - setupMock: func(m *installation.MockInstallationManager) { - config := &types.InstallationConfig{ - AdminConsolePort: 9000, - GlobalCIDR: "10.0.0.1/16", - } - - mock.InOrder( - m.On("GetConfig").Return(config, nil), - m.On("SetConfigDefaults", config).Return(nil), - m.On("ValidateConfig", config).Return(nil), - ) - }, - expectedErr: false, - expectedValue: &types.InstallationConfig{ - AdminConsolePort: 9000, - GlobalCIDR: "10.0.0.1/16", - }, - }, - { - name: "read config error", - setupMock: func(m *installation.MockInstallationManager) { - m.On("GetConfig").Return(nil, errors.New("read error")) - }, - expectedErr: true, - expectedValue: nil, - }, - { - name: "set defaults error", - setupMock: func(m *installation.MockInstallationManager) { - config := &types.InstallationConfig{} - mock.InOrder( - m.On("GetConfig").Return(config, nil), - m.On("SetConfigDefaults", config).Return(errors.New("defaults error")), - ) - }, - expectedErr: true, - expectedValue: nil, - }, - { - name: "validate error", - setupMock: func(m *installation.MockInstallationManager) { - config := &types.InstallationConfig{} - mock.InOrder( - m.On("GetConfig").Return(config, nil), - m.On("SetConfigDefaults", config).Return(nil), - m.On("ValidateConfig", config).Return(errors.New("validation error")), - ) - }, - expectedErr: true, - expectedValue: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockManager := &installation.MockInstallationManager{} - tt.setupMock(mockManager) - - controller, err := NewInstallController(WithInstallationManager(mockManager)) - require.NoError(t, err) - - result, err := controller.GetInstallationConfig(t.Context()) - - if tt.expectedErr { - assert.Error(t, err) - assert.Nil(t, result) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expectedValue, result) - } - - mockManager.AssertExpectations(t) - }) - } -} - -func TestConfigureInstallation(t *testing.T) { - tests := []struct { - name string - config *types.InstallationConfig - setupMock func(*installation.MockInstallationManager, *types.InstallationConfig) - expectedErr bool - }{ - { - name: "successful configure installation", - config: &types.InstallationConfig{ - LocalArtifactMirrorPort: 9000, - DataDirectory: t.TempDir(), - }, - setupMock: func(m *installation.MockInstallationManager, config *types.InstallationConfig) { - mock.InOrder( - m.On("ValidateConfig", config).Return(nil), - m.On("SetConfig", *config).Return(nil), - m.On("ConfigureHost", t.Context(), config).Return(nil), - ) - }, - expectedErr: false, - }, - { - name: "validate error", - config: &types.InstallationConfig{}, - setupMock: func(m *installation.MockInstallationManager, config *types.InstallationConfig) { - m.On("ValidateConfig", config).Return(errors.New("validation error")) - }, - expectedErr: true, - }, - { - name: "set config error", - config: &types.InstallationConfig{}, - setupMock: func(m *installation.MockInstallationManager, config *types.InstallationConfig) { - mock.InOrder( - m.On("ValidateConfig", config).Return(nil), - m.On("SetConfig", *config).Return(errors.New("set config error")), - ) - }, - expectedErr: true, - }, - { - name: "with global CIDR", - config: &types.InstallationConfig{ - GlobalCIDR: "10.0.0.0/16", - DataDirectory: t.TempDir(), - }, - setupMock: func(m *installation.MockInstallationManager, config *types.InstallationConfig) { - // Create a copy with expected CIDR values after computation - configWithCIDRs := *config - configWithCIDRs.PodCIDR = "10.0.0.0/17" - configWithCIDRs.ServiceCIDR = "10.0.128.0/17" - - mock.InOrder( - m.On("ValidateConfig", config).Return(nil), - m.On("SetConfig", configWithCIDRs).Return(nil), - m.On("ConfigureHost", t.Context(), &configWithCIDRs).Return(nil), - ) - }, - expectedErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockManager := &installation.MockInstallationManager{} - - // Create a copy of the config to avoid modifying the original - configCopy := *tt.config - - tt.setupMock(mockManager, &configCopy) - - controller, err := NewInstallController(WithInstallationManager(mockManager)) - require.NoError(t, err) - - err = controller.ConfigureInstallation(t.Context(), tt.config) - if tt.expectedErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - - mockManager.AssertExpectations(t) - }) - } -} - -// TestIntegrationComputeCIDRs tests the CIDR computation with real networking utility -func TestIntegrationComputeCIDRs(t *testing.T) { - tests := []struct { - name string - globalCIDR string - expectedPod string - expectedSvc string - expectedErr bool - }{ - { - name: "valid cidr 10.0.0.0/16", - globalCIDR: "10.0.0.0/16", - expectedPod: "10.0.0.0/17", - expectedSvc: "10.0.128.0/17", - expectedErr: false, - }, - { - name: "valid cidr 192.168.0.0/16", - globalCIDR: "192.168.0.0/16", - expectedPod: "192.168.0.0/17", - expectedSvc: "192.168.128.0/17", - expectedErr: false, - }, - { - name: "no global cidr", - globalCIDR: "", - expectedPod: "", // Should remain unchanged - expectedSvc: "", // Should remain unchanged - expectedErr: false, - }, - { - name: "invalid cidr", - globalCIDR: "not-a-cidr", - expectedErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - controller, err := NewInstallController() - require.NoError(t, err) - - config := &types.InstallationConfig{ - GlobalCIDR: tt.globalCIDR, - } - - err = controller.computeCIDRs(config) - - if tt.expectedErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expectedPod, config.PodCIDR) - assert.Equal(t, tt.expectedSvc, config.ServiceCIDR) - } - }) - } -} - -func TestRunHostPreflights(t *testing.T) { - expectedHPF := &troubleshootv1beta2.HostPreflightSpec{ - Collectors: []*troubleshootv1beta2.HostCollect{ - { - Time: &troubleshootv1beta2.HostTime{}, - }, - }, - } - - expectedProxy := &ecv1beta1.ProxySpec{ - HTTPProxy: "http://proxy.example.com", - HTTPSProxy: "https://proxy.example.com", - ProvidedNoProxy: "provided-proxy.com", - NoProxy: "no-proxy.com", - } - - tests := []struct { - name string - setupMocks func(*installation.MockInstallationManager, *preflight.MockHostPreflightManager) - expectedErr bool - }{ - { - name: "successful run preflights", - setupMocks: func(im *installation.MockInstallationManager, pm *preflight.MockHostPreflightManager) { - mock.InOrder( - im.On("GetConfig").Return(&types.InstallationConfig{}, nil), - pm.On("PrepareHostPreflights", t.Context(), mock.Anything).Return(expectedHPF, expectedProxy, nil), - pm.On("RunHostPreflights", t.Context(), mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { - return expectedHPF == opts.HostPreflightSpec && expectedProxy == opts.Proxy - })).Return(nil), - ) - }, - expectedErr: false, - }, - { - name: "prepare preflights error", - setupMocks: func(im *installation.MockInstallationManager, pm *preflight.MockHostPreflightManager) { - mock.InOrder( - im.On("GetConfig").Return(&types.InstallationConfig{}, nil), - pm.On("PrepareHostPreflights", t.Context(), mock.Anything).Return(nil, nil, errors.New("prepare error")), - ) - }, - expectedErr: true, - }, - { - name: "run preflights error", - setupMocks: func(im *installation.MockInstallationManager, pm *preflight.MockHostPreflightManager) { - mock.InOrder( - im.On("GetConfig").Return(&types.InstallationConfig{}, nil), - pm.On("PrepareHostPreflights", t.Context(), mock.Anything).Return(expectedHPF, expectedProxy, nil), - pm.On("RunHostPreflights", t.Context(), mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { - return expectedHPF == opts.HostPreflightSpec && expectedProxy == opts.Proxy - })).Return(errors.New("run preflights error")), - ) - }, - expectedErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockInstallationManager := &installation.MockInstallationManager{} - mockPreflightManager := &preflight.MockHostPreflightManager{} - tt.setupMocks(mockInstallationManager, mockPreflightManager) - - controller, err := NewInstallController( - WithInstallationManager(mockInstallationManager), - WithHostPreflightManager(mockPreflightManager), - WithReleaseData(getTestReleaseData()), - ) - require.NoError(t, err) - - err = controller.RunHostPreflights(t.Context()) - - if tt.expectedErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - - mockInstallationManager.AssertExpectations(t) - mockPreflightManager.AssertExpectations(t) - }) - } -} - -func TestGetHostPreflightStatus(t *testing.T) { - tests := []struct { - name string - setupMock func(*preflight.MockHostPreflightManager) - expectedErr bool - expectedValue *types.Status - }{ - { - name: "successful get status", - setupMock: func(m *preflight.MockHostPreflightManager) { - status := &types.Status{ - State: types.StateFailed, - } - m.On("GetHostPreflightStatus", t.Context()).Return(status, nil) - }, - expectedErr: false, - expectedValue: &types.Status{ - State: types.StateFailed, - }, - }, - { - name: "get status error", - setupMock: func(m *preflight.MockHostPreflightManager) { - m.On("GetHostPreflightStatus", t.Context()).Return(nil, errors.New("get status error")) - }, - expectedErr: true, - expectedValue: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockManager := &preflight.MockHostPreflightManager{} - tt.setupMock(mockManager) - - controller, err := NewInstallController(WithHostPreflightManager(mockManager)) - require.NoError(t, err) - - result, err := controller.GetHostPreflightStatus(t.Context()) - - if tt.expectedErr { - assert.Error(t, err) - assert.Nil(t, result) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expectedValue, result) - } - - mockManager.AssertExpectations(t) - }) - } -} - -func TestGetHostPreflightOutput(t *testing.T) { - tests := []struct { - name string - setupMock func(*preflight.MockHostPreflightManager) - expectedErr bool - expectedValue *types.HostPreflightsOutput - }{ - { - name: "successful get output", - setupMock: func(m *preflight.MockHostPreflightManager) { - output := &types.HostPreflightsOutput{ - Pass: []types.HostPreflightsRecord{ - { - Title: "Test Check", - Message: "Test check passed", - }, - }, - } - m.On("GetHostPreflightOutput", t.Context()).Return(output, nil) - }, - expectedErr: false, - expectedValue: &types.HostPreflightsOutput{ - Pass: []types.HostPreflightsRecord{ - { - Title: "Test Check", - Message: "Test check passed", - }, - }, - }, - }, - { - name: "get output error", - setupMock: func(m *preflight.MockHostPreflightManager) { - m.On("GetHostPreflightOutput", t.Context()).Return(nil, errors.New("get output error")) - }, - expectedErr: true, - expectedValue: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockManager := &preflight.MockHostPreflightManager{} - tt.setupMock(mockManager) - - controller, err := NewInstallController(WithHostPreflightManager(mockManager)) - require.NoError(t, err) - - result, err := controller.GetHostPreflightOutput(t.Context()) - - if tt.expectedErr { - assert.Error(t, err) - assert.Nil(t, result) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expectedValue, result) - } - - mockManager.AssertExpectations(t) - }) - } -} - -func TestGetHostPreflightTitles(t *testing.T) { - tests := []struct { - name string - setupMock func(*preflight.MockHostPreflightManager) - expectedErr bool - expectedValue []string - }{ - { - name: "successful get titles", - setupMock: func(m *preflight.MockHostPreflightManager) { - titles := []string{"Check 1", "Check 2"} - m.On("GetHostPreflightTitles", t.Context()).Return(titles, nil) - }, - expectedErr: false, - expectedValue: []string{"Check 1", "Check 2"}, - }, - { - name: "get titles error", - setupMock: func(m *preflight.MockHostPreflightManager) { - m.On("GetHostPreflightTitles", t.Context()).Return(nil, errors.New("get titles error")) - }, - expectedErr: true, - expectedValue: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockManager := &preflight.MockHostPreflightManager{} - tt.setupMock(mockManager) - - controller, err := NewInstallController(WithHostPreflightManager(mockManager)) - require.NoError(t, err) - - result, err := controller.GetHostPreflightTitles(t.Context()) - - if tt.expectedErr { - assert.Error(t, err) - assert.Nil(t, result) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expectedValue, result) - } - - mockManager.AssertExpectations(t) - }) - } -} - -func TestGetInstallationStatus(t *testing.T) { - tests := []struct { - name string - setupMock func(*installation.MockInstallationManager) - expectedErr bool - expectedValue *types.Status - }{ - { - name: "successful get status", - setupMock: func(m *installation.MockInstallationManager) { - status := &types.Status{ - State: types.StateRunning, - } - m.On("GetStatus").Return(status, nil) - }, - expectedErr: false, - expectedValue: &types.Status{ - State: types.StateRunning, - }, - }, - { - name: "get status error", - setupMock: func(m *installation.MockInstallationManager) { - m.On("GetStatus").Return(nil, errors.New("get status error")) - }, - expectedErr: true, - expectedValue: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockManager := &installation.MockInstallationManager{} - tt.setupMock(mockManager) - - controller, err := NewInstallController(WithInstallationManager(mockManager)) - require.NoError(t, err) - - result, err := controller.GetInstallationStatus(t.Context()) - - if tt.expectedErr { - assert.Error(t, err) - assert.Nil(t, result) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expectedValue, result) - } - - mockManager.AssertExpectations(t) - }) - } -} - -func TestSetupInfra(t *testing.T) { - tests := []struct { - name string - setupMocks func(*preflight.MockHostPreflightManager, *installation.MockInstallationManager, *infra.MockInfraManager, *metrics.MockReporter) - expectedErr bool - }{ - { - name: "successful setup with passed preflights", - setupMocks: func(pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, r *metrics.MockReporter) { - preflightStatus := &types.Status{ - State: types.StateSucceeded, - } - config := &types.InstallationConfig{ - AdminConsolePort: 8000, - } - mock.InOrder( - pm.On("GetHostPreflightStatus", t.Context()).Return(preflightStatus, nil), - im.On("GetConfig").Return(config, nil), - fm.On("Install", t.Context(), config).Return(nil), - ) - }, - expectedErr: false, - }, - { - name: "successful setup with failed preflights", - setupMocks: func(pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, r *metrics.MockReporter) { - preflightStatus := &types.Status{ - State: types.StateFailed, - } - preflightOutput := &types.HostPreflightsOutput{ - Fail: []types.HostPreflightsRecord{ - { - Title: "Test Check", - Message: "Test check failed", - }, - }, - } - config := &types.InstallationConfig{ - AdminConsolePort: 8000, - } - mock.InOrder( - pm.On("GetHostPreflightStatus", t.Context()).Return(preflightStatus, nil), - pm.On("GetHostPreflightOutput", t.Context()).Return(preflightOutput, nil), - r.On("ReportPreflightsFailed", t.Context(), preflightOutput).Return(nil), - im.On("GetConfig").Return(config, nil), - fm.On("Install", t.Context(), config).Return(nil), - ) - }, - expectedErr: false, - }, - { - name: "preflight status error", - setupMocks: func(pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, r *metrics.MockReporter) { - pm.On("GetHostPreflightStatus", t.Context()).Return(nil, errors.New("get preflight status error")) - }, - expectedErr: true, - }, - { - name: "preflight not completed", - setupMocks: func(pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, r *metrics.MockReporter) { - preflightStatus := &types.Status{ - State: types.StateRunning, - } - pm.On("GetHostPreflightStatus", t.Context()).Return(preflightStatus, nil) - }, - expectedErr: true, - }, - { - name: "preflight output error", - setupMocks: func(pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, r *metrics.MockReporter) { - preflightStatus := &types.Status{ - State: types.StateFailed, - } - mock.InOrder( - pm.On("GetHostPreflightStatus", t.Context()).Return(preflightStatus, nil), - pm.On("GetHostPreflightOutput", t.Context()).Return(nil, errors.New("get output error")), - ) - }, - expectedErr: true, - }, - { - name: "get config error", - setupMocks: func(pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, r *metrics.MockReporter) { - preflightStatus := &types.Status{ - State: types.StateSucceeded, - } - mock.InOrder( - pm.On("GetHostPreflightStatus", t.Context()).Return(preflightStatus, nil), - im.On("GetConfig").Return(nil, errors.New("get config error")), - ) - }, - expectedErr: true, - }, - { - name: "install infra error", - setupMocks: func(pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, r *metrics.MockReporter) { - preflightStatus := &types.Status{ - State: types.StateSucceeded, - } - config := &types.InstallationConfig{ - AdminConsolePort: 8000, - } - mock.InOrder( - pm.On("GetHostPreflightStatus", t.Context()).Return(preflightStatus, nil), - im.On("GetConfig").Return(config, nil), - fm.On("Install", t.Context(), config).Return(errors.New("install error")), - ) - }, - expectedErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockPreflightManager := &preflight.MockHostPreflightManager{} - mockInstallationManager := &installation.MockInstallationManager{} - mockInfraManager := &infra.MockInfraManager{} - mockMetricsReporter := &metrics.MockReporter{} - tt.setupMocks(mockPreflightManager, mockInstallationManager, mockInfraManager, mockMetricsReporter) - - controller, err := NewInstallController( - WithHostPreflightManager(mockPreflightManager), - WithInstallationManager(mockInstallationManager), - WithInfraManager(mockInfraManager), - WithMetricsReporter(mockMetricsReporter), - ) - require.NoError(t, err) - - err = controller.SetupInfra(t.Context()) - - if tt.expectedErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - - mockPreflightManager.AssertExpectations(t) - mockInstallationManager.AssertExpectations(t) - mockInfraManager.AssertExpectations(t) - mockMetricsReporter.AssertExpectations(t) - }) - } -} - -func TestGetInfra(t *testing.T) { - tests := []struct { - name string - setupMock func(*infra.MockInfraManager) - expectedErr bool - expectedValue *types.Infra - }{ - { - name: "successful get infra", - setupMock: func(m *infra.MockInfraManager) { - infra := &types.Infra{ - Components: []types.InfraComponent{ - { - Name: infra.K0sComponentName, - Status: &types.Status{ - State: types.StateRunning, - }, - }, - }, - Status: &types.Status{ - State: types.StateRunning, - }, - } - m.On("Get").Return(infra, nil) - }, - expectedErr: false, - expectedValue: &types.Infra{ - Components: []types.InfraComponent{ - { - Name: infra.K0sComponentName, - Status: &types.Status{ - State: types.StateRunning, - }, - }, - }, - Status: &types.Status{ - State: types.StateRunning, - }, - }, - }, - { - name: "get infra error", - setupMock: func(m *infra.MockInfraManager) { - m.On("Get").Return(nil, errors.New("get infra error")) - }, - expectedErr: true, - expectedValue: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockManager := &infra.MockInfraManager{} - tt.setupMock(mockManager) - - controller, err := NewInstallController(WithInfraManager(mockManager)) - require.NoError(t, err) - - result, err := controller.GetInfra(t.Context()) - - if tt.expectedErr { - assert.Error(t, err) - assert.Nil(t, result) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expectedValue, result) - } - - mockManager.AssertExpectations(t) - }) - } -} - -func TestGetStatus(t *testing.T) { - tests := []struct { - name string - install *types.Install - expectedValue *types.Status - }{ - { - name: "successful get status", - install: &types.Install{ - Status: &types.Status{ - State: types.StateFailed, - }, - }, - expectedValue: &types.Status{ - State: types.StateFailed, - }, - }, - { - name: "nil status", - install: &types.Install{}, - expectedValue: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - controller := &InstallController{ - install: tt.install, - } - - result, err := controller.GetStatus(t.Context()) - - assert.NoError(t, err) - assert.Equal(t, tt.expectedValue, result) - }) - } -} - -func TestSetStatus(t *testing.T) { - tests := []struct { - name string - status *types.Status - expectedErr bool - }{ - { - name: "successful set status", - status: &types.Status{ - State: types.StateFailed, - }, - expectedErr: false, - }, - { - name: "nil status", - status: nil, - expectedErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - controller, err := NewInstallController() - require.NoError(t, err) - - err = controller.SetStatus(t.Context(), tt.status) - - if tt.expectedErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.status, controller.install.Status) - } - }) - } -} - -func getTestReleaseData() *release.ReleaseData { - return &release.ReleaseData{ - EmbeddedClusterConfig: &ecv1beta1.Config{}, - ChannelRelease: &release.ChannelRelease{}, - } -} - -func WithInfraManager(infraManager infra.InfraManager) InstallControllerOption { - return func(c *InstallController) { - c.infraManager = infraManager - } -} diff --git a/api/controllers/install/hostpreflight.go b/api/controllers/install/hostpreflight.go deleted file mode 100644 index 34efaab699..0000000000 --- a/api/controllers/install/hostpreflight.go +++ /dev/null @@ -1,53 +0,0 @@ -package install - -import ( - "context" - "fmt" - - "github.com/replicatedhq/embedded-cluster/api/internal/managers/preflight" - "github.com/replicatedhq/embedded-cluster/api/pkg/utils" - "github.com/replicatedhq/embedded-cluster/api/types" - "github.com/replicatedhq/embedded-cluster/pkg/netutils" -) - -func (c *InstallController) RunHostPreflights(ctx context.Context) error { - // Get current installation config and add it to options - config, err := c.installationManager.GetConfig() - if err != nil { - return fmt.Errorf("failed to read installation config: %w", err) - } - - // Get the configured custom domains - ecDomains := utils.GetDomains(c.releaseData) - - // Prepare host preflights - hpf, proxy, err := c.hostPreflightManager.PrepareHostPreflights(ctx, preflight.PrepareHostPreflightOptions{ - InstallationConfig: config, - ReplicatedAppURL: netutils.MaybeAddHTTPS(ecDomains.ReplicatedAppDomain), - ProxyRegistryURL: netutils.MaybeAddHTTPS(ecDomains.ProxyRegistryDomain), - HostPreflightSpec: c.releaseData.HostPreflights, - EmbeddedClusterConfig: c.releaseData.EmbeddedClusterConfig, - IsAirgap: c.airgapBundle != "", - }) - if err != nil { - return fmt.Errorf("failed to prepare host preflights: %w", err) - } - - // Run host preflights - return c.hostPreflightManager.RunHostPreflights(ctx, preflight.RunHostPreflightOptions{ - HostPreflightSpec: hpf, - Proxy: proxy, - }) -} - -func (c *InstallController) GetHostPreflightStatus(ctx context.Context) (*types.Status, error) { - return c.hostPreflightManager.GetHostPreflightStatus(ctx) -} - -func (c *InstallController) GetHostPreflightOutput(ctx context.Context) (*types.HostPreflightsOutput, error) { - return c.hostPreflightManager.GetHostPreflightOutput(ctx) -} - -func (c *InstallController) GetHostPreflightTitles(ctx context.Context) ([]string, error) { - return c.hostPreflightManager.GetHostPreflightTitles(ctx) -} diff --git a/api/controllers/install/infra.go b/api/controllers/install/infra.go deleted file mode 100644 index f0c4e2e594..0000000000 --- a/api/controllers/install/infra.go +++ /dev/null @@ -1,45 +0,0 @@ -package install - -import ( - "context" - "fmt" - - "github.com/replicatedhq/embedded-cluster/api/types" -) - -func (c *InstallController) SetupInfra(ctx context.Context) error { - preflightStatus, err := c.GetHostPreflightStatus(ctx) - if err != nil { - return fmt.Errorf("get install host preflight status: %w", err) - } - - if preflightStatus.State != types.StateFailed && preflightStatus.State != types.StateSucceeded { - return fmt.Errorf("host preflight checks did not complete") - } - - if preflightStatus.State == types.StateFailed && c.metricsReporter != nil { - preflightOutput, err := c.GetHostPreflightOutput(ctx) - if err != nil { - return fmt.Errorf("get install host preflight output: %w", err) - } - if preflightOutput != nil { - c.metricsReporter.ReportPreflightsFailed(ctx, preflightOutput) - } - } - - // Get current installation config - config, err := c.installationManager.GetConfig() - if err != nil { - return fmt.Errorf("failed to read installation config: %w", err) - } - - if err := c.infraManager.Install(ctx, config); err != nil { - return fmt.Errorf("install infra: %w", err) - } - - return nil -} - -func (c *InstallController) GetInfra(ctx context.Context) (*types.Infra, error) { - return c.infraManager.Get() -} diff --git a/api/controllers/install/installation.go b/api/controllers/install/installation.go deleted file mode 100644 index 63a30bd008..0000000000 --- a/api/controllers/install/installation.go +++ /dev/null @@ -1,78 +0,0 @@ -package install - -import ( - "context" - "fmt" - "os" - - "github.com/replicatedhq/embedded-cluster/api/types" - "github.com/replicatedhq/embedded-cluster/pkg/netutils" -) - -func (c *InstallController) GetInstallationConfig(ctx context.Context) (*types.InstallationConfig, error) { - config, err := c.installationManager.GetConfig() - if err != nil { - return nil, err - } - - if config == nil { - return nil, fmt.Errorf("installation config is nil") - } - - if err := c.installationManager.SetConfigDefaults(config); err != nil { - return nil, fmt.Errorf("set defaults: %w", err) - } - - if err := c.installationManager.ValidateConfig(config); err != nil { - return nil, fmt.Errorf("validate: %w", err) - } - - return config, nil -} - -func (c *InstallController) ConfigureInstallation(ctx context.Context, config *types.InstallationConfig) error { - if err := c.installationManager.ValidateConfig(config); err != nil { - return fmt.Errorf("validate: %w", err) - } - - if err := c.computeCIDRs(config); err != nil { - return fmt.Errorf("compute cidrs: %w", err) - } - - if err := c.installationManager.SetConfig(*config); err != nil { - return fmt.Errorf("write: %w", err) - } - - // TODO (@team): discuss the distinction between the runtime config and the installation config - // update the runtime config - c.rc.SetDataDir(config.DataDirectory) - c.rc.SetLocalArtifactMirrorPort(config.LocalArtifactMirrorPort) - c.rc.SetAdminConsolePort(config.AdminConsolePort) - - // update process env vars from the runtime config - os.Setenv("KUBECONFIG", c.rc.PathToKubeConfig()) - os.Setenv("TMPDIR", c.rc.EmbeddedClusterTmpSubDir()) - - if err := c.installationManager.ConfigureHost(ctx, config); err != nil { - return fmt.Errorf("configure: %w", err) - } - - return nil -} - -func (c *InstallController) computeCIDRs(config *types.InstallationConfig) error { - if config.GlobalCIDR != "" { - podCIDR, serviceCIDR, err := netutils.SplitNetworkCIDR(config.GlobalCIDR) - if err != nil { - return fmt.Errorf("split network cidr: %w", err) - } - config.PodCIDR = podCIDR - config.ServiceCIDR = serviceCIDR - } - - return nil -} - -func (c *InstallController) GetInstallationStatus(ctx context.Context) (*types.Status, error) { - return c.installationManager.GetStatus() -} diff --git a/api/controllers/install/status.go b/api/controllers/install/status.go deleted file mode 100644 index f8359e3ae2..0000000000 --- a/api/controllers/install/status.go +++ /dev/null @@ -1,18 +0,0 @@ -package install - -import ( - "context" - - "github.com/replicatedhq/embedded-cluster/api/types" -) - -func (c *InstallController) SetStatus(ctx context.Context, status *types.Status) error { - c.mu.Lock() - defer c.mu.Unlock() - c.install.Status = status - return nil -} - -func (c *InstallController) GetStatus(ctx context.Context) (*types.Status, error) { - return c.install.Status, nil -} diff --git a/api/controllers/kubernetes/install/controller.go b/api/controllers/kubernetes/install/controller.go new file mode 100644 index 0000000000..2836a0a740 --- /dev/null +++ b/api/controllers/kubernetes/install/controller.go @@ -0,0 +1,176 @@ +package install + +import ( + "context" + "sync" + + "github.com/replicatedhq/embedded-cluster/api/internal/managers/kubernetes/infra" + "github.com/replicatedhq/embedded-cluster/api/internal/managers/kubernetes/installation" + "github.com/replicatedhq/embedded-cluster/api/internal/statemachine" + "github.com/replicatedhq/embedded-cluster/api/internal/store" + "github.com/replicatedhq/embedded-cluster/api/pkg/logger" + "github.com/replicatedhq/embedded-cluster/api/types" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/kubernetesinstallation" + "github.com/replicatedhq/embedded-cluster/pkg/metrics" + "github.com/replicatedhq/embedded-cluster/pkg/release" + "github.com/sirupsen/logrus" + "k8s.io/cli-runtime/pkg/genericclioptions" +) + +type Controller interface { + GetInstallationConfig(ctx context.Context) (types.KubernetesInstallationConfig, error) + ConfigureInstallation(ctx context.Context, config types.KubernetesInstallationConfig) error + GetInstallationStatus(ctx context.Context) (types.Status, error) + SetupInfra(ctx context.Context) error + GetInfra(ctx context.Context) (types.Infra, error) +} + +var _ Controller = (*InstallController)(nil) + +type InstallController struct { + installationManager installation.InstallationManager + infraManager infra.InfraManager + metricsReporter metrics.ReporterInterface + restClientGetterFactory func(namespace string) genericclioptions.RESTClientGetter + releaseData *release.ReleaseData + password string + tlsConfig types.TLSConfig + license []byte + airgapBundle string + configValues string + endUserConfig *ecv1beta1.Config + store store.Store + ki kubernetesinstallation.Installation + stateMachine statemachine.Interface + logger logrus.FieldLogger + mu sync.RWMutex +} + +type InstallControllerOption func(*InstallController) + +func WithInstallation(ki kubernetesinstallation.Installation) InstallControllerOption { + return func(c *InstallController) { + c.ki = ki + } +} + +func WithLogger(logger logrus.FieldLogger) InstallControllerOption { + return func(c *InstallController) { + c.logger = logger + } +} + +func WithMetricsReporter(metricsReporter metrics.ReporterInterface) InstallControllerOption { + return func(c *InstallController) { + c.metricsReporter = metricsReporter + } +} + +func WithRESTClientGetterFactory(restClientGetterFactory func(namespace string) genericclioptions.RESTClientGetter) InstallControllerOption { + return func(c *InstallController) { + c.restClientGetterFactory = restClientGetterFactory + } +} + +func WithReleaseData(releaseData *release.ReleaseData) InstallControllerOption { + return func(c *InstallController) { + c.releaseData = releaseData + } +} + +func WithPassword(password string) InstallControllerOption { + return func(c *InstallController) { + c.password = password + } +} + +func WithTLSConfig(tlsConfig types.TLSConfig) InstallControllerOption { + return func(c *InstallController) { + c.tlsConfig = tlsConfig + } +} + +func WithLicense(license []byte) InstallControllerOption { + return func(c *InstallController) { + c.license = license + } +} + +func WithAirgapBundle(airgapBundle string) InstallControllerOption { + return func(c *InstallController) { + c.airgapBundle = airgapBundle + } +} + +func WithConfigValues(configValues string) InstallControllerOption { + return func(c *InstallController) { + c.configValues = configValues + } +} + +func WithEndUserConfig(endUserConfig *ecv1beta1.Config) InstallControllerOption { + return func(c *InstallController) { + c.endUserConfig = endUserConfig + } +} + +func WithInstallationManager(installationManager installation.InstallationManager) InstallControllerOption { + return func(c *InstallController) { + c.installationManager = installationManager + } +} + +func WithInfraManager(infraManager infra.InfraManager) InstallControllerOption { + return func(c *InstallController) { + c.infraManager = infraManager + } +} + +func WithStateMachine(stateMachine statemachine.Interface) InstallControllerOption { + return func(c *InstallController) { + c.stateMachine = stateMachine + } +} + +func WithStore(store store.Store) InstallControllerOption { + return func(c *InstallController) { + c.store = store + } +} + +func NewInstallController(opts ...InstallControllerOption) (*InstallController, error) { + controller := &InstallController{ + store: store.NewMemoryStore(), + logger: logger.NewDiscardLogger(), + stateMachine: NewStateMachine(), + } + + for _, opt := range opts { + opt(controller) + } + + if controller.installationManager == nil { + controller.installationManager = installation.NewInstallationManager( + installation.WithLogger(controller.logger), + installation.WithInstallationStore(controller.store.KubernetesInstallationStore()), + ) + } + + if controller.infraManager == nil { + controller.infraManager = infra.NewInfraManager( + infra.WithLogger(controller.logger), + infra.WithInfraStore(controller.store.LinuxInfraStore()), + infra.WithRESTClientGetterFactory(controller.restClientGetterFactory), + infra.WithPassword(controller.password), + infra.WithTLSConfig(controller.tlsConfig), + infra.WithLicense(controller.license), + infra.WithAirgapBundle(controller.airgapBundle), + infra.WithConfigValues(controller.configValues), + infra.WithReleaseData(controller.releaseData), + infra.WithEndUserConfig(controller.endUserConfig), + ) + } + + return controller, nil +} diff --git a/api/controllers/kubernetes/install/controller_mock.go b/api/controllers/kubernetes/install/controller_mock.go new file mode 100644 index 0000000000..94d54ff9ce --- /dev/null +++ b/api/controllers/kubernetes/install/controller_mock.go @@ -0,0 +1,54 @@ +package install + +import ( + "context" + + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/stretchr/testify/mock" +) + +var _ Controller = (*MockController)(nil) + +// MockController is a mock implementation of the Controller interface +type MockController struct { + mock.Mock +} + +// GetInstallationConfig mocks the GetInstallationConfig method +func (m *MockController) GetInstallationConfig(ctx context.Context) (types.KubernetesInstallationConfig, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return types.KubernetesInstallationConfig{}, args.Error(1) + } + return args.Get(0).(types.KubernetesInstallationConfig), args.Error(1) +} + +// ConfigureInstallation mocks the ConfigureInstallation method +func (m *MockController) ConfigureInstallation(ctx context.Context, config types.KubernetesInstallationConfig) error { + args := m.Called(ctx, config) + return args.Error(0) +} + +// GetInstallationStatus mocks the GetInstallationStatus method +func (m *MockController) GetInstallationStatus(ctx context.Context) (types.Status, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return types.Status{}, args.Error(1) + } + return args.Get(0).(types.Status), args.Error(1) +} + +// SetupInfra mocks the SetupInfra method +func (m *MockController) SetupInfra(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +// GetInfra mocks the GetInfra method +func (m *MockController) GetInfra(ctx context.Context) (types.Infra, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return types.Infra{}, args.Error(1) + } + return args.Get(0).(types.Infra), args.Error(1) +} diff --git a/api/controllers/kubernetes/install/controller_test.go b/api/controllers/kubernetes/install/controller_test.go new file mode 100644 index 0000000000..86e6e47039 --- /dev/null +++ b/api/controllers/kubernetes/install/controller_test.go @@ -0,0 +1,453 @@ +package install + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/replicatedhq/embedded-cluster/api/internal/managers/kubernetes/infra" + "github.com/replicatedhq/embedded-cluster/api/internal/managers/kubernetes/installation" + "github.com/replicatedhq/embedded-cluster/api/internal/statemachine" + "github.com/replicatedhq/embedded-cluster/api/internal/store" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/kubernetesinstallation" + "github.com/replicatedhq/embedded-cluster/pkg/metrics" +) + +func TestGetInstallationConfig(t *testing.T) { + tests := []struct { + name string + setupMock func(*installation.MockInstallationManager) + expectedErr bool + expectedValue types.KubernetesInstallationConfig + }{ + { + name: "successful get", + setupMock: func(m *installation.MockInstallationManager) { + config := types.KubernetesInstallationConfig{ + AdminConsolePort: 9000, + HTTPProxy: "http://proxy.example.com:3128", + HTTPSProxy: "https://proxy.example.com:3128", + NoProxy: "localhost,127.0.0.1", + } + + mock.InOrder( + m.On("GetConfig").Return(config, nil), + m.On("SetConfigDefaults", &config).Return(nil), + m.On("ValidateConfig", config, 9001).Return(nil), + ) + }, + expectedErr: false, + expectedValue: types.KubernetesInstallationConfig{ + AdminConsolePort: 9000, + HTTPProxy: "http://proxy.example.com:3128", + HTTPSProxy: "https://proxy.example.com:3128", + NoProxy: "localhost,127.0.0.1", + }, + }, + { + name: "read config error", + setupMock: func(m *installation.MockInstallationManager) { + m.On("GetConfig").Return(types.KubernetesInstallationConfig{}, errors.New("read error")) + }, + expectedErr: true, + expectedValue: types.KubernetesInstallationConfig{}, + }, + { + name: "set defaults error", + setupMock: func(m *installation.MockInstallationManager) { + config := types.KubernetesInstallationConfig{} + mock.InOrder( + m.On("GetConfig").Return(config, nil), + m.On("SetConfigDefaults", &config).Return(errors.New("defaults error")), + ) + }, + expectedErr: true, + expectedValue: types.KubernetesInstallationConfig{}, + }, + { + name: "validate error", + setupMock: func(m *installation.MockInstallationManager) { + config := types.KubernetesInstallationConfig{} + mock.InOrder( + m.On("GetConfig").Return(config, nil), + m.On("SetConfigDefaults", &config).Return(nil), + m.On("ValidateConfig", config, 9001).Return(errors.New("validation error")), + ) + }, + expectedErr: true, + expectedValue: types.KubernetesInstallationConfig{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ki := kubernetesinstallation.New(nil) + ki.SetManagerPort(9001) + + mockManager := &installation.MockInstallationManager{} + tt.setupMock(mockManager) + + controller, err := NewInstallController( + WithInstallation(ki), + WithInstallationManager(mockManager), + ) + require.NoError(t, err) + + result, err := controller.GetInstallationConfig(t.Context()) + + if tt.expectedErr { + assert.Error(t, err) + assert.Equal(t, types.KubernetesInstallationConfig{}, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedValue, result) + } + + mockManager.AssertExpectations(t) + }) + } +} + +func TestConfigureInstallation(t *testing.T) { + tests := []struct { + name string + config types.KubernetesInstallationConfig + currentState statemachine.State + expectedState statemachine.State + setupMock func(*installation.MockInstallationManager, *kubernetesinstallation.MockInstallation, types.KubernetesInstallationConfig) + expectedErr bool + }{ + { + name: "successful configure installation", + config: types.KubernetesInstallationConfig{ + AdminConsolePort: 9000, + HTTPProxy: "http://proxy.example.com:3128", + HTTPSProxy: "https://proxy.example.com:3128", + NoProxy: "localhost,127.0.0.1", + }, + currentState: StateNew, + expectedState: StateInstallationConfigured, + setupMock: func(m *installation.MockInstallationManager, ki *kubernetesinstallation.MockInstallation, config types.KubernetesInstallationConfig) { + mock.InOrder( + m.On("ConfigureInstallation", mock.Anything, ki, config).Return(nil), + ) + }, + expectedErr: false, + }, + { + name: "configure installation error", + config: types.KubernetesInstallationConfig{}, + currentState: StateNew, + expectedState: StateInstallationConfigurationFailed, + setupMock: func(m *installation.MockInstallationManager, ki *kubernetesinstallation.MockInstallation, config types.KubernetesInstallationConfig) { + m.On("ConfigureInstallation", mock.Anything, ki, config).Return(errors.New("validation error")) + }, + expectedErr: true, + }, + { + name: "invalid state transition", + config: types.KubernetesInstallationConfig{ + AdminConsolePort: 9000, + }, + currentState: StateInfrastructureInstalling, + expectedState: StateInfrastructureInstalling, + setupMock: func(m *installation.MockInstallationManager, ki *kubernetesinstallation.MockInstallation, config types.KubernetesInstallationConfig) { + }, + expectedErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockInstallation := &kubernetesinstallation.MockInstallation{} + + sm := NewStateMachine(WithCurrentState(tt.currentState)) + + mockManager := &installation.MockInstallationManager{} + + tt.setupMock(mockManager, mockInstallation, tt.config) + + controller, err := NewInstallController( + WithInstallation(mockInstallation), + WithStateMachine(sm), + WithInstallationManager(mockManager), + ) + require.NoError(t, err) + + err = controller.ConfigureInstallation(t.Context(), tt.config) + if tt.expectedErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + + assert.NotEqual(t, tt.currentState, sm.CurrentState(), "state should have changed and should not be %s", tt.currentState) + } + + assert.Eventually(t, func() bool { + return sm.CurrentState() == tt.expectedState + }, time.Second, 100*time.Millisecond, "state should be %s but is %s", tt.expectedState, sm.CurrentState()) + assert.False(t, sm.IsLockAcquired(), "state machine should not be locked after configuration") + + mockManager.AssertExpectations(t) + mockInstallation.AssertExpectations(t) + }) + } +} + +func TestGetInstallationStatus(t *testing.T) { + tests := []struct { + name string + setupMock func(*installation.MockInstallationManager) + expectedErr bool + expectedValue types.Status + }{ + { + name: "successful get status", + setupMock: func(m *installation.MockInstallationManager) { + status := types.Status{ + State: types.StateRunning, + } + m.On("GetStatus").Return(status, nil) + }, + expectedErr: false, + expectedValue: types.Status{ + State: types.StateRunning, + }, + }, + { + name: "get status error", + setupMock: func(m *installation.MockInstallationManager) { + m.On("GetStatus").Return(types.Status{}, errors.New("get status error")) + }, + expectedErr: true, + expectedValue: types.Status{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockManager := &installation.MockInstallationManager{} + tt.setupMock(mockManager) + + controller, err := NewInstallController(WithInstallationManager(mockManager)) + require.NoError(t, err) + + result, err := controller.GetInstallationStatus(t.Context()) + + if tt.expectedErr { + assert.Error(t, err) + assert.Equal(t, types.Status{}, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedValue, result) + } + + mockManager.AssertExpectations(t) + }) + } +} + +func TestSetupInfra(t *testing.T) { + tests := []struct { + name string + currentState statemachine.State + expectedState statemachine.State + setupMocks func(kubernetesinstallation.Installation, *installation.MockInstallationManager, *infra.MockInfraManager, *metrics.MockReporter, *store.MockStore) + expectedErr error + }{ + { + name: "successful setup", + currentState: StateInstallationConfigured, + expectedState: StateSucceeded, + setupMocks: func(ki kubernetesinstallation.Installation, im *installation.MockInstallationManager, fm *infra.MockInfraManager, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + fm.On("Install", mock.Anything, ki).Return(nil), + // TODO: we are not yet reporting + // mr.On("ReportInstallationSucceeded", mock.Anything), + ) + }, + expectedErr: nil, + }, + { + name: "install infra error", + currentState: StateInstallationConfigured, + expectedState: StateInfrastructureInstallFailed, + setupMocks: func(ki kubernetesinstallation.Installation, im *installation.MockInstallationManager, fm *infra.MockInfraManager, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + fm.On("Install", mock.Anything, ki).Return(errors.New("install error")), + st.LinuxInfraMockStore.On("GetStatus").Return(types.Status{Description: "install error"}, nil), + // TODO: we are not yet reporting + // mr.On("ReportInstallationFailed", mock.Anything, errors.New("install error")), + ) + }, + expectedErr: nil, + }, + { + name: "install infra error without report if infra store fails", + currentState: StateInstallationConfigured, + expectedState: StateInfrastructureInstallFailed, + setupMocks: func(ki kubernetesinstallation.Installation, im *installation.MockInstallationManager, fm *infra.MockInfraManager, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + fm.On("Install", mock.Anything, ki).Return(errors.New("install error")), + st.LinuxInfraMockStore.On("GetStatus").Return(nil, assert.AnError), + ) + }, + expectedErr: nil, + }, + { + name: "install infra panic", + currentState: StateInstallationConfigured, + expectedState: StateInfrastructureInstallFailed, + setupMocks: func(ki kubernetesinstallation.Installation, im *installation.MockInstallationManager, fm *infra.MockInfraManager, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + fm.On("Install", mock.Anything, ki).Panic("this is a panic"), + st.LinuxInfraMockStore.On("GetStatus").Return(types.Status{Description: "this is a panic"}, nil), + // TODO: we are not yet reporting + // mr.On("ReportInstallationFailed", mock.Anything, errors.New("this is a panic")), + ) + }, + expectedErr: nil, + }, + { + name: "invalid state transition", + currentState: StateNew, + expectedState: StateNew, + setupMocks: func(ki kubernetesinstallation.Installation, im *installation.MockInstallationManager, fm *infra.MockInfraManager, mr *metrics.MockReporter, st *store.MockStore) { + }, + expectedErr: assert.AnError, // Just check that an error occurs, don't care about exact message + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sm := NewStateMachine(WithCurrentState(tt.currentState)) + + ki := kubernetesinstallation.New(nil) + ki.SetManagerPort(9001) + + mockInstallationManager := &installation.MockInstallationManager{} + mockInfraManager := &infra.MockInfraManager{} + mockMetricsReporter := &metrics.MockReporter{} + mockStore := &store.MockStore{} + tt.setupMocks(ki, mockInstallationManager, mockInfraManager, mockMetricsReporter, mockStore) + + controller, err := NewInstallController( + WithInstallation(ki), + WithStateMachine(sm), + WithInstallationManager(mockInstallationManager), + WithInfraManager(mockInfraManager), + WithMetricsReporter(mockMetricsReporter), + WithStore(mockStore), + ) + require.NoError(t, err) + + err = controller.SetupInfra(t.Context()) + + if tt.expectedErr != nil { + require.Error(t, err) + + // Check for specific error types + var expectedAPIErr *types.APIError + if errors.As(tt.expectedErr, &expectedAPIErr) { + // For API errors, check the exact type and status code + var actualAPIErr *types.APIError + require.True(t, errors.As(err, &actualAPIErr), "expected error to be of type *types.APIError, got %T", err) + assert.Equal(t, expectedAPIErr.StatusCode, actualAPIErr.StatusCode, "status codes should match") + assert.Contains(t, actualAPIErr.Error(), expectedAPIErr.Unwrap().Error(), "error messages should contain expected content") + } + } else { + require.NoError(t, err) + } + + assert.Eventually(t, func() bool { + t.Logf("Current state: %s, Expected state: %s", sm.CurrentState(), tt.expectedState) + return sm.CurrentState() == tt.expectedState + }, time.Second, 100*time.Millisecond, "state should be %s", tt.expectedState) + assert.False(t, sm.IsLockAcquired(), "state machine should not be locked after running infra setup") + + mockInstallationManager.AssertExpectations(t) + mockInfraManager.AssertExpectations(t) + mockMetricsReporter.AssertExpectations(t) + mockStore.KubernetesInfraMockStore.AssertExpectations(t) + mockStore.KubernetesInstallationMockStore.AssertExpectations(t) + }) + } +} + +func TestGetInfra(t *testing.T) { + tests := []struct { + name string + setupMock func(*infra.MockInfraManager) + expectedErr bool + expectedValue types.Infra + }{ + { + name: "successful get infra", + setupMock: func(m *infra.MockInfraManager) { + infra := types.Infra{ + Components: []types.InfraComponent{ + { + Name: "Admin Console", + Status: types.Status{ + State: types.StateRunning, + }, + }, + }, + Status: types.Status{ + State: types.StateRunning, + }, + } + m.On("Get").Return(infra, nil) + }, + expectedErr: false, + expectedValue: types.Infra{ + Components: []types.InfraComponent{ + { + Name: "Admin Console", + Status: types.Status{ + State: types.StateRunning, + }, + }, + }, + Status: types.Status{ + State: types.StateRunning, + }, + }, + }, + { + name: "get infra error", + setupMock: func(m *infra.MockInfraManager) { + m.On("Get").Return(nil, errors.New("get infra error")) + }, + expectedErr: true, + expectedValue: types.Infra{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockManager := &infra.MockInfraManager{} + tt.setupMock(mockManager) + + controller, err := NewInstallController(WithInfraManager(mockManager)) + require.NoError(t, err) + + result, err := controller.GetInfra(t.Context()) + + if tt.expectedErr { + assert.Error(t, err) + assert.Equal(t, types.Infra{}, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedValue, result) + } + + mockManager.AssertExpectations(t) + }) + } +} diff --git a/api/controllers/kubernetes/install/infra.go b/api/controllers/kubernetes/install/infra.go new file mode 100644 index 0000000000..f3f0a813ba --- /dev/null +++ b/api/controllers/kubernetes/install/infra.go @@ -0,0 +1,66 @@ +package install + +import ( + "context" + "fmt" + "runtime/debug" + + "github.com/replicatedhq/embedded-cluster/api/types" +) + +func (c *InstallController) SetupInfra(ctx context.Context) (finalErr error) { + lock, err := c.stateMachine.AcquireLock() + if err != nil { + return types.NewConflictError(err) + } + + defer func() { + if r := recover(); r != nil { + finalErr = fmt.Errorf("panic: %v: %s", r, string(debug.Stack())) + } + if finalErr != nil { + lock.Release() + } + }() + + err = c.stateMachine.Transition(lock, StateInfrastructureInstalling) + if err != nil { + return types.NewConflictError(err) + } + + go func() (finalErr error) { + // Background context is used to avoid canceling the operation if the context is canceled + ctx := context.Background() + + defer lock.Release() + + defer func() { + if r := recover(); r != nil { + finalErr = fmt.Errorf("panic: %v: %s", r, string(debug.Stack())) + } + if finalErr != nil { + c.logger.Error(finalErr) + + if err := c.stateMachine.Transition(lock, StateInfrastructureInstallFailed); err != nil { + c.logger.Errorf("failed to transition states: %w", err) + } + } else { + if err := c.stateMachine.Transition(lock, StateSucceeded); err != nil { + c.logger.Errorf("failed to transition states: %w", err) + } + } + }() + + if err := c.infraManager.Install(ctx, c.ki); err != nil { + return fmt.Errorf("failed to install infrastructure: %w", err) + } + + return nil + }() + + return nil +} + +func (c *InstallController) GetInfra(ctx context.Context) (types.Infra, error) { + return c.infraManager.Get() +} diff --git a/api/controllers/kubernetes/install/installation.go b/api/controllers/kubernetes/install/installation.go new file mode 100644 index 0000000000..5208314c09 --- /dev/null +++ b/api/controllers/kubernetes/install/installation.go @@ -0,0 +1,65 @@ +package install + +import ( + "context" + "fmt" + "runtime/debug" + + "github.com/replicatedhq/embedded-cluster/api/types" +) + +func (c *InstallController) GetInstallationConfig(ctx context.Context) (types.KubernetesInstallationConfig, error) { + config, err := c.installationManager.GetConfig() + if err != nil { + return types.KubernetesInstallationConfig{}, err + } + + if err := c.installationManager.SetConfigDefaults(&config); err != nil { + return types.KubernetesInstallationConfig{}, fmt.Errorf("set defaults: %w", err) + } + + if err := c.installationManager.ValidateConfig(config, c.ki.ManagerPort()); err != nil { + return types.KubernetesInstallationConfig{}, fmt.Errorf("validate: %w", err) + } + + return config, nil +} + +func (c *InstallController) ConfigureInstallation(ctx context.Context, config types.KubernetesInstallationConfig) (finalErr error) { + lock, err := c.stateMachine.AcquireLock() + if err != nil { + return types.NewConflictError(err) + } + defer lock.Release() + + if err := c.stateMachine.ValidateTransition(lock, StateInstallationConfigured); err != nil { + return types.NewConflictError(err) + } + + defer func() { + if r := recover(); r != nil { + finalErr = fmt.Errorf("panic: %v: %s", r, string(debug.Stack())) + } + if finalErr != nil { + c.logger.Error(finalErr) + + if err := c.stateMachine.Transition(lock, StateInstallationConfigurationFailed); err != nil { + c.logger.Errorf("failed to transition states: %w", err) + } + } else { + if err := c.stateMachine.Transition(lock, StateInstallationConfigured); err != nil { + c.logger.Errorf("failed to transition states: %w", err) + } + } + }() + + if err := c.installationManager.ConfigureInstallation(ctx, c.ki, config); err != nil { + return err + } + + return nil +} + +func (c *InstallController) GetInstallationStatus(ctx context.Context) (types.Status, error) { + return c.installationManager.GetStatus() +} diff --git a/api/controllers/kubernetes/install/statemachine.go b/api/controllers/kubernetes/install/statemachine.go new file mode 100644 index 0000000000..4af11b4ecb --- /dev/null +++ b/api/controllers/kubernetes/install/statemachine.go @@ -0,0 +1,60 @@ +package install + +import ( + "github.com/replicatedhq/embedded-cluster/api/internal/statemachine" + "github.com/sirupsen/logrus" +) + +const ( + // StateNew is the initial state of the install process + StateNew statemachine.State = "New" + // StateInstallationConfigurationFailed is the state of the install process when the installation failed to be configured + StateInstallationConfigurationFailed statemachine.State = "InstallationConfigurationFailed" + // StateInstallationConfigured is the state of the install process when the installation is configured + StateInstallationConfigured statemachine.State = "InstallationConfigured" + // StateInfrastructureInstalling is the state of the install process when the infrastructure is being installed + StateInfrastructureInstalling statemachine.State = "InfrastructureInstalling" + // StateInfrastructureInstallFailed is a final state of the install process when the infrastructure failed to isntall + StateInfrastructureInstallFailed statemachine.State = "InfrastructureInstallFailed" + // StateSucceeded is the final state of the install process when the install has succeeded + StateSucceeded statemachine.State = "Succeeded" +) + +var validStateTransitions = map[statemachine.State][]statemachine.State{ + StateNew: {StateInstallationConfigured, StateInstallationConfigurationFailed}, + StateInstallationConfigurationFailed: {StateInstallationConfigured, StateInstallationConfigurationFailed}, + StateInstallationConfigured: {StateInfrastructureInstalling, StateInstallationConfigured, StateInstallationConfigurationFailed}, + StateInfrastructureInstalling: {StateSucceeded, StateInfrastructureInstallFailed}, + StateInfrastructureInstallFailed: {}, + StateSucceeded: {}, +} + +type StateMachineOptions struct { + CurrentState statemachine.State + Logger logrus.FieldLogger +} + +type StateMachineOption func(*StateMachineOptions) + +func WithCurrentState(currentState statemachine.State) StateMachineOption { + return func(o *StateMachineOptions) { + o.CurrentState = currentState + } +} + +func WithStateMachineLogger(logger logrus.FieldLogger) StateMachineOption { + return func(o *StateMachineOptions) { + o.Logger = logger + } +} + +// NewStateMachine creates a new state machine starting in the New state +func NewStateMachine(opts ...StateMachineOption) statemachine.Interface { + options := &StateMachineOptions{ + CurrentState: StateNew, + } + for _, opt := range opts { + opt(options) + } + return statemachine.New(options.CurrentState, validStateTransitions) +} diff --git a/api/controllers/kubernetes/install/statemachine_test.go b/api/controllers/kubernetes/install/statemachine_test.go new file mode 100644 index 0000000000..f527a99651 --- /dev/null +++ b/api/controllers/kubernetes/install/statemachine_test.go @@ -0,0 +1,107 @@ +package install + +import ( + "slices" + "testing" + + "github.com/replicatedhq/embedded-cluster/api/internal/statemachine" + "github.com/stretchr/testify/assert" +) + +func TestStateMachineTransitions(t *testing.T) { + tests := []struct { + name string + startState statemachine.State + validTransitions []statemachine.State + }{ + { + name: `State "New" can transition to "InstallationConfigured" or "InstallationConfigurationFailed"`, + startState: StateNew, + validTransitions: []statemachine.State{ + StateInstallationConfigured, + StateInstallationConfigurationFailed, + }, + }, + { + name: `State "InstallationConfigurationFailed" can transition to "InstallationConfigured" or "InstallationConfigurationFailed"`, + startState: StateInstallationConfigurationFailed, + validTransitions: []statemachine.State{ + StateInstallationConfigured, + StateInstallationConfigurationFailed, + }, + }, + { + name: `State "InstallationConfigured" can transition to "InfrastructureInstalling" or "InstallationConfigured" or "InstallationConfigurationFailed"`, + startState: StateInstallationConfigured, + validTransitions: []statemachine.State{ + StateInfrastructureInstalling, + StateInstallationConfigured, + StateInstallationConfigurationFailed, + }, + }, + { + name: `State "InfrastructureInstalling" can transition to "Succeeded" or "InfrastructureInstallFailed"`, + startState: StateInfrastructureInstalling, + validTransitions: []statemachine.State{ + StateSucceeded, + StateInfrastructureInstallFailed, + }, + }, + { + name: `State "InfrastructureInstallFailed" can not transition to any other state`, + startState: StateInfrastructureInstallFailed, + validTransitions: []statemachine.State{}, + }, + { + name: `State "Succeeded" can not transition to any other state`, + startState: StateSucceeded, + validTransitions: []statemachine.State{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for nextState := range validStateTransitions { + sm := NewStateMachine(WithCurrentState(tt.startState)) + + lock, err := sm.AcquireLock() + if err != nil { + t.Fatalf("failed to acquire lock: %v", err) + } + defer lock.Release() + + err = sm.Transition(lock, nextState) + if !slices.Contains(tt.validTransitions, nextState) { + assert.Error(t, err, "expected error for transition from %s to %s", tt.startState, nextState) + } else { + assert.NoError(t, err, "unexpected error for transition from %s to %s", tt.startState, nextState) + + // Verify state has changed + assert.Equal(t, nextState, sm.CurrentState(), "state should change after commit") + } + } + }) + } +} + +func TestIsFinalState(t *testing.T) { + finalStates := []statemachine.State{ + StateSucceeded, + StateInfrastructureInstallFailed, + } + + for state := range validStateTransitions { + var isFinal bool + if slices.Contains(finalStates, state) { + isFinal = true + } + + sm := NewStateMachine(WithCurrentState(state)) + + if isFinal { + assert.True(t, sm.IsFinalState(), "expected state %s to be final", state) + } else { + assert.False(t, sm.IsFinalState(), "expected state %s to not be final", state) + } + } +} diff --git a/api/controllers/install/controller.go b/api/controllers/linux/install/controller.go similarity index 54% rename from api/controllers/install/controller.go rename to api/controllers/linux/install/controller.go index a94d3f68d6..a018c53307 100644 --- a/api/controllers/install/controller.go +++ b/api/controllers/linux/install/controller.go @@ -4,9 +4,12 @@ import ( "context" "sync" - "github.com/replicatedhq/embedded-cluster/api/internal/managers/infra" - "github.com/replicatedhq/embedded-cluster/api/internal/managers/installation" - "github.com/replicatedhq/embedded-cluster/api/internal/managers/preflight" + "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/infra" + "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/installation" + "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/preflight" + "github.com/replicatedhq/embedded-cluster/api/internal/statemachine" + "github.com/replicatedhq/embedded-cluster/api/internal/store" + "github.com/replicatedhq/embedded-cluster/api/internal/utils" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" @@ -18,38 +21,44 @@ import ( ) type Controller interface { - GetInstallationConfig(ctx context.Context) (*types.InstallationConfig, error) - ConfigureInstallation(ctx context.Context, config *types.InstallationConfig) error - GetInstallationStatus(ctx context.Context) (*types.Status, error) - RunHostPreflights(ctx context.Context) error - GetHostPreflightStatus(ctx context.Context) (*types.Status, error) + GetInstallationConfig(ctx context.Context) (types.LinuxInstallationConfig, error) + ConfigureInstallation(ctx context.Context, config types.LinuxInstallationConfig) error + GetInstallationStatus(ctx context.Context) (types.Status, error) + RunHostPreflights(ctx context.Context, opts RunHostPreflightsOptions) error + GetHostPreflightStatus(ctx context.Context) (types.Status, error) GetHostPreflightOutput(ctx context.Context) (*types.HostPreflightsOutput, error) GetHostPreflightTitles(ctx context.Context) ([]string, error) - SetupInfra(ctx context.Context) error - GetInfra(ctx context.Context) (*types.Infra, error) - SetStatus(ctx context.Context, status *types.Status) error - GetStatus(ctx context.Context) (*types.Status, error) + SetupInfra(ctx context.Context, ignoreHostPreflights bool) error + GetInfra(ctx context.Context) (types.Infra, error) +} + +type RunHostPreflightsOptions struct { + IsUI bool } var _ Controller = (*InstallController)(nil) type InstallController struct { - install *types.Install - installationManager installation.InstallationManager - hostPreflightManager preflight.HostPreflightManager - infraManager infra.InfraManager - rc runtimeconfig.RuntimeConfig - logger logrus.FieldLogger - hostUtils hostutils.HostUtilsInterface - metricsReporter metrics.ReporterInterface - releaseData *release.ReleaseData - password string - tlsConfig types.TLSConfig - licenseFile string - airgapBundle string - configValues string - endUserConfig *ecv1beta1.Config - mu sync.RWMutex + installationManager installation.InstallationManager + hostPreflightManager preflight.HostPreflightManager + infraManager infra.InfraManager + hostUtils hostutils.HostUtilsInterface + netUtils utils.NetUtils + metricsReporter metrics.ReporterInterface + releaseData *release.ReleaseData + password string + tlsConfig types.TLSConfig + license []byte + airgapBundle string + configValues string + endUserConfig *ecv1beta1.Config + clusterID string + store store.Store + rc runtimeconfig.RuntimeConfig + stateMachine statemachine.Interface + logger logrus.FieldLogger + mu sync.RWMutex + allowIgnoreHostPreflights bool } type InstallControllerOption func(*InstallController) @@ -72,6 +81,12 @@ func WithHostUtils(hostUtils hostutils.HostUtilsInterface) InstallControllerOpti } } +func WithNetUtils(netUtils utils.NetUtils) InstallControllerOption { + return func(c *InstallController) { + c.netUtils = netUtils + } +} + func WithMetricsReporter(metricsReporter metrics.ReporterInterface) InstallControllerOption { return func(c *InstallController) { c.metricsReporter = metricsReporter @@ -96,9 +111,9 @@ func WithTLSConfig(tlsConfig types.TLSConfig) InstallControllerOption { } } -func WithLicenseFile(licenseFile string) InstallControllerOption { +func WithLicense(license []byte) InstallControllerOption { return func(c *InstallController) { - c.licenseFile = licenseFile + c.license = license } } @@ -120,6 +135,18 @@ func WithEndUserConfig(endUserConfig *ecv1beta1.Config) InstallControllerOption } } +func WithClusterID(clusterID string) InstallControllerOption { + return func(c *InstallController) { + c.clusterID = clusterID + } +} + +func WithAllowIgnoreHostPreflights(allowIgnoreHostPreflights bool) InstallControllerOption { + return func(c *InstallController) { + c.allowIgnoreHostPreflights = allowIgnoreHostPreflights + } +} + func WithInstallationManager(installationManager installation.InstallationManager) InstallControllerOption { return func(c *InstallController) { c.installationManager = installationManager @@ -132,21 +159,37 @@ func WithHostPreflightManager(hostPreflightManager preflight.HostPreflightManage } } +func WithInfraManager(infraManager infra.InfraManager) InstallControllerOption { + return func(c *InstallController) { + c.infraManager = infraManager + } +} + +func WithStateMachine(stateMachine statemachine.Interface) InstallControllerOption { + return func(c *InstallController) { + c.stateMachine = stateMachine + } +} + +func WithStore(store store.Store) InstallControllerOption { + return func(c *InstallController) { + c.store = store + } +} + func NewInstallController(opts ...InstallControllerOption) (*InstallController, error) { controller := &InstallController{ - install: types.NewInstall(), + store: store.NewMemoryStore(), + rc: runtimeconfig.New(nil), + logger: logger.NewDiscardLogger(), } for _, opt := range opts { opt(controller) } - if controller.rc == nil { - controller.rc = runtimeconfig.New(nil) - } - - if controller.logger == nil { - controller.logger = logger.NewDiscardLogger() + if controller.stateMachine == nil { + controller.stateMachine = NewStateMachine(WithStateMachineLogger(controller.logger)) } if controller.hostUtils == nil { @@ -155,39 +198,45 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, ) } + if controller.netUtils == nil { + controller.netUtils = utils.NewNetUtils() + } + if controller.installationManager == nil { controller.installationManager = installation.NewInstallationManager( - installation.WithRuntimeConfig(controller.rc), installation.WithLogger(controller.logger), - installation.WithInstallation(controller.install.Steps.Installation), - installation.WithLicenseFile(controller.licenseFile), + installation.WithInstallationStore(controller.store.LinuxInstallationStore()), + installation.WithLicense(controller.license), installation.WithAirgapBundle(controller.airgapBundle), installation.WithHostUtils(controller.hostUtils), + installation.WithNetUtils(controller.netUtils), ) } if controller.hostPreflightManager == nil { controller.hostPreflightManager = preflight.NewHostPreflightManager( - preflight.WithRuntimeConfig(controller.rc), preflight.WithLogger(controller.logger), - preflight.WithMetricsReporter(controller.metricsReporter), - preflight.WithHostPreflightStore(preflight.NewMemoryStore(controller.install.Steps.HostPreflight)), + preflight.WithHostPreflightStore(controller.store.LinuxPreflightStore()), + preflight.WithNetUtils(controller.netUtils), ) } if controller.infraManager == nil { controller.infraManager = infra.NewInfraManager( - infra.WithRuntimeConfig(controller.rc), infra.WithLogger(controller.logger), - infra.WithInfra(controller.install.Steps.Infra), + infra.WithInfraStore(controller.store.LinuxInfraStore()), infra.WithPassword(controller.password), infra.WithTLSConfig(controller.tlsConfig), - infra.WithLicenseFile(controller.licenseFile), + infra.WithLicense(controller.license), infra.WithAirgapBundle(controller.airgapBundle), infra.WithConfigValues(controller.configValues), infra.WithReleaseData(controller.releaseData), infra.WithEndUserConfig(controller.endUserConfig), + infra.WithClusterID(controller.clusterID), ) } + + controller.registerReportingHandlers() + return controller, nil } diff --git a/api/controllers/linux/install/controller_mock.go b/api/controllers/linux/install/controller_mock.go new file mode 100644 index 0000000000..1dfc925208 --- /dev/null +++ b/api/controllers/linux/install/controller_mock.go @@ -0,0 +1,87 @@ +package install + +import ( + "context" + + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/stretchr/testify/mock" +) + +var _ Controller = (*MockController)(nil) + +// MockController is a mock implementation of the Controller interface +type MockController struct { + mock.Mock +} + +// GetInstallationConfig mocks the GetInstallationConfig method +func (m *MockController) GetInstallationConfig(ctx context.Context) (types.LinuxInstallationConfig, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return types.LinuxInstallationConfig{}, args.Error(1) + } + return args.Get(0).(types.LinuxInstallationConfig), args.Error(1) +} + +// ConfigureInstallation mocks the ConfigureInstallation method +func (m *MockController) ConfigureInstallation(ctx context.Context, config types.LinuxInstallationConfig) error { + args := m.Called(ctx, config) + return args.Error(0) +} + +// GetInstallationStatus mocks the GetInstallationStatus method +func (m *MockController) GetInstallationStatus(ctx context.Context) (types.Status, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return types.Status{}, args.Error(1) + } + return args.Get(0).(types.Status), args.Error(1) +} + +// RunHostPreflights mocks the RunHostPreflights method +func (m *MockController) RunHostPreflights(ctx context.Context, opts RunHostPreflightsOptions) error { + args := m.Called(ctx, opts) + return args.Error(0) +} + +// GetHostPreflightStatus mocks the GetHostPreflightStatus method +func (m *MockController) GetHostPreflightStatus(ctx context.Context) (types.Status, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return types.Status{}, args.Error(1) + } + return args.Get(0).(types.Status), args.Error(1) +} + +// GetHostPreflightOutput mocks the GetHostPreflightOutput method +func (m *MockController) GetHostPreflightOutput(ctx context.Context) (*types.HostPreflightsOutput, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*types.HostPreflightsOutput), args.Error(1) +} + +// GetHostPreflightTitles mocks the GetHostPreflightTitles method +func (m *MockController) GetHostPreflightTitles(ctx context.Context) ([]string, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]string), args.Error(1) +} + +// SetupInfra mocks the SetupInfra method +func (m *MockController) SetupInfra(ctx context.Context, ignoreHostPreflights bool) error { + args := m.Called(ctx, ignoreHostPreflights) + return args.Error(0) +} + +// GetInfra mocks the GetInfra method +func (m *MockController) GetInfra(ctx context.Context) (types.Infra, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return types.Infra{}, args.Error(1) + } + return args.Get(0).(types.Infra), args.Error(1) +} diff --git a/api/controllers/linux/install/controller_test.go b/api/controllers/linux/install/controller_test.go new file mode 100644 index 0000000000..ad7b49ea04 --- /dev/null +++ b/api/controllers/linux/install/controller_test.go @@ -0,0 +1,1287 @@ +package install + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/infra" + "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/installation" + "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/preflight" + "github.com/replicatedhq/embedded-cluster/api/internal/statemachine" + "github.com/replicatedhq/embedded-cluster/api/internal/store" + "github.com/replicatedhq/embedded-cluster/api/types" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/metrics" + "github.com/replicatedhq/embedded-cluster/pkg/release" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" +) + +var failedPreflightOutput = &types.HostPreflightsOutput{ + Fail: []types.HostPreflightsRecord{ + { + Title: "Test Check", + Message: "Test check failed", + }, + }, +} + +var successfulPreflightOutput = &types.HostPreflightsOutput{ + Pass: []types.HostPreflightsRecord{ + { + Title: "Test Check", + Message: "Test check passed", + }, + }, +} + +var warnPreflightOutput = &types.HostPreflightsOutput{ + Warn: []types.HostPreflightsRecord{ + { + Title: "Test Check", + Message: "Test check warning", + }, + }, +} + +func TestGetInstallationConfig(t *testing.T) { + tests := []struct { + name string + setupMock func(*installation.MockInstallationManager, string) + expectedErr bool + expectedValue func(string) types.LinuxInstallationConfig + }{ + { + name: "successful read and defaults", + setupMock: func(m *installation.MockInstallationManager, tempDir string) { + config := types.LinuxInstallationConfig{ + AdminConsolePort: 9000, + GlobalCIDR: "10.0.0.1/16", + } + + mock.InOrder( + m.On("GetConfig").Return(config, nil), + m.On("SetConfigDefaults", &config, mock.AnythingOfType("*runtimeconfig.runtimeConfig")).Run(func(args mock.Arguments) { + cfg := args.Get(0).(*types.LinuxInstallationConfig) + cfg.DataDirectory = tempDir + }).Return(nil), + m.On("ValidateConfig", mock.MatchedBy(func(cfg types.LinuxInstallationConfig) bool { + return cfg.AdminConsolePort == 9000 && + cfg.GlobalCIDR == "10.0.0.1/16" && + cfg.DataDirectory == tempDir + }), 9001).Return(nil), + ) + }, + expectedErr: false, + expectedValue: func(tempDir string) types.LinuxInstallationConfig { + return types.LinuxInstallationConfig{ + AdminConsolePort: 9000, + GlobalCIDR: "10.0.0.1/16", + DataDirectory: tempDir, + } + }, + }, + { + name: "read config error", + setupMock: func(m *installation.MockInstallationManager, tempDir string) { + m.On("GetConfig").Return(nil, errors.New("read error")) + }, + expectedErr: true, + expectedValue: func(tempDir string) types.LinuxInstallationConfig { + return types.LinuxInstallationConfig{} + }, + }, + { + name: "set defaults error", + setupMock: func(m *installation.MockInstallationManager, tempDir string) { + config := types.LinuxInstallationConfig{} + mock.InOrder( + m.On("GetConfig").Return(config, nil), + m.On("SetConfigDefaults", &config, mock.AnythingOfType("*runtimeconfig.runtimeConfig")).Return(errors.New("defaults error")), + ) + }, + expectedErr: true, + expectedValue: func(tempDir string) types.LinuxInstallationConfig { + return types.LinuxInstallationConfig{} + }, + }, + { + name: "validate error", + setupMock: func(m *installation.MockInstallationManager, tempDir string) { + config := types.LinuxInstallationConfig{} + + mock.InOrder( + m.On("GetConfig").Return(config, nil), + m.On("SetConfigDefaults", &config, mock.AnythingOfType("*runtimeconfig.runtimeConfig")).Run(func(args mock.Arguments) { + cfg := args.Get(0).(*types.LinuxInstallationConfig) + cfg.DataDirectory = tempDir + }).Return(nil), + m.On("ValidateConfig", mock.MatchedBy(func(cfg types.LinuxInstallationConfig) bool { + return cfg.DataDirectory == tempDir + }), 9001).Return(errors.New("validation error")), + ) + }, + expectedErr: true, + expectedValue: func(tempDir string) types.LinuxInstallationConfig { + return types.LinuxInstallationConfig{} + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + rc := runtimeconfig.New(nil, runtimeconfig.WithEnvSetter(&testEnvSetter{})) + rc.SetDataDir(tempDir) + rc.SetManagerPort(9001) + + mockManager := &installation.MockInstallationManager{} + tt.setupMock(mockManager, rc.EmbeddedClusterHomeDirectory()) + + controller, err := NewInstallController( + WithRuntimeConfig(rc), + WithInstallationManager(mockManager), + ) + require.NoError(t, err) + + result, err := controller.GetInstallationConfig(t.Context()) + + if tt.expectedErr { + assert.Error(t, err) + assert.Equal(t, types.LinuxInstallationConfig{}, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedValue(rc.EmbeddedClusterHomeDirectory()), result) + } + + mockManager.AssertExpectations(t) + }) + } +} + +func TestConfigureInstallation(t *testing.T) { + tests := []struct { + name string + config types.LinuxInstallationConfig + currentState statemachine.State + expectedState statemachine.State + setupMock func(*installation.MockInstallationManager, runtimeconfig.RuntimeConfig, types.LinuxInstallationConfig, *store.MockStore, *metrics.MockReporter) + expectedErr bool + }{ + { + name: "successful configure installation", + config: types.LinuxInstallationConfig{ + LocalArtifactMirrorPort: 9000, + DataDirectory: t.TempDir(), + }, + currentState: StateNew, + expectedState: StateHostConfigured, + + setupMock: func(m *installation.MockInstallationManager, rc runtimeconfig.RuntimeConfig, config types.LinuxInstallationConfig, st *store.MockStore, mr *metrics.MockReporter) { + mock.InOrder( + m.On("ValidateConfig", config, 9001).Return(nil), + m.On("SetConfig", config).Return(nil), + m.On("ConfigureHost", mock.Anything, rc).Return(nil), + ) + }, + expectedErr: false, + }, + { + name: "validatation error", + config: types.LinuxInstallationConfig{}, + currentState: StateNew, + expectedState: StateInstallationConfigurationFailed, + setupMock: func(m *installation.MockInstallationManager, rc runtimeconfig.RuntimeConfig, config types.LinuxInstallationConfig, st *store.MockStore, mr *metrics.MockReporter) { + mock.InOrder( + m.On("ValidateConfig", config, 9001).Return(errors.New("validation error")), + // Status is set in the store by the controller when configuring the installation + st.LinuxInstallationMockStore.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { + return status.State == types.StateFailed && status.Description == "validate: validation error" + })).Return(nil), + st.LinuxInstallationMockStore.On("GetStatus").Return(types.Status{Description: "validate: validation error"}, nil), + mr.On("ReportInstallationFailed", mock.Anything, errors.New("validate: validation error")), + ) + }, + expectedErr: true, + }, + { + name: "validation error on retry from host already configured", + config: types.LinuxInstallationConfig{}, + currentState: StateHostConfigured, + expectedState: StateInstallationConfigurationFailed, + setupMock: func(m *installation.MockInstallationManager, rc runtimeconfig.RuntimeConfig, config types.LinuxInstallationConfig, st *store.MockStore, mr *metrics.MockReporter) { + mock.InOrder( + m.On("ValidateConfig", config, 9001).Return(errors.New("validation error")), + // Status is set in the store by the controller when configuring the installation + st.LinuxInstallationMockStore.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { + return status.State == types.StateFailed && status.Description == "validate: validation error" + })).Return(nil), + st.LinuxInstallationMockStore.On("GetStatus").Return(types.Status{Description: "validate: validation error"}, nil), + mr.On("ReportInstallationFailed", mock.Anything, errors.New("validate: validation error")), + ) + }, + expectedErr: true, + }, + { + name: "validation error on retry from host that failed to configure", + config: types.LinuxInstallationConfig{}, + currentState: StateHostConfigurationFailed, + expectedState: StateInstallationConfigurationFailed, + setupMock: func(m *installation.MockInstallationManager, rc runtimeconfig.RuntimeConfig, config types.LinuxInstallationConfig, st *store.MockStore, mr *metrics.MockReporter) { + mock.InOrder( + m.On("ValidateConfig", config, 9001).Return(errors.New("validation error")), + // Status is set in the store by the controller when configuring the installation + st.LinuxInstallationMockStore.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { + return status.State == types.StateFailed && status.Description == "validate: validation error" + })).Return(nil), + st.LinuxInstallationMockStore.On("GetStatus").Return(types.Status{Description: "validate: validation error"}, nil), + mr.On("ReportInstallationFailed", mock.Anything, errors.New("validate: validation error")), + ) + }, + expectedErr: true, + }, + { + name: "set config error", + config: types.LinuxInstallationConfig{}, + currentState: StateNew, + expectedState: StateInstallationConfigurationFailed, + setupMock: func(m *installation.MockInstallationManager, rc runtimeconfig.RuntimeConfig, config types.LinuxInstallationConfig, st *store.MockStore, mr *metrics.MockReporter) { + mock.InOrder( + m.On("ValidateConfig", config, 9001).Return(nil), + m.On("SetConfig", config).Return(errors.New("set config error")), + // Status is set in the store by the controller when configuring the installation + st.LinuxInstallationMockStore.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { + return status.State == types.StateFailed && status.Description == "write: set config error" + })).Return(nil), + st.LinuxInstallationMockStore.On("GetStatus").Return(types.Status{Description: "validate: validation error"}, nil), + mr.On("ReportInstallationFailed", mock.Anything, errors.New("validate: validation error")), + ) + }, + expectedErr: true, + }, + { + name: "set config error on retry from host already configured", + config: types.LinuxInstallationConfig{}, + currentState: StateHostConfigured, + expectedState: StateInstallationConfigurationFailed, + setupMock: func(m *installation.MockInstallationManager, rc runtimeconfig.RuntimeConfig, config types.LinuxInstallationConfig, st *store.MockStore, mr *metrics.MockReporter) { + mock.InOrder( + m.On("ValidateConfig", config, 9001).Return(nil), + m.On("SetConfig", config).Return(errors.New("set config error")), + // Status is set in the store by the controller when configuring the installation + st.LinuxInstallationMockStore.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { + return status.State == types.StateFailed && status.Description == "write: set config error" + })).Return(nil), + st.LinuxInstallationMockStore.On("GetStatus").Return(types.Status{Description: "write: set config error"}, nil), + mr.On("ReportInstallationFailed", mock.Anything, errors.New("write: set config error")), + ) + }, + expectedErr: true, + }, + { + name: "set config error on retry from host that failed to configure", + config: types.LinuxInstallationConfig{}, + currentState: StateHostConfigurationFailed, + expectedState: StateInstallationConfigurationFailed, + setupMock: func(m *installation.MockInstallationManager, rc runtimeconfig.RuntimeConfig, config types.LinuxInstallationConfig, st *store.MockStore, mr *metrics.MockReporter) { + mock.InOrder( + m.On("ValidateConfig", config, 9001).Return(nil), + m.On("SetConfig", config).Return(errors.New("set config error")), + // Status is set in the store by the controller when configuring the installation + st.LinuxInstallationMockStore.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { + return status.State == types.StateFailed && status.Description == "write: set config error" + })).Return(nil), + st.LinuxInstallationMockStore.On("GetStatus").Return(types.Status{Description: "write: set config error"}, nil), + mr.On("ReportInstallationFailed", mock.Anything, errors.New("write: set config error")), + ) + }, + expectedErr: true, + }, + { + name: "configure host error", + config: types.LinuxInstallationConfig{ + LocalArtifactMirrorPort: 9000, + DataDirectory: t.TempDir(), + }, + currentState: StateNew, + expectedState: StateHostConfigurationFailed, + setupMock: func(m *installation.MockInstallationManager, rc runtimeconfig.RuntimeConfig, config types.LinuxInstallationConfig, st *store.MockStore, mr *metrics.MockReporter) { + mock.InOrder( + m.On("ValidateConfig", config, 9001).Return(nil), + m.On("SetConfig", config).Return(nil), + m.On("ConfigureHost", mock.Anything, rc).Return(errors.New("configure host error")), + st.LinuxInstallationMockStore.On("GetStatus").Return(types.Status{Description: "configure host error"}, nil), + mr.On("ReportInstallationFailed", mock.Anything, errors.New("configure host error")), + ) + }, + expectedErr: false, + }, + { + name: "configure host error on retry from host already configured", + config: types.LinuxInstallationConfig{ + LocalArtifactMirrorPort: 9000, + DataDirectory: t.TempDir(), + }, + currentState: StateHostConfigured, + expectedState: StateHostConfigurationFailed, + setupMock: func(m *installation.MockInstallationManager, rc runtimeconfig.RuntimeConfig, config types.LinuxInstallationConfig, st *store.MockStore, mr *metrics.MockReporter) { + mock.InOrder( + m.On("ValidateConfig", config, 9001).Return(nil), + m.On("SetConfig", config).Return(nil), + m.On("ConfigureHost", mock.Anything, rc).Return(errors.New("configure host error")), + st.LinuxInstallationMockStore.On("GetStatus").Return(types.Status{Description: "configure host error"}, nil), + mr.On("ReportInstallationFailed", mock.Anything, errors.New("configure host error")), + ) + }, + expectedErr: false, + }, + { + name: "configure host error on retry from host that failed to configure", + config: types.LinuxInstallationConfig{ + LocalArtifactMirrorPort: 9000, + DataDirectory: t.TempDir(), + }, + currentState: StateHostConfigurationFailed, + expectedState: StateHostConfigurationFailed, + setupMock: func(m *installation.MockInstallationManager, rc runtimeconfig.RuntimeConfig, config types.LinuxInstallationConfig, st *store.MockStore, mr *metrics.MockReporter) { + mock.InOrder( + m.On("ValidateConfig", config, 9001).Return(nil), + m.On("SetConfig", config).Return(nil), + m.On("ConfigureHost", mock.Anything, rc).Return(errors.New("configure host error")), + st.LinuxInstallationMockStore.On("GetStatus").Return(types.Status{Description: "configure host error"}, nil), + mr.On("ReportInstallationFailed", mock.Anything, errors.New("configure host error")), + ) + }, + expectedErr: false, + }, + { + name: "with global CIDR", + config: types.LinuxInstallationConfig{ + GlobalCIDR: "10.0.0.0/16", + DataDirectory: t.TempDir(), + }, + currentState: StateNew, + expectedState: StateHostConfigured, + setupMock: func(m *installation.MockInstallationManager, rc runtimeconfig.RuntimeConfig, config types.LinuxInstallationConfig, st *store.MockStore, mr *metrics.MockReporter) { + // Create a copy with expected CIDR values after computation + configWithCIDRs := config + configWithCIDRs.PodCIDR = "10.0.0.0/17" + configWithCIDRs.ServiceCIDR = "10.0.128.0/17" + + mock.InOrder( + m.On("ValidateConfig", config, 9001).Return(nil), + m.On("SetConfig", configWithCIDRs).Return(nil), + m.On("ConfigureHost", mock.Anything, rc).Return(nil), + ) + }, + expectedErr: false, + }, + { + name: "invalid state transition", + config: types.LinuxInstallationConfig{ + LocalArtifactMirrorPort: 9000, + DataDirectory: t.TempDir(), + }, + currentState: StateInfrastructureInstalling, + expectedState: StateInfrastructureInstalling, + setupMock: func(m *installation.MockInstallationManager, rc runtimeconfig.RuntimeConfig, config types.LinuxInstallationConfig, st *store.MockStore, mr *metrics.MockReporter) { + }, + expectedErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rc := runtimeconfig.New(nil, runtimeconfig.WithEnvSetter(&testEnvSetter{})) + rc.SetDataDir(t.TempDir()) + rc.SetManagerPort(9001) + + sm := NewStateMachine(WithCurrentState(tt.currentState)) + + mockManager := &installation.MockInstallationManager{} + metricsReporter := &metrics.MockReporter{} + mockStore := &store.MockStore{} + + tt.setupMock(mockManager, rc, tt.config, mockStore, metricsReporter) + + controller, err := NewInstallController( + WithRuntimeConfig(rc), + WithStateMachine(sm), + WithInstallationManager(mockManager), + WithStore(mockStore), + WithMetricsReporter(metricsReporter), + ) + require.NoError(t, err) + + err = controller.ConfigureInstallation(t.Context(), tt.config) + if tt.expectedErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Eventually(t, func() bool { + return sm.CurrentState() == tt.expectedState + }, time.Second, 100*time.Millisecond, "state should be %s but is %s", tt.expectedState, sm.CurrentState()) + assert.False(t, sm.IsLockAcquired(), "state machine should not be locked after configuration") + + mockManager.AssertExpectations(t) + metricsReporter.AssertExpectations(t) + mockStore.LinuxInfraMockStore.AssertExpectations(t) + mockStore.LinuxInstallationMockStore.AssertExpectations(t) + mockStore.LinuxPreflightMockStore.AssertExpectations(t) + }) + } +} + +// TestIntegrationComputeCIDRs tests the CIDR computation with real networking utility +func TestIntegrationComputeCIDRs(t *testing.T) { + tests := []struct { + name string + globalCIDR string + expectedPod string + expectedSvc string + expectedErr bool + }{ + { + name: "valid cidr 10.0.0.0/16", + globalCIDR: "10.0.0.0/16", + expectedPod: "10.0.0.0/17", + expectedSvc: "10.0.128.0/17", + expectedErr: false, + }, + { + name: "valid cidr 192.168.0.0/16", + globalCIDR: "192.168.0.0/16", + expectedPod: "192.168.0.0/17", + expectedSvc: "192.168.128.0/17", + expectedErr: false, + }, + { + name: "no global cidr", + globalCIDR: "", + expectedPod: "", // Should remain unchanged + expectedSvc: "", // Should remain unchanged + expectedErr: false, + }, + { + name: "invalid cidr", + globalCIDR: "not-a-cidr", + expectedErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + controller, err := NewInstallController() + require.NoError(t, err) + + config := types.LinuxInstallationConfig{ + GlobalCIDR: tt.globalCIDR, + } + + err = controller.computeCIDRs(&config) + + if tt.expectedErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedPod, config.PodCIDR) + assert.Equal(t, tt.expectedSvc, config.ServiceCIDR) + } + }) + } +} + +func TestRunHostPreflights(t *testing.T) { + expectedHPF := &troubleshootv1beta2.HostPreflightSpec{ + Collectors: []*troubleshootv1beta2.HostCollect{ + { + Time: &troubleshootv1beta2.HostTime{}, + }, + }, + } + + tests := []struct { + name string + currentState statemachine.State + expectedState statemachine.State + setupMocks func(*preflight.MockHostPreflightManager, runtimeconfig.RuntimeConfig, *metrics.MockReporter, *store.MockStore) + expectedErr bool + }{ + { + name: "successful run preflights without preflight errors", + currentState: StateHostConfigured, + expectedState: StatePreflightsSucceeded, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + pm.On("PrepareHostPreflights", t.Context(), rc, mock.Anything).Return(expectedHPF, nil), + pm.On("RunHostPreflights", mock.Anything, rc, mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { + return expectedHPF == opts.HostPreflightSpec + })).Return(nil), + pm.On("GetHostPreflightOutput", mock.Anything).Return(successfulPreflightOutput, nil), + ) + }, + expectedErr: false, + }, + { + name: "successful run preflights from preflights execution failed state without preflight errors", + currentState: StatePreflightsExecutionFailed, + expectedState: StatePreflightsSucceeded, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + pm.On("PrepareHostPreflights", t.Context(), rc, mock.Anything).Return(expectedHPF, nil), + pm.On("RunHostPreflights", mock.Anything, rc, mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { + return expectedHPF == opts.HostPreflightSpec + })).Return(nil), + pm.On("GetHostPreflightOutput", mock.Anything).Return(successfulPreflightOutput, nil), + ) + }, + expectedErr: false, + }, + { + name: "successful run preflights from preflights failed state without preflight errors", + currentState: StatePreflightsFailed, + expectedState: StatePreflightsSucceeded, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + pm.On("PrepareHostPreflights", t.Context(), rc, mock.Anything).Return(expectedHPF, nil), + pm.On("RunHostPreflights", mock.Anything, rc, mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { + return expectedHPF == opts.HostPreflightSpec + })).Return(nil), + pm.On("GetHostPreflightOutput", mock.Anything).Return(successfulPreflightOutput, nil), + ) + }, + expectedErr: false, + }, + { + name: "successful run preflights from preflights failure bypassed state without preflight errors", + currentState: StatePreflightsFailedBypassed, + expectedState: StatePreflightsSucceeded, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + pm.On("PrepareHostPreflights", t.Context(), rc, mock.Anything).Return(expectedHPF, nil), + pm.On("RunHostPreflights", mock.Anything, rc, mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { + return expectedHPF == opts.HostPreflightSpec + })).Return(nil), + pm.On("GetHostPreflightOutput", mock.Anything).Return(successfulPreflightOutput, nil), + ) + }, + expectedErr: false, + }, + { + name: "successful run preflights with preflight errors", + currentState: StatePreflightsFailedBypassed, + expectedState: StatePreflightsFailed, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + pm.On("PrepareHostPreflights", t.Context(), rc, mock.Anything).Return(expectedHPF, nil), + pm.On("RunHostPreflights", mock.Anything, rc, mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { + return expectedHPF == opts.HostPreflightSpec + })).Return(nil), + pm.On("GetHostPreflightOutput", mock.Anything).Return(failedPreflightOutput, nil), + st.LinuxPreflightMockStore.On("GetOutput").Return(failedPreflightOutput, nil), + mr.On("ReportPreflightsFailed", mock.Anything, failedPreflightOutput).Return(nil), + ) + }, + expectedErr: false, + }, + { + name: "successful run preflights with preflight errors and failure to get output for reporting", + currentState: StatePreflightsFailedBypassed, + expectedState: StatePreflightsFailed, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + pm.On("PrepareHostPreflights", t.Context(), rc, mock.Anything).Return(expectedHPF, nil), + pm.On("RunHostPreflights", mock.Anything, rc, mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { + return expectedHPF == opts.HostPreflightSpec + })).Return(nil), + pm.On("GetHostPreflightOutput", mock.Anything).Return(failedPreflightOutput, nil), + st.LinuxPreflightMockStore.On("GetOutput").Return(nil, assert.AnError), + ) + }, + expectedErr: false, + }, + { + name: "successful run preflights from preflights execution failed state with preflight errors", + currentState: StatePreflightsExecutionFailed, + expectedState: StatePreflightsFailed, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + pm.On("PrepareHostPreflights", t.Context(), rc, mock.Anything).Return(expectedHPF, nil), + pm.On("RunHostPreflights", mock.Anything, rc, mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { + return expectedHPF == opts.HostPreflightSpec + })).Return(nil), + pm.On("GetHostPreflightOutput", mock.Anything).Return(failedPreflightOutput, nil), + st.LinuxPreflightMockStore.On("GetOutput").Return(failedPreflightOutput, nil), + mr.On("ReportPreflightsFailed", mock.Anything, failedPreflightOutput).Return(nil), + ) + }, + expectedErr: false, + }, + { + name: "successful run preflights from preflights failed state with preflight errors", + currentState: StatePreflightsFailed, + expectedState: StatePreflightsFailed, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + pm.On("PrepareHostPreflights", t.Context(), rc, mock.Anything).Return(expectedHPF, nil), + pm.On("RunHostPreflights", mock.Anything, rc, mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { + return expectedHPF == opts.HostPreflightSpec + })).Return(nil), + pm.On("GetHostPreflightOutput", mock.Anything).Return(failedPreflightOutput, nil), + st.LinuxPreflightMockStore.On("GetOutput").Return(failedPreflightOutput, nil), + mr.On("ReportPreflightsFailed", mock.Anything, failedPreflightOutput).Return(nil), + ) + }, + expectedErr: false, + }, + { + name: "successful run preflights from preflights failure bypassed state with preflight errors", + currentState: StatePreflightsFailedBypassed, + expectedState: StatePreflightsFailed, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + pm.On("PrepareHostPreflights", t.Context(), rc, mock.Anything).Return(expectedHPF, nil), + pm.On("RunHostPreflights", mock.Anything, rc, mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { + return expectedHPF == opts.HostPreflightSpec + })).Return(nil), + pm.On("GetHostPreflightOutput", mock.Anything).Return(failedPreflightOutput, nil), + st.LinuxPreflightMockStore.On("GetOutput").Return(failedPreflightOutput, nil), + mr.On("ReportPreflightsFailed", mock.Anything, failedPreflightOutput).Return(nil), + ) + }, + expectedErr: false, + }, + { + name: "successful run preflights with get preflight output error", + currentState: StateHostConfigured, + expectedState: StatePreflightsExecutionFailed, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + pm.On("PrepareHostPreflights", t.Context(), rc, mock.Anything).Return(expectedHPF, nil), + pm.On("RunHostPreflights", mock.Anything, rc, mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { + return expectedHPF == opts.HostPreflightSpec + })).Return(nil), + pm.On("GetHostPreflightOutput", mock.Anything).Return(nil, assert.AnError), + ) + }, + expectedErr: false, + }, + { + name: "successful run preflights with nil preflight output", + currentState: StateHostConfigured, + expectedState: StatePreflightsSucceeded, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + pm.On("PrepareHostPreflights", t.Context(), rc, mock.Anything).Return(expectedHPF, nil), + pm.On("RunHostPreflights", mock.Anything, rc, mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { + return expectedHPF == opts.HostPreflightSpec + })).Return(nil), + pm.On("GetHostPreflightOutput", mock.Anything).Return(nil, nil), + ) + }, + expectedErr: false, + }, + { + name: "successful run preflights with preflight warnings", + currentState: StateHostConfigured, + expectedState: StatePreflightsSucceeded, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + pm.On("PrepareHostPreflights", t.Context(), rc, mock.Anything).Return(expectedHPF, nil), + pm.On("RunHostPreflights", mock.Anything, rc, mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { + return expectedHPF == opts.HostPreflightSpec + })).Return(nil), + pm.On("GetHostPreflightOutput", mock.Anything).Return(warnPreflightOutput, nil), + ) + }, + expectedErr: false, + }, + { + name: "prepare preflights error", + currentState: StateHostConfigured, + expectedState: StateHostConfigured, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + pm.On("PrepareHostPreflights", t.Context(), rc, mock.Anything).Return(nil, errors.New("prepare error")), + ) + }, + expectedErr: true, + }, + { + name: "run preflights error", + currentState: StateHostConfigured, + expectedState: StatePreflightsExecutionFailed, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + pm.On("PrepareHostPreflights", t.Context(), rc, mock.Anything).Return(expectedHPF, nil), + pm.On("RunHostPreflights", mock.Anything, rc, mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { + return expectedHPF == opts.HostPreflightSpec + })).Return(errors.New("run preflights error")), + ) + }, + expectedErr: false, + }, + { + name: "run preflights panic", + currentState: StateHostConfigured, + expectedState: StatePreflightsExecutionFailed, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + pm.On("PrepareHostPreflights", t.Context(), rc, mock.Anything).Return(expectedHPF, nil), + pm.On("RunHostPreflights", mock.Anything, rc, mock.MatchedBy(func(opts preflight.RunHostPreflightOptions) bool { + return expectedHPF == opts.HostPreflightSpec + })).Panic("this is a panic"), + ) + }, + expectedErr: false, + }, + { + name: "invalid state transition", + currentState: StateInfrastructureInstalling, + expectedState: StateInfrastructureInstalling, + setupMocks: func(pm *preflight.MockHostPreflightManager, rc runtimeconfig.RuntimeConfig, mr *metrics.MockReporter, st *store.MockStore) { + }, + expectedErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rc := runtimeconfig.New(nil) + rc.SetDataDir(t.TempDir()) + rc.SetProxySpec(&ecv1beta1.ProxySpec{ + HTTPProxy: "http://proxy.example.com", + HTTPSProxy: "https://proxy.example.com", + ProvidedNoProxy: "provided-proxy.com", + NoProxy: "no-proxy.com", + }) + + sm := NewStateMachine(WithCurrentState(tt.currentState)) + + mockPreflightManager := &preflight.MockHostPreflightManager{} + mockReporter := &metrics.MockReporter{} + mockStore := &store.MockStore{} + tt.setupMocks(mockPreflightManager, rc, mockReporter, mockStore) + + controller, err := NewInstallController( + WithRuntimeConfig(rc), + WithStateMachine(sm), + WithHostPreflightManager(mockPreflightManager), + WithReleaseData(getTestReleaseData()), + WithMetricsReporter(mockReporter), + WithStore(mockStore), + ) + require.NoError(t, err) + + err = controller.RunHostPreflights(t.Context(), RunHostPreflightsOptions{}) + + if tt.expectedErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + assert.Eventually(t, func() bool { + return sm.CurrentState() == tt.expectedState + }, time.Second, 100*time.Millisecond, "state should be %s but is %s", tt.expectedState, sm.CurrentState()) + assert.False(t, sm.IsLockAcquired(), "state machine should not be locked after running preflights") + + mockPreflightManager.AssertExpectations(t) + mockReporter.AssertExpectations(t) + mockStore.LinuxInfraMockStore.AssertExpectations(t) + mockStore.LinuxInstallationMockStore.AssertExpectations(t) + mockStore.LinuxPreflightMockStore.AssertExpectations(t) + }) + } +} + +func TestGetHostPreflightStatus(t *testing.T) { + tests := []struct { + name string + setupMock func(*preflight.MockHostPreflightManager) + expectedErr bool + expectedValue types.Status + }{ + { + name: "successful get status", + setupMock: func(m *preflight.MockHostPreflightManager) { + status := types.Status{ + State: types.StateFailed, + } + m.On("GetHostPreflightStatus", t.Context()).Return(status, nil) + }, + expectedErr: false, + expectedValue: types.Status{ + State: types.StateFailed, + }, + }, + { + name: "get status error", + setupMock: func(m *preflight.MockHostPreflightManager) { + m.On("GetHostPreflightStatus", t.Context()).Return(nil, errors.New("get status error")) + }, + expectedErr: true, + expectedValue: types.Status{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockManager := &preflight.MockHostPreflightManager{} + tt.setupMock(mockManager) + + controller, err := NewInstallController(WithHostPreflightManager(mockManager)) + require.NoError(t, err) + + result, err := controller.GetHostPreflightStatus(t.Context()) + + if tt.expectedErr { + assert.Error(t, err) + assert.Equal(t, types.Status{}, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedValue, result) + } + + mockManager.AssertExpectations(t) + }) + } +} + +func TestGetHostPreflightOutput(t *testing.T) { + tests := []struct { + name string + setupMock func(*preflight.MockHostPreflightManager) + expectedErr bool + expectedValue *types.HostPreflightsOutput + }{ + { + name: "successful get output", + setupMock: func(m *preflight.MockHostPreflightManager) { + output := successfulPreflightOutput + m.On("GetHostPreflightOutput", t.Context()).Return(output, nil) + }, + expectedErr: false, + expectedValue: successfulPreflightOutput, + }, + { + name: "get output error", + setupMock: func(m *preflight.MockHostPreflightManager) { + m.On("GetHostPreflightOutput", t.Context()).Return(nil, errors.New("get output error")) + }, + expectedErr: true, + expectedValue: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockManager := &preflight.MockHostPreflightManager{} + tt.setupMock(mockManager) + + controller, err := NewInstallController(WithHostPreflightManager(mockManager)) + require.NoError(t, err) + + result, err := controller.GetHostPreflightOutput(t.Context()) + + if tt.expectedErr { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedValue, result) + } + + mockManager.AssertExpectations(t) + }) + } +} + +func TestGetHostPreflightTitles(t *testing.T) { + tests := []struct { + name string + setupMock func(*preflight.MockHostPreflightManager) + expectedErr bool + expectedValue []string + }{ + { + name: "successful get titles", + setupMock: func(m *preflight.MockHostPreflightManager) { + titles := []string{"Check 1", "Check 2"} + m.On("GetHostPreflightTitles", t.Context()).Return(titles, nil) + }, + expectedErr: false, + expectedValue: []string{"Check 1", "Check 2"}, + }, + { + name: "get titles error", + setupMock: func(m *preflight.MockHostPreflightManager) { + m.On("GetHostPreflightTitles", t.Context()).Return(nil, errors.New("get titles error")) + }, + expectedErr: true, + expectedValue: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockManager := &preflight.MockHostPreflightManager{} + tt.setupMock(mockManager) + + controller, err := NewInstallController(WithHostPreflightManager(mockManager)) + require.NoError(t, err) + + result, err := controller.GetHostPreflightTitles(t.Context()) + + if tt.expectedErr { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedValue, result) + } + + mockManager.AssertExpectations(t) + }) + } +} + +func TestGetInstallationStatus(t *testing.T) { + tests := []struct { + name string + setupMock func(*installation.MockInstallationManager) + expectedErr bool + expectedValue types.Status + }{ + { + name: "successful get status", + setupMock: func(m *installation.MockInstallationManager) { + status := types.Status{ + State: types.StateRunning, + } + m.On("GetStatus").Return(status, nil) + }, + expectedErr: false, + expectedValue: types.Status{ + State: types.StateRunning, + }, + }, + { + name: "get status error", + setupMock: func(m *installation.MockInstallationManager) { + m.On("GetStatus").Return(nil, errors.New("get status error")) + }, + expectedErr: true, + expectedValue: types.Status{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockManager := &installation.MockInstallationManager{} + tt.setupMock(mockManager) + + controller, err := NewInstallController(WithInstallationManager(mockManager)) + require.NoError(t, err) + + result, err := controller.GetInstallationStatus(t.Context()) + + if tt.expectedErr { + assert.Error(t, err) + assert.Equal(t, types.Status{}, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedValue, result) + } + + mockManager.AssertExpectations(t) + }) + } +} + +func TestSetupInfra(t *testing.T) { + tests := []struct { + name string + clientIgnoreHostPreflights bool // From HTTP request + serverAllowIgnoreHostPreflights bool // From CLI flag + currentState statemachine.State + expectedState statemachine.State + setupMocks func(runtimeconfig.RuntimeConfig, *preflight.MockHostPreflightManager, *installation.MockInstallationManager, *infra.MockInfraManager, *metrics.MockReporter, *store.MockStore) + expectedErr error + }{ + { + name: "successful setup with passed preflights", + clientIgnoreHostPreflights: false, + serverAllowIgnoreHostPreflights: true, + currentState: StatePreflightsSucceeded, + expectedState: StateSucceeded, + setupMocks: func(rc runtimeconfig.RuntimeConfig, pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + fm.On("Install", mock.Anything, rc).Return(nil), + mr.On("ReportInstallationSucceeded", mock.Anything), + ) + }, + expectedErr: nil, + }, + { + name: "successful setup with failed preflights - ignored with CLI flag", + clientIgnoreHostPreflights: true, + serverAllowIgnoreHostPreflights: true, + currentState: StatePreflightsFailed, + expectedState: StateSucceeded, + setupMocks: func(rc runtimeconfig.RuntimeConfig, pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + st.LinuxPreflightMockStore.On("GetOutput").Return(failedPreflightOutput, nil), + mr.On("ReportPreflightsBypassed", mock.Anything, failedPreflightOutput), + fm.On("Install", mock.Anything, rc).Return(nil), + mr.On("ReportInstallationSucceeded", mock.Anything), + ) + }, + expectedErr: nil, + }, + { + name: "failed setup with failed preflights - not ignored", + clientIgnoreHostPreflights: false, + serverAllowIgnoreHostPreflights: true, + currentState: StatePreflightsFailed, + expectedState: StatePreflightsFailed, + setupMocks: func(rc runtimeconfig.RuntimeConfig, pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, mr *metrics.MockReporter, st *store.MockStore) { + }, + expectedErr: types.NewBadRequestError(ErrPreflightChecksFailed), + }, + { + name: "install infra error", + clientIgnoreHostPreflights: false, + serverAllowIgnoreHostPreflights: true, + currentState: StatePreflightsSucceeded, + expectedState: StateInfrastructureInstallFailed, + setupMocks: func(rc runtimeconfig.RuntimeConfig, pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + fm.On("Install", mock.Anything, rc).Return(errors.New("install error")), + st.LinuxInfraMockStore.On("GetStatus").Return(types.Status{Description: "install error"}, nil), + mr.On("ReportInstallationFailed", mock.Anything, errors.New("install error")), + ) + }, + expectedErr: nil, + }, + { + name: "install infra error without report if infra store fails", + clientIgnoreHostPreflights: false, + serverAllowIgnoreHostPreflights: true, + currentState: StatePreflightsSucceeded, + expectedState: StateInfrastructureInstallFailed, + setupMocks: func(rc runtimeconfig.RuntimeConfig, pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + fm.On("Install", mock.Anything, rc).Return(errors.New("install error")), + st.LinuxInfraMockStore.On("GetStatus").Return(nil, assert.AnError), + ) + }, + expectedErr: nil, + }, + { + name: "install infra panic", + clientIgnoreHostPreflights: false, + serverAllowIgnoreHostPreflights: true, + currentState: StatePreflightsSucceeded, + expectedState: StateInfrastructureInstallFailed, + setupMocks: func(rc runtimeconfig.RuntimeConfig, pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, mr *metrics.MockReporter, st *store.MockStore) { + mock.InOrder( + fm.On("Install", mock.Anything, rc).Panic("this is a panic"), + st.LinuxInfraMockStore.On("GetStatus").Return(types.Status{Description: "this is a panic"}, nil), + mr.On("ReportInstallationFailed", mock.Anything, errors.New("this is a panic")), + ) + }, + expectedErr: nil, + }, + { + name: "invalid state transition", + clientIgnoreHostPreflights: false, + serverAllowIgnoreHostPreflights: true, + currentState: StateInstallationConfigured, + expectedState: StateInstallationConfigured, + setupMocks: func(rc runtimeconfig.RuntimeConfig, pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, mr *metrics.MockReporter, st *store.MockStore) { + }, + expectedErr: assert.AnError, // Just check that an error occurs, don't care about exact message + }, + { + name: "failed preflights with ignore flag but CLI flag disabled", + clientIgnoreHostPreflights: true, + serverAllowIgnoreHostPreflights: false, + currentState: StatePreflightsFailed, + expectedState: StatePreflightsFailed, + setupMocks: func(rc runtimeconfig.RuntimeConfig, pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, mr *metrics.MockReporter, st *store.MockStore) { + }, + expectedErr: types.NewBadRequestError(ErrPreflightChecksFailed), + }, + { + name: "failed preflights without ignore flag and CLI flag disabled", + clientIgnoreHostPreflights: false, + serverAllowIgnoreHostPreflights: false, + currentState: StatePreflightsFailed, + expectedState: StatePreflightsFailed, + setupMocks: func(rc runtimeconfig.RuntimeConfig, pm *preflight.MockHostPreflightManager, im *installation.MockInstallationManager, fm *infra.MockInfraManager, mr *metrics.MockReporter, st *store.MockStore) { + }, + expectedErr: types.NewBadRequestError(ErrPreflightChecksFailed), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sm := NewStateMachine(WithCurrentState(tt.currentState)) + + rc := runtimeconfig.New(nil) + rc.SetDataDir(t.TempDir()) + rc.SetManagerPort(9001) + + mockPreflightManager := &preflight.MockHostPreflightManager{} + mockInstallationManager := &installation.MockInstallationManager{} + mockInfraManager := &infra.MockInfraManager{} + mockMetricsReporter := &metrics.MockReporter{} + mockStore := &store.MockStore{} + tt.setupMocks(rc, mockPreflightManager, mockInstallationManager, mockInfraManager, mockMetricsReporter, mockStore) + + controller, err := NewInstallController( + WithRuntimeConfig(rc), + WithStateMachine(sm), + WithHostPreflightManager(mockPreflightManager), + WithInstallationManager(mockInstallationManager), + WithInfraManager(mockInfraManager), + WithAllowIgnoreHostPreflights(tt.serverAllowIgnoreHostPreflights), + WithMetricsReporter(mockMetricsReporter), + WithStore(mockStore), + ) + require.NoError(t, err) + + err = controller.SetupInfra(t.Context(), tt.clientIgnoreHostPreflights) + + if tt.expectedErr != nil { + require.Error(t, err) + + // Check for specific error types + var expectedAPIErr *types.APIError + if errors.As(tt.expectedErr, &expectedAPIErr) { + // For API errors, check the exact type and status code + var actualAPIErr *types.APIError + require.True(t, errors.As(err, &actualAPIErr), "expected error to be of type *types.APIError, got %T", err) + assert.Equal(t, expectedAPIErr.StatusCode, actualAPIErr.StatusCode, "status codes should match") + assert.Contains(t, actualAPIErr.Error(), expectedAPIErr.Unwrap().Error(), "error messages should contain expected content") + } + } else { + require.NoError(t, err) + } + + assert.Eventually(t, func() bool { + t.Logf("Current state: %s, Expected state: %s", sm.CurrentState(), tt.expectedState) + return sm.CurrentState() == tt.expectedState + }, time.Second, 100*time.Millisecond, "state should be %s", tt.expectedState) + assert.False(t, sm.IsLockAcquired(), "state machine should not be locked after running infra setup") + + mockPreflightManager.AssertExpectations(t) + mockInstallationManager.AssertExpectations(t) + mockInfraManager.AssertExpectations(t) + mockMetricsReporter.AssertExpectations(t) + mockStore.LinuxInfraMockStore.AssertExpectations(t) + mockStore.LinuxInstallationMockStore.AssertExpectations(t) + mockStore.LinuxPreflightMockStore.AssertExpectations(t) + }) + } +} + +func TestGetInfra(t *testing.T) { + tests := []struct { + name string + setupMock func(*infra.MockInfraManager) + expectedErr bool + expectedValue types.Infra + }{ + { + name: "successful get infra", + setupMock: func(m *infra.MockInfraManager) { + infra := types.Infra{ + Components: []types.InfraComponent{ + { + Name: infra.K0sComponentName, + Status: types.Status{ + State: types.StateRunning, + }, + }, + }, + Status: types.Status{ + State: types.StateRunning, + }, + } + m.On("Get").Return(infra, nil) + }, + expectedErr: false, + expectedValue: types.Infra{ + Components: []types.InfraComponent{ + { + Name: infra.K0sComponentName, + Status: types.Status{ + State: types.StateRunning, + }, + }, + }, + Status: types.Status{ + State: types.StateRunning, + }, + }, + }, + { + name: "get infra error", + setupMock: func(m *infra.MockInfraManager) { + m.On("Get").Return(nil, errors.New("get infra error")) + }, + expectedErr: true, + expectedValue: types.Infra{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockManager := &infra.MockInfraManager{} + tt.setupMock(mockManager) + + controller, err := NewInstallController(WithInfraManager(mockManager)) + require.NoError(t, err) + + result, err := controller.GetInfra(t.Context()) + + if tt.expectedErr { + assert.Error(t, err) + assert.Equal(t, types.Infra{}, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedValue, result) + } + + mockManager.AssertExpectations(t) + }) + } +} + +func getTestReleaseData() *release.ReleaseData { + return &release.ReleaseData{ + EmbeddedClusterConfig: &ecv1beta1.Config{}, + ChannelRelease: &release.ChannelRelease{}, + } +} + +type testEnvSetter struct { + env map[string]string +} + +func (e *testEnvSetter) Setenv(key string, val string) error { + if e.env == nil { + e.env = make(map[string]string) + } + e.env[key] = val + return nil +} diff --git a/api/controllers/linux/install/hostpreflight.go b/api/controllers/linux/install/hostpreflight.go new file mode 100644 index 0000000000..c3afe6cffc --- /dev/null +++ b/api/controllers/linux/install/hostpreflight.go @@ -0,0 +1,120 @@ +package install + +import ( + "context" + "fmt" + "runtime/debug" + + "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/preflight" + "github.com/replicatedhq/embedded-cluster/api/internal/statemachine" + "github.com/replicatedhq/embedded-cluster/api/internal/utils" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/netutils" +) + +func (c *InstallController) RunHostPreflights(ctx context.Context, opts RunHostPreflightsOptions) (finalErr error) { + lock, err := c.stateMachine.AcquireLock() + if err != nil { + return types.NewConflictError(err) + } + + defer func() { + if r := recover(); r != nil { + finalErr = fmt.Errorf("panic: %v: %s", r, string(debug.Stack())) + } + if finalErr != nil { + lock.Release() + } + }() + + if err := c.stateMachine.ValidateTransition(lock, StatePreflightsRunning); err != nil { + return types.NewConflictError(err) + } + + // Get the configured custom domains + ecDomains := utils.GetDomains(c.releaseData) + + // Prepare host preflights + hpf, err := c.hostPreflightManager.PrepareHostPreflights(ctx, c.rc, preflight.PrepareHostPreflightOptions{ + ReplicatedAppURL: netutils.MaybeAddHTTPS(ecDomains.ReplicatedAppDomain), + ProxyRegistryURL: netutils.MaybeAddHTTPS(ecDomains.ProxyRegistryDomain), + HostPreflightSpec: c.releaseData.HostPreflights, + EmbeddedClusterConfig: c.releaseData.EmbeddedClusterConfig, + IsAirgap: c.airgapBundle != "", + IsUI: opts.IsUI, + }) + if err != nil { + return fmt.Errorf("prepare host preflights: %w", err) + } + + err = c.stateMachine.Transition(lock, StatePreflightsRunning) + if err != nil { + return fmt.Errorf("transition states: %w", err) + } + + go func() (finalErr error) { + // Background context is used to avoid canceling the operation if the context is canceled + ctx := context.Background() + + defer lock.Release() + + defer func() { + if r := recover(); r != nil { + finalErr = fmt.Errorf("panic running host preflights: %v: %s", r, string(debug.Stack())) + } + // Handle errors from preflight execution + if finalErr != nil { + c.logger.Error(finalErr) + + if err := c.stateMachine.Transition(lock, StatePreflightsExecutionFailed); err != nil { + c.logger.Errorf("failed to transition states: %w", err) + } + return + } + + // Get the state from the preflights output + state := c.getStateFromPreflightsOutput(ctx) + // Transition to the appropriate state based on preflight results + if err := c.stateMachine.Transition(lock, state); err != nil { + c.logger.Errorf("failed to transition states: %w", err) + } + }() + + err := c.hostPreflightManager.RunHostPreflights(ctx, c.rc, preflight.RunHostPreflightOptions{ + HostPreflightSpec: hpf, + }) + if err != nil { + return fmt.Errorf("run host preflights: %w", err) + } + + return nil + }() + + return nil +} + +func (c *InstallController) getStateFromPreflightsOutput(ctx context.Context) statemachine.State { + output, err := c.GetHostPreflightOutput(ctx) + // If there was an error getting the state we assume preflight execution failed + if err != nil { + c.logger.WithError(err).Error("error getting preflight output") + return StatePreflightsExecutionFailed + } + // If there is no output, we assume preflights succeeded + if output == nil || !output.HasFail() { + return StatePreflightsSucceeded + } + return StatePreflightsFailed +} + +func (c *InstallController) GetHostPreflightStatus(ctx context.Context) (types.Status, error) { + return c.hostPreflightManager.GetHostPreflightStatus(ctx) +} + +func (c *InstallController) GetHostPreflightOutput(ctx context.Context) (*types.HostPreflightsOutput, error) { + return c.hostPreflightManager.GetHostPreflightOutput(ctx) +} + +func (c *InstallController) GetHostPreflightTitles(ctx context.Context) ([]string, error) { + return c.hostPreflightManager.GetHostPreflightTitles(ctx) +} diff --git a/api/controllers/linux/install/infra.go b/api/controllers/linux/install/infra.go new file mode 100644 index 0000000000..cf7adeb2f8 --- /dev/null +++ b/api/controllers/linux/install/infra.go @@ -0,0 +1,82 @@ +package install + +import ( + "context" + "errors" + "fmt" + "runtime/debug" + + "github.com/replicatedhq/embedded-cluster/api/types" +) + +var ( + ErrPreflightChecksFailed = errors.New("preflight checks failed") +) + +func (c *InstallController) SetupInfra(ctx context.Context, ignoreHostPreflights bool) (finalErr error) { + lock, err := c.stateMachine.AcquireLock() + if err != nil { + return types.NewConflictError(err) + } + + defer func() { + if r := recover(); r != nil { + finalErr = fmt.Errorf("panic: %v: %s", r, string(debug.Stack())) + } + if finalErr != nil { + lock.Release() + } + }() + + // Check if preflights have failed and if we should ignore them + if c.stateMachine.CurrentState() == StatePreflightsFailed { + if !ignoreHostPreflights || !c.allowIgnoreHostPreflights { + return types.NewBadRequestError(ErrPreflightChecksFailed) + } + err = c.stateMachine.Transition(lock, StatePreflightsFailedBypassed) + if err != nil { + return fmt.Errorf("failed to transition states: %w", err) + } + } + + err = c.stateMachine.Transition(lock, StateInfrastructureInstalling) + if err != nil { + return types.NewConflictError(err) + } + + go func() (finalErr error) { + // Background context is used to avoid canceling the operation if the context is canceled + ctx := context.Background() + + defer lock.Release() + + defer func() { + if r := recover(); r != nil { + finalErr = fmt.Errorf("panic: %v: %s", r, string(debug.Stack())) + } + if finalErr != nil { + c.logger.Error(finalErr) + + if err := c.stateMachine.Transition(lock, StateInfrastructureInstallFailed); err != nil { + c.logger.Errorf("failed to transition states: %w", err) + } + } else { + if err := c.stateMachine.Transition(lock, StateSucceeded); err != nil { + c.logger.Errorf("failed to transition states: %w", err) + } + } + }() + + if err := c.infraManager.Install(ctx, c.rc); err != nil { + return fmt.Errorf("failed to install infrastructure: %w", err) + } + + return nil + }() + + return nil +} + +func (c *InstallController) GetInfra(ctx context.Context) (types.Infra, error) { + return c.infraManager.Get() +} diff --git a/api/controllers/linux/install/installation.go b/api/controllers/linux/install/installation.go new file mode 100644 index 0000000000..d4a1f2b4e2 --- /dev/null +++ b/api/controllers/linux/install/installation.go @@ -0,0 +1,157 @@ +package install + +import ( + "context" + "fmt" + "time" + + "github.com/replicatedhq/embedded-cluster/api/types" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" + "github.com/replicatedhq/embedded-cluster/pkg/netutils" +) + +func (c *InstallController) GetInstallationConfig(ctx context.Context) (types.LinuxInstallationConfig, error) { + config, err := c.installationManager.GetConfig() + if err != nil { + return types.LinuxInstallationConfig{}, err + } + + if err := c.installationManager.SetConfigDefaults(&config, c.rc); err != nil { + return types.LinuxInstallationConfig{}, fmt.Errorf("set defaults: %w", err) + } + + if err := c.installationManager.ValidateConfig(config, c.rc.ManagerPort()); err != nil { + return types.LinuxInstallationConfig{}, fmt.Errorf("validate: %w", err) + } + + return config, nil +} + +func (c *InstallController) ConfigureInstallation(ctx context.Context, config types.LinuxInstallationConfig) error { + err := c.configureInstallation(ctx, config) + if err != nil { + return err + } + + go func() { + // Background context is used to avoid canceling the operation if the context is canceled + ctx := context.Background() + + lock, err := c.stateMachine.AcquireLock() + if err != nil { + c.logger.Error("failed to acquire lock", "error", err) + return + } + defer lock.Release() + + err = c.installationManager.ConfigureHost(ctx, c.rc) + + if err != nil { + c.logger.Error("failed to configure host", "error", err) + err = c.stateMachine.Transition(lock, StateHostConfigurationFailed) + if err != nil { + c.logger.Error("failed to transition states", "error", err) + } + } else { + err = c.stateMachine.Transition(lock, StateHostConfigured) + if err != nil { + c.logger.Error("failed to transition states", "error", err) + } + } + }() + + return nil +} + +func (c *InstallController) configureInstallation(ctx context.Context, config types.LinuxInstallationConfig) (finalErr error) { + lock, err := c.stateMachine.AcquireLock() + if err != nil { + return types.NewConflictError(err) + } + defer lock.Release() + + if err := c.stateMachine.ValidateTransition(lock, StateInstallationConfigured); err != nil { + return types.NewConflictError(err) + } + + defer func() { + if finalErr != nil { + failureStatus := types.Status{ + State: types.StateFailed, + Description: finalErr.Error(), + LastUpdated: time.Now(), + } + + if err = c.store.LinuxInstallationStore().SetStatus(failureStatus); err != nil { + c.logger.Errorf("failed to update status: %w", err) + } + + if err := c.stateMachine.Transition(lock, StateInstallationConfigurationFailed); err != nil { + c.logger.Errorf("failed to transition states: %w", err) + } + } + }() + + if err := c.installationManager.ValidateConfig(config, c.rc.ManagerPort()); err != nil { + return fmt.Errorf("validate: %w", err) + } + + if err := c.computeCIDRs(&config); err != nil { + return fmt.Errorf("compute cidrs: %w", err) + } + + if err := c.installationManager.SetConfig(config); err != nil { + return fmt.Errorf("write: %w", err) + } + + proxy, err := newconfig.GetProxySpec(config.HTTPProxy, config.HTTPSProxy, config.NoProxy, config.PodCIDR, config.ServiceCIDR, config.NetworkInterface, c.netUtils) + if err != nil { + return fmt.Errorf("get proxy spec: %w", err) + } + + networkSpec := ecv1beta1.NetworkSpec{ + NetworkInterface: config.NetworkInterface, + GlobalCIDR: config.GlobalCIDR, + PodCIDR: config.PodCIDR, + ServiceCIDR: config.ServiceCIDR, + NodePortRange: c.rc.NodePortRange(), + } + + // TODO (@team): discuss the distinction between the runtime config and the installation config + // update the runtime config + c.rc.SetDataDir(config.DataDirectory) + c.rc.SetLocalArtifactMirrorPort(config.LocalArtifactMirrorPort) + c.rc.SetAdminConsolePort(config.AdminConsolePort) + c.rc.SetProxySpec(proxy) + c.rc.SetNetworkSpec(networkSpec) + + // update process env vars from the runtime config + if err := c.rc.SetEnv(); err != nil { + return fmt.Errorf("set env vars: %w", err) + } + + err = c.stateMachine.Transition(lock, StateInstallationConfigured) + if err != nil { + return fmt.Errorf("failed to transition states: %w", err) + } + + return nil +} + +func (c *InstallController) computeCIDRs(config *types.LinuxInstallationConfig) error { + if config.GlobalCIDR != "" { + podCIDR, serviceCIDR, err := netutils.SplitNetworkCIDR(config.GlobalCIDR) + if err != nil { + return fmt.Errorf("split network cidr: %w", err) + } + config.PodCIDR = podCIDR + config.ServiceCIDR = serviceCIDR + } + + return nil +} + +func (c *InstallController) GetInstallationStatus(ctx context.Context) (types.Status, error) { + return c.installationManager.GetStatus() +} diff --git a/api/controllers/linux/install/reporting_handlers.go b/api/controllers/linux/install/reporting_handlers.go new file mode 100644 index 0000000000..9006d9257c --- /dev/null +++ b/api/controllers/linux/install/reporting_handlers.go @@ -0,0 +1,69 @@ +package install + +import ( + "context" + "errors" + "fmt" + + "github.com/replicatedhq/embedded-cluster/api/internal/statemachine" + "github.com/replicatedhq/embedded-cluster/api/types" +) + +func (c *InstallController) registerReportingHandlers() { + c.stateMachine.RegisterEventHandler(StateSucceeded, c.reportInstallSucceeded) + c.stateMachine.RegisterEventHandler(StateInfrastructureInstallFailed, c.reportInstallFailed) + c.stateMachine.RegisterEventHandler(StateHostConfigurationFailed, c.reportInstallFailed) + c.stateMachine.RegisterEventHandler(StateInstallationConfigurationFailed, c.reportInstallFailed) + c.stateMachine.RegisterEventHandler(StatePreflightsFailed, c.reportPreflightsFailed) + c.stateMachine.RegisterEventHandler(StatePreflightsFailedBypassed, c.reportPreflightsBypassed) +} + +func (c *InstallController) reportInstallSucceeded(ctx context.Context, _, _ statemachine.State) { + c.metricsReporter.ReportInstallationSucceeded(ctx) +} + +func (c *InstallController) reportInstallFailed(ctx context.Context, _, toState statemachine.State) { + var status types.Status + var err error + + switch toState { + case StateInstallationConfigurationFailed: + status, err = c.store.LinuxInstallationStore().GetStatus() + if err != nil { + err = fmt.Errorf("failed to get status from installation store: %w", err) + } + case StateHostConfigurationFailed: + status, err = c.store.LinuxInstallationStore().GetStatus() + if err != nil { + err = fmt.Errorf("failed to get status from installation store: %w", err) + } + case StateInfrastructureInstallFailed: + status, err = c.store.LinuxInfraStore().GetStatus() + if err != nil { + err = fmt.Errorf("failed to get status from infra store: %w", err) + } + } + if err != nil { + c.logger.WithError(err).Error("failed to report failled install") + return + } + c.metricsReporter.ReportInstallationFailed(ctx, errors.New(status.Description)) +} + +func (c *InstallController) reportPreflightsFailed(ctx context.Context, _, _ statemachine.State) { + output, err := c.store.LinuxPreflightStore().GetOutput() + if err != nil { + c.logger.WithError(fmt.Errorf("failed to get output from preflight store: %w", err)).Error("failed to report preflights failed") + return + } + c.metricsReporter.ReportPreflightsFailed(ctx, output) +} + +func (c *InstallController) reportPreflightsBypassed(ctx context.Context, _, _ statemachine.State) { + output, err := c.store.LinuxPreflightStore().GetOutput() + if err != nil { + c.logger.WithError(fmt.Errorf("failed to get output from preflight store: %w", err)).Error("failed to report preflights bypassed") + return + } + c.metricsReporter.ReportPreflightsBypassed(ctx, output) +} diff --git a/api/controllers/linux/install/statemachine.go b/api/controllers/linux/install/statemachine.go new file mode 100644 index 0000000000..ece8fbb6d3 --- /dev/null +++ b/api/controllers/linux/install/statemachine.go @@ -0,0 +1,83 @@ +package install + +import ( + "github.com/replicatedhq/embedded-cluster/api/internal/statemachine" + "github.com/replicatedhq/embedded-cluster/api/pkg/logger" + "github.com/sirupsen/logrus" +) + +const ( + // StateNew is the initial state of the install process + StateNew statemachine.State = "New" + // StateInstallationConfigurationFailed is the state of the install process when the installation failed to be configured + StateInstallationConfigurationFailed statemachine.State = "InstallationConfigurationFailed" + // StateInstallationConfigured is the state of the install process when the installation is configured + StateInstallationConfigured statemachine.State = "InstallationConfigured" + // StateHostConfigurationFailed is the state of the install process when the installation failed to be configured + StateHostConfigurationFailed statemachine.State = "HostConfigurationFailed" + // StateHostConfigured is the state of the install process when the host is configured + StateHostConfigured statemachine.State = "HostConfigured" + // StatePreflightsRunning is the state of the install process when the preflights are running + StatePreflightsRunning statemachine.State = "PreflightsRunning" + // StatePreflightsExecutionFailed is the state of the install process when the preflights failed to execute due to an underlying system error + StatePreflightsExecutionFailed statemachine.State = "PreflightsExecutionFailed" + // StatePreflightsSucceeded is the state of the install process when the preflights have succeeded + StatePreflightsSucceeded statemachine.State = "PreflightsSucceeded" + // StatePreflightsFailed is the state of the install process when the preflights execution succeeded but the preflights detected issues on the host + StatePreflightsFailed statemachine.State = "PreflightsFailed" + // StatePreflightsFailedBypassed is the state of the install process when, despite preflights failing, the user has chosen to bypass the preflights and continue with the installation + StatePreflightsFailedBypassed statemachine.State = "PreflightsFailedBypassed" + // StateInfrastructureInstalling is the state of the install process when the infrastructure is being installed + StateInfrastructureInstalling statemachine.State = "InfrastructureInstalling" + // StateInfrastructureInstallFailed is a final state of the install process when the infrastructure failed to isntall + StateInfrastructureInstallFailed statemachine.State = "InfrastructureInstallFailed" + // StateSucceeded is the final state of the install process when the install has succeeded + StateSucceeded statemachine.State = "Succeeded" +) + +var validStateTransitions = map[statemachine.State][]statemachine.State{ + StateNew: {StateInstallationConfigured, StateInstallationConfigurationFailed}, + StateInstallationConfigurationFailed: {StateInstallationConfigured, StateInstallationConfigurationFailed}, + StateInstallationConfigured: {StateHostConfigured, StateHostConfigurationFailed}, + StateHostConfigurationFailed: {StateInstallationConfigured, StateInstallationConfigurationFailed}, + StateHostConfigured: {StatePreflightsRunning, StateInstallationConfigured, StateInstallationConfigurationFailed}, + StatePreflightsRunning: {StatePreflightsSucceeded, StatePreflightsFailed, StatePreflightsExecutionFailed}, + StatePreflightsExecutionFailed: {StatePreflightsRunning, StateInstallationConfigured, StateInstallationConfigurationFailed}, + StatePreflightsSucceeded: {StateInfrastructureInstalling, StatePreflightsRunning, StateInstallationConfigured, StateInstallationConfigurationFailed}, + StatePreflightsFailed: {StatePreflightsFailedBypassed, StatePreflightsRunning, StateInstallationConfigured, StateInstallationConfigurationFailed}, + StatePreflightsFailedBypassed: {StateInfrastructureInstalling, StatePreflightsRunning, StateInstallationConfigured, StateInstallationConfigurationFailed}, + StateInfrastructureInstalling: {StateSucceeded, StateInfrastructureInstallFailed}, + StateInfrastructureInstallFailed: {}, + StateSucceeded: {}, +} + +type StateMachineOptions struct { + CurrentState statemachine.State + Logger logrus.FieldLogger +} + +type StateMachineOption func(*StateMachineOptions) + +func WithCurrentState(currentState statemachine.State) StateMachineOption { + return func(o *StateMachineOptions) { + o.CurrentState = currentState + } +} + +func WithStateMachineLogger(logger logrus.FieldLogger) StateMachineOption { + return func(o *StateMachineOptions) { + o.Logger = logger + } +} + +// NewStateMachine creates a new state machine starting in the New state +func NewStateMachine(opts ...StateMachineOption) statemachine.Interface { + options := &StateMachineOptions{ + CurrentState: StateNew, + Logger: logger.NewDiscardLogger(), + } + for _, opt := range opts { + opt(options) + } + return statemachine.New(options.CurrentState, validStateTransitions, statemachine.WithLogger(options.Logger)) +} diff --git a/api/controllers/linux/install/statemachine_test.go b/api/controllers/linux/install/statemachine_test.go new file mode 100644 index 0000000000..508b6a5216 --- /dev/null +++ b/api/controllers/linux/install/statemachine_test.go @@ -0,0 +1,171 @@ +package install + +import ( + "slices" + "testing" + + "github.com/replicatedhq/embedded-cluster/api/internal/statemachine" + "github.com/stretchr/testify/assert" +) + +func TestStateMachineTransitions(t *testing.T) { + tests := []struct { + name string + startState statemachine.State + validTransitions []statemachine.State + }{ + { + name: `State "New" can transition to "InstallationConfigured" or "InstallationConfigurationFailed"`, + startState: StateNew, + validTransitions: []statemachine.State{ + StateInstallationConfigured, + StateInstallationConfigurationFailed, + }, + }, + { + name: `State "InstallationConfigurationFailed" can transition to "InstallationConfigured" or "InstallationConfigurationFailed"`, + startState: StateInstallationConfigurationFailed, + validTransitions: []statemachine.State{ + StateInstallationConfigured, + StateInstallationConfigurationFailed, + }, + }, + { + name: `State "InstallationConfigured" can transition to "HostConfigured" or "HostConfigurationFailed"`, + startState: StateInstallationConfigured, + validTransitions: []statemachine.State{ + StateHostConfigured, + StateHostConfigurationFailed, + }, + }, + { + name: `State "HostConfigurationFailed" can transition to "InstallationConfigured" or "InstallationConfigurationFailed"`, + startState: StateHostConfigurationFailed, + validTransitions: []statemachine.State{ + StateInstallationConfigured, + StateInstallationConfigurationFailed, + }, + }, + { + name: `State "HostConfigured" can transition to "PreflightsRunning" or "InstallationConfigured" or "InstallationConfigurationFailed"`, + startState: StateHostConfigured, + validTransitions: []statemachine.State{ + StatePreflightsRunning, + StateInstallationConfigured, + StateInstallationConfigurationFailed, + }, + }, + { + name: `State "PreflightsRunning" can transition to "PreflightsSucceeded", "PreflightsFailed", or "PreflightsExecutionFailed"`, + startState: StatePreflightsRunning, + validTransitions: []statemachine.State{ + StatePreflightsSucceeded, + StatePreflightsFailed, + StatePreflightsExecutionFailed, + }, + }, + { + name: `State "PreflightsExecutionFailed" can transition to "PreflightsRunning", "InstallationConfigured", or "InstallationConfigurationFailed"`, + startState: StatePreflightsExecutionFailed, + validTransitions: []statemachine.State{ + StatePreflightsRunning, + StateInstallationConfigured, + StateInstallationConfigurationFailed, + }, + }, + { + name: `State "PreflightsSucceeded" can transition to "InfrastructureInstalling", "PreflightsRunning", "InstallationConfigured", or "InstallationConfigurationFailed"`, + startState: StatePreflightsSucceeded, + validTransitions: []statemachine.State{ + StateInfrastructureInstalling, + StatePreflightsRunning, + StateInstallationConfigured, + StateInstallationConfigurationFailed, + }, + }, + { + name: `State "PreflightsFailed" can transition to "PreflightsFailedBypassed", "PreflightsRunning", "InstallationConfigured", or "InstallationConfigurationFailed"`, + startState: StatePreflightsFailed, + validTransitions: []statemachine.State{ + StatePreflightsFailedBypassed, + StatePreflightsRunning, + StateInstallationConfigured, + StateInstallationConfigurationFailed, + }, + }, + { + name: `State "PreflightsFailedBypassed" can transition to "InfrastructureInstalling", "PreflightsRunning", "InstallationConfigured", or "InstallationConfigurationFailed"`, + startState: StatePreflightsFailedBypassed, + validTransitions: []statemachine.State{ + StateInfrastructureInstalling, + StatePreflightsRunning, + StateInstallationConfigured, + StateInstallationConfigurationFailed, + }, + }, + { + name: `State "InfrastructureInstalling" can transition to "Succeeded" or "InfrastructureInstallFailed"`, + startState: StateInfrastructureInstalling, + validTransitions: []statemachine.State{ + StateSucceeded, + StateInfrastructureInstallFailed, + }, + }, + { + name: `State "InfrastructureInstallFailed" can not transition to any other state`, + startState: StateInfrastructureInstallFailed, + validTransitions: []statemachine.State{}, + }, + { + name: `State "Succeeded" can not transition to any other state`, + startState: StateSucceeded, + validTransitions: []statemachine.State{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for nextState := range validStateTransitions { + sm := NewStateMachine(WithCurrentState(tt.startState)) + + lock, err := sm.AcquireLock() + if err != nil { + t.Fatalf("failed to acquire lock: %v", err) + } + defer lock.Release() + + err = sm.Transition(lock, nextState) + if !slices.Contains(tt.validTransitions, nextState) { + assert.Error(t, err, "expected error for transition from %s to %s", tt.startState, nextState) + } else { + assert.NoError(t, err, "unexpected error for transition from %s to %s", tt.startState, nextState) + + // Verify state has changed + assert.Equal(t, nextState, sm.CurrentState(), "state should change after commit") + } + } + }) + } +} + +func TestIsFinalState(t *testing.T) { + finalStates := []statemachine.State{ + StateSucceeded, + StateInfrastructureInstallFailed, + } + + for state := range validStateTransitions { + var isFinal bool + if slices.Contains(finalStates, state) { + isFinal = true + } + + sm := NewStateMachine(WithCurrentState(state)) + + if isFinal { + assert.True(t, sm.IsFinalState(), "expected state %s to be final", state) + } else { + assert.False(t, sm.IsFinalState(), "expected state %s to not be final", state) + } + } +} diff --git a/api/docs/docs.go b/api/docs/docs.go index 00635b4fda..47938d3b8a 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -6,10 +6,10 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"status_code":{"type":"integer"}},"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"type":"object"},"types.Health":{"properties":{"status":{"type":"string"}},"type":"object"},"types.HostPreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.HostPreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.HostPreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.HostPreflightsRecord"},"type":"array","uniqueItems":false}},"type":"object"},"types.HostPreflightsRecord":{"properties":{"message":{"type":"string"},"title":{"type":"string"}},"type":"object"},"types.Infra":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.InfraComponent"},"type":"array","uniqueItems":false},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"output":{"$ref":"#/components/schemas/types.HostPreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.InstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"dataDirectory":{"type":"string"},"globalCidr":{"type":"string"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"localArtifactMirrorPort":{"type":"integer"},"networkInterface":{"type":"string"},"noProxy":{"type":"string"},"podCidr":{"type":"string"},"serviceCidr":{"type":"string"}},"type":"object"},"types.State":{"type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"type":"object"}},"securitySchemes":{"bearerauth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}}, + "components": {"schemas":{"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"status_code":{"type":"integer"}},"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"type":"object"},"types.GetListAvailableNetworkInterfacesResponse":{"properties":{"networkInterfaces":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.Health":{"properties":{"status":{"type":"string"}},"type":"object"},"types.HostPreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.HostPreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.HostPreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.HostPreflightsRecord"},"type":"array","uniqueItems":false}},"type":"object"},"types.HostPreflightsRecord":{"properties":{"message":{"type":"string"},"title":{"type":"string"}},"type":"object"},"types.Infra":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.InfraComponent"},"type":"array","uniqueItems":false},"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"allowIgnoreHostPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.HostPreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.KubernetesInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"noProxy":{"type":"string"}},"type":"object"},"types.LinuxInfraSetupRequest":{"properties":{"ignoreHostPreflights":{"type":"boolean"}},"type":"object"},"types.LinuxInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"dataDirectory":{"type":"string"},"globalCidr":{"type":"string"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"localArtifactMirrorPort":{"type":"integer"},"networkInterface":{"type":"string"},"noProxy":{"type":"string"},"podCidr":{"type":"string"},"serviceCidr":{"type":"string"}},"type":"object"},"types.PostInstallRunHostPreflightsRequest":{"properties":{"isUi":{"type":"boolean"}},"type":"object"},"types.State":{"type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"type":"object"}},"securitySchemes":{"bearerauth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}}, "info": {"contact":{"email":"support@replicated.com","name":"API Support","url":"https://github.com/replicatedhq/embedded-cluster/issues"},"description":"{{escape .Description}}","license":{"name":"Apache 2.0","url":"http://www.apache.org/licenses/LICENSE-2.0.html"},"termsOfService":"http://swagger.io/terms/","title":"{{.Title}}","version":"{{.Version}}"}, "externalDocs": {"description":"OpenAPI","url":"https://swagger.io/resources/open-api/"}, - "paths": {"/auth/login":{"post":{"description":"Authenticate a user","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthRequest"}}},"description":"Auth Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthResponse"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Unauthorized"}},"summary":"Authenticate a user","tags":["auth"]}},"/health":{"get":{"description":"get the health of the API","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Health"}}},"description":"OK"}},"summary":"Get the health of the API","tags":["health"]}},"/install/host-preflights/run":{"post":{"description":"Run install host preflight checks using installation config and client-provided data","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Run install host preflight checks","tags":["install"]}},"/install/host-preflights/status":{"get":{"description":"Get the current status and results of host preflight checks for install","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get host preflight status for install","tags":["install"]}},"/install/infra/setup":{"post":{"description":"Setup infra components","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Setup infra components","tags":["install"]}},"/install/infra/status":{"get":{"description":"Get the current status of the infra","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra","tags":["install"]}},"/install/installation/config":{"get":{"description":"get the installation config","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallationConfig"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the installation config","tags":["install"]}},"/install/installation/configure":{"post":{"description":"configure the installation for install","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallationConfig"}}},"description":"Installation config","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Configure the installation for install","tags":["install"]}},"/install/installation/status":{"get":{"description":"Get the current status of the installation configuration for install","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get installation configuration status for install","tags":["install"]}},"/install/status":{"get":{"description":"Get the current status of the install workflow","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the install workflow","tags":["install"]},"post":{"description":"Set the status of the install workflow","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"Status","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Set the status of the install workflow","tags":["install"]}}}, + "paths": {"/auth/login":{"post":{"description":"Authenticate a user","operationId":"postAuthLogin","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthRequest"}}},"description":"Auth Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthResponse"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Unauthorized"}},"summary":"Authenticate a user","tags":["auth"]}},"/console/available-network-interfaces":{"get":{"description":"List available network interfaces","operationId":"getConsoleListAvailableNetworkInterfaces","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.GetListAvailableNetworkInterfacesResponse"}}},"description":"OK"}},"summary":"List available network interfaces","tags":["console"]}},"/health":{"get":{"description":"get the health of the API","operationId":"getHealth","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Health"}}},"description":"OK"}},"summary":"Get the health of the API","tags":["health"]}},"/kubernetes/install/infra/setup":{"post":{"description":"Setup infra components","operationId":"postKubernetesInstallSetupInfra","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Setup infra components","tags":["kubernetes-install"]}},"/kubernetes/install/infra/status":{"get":{"description":"Get the current status of the infra","operationId":"getKubernetesInstallInfraStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra","tags":["kubernetes-install"]}},"/kubernetes/install/installation/config":{"get":{"description":"get the Kubernetes installation config","operationId":"getKubernetesInstallInstallationConfig","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the Kubernetes installation config","tags":["kubernetes-install"]}},"/kubernetes/install/installation/configure":{"post":{"description":"configure the Kubernetes installation for install","operationId":"postKubernetesInstallConfigureInstallation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}}},"description":"Installation config","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Configure the Kubernetes installation for install","tags":["kubernetes-install"]}},"/kubernetes/install/installation/status":{"get":{"description":"Get the current status of the installation configuration for install","operationId":"getKubernetesInstallInstallationStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get installation configuration status for install","tags":["kubernetes-install"]}},"/linux/install/host-preflights/run":{"post":{"description":"Run install host preflight checks using installation config and client-provided data","operationId":"postLinuxInstallRunHostPreflights","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PostInstallRunHostPreflightsRequest"}}},"description":"Post Install Run Host Preflights Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Run install host preflight checks","tags":["linux-install"]}},"/linux/install/host-preflights/status":{"get":{"description":"Get the current status and results of host preflight checks for install","operationId":"getLinuxInstallHostPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get host preflight status for install","tags":["linux-install"]}},"/linux/install/infra/setup":{"post":{"description":"Setup infra components","operationId":"postLinuxInstallSetupInfra","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInfraSetupRequest"}}},"description":"Infra Setup Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Setup infra components","tags":["linux-install"]}},"/linux/install/infra/status":{"get":{"description":"Get the current status of the infra","operationId":"getLinuxInstallInfraStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra","tags":["linux-install"]}},"/linux/install/installation/config":{"get":{"description":"get the installation config","operationId":"getLinuxInstallInstallationConfig","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the installation config","tags":["linux-install"]}},"/linux/install/installation/configure":{"post":{"description":"configure the installation for install","operationId":"postLinuxInstallConfigureInstallation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}}},"description":"Installation config","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Configure the installation for install","tags":["linux-install"]}},"/linux/install/installation/status":{"get":{"description":"Get the current status of the installation configuration for install","operationId":"getLinuxInstallInstallationStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get installation configuration status for install","tags":["linux-install"]}}}, "openapi": "3.1.0", "servers": [ {"url":"/api"} diff --git a/api/docs/swagger.json b/api/docs/swagger.json index a805109313..b5570cb171 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1,8 +1,8 @@ { - "components": {"schemas":{"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"status_code":{"type":"integer"}},"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"type":"object"},"types.Health":{"properties":{"status":{"type":"string"}},"type":"object"},"types.HostPreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.HostPreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.HostPreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.HostPreflightsRecord"},"type":"array","uniqueItems":false}},"type":"object"},"types.HostPreflightsRecord":{"properties":{"message":{"type":"string"},"title":{"type":"string"}},"type":"object"},"types.Infra":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.InfraComponent"},"type":"array","uniqueItems":false},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"output":{"$ref":"#/components/schemas/types.HostPreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.InstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"dataDirectory":{"type":"string"},"globalCidr":{"type":"string"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"localArtifactMirrorPort":{"type":"integer"},"networkInterface":{"type":"string"},"noProxy":{"type":"string"},"podCidr":{"type":"string"},"serviceCidr":{"type":"string"}},"type":"object"},"types.State":{"type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"type":"object"}},"securitySchemes":{"bearerauth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}}, + "components": {"schemas":{"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"status_code":{"type":"integer"}},"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"type":"object"},"types.GetListAvailableNetworkInterfacesResponse":{"properties":{"networkInterfaces":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.Health":{"properties":{"status":{"type":"string"}},"type":"object"},"types.HostPreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.HostPreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.HostPreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.HostPreflightsRecord"},"type":"array","uniqueItems":false}},"type":"object"},"types.HostPreflightsRecord":{"properties":{"message":{"type":"string"},"title":{"type":"string"}},"type":"object"},"types.Infra":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.InfraComponent"},"type":"array","uniqueItems":false},"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"allowIgnoreHostPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.HostPreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.KubernetesInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"noProxy":{"type":"string"}},"type":"object"},"types.LinuxInfraSetupRequest":{"properties":{"ignoreHostPreflights":{"type":"boolean"}},"type":"object"},"types.LinuxInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"dataDirectory":{"type":"string"},"globalCidr":{"type":"string"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"localArtifactMirrorPort":{"type":"integer"},"networkInterface":{"type":"string"},"noProxy":{"type":"string"},"podCidr":{"type":"string"},"serviceCidr":{"type":"string"}},"type":"object"},"types.PostInstallRunHostPreflightsRequest":{"properties":{"isUi":{"type":"boolean"}},"type":"object"},"types.State":{"type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"type":"object"}},"securitySchemes":{"bearerauth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}}, "info": {"contact":{"email":"support@replicated.com","name":"API Support","url":"https://github.com/replicatedhq/embedded-cluster/issues"},"description":"This is the API for the Embedded Cluster project.","license":{"name":"Apache 2.0","url":"http://www.apache.org/licenses/LICENSE-2.0.html"},"termsOfService":"http://swagger.io/terms/","title":"Embedded Cluster API","version":"0.1"}, "externalDocs": {"description":"OpenAPI","url":"https://swagger.io/resources/open-api/"}, - "paths": {"/auth/login":{"post":{"description":"Authenticate a user","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthRequest"}}},"description":"Auth Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthResponse"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Unauthorized"}},"summary":"Authenticate a user","tags":["auth"]}},"/health":{"get":{"description":"get the health of the API","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Health"}}},"description":"OK"}},"summary":"Get the health of the API","tags":["health"]}},"/install/host-preflights/run":{"post":{"description":"Run install host preflight checks using installation config and client-provided data","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Run install host preflight checks","tags":["install"]}},"/install/host-preflights/status":{"get":{"description":"Get the current status and results of host preflight checks for install","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get host preflight status for install","tags":["install"]}},"/install/infra/setup":{"post":{"description":"Setup infra components","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Setup infra components","tags":["install"]}},"/install/infra/status":{"get":{"description":"Get the current status of the infra","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra","tags":["install"]}},"/install/installation/config":{"get":{"description":"get the installation config","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallationConfig"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the installation config","tags":["install"]}},"/install/installation/configure":{"post":{"description":"configure the installation for install","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallationConfig"}}},"description":"Installation config","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Configure the installation for install","tags":["install"]}},"/install/installation/status":{"get":{"description":"Get the current status of the installation configuration for install","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get installation configuration status for install","tags":["install"]}},"/install/status":{"get":{"description":"Get the current status of the install workflow","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the install workflow","tags":["install"]},"post":{"description":"Set the status of the install workflow","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"Status","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Set the status of the install workflow","tags":["install"]}}}, + "paths": {"/auth/login":{"post":{"description":"Authenticate a user","operationId":"postAuthLogin","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthRequest"}}},"description":"Auth Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthResponse"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Unauthorized"}},"summary":"Authenticate a user","tags":["auth"]}},"/console/available-network-interfaces":{"get":{"description":"List available network interfaces","operationId":"getConsoleListAvailableNetworkInterfaces","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.GetListAvailableNetworkInterfacesResponse"}}},"description":"OK"}},"summary":"List available network interfaces","tags":["console"]}},"/health":{"get":{"description":"get the health of the API","operationId":"getHealth","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Health"}}},"description":"OK"}},"summary":"Get the health of the API","tags":["health"]}},"/kubernetes/install/infra/setup":{"post":{"description":"Setup infra components","operationId":"postKubernetesInstallSetupInfra","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Setup infra components","tags":["kubernetes-install"]}},"/kubernetes/install/infra/status":{"get":{"description":"Get the current status of the infra","operationId":"getKubernetesInstallInfraStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra","tags":["kubernetes-install"]}},"/kubernetes/install/installation/config":{"get":{"description":"get the Kubernetes installation config","operationId":"getKubernetesInstallInstallationConfig","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the Kubernetes installation config","tags":["kubernetes-install"]}},"/kubernetes/install/installation/configure":{"post":{"description":"configure the Kubernetes installation for install","operationId":"postKubernetesInstallConfigureInstallation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}}},"description":"Installation config","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Configure the Kubernetes installation for install","tags":["kubernetes-install"]}},"/kubernetes/install/installation/status":{"get":{"description":"Get the current status of the installation configuration for install","operationId":"getKubernetesInstallInstallationStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get installation configuration status for install","tags":["kubernetes-install"]}},"/linux/install/host-preflights/run":{"post":{"description":"Run install host preflight checks using installation config and client-provided data","operationId":"postLinuxInstallRunHostPreflights","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PostInstallRunHostPreflightsRequest"}}},"description":"Post Install Run Host Preflights Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Run install host preflight checks","tags":["linux-install"]}},"/linux/install/host-preflights/status":{"get":{"description":"Get the current status and results of host preflight checks for install","operationId":"getLinuxInstallHostPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get host preflight status for install","tags":["linux-install"]}},"/linux/install/infra/setup":{"post":{"description":"Setup infra components","operationId":"postLinuxInstallSetupInfra","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInfraSetupRequest"}}},"description":"Infra Setup Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Setup infra components","tags":["linux-install"]}},"/linux/install/infra/status":{"get":{"description":"Get the current status of the infra","operationId":"getLinuxInstallInfraStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra","tags":["linux-install"]}},"/linux/install/installation/config":{"get":{"description":"get the installation config","operationId":"getLinuxInstallInstallationConfig","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the installation config","tags":["linux-install"]}},"/linux/install/installation/configure":{"post":{"description":"configure the installation for install","operationId":"postLinuxInstallConfigureInstallation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}}},"description":"Installation config","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Configure the installation for install","tags":["linux-install"]}},"/linux/install/installation/status":{"get":{"description":"Get the current status of the installation configuration for install","operationId":"getLinuxInstallInstallationStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get installation configuration status for install","tags":["linux-install"]}}}, "openapi": "3.1.0", "servers": [ {"url":"/api"} diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index cc9087521d..cbbf03a629 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -24,6 +24,14 @@ components: token: type: string type: object + types.GetListAvailableNetworkInterfacesResponse: + properties: + networkInterfaces: + items: + type: string + type: array + uniqueItems: false + type: object types.Health: properties: status: @@ -61,6 +69,8 @@ components: $ref: '#/components/schemas/types.InfraComponent' type: array uniqueItems: false + logs: + type: string status: $ref: '#/components/schemas/types.Status' type: object @@ -73,6 +83,8 @@ components: type: object types.InstallHostPreflightsStatusResponse: properties: + allowIgnoreHostPreflights: + type: boolean output: $ref: '#/components/schemas/types.HostPreflightsOutput' status: @@ -83,7 +95,23 @@ components: type: array uniqueItems: false type: object - types.InstallationConfig: + types.KubernetesInstallationConfig: + properties: + adminConsolePort: + type: integer + httpProxy: + type: string + httpsProxy: + type: string + noProxy: + type: string + type: object + types.LinuxInfraSetupRequest: + properties: + ignoreHostPreflights: + type: boolean + type: object + types.LinuxInstallationConfig: properties: adminConsolePort: type: integer @@ -106,6 +134,11 @@ components: serviceCidr: type: string type: object + types.PostInstallRunHostPreflightsRequest: + properties: + isUi: + type: boolean + type: object types.State: type: string x-enum-varnames: @@ -147,6 +180,7 @@ paths: /auth/login: post: description: Authenticate a user + operationId: postAuthLogin requestBody: content: application/json: @@ -170,9 +204,24 @@ paths: summary: Authenticate a user tags: - auth + /console/available-network-interfaces: + get: + description: List available network interfaces + operationId: getConsoleListAvailableNetworkInterfaces + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/types.GetListAvailableNetworkInterfacesResponse' + description: OK + summary: List available network interfaces + tags: + - console /health: get: description: get the health of the API + operationId: getHealth responses: "200": content: @@ -183,143 +232,204 @@ paths: summary: Get the health of the API tags: - health - /install/host-preflights/run: + /kubernetes/install/infra/setup: post: - description: Run install host preflight checks using installation config and - client-provided data + description: Setup infra components + operationId: postKubernetesInstallSetupInfra + requestBody: + content: + application/json: + schema: + type: object responses: "200": content: application/json: schema: - $ref: '#/components/schemas/types.InstallHostPreflightsStatusResponse' + $ref: '#/components/schemas/types.Infra' description: OK security: - bearerauth: [] - summary: Run install host preflight checks + summary: Setup infra components tags: - - install - /install/host-preflights/status: + - kubernetes-install + /kubernetes/install/infra/status: get: - description: Get the current status and results of host preflight checks for - install + description: Get the current status of the infra + operationId: getKubernetesInstallInfraStatus responses: "200": content: application/json: schema: - $ref: '#/components/schemas/types.InstallHostPreflightsStatusResponse' + $ref: '#/components/schemas/types.Infra' description: OK security: - bearerauth: [] - summary: Get host preflight status for install + summary: Get the status of the infra + tags: + - kubernetes-install + /kubernetes/install/installation/config: + get: + description: get the Kubernetes installation config + operationId: getKubernetesInstallInstallationConfig + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/types.KubernetesInstallationConfig' + description: OK + security: + - bearerauth: [] + summary: Get the Kubernetes installation config tags: - - install - /install/infra/setup: + - kubernetes-install + /kubernetes/install/installation/configure: post: - description: Setup infra components + description: configure the Kubernetes installation for install + operationId: postKubernetesInstallConfigureInstallation + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/types.KubernetesInstallationConfig' + description: Installation config + required: true responses: "200": content: application/json: schema: - $ref: '#/components/schemas/types.Infra' + $ref: '#/components/schemas/types.Status' description: OK security: - bearerauth: [] - summary: Setup infra components + summary: Configure the Kubernetes installation for install tags: - - install - /install/infra/status: + - kubernetes-install + /kubernetes/install/installation/status: get: - description: Get the current status of the infra + description: Get the current status of the installation configuration for install + operationId: getKubernetesInstallInstallationStatus responses: "200": content: application/json: schema: - $ref: '#/components/schemas/types.Infra' + $ref: '#/components/schemas/types.Status' description: OK security: - bearerauth: [] - summary: Get the status of the infra + summary: Get installation configuration status for install tags: - - install - /install/installation/config: + - kubernetes-install + /linux/install/host-preflights/run: + post: + description: Run install host preflight checks using installation config and + client-provided data + operationId: postLinuxInstallRunHostPreflights + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/types.PostInstallRunHostPreflightsRequest' + description: Post Install Run Host Preflights Request + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/types.InstallHostPreflightsStatusResponse' + description: OK + security: + - bearerauth: [] + summary: Run install host preflight checks + tags: + - linux-install + /linux/install/host-preflights/status: get: - description: get the installation config + description: Get the current status and results of host preflight checks for + install + operationId: getLinuxInstallHostPreflightsStatus responses: "200": content: application/json: schema: - $ref: '#/components/schemas/types.InstallationConfig' + $ref: '#/components/schemas/types.InstallHostPreflightsStatusResponse' description: OK security: - bearerauth: [] - summary: Get the installation config + summary: Get host preflight status for install tags: - - install - /install/installation/configure: + - linux-install + /linux/install/infra/setup: post: - description: configure the installation for install + description: Setup infra components + operationId: postLinuxInstallSetupInfra requestBody: content: application/json: schema: - $ref: '#/components/schemas/types.InstallationConfig' - description: Installation config + $ref: '#/components/schemas/types.LinuxInfraSetupRequest' + description: Infra Setup Request required: true responses: "200": content: application/json: schema: - $ref: '#/components/schemas/types.Status' + $ref: '#/components/schemas/types.Infra' description: OK security: - bearerauth: [] - summary: Configure the installation for install + summary: Setup infra components tags: - - install - /install/installation/status: + - linux-install + /linux/install/infra/status: get: - description: Get the current status of the installation configuration for install + description: Get the current status of the infra + operationId: getLinuxInstallInfraStatus responses: "200": content: application/json: schema: - $ref: '#/components/schemas/types.Status' + $ref: '#/components/schemas/types.Infra' description: OK security: - bearerauth: [] - summary: Get installation configuration status for install + summary: Get the status of the infra tags: - - install - /install/status: + - linux-install + /linux/install/installation/config: get: - description: Get the current status of the install workflow + description: get the installation config + operationId: getLinuxInstallInstallationConfig responses: "200": content: application/json: schema: - $ref: '#/components/schemas/types.Status' + $ref: '#/components/schemas/types.LinuxInstallationConfig' description: OK security: - bearerauth: [] - summary: Get the status of the install workflow + summary: Get the installation config tags: - - install + - linux-install + /linux/install/installation/configure: post: - description: Set the status of the install workflow + description: configure the installation for install + operationId: postLinuxInstallConfigureInstallation requestBody: content: application/json: schema: - $ref: '#/components/schemas/types.Status' - description: Status + $ref: '#/components/schemas/types.LinuxInstallationConfig' + description: Installation config required: true responses: "200": @@ -330,8 +440,24 @@ paths: description: OK security: - bearerauth: [] - summary: Set the status of the install workflow + summary: Configure the installation for install + tags: + - linux-install + /linux/install/installation/status: + get: + description: Get the current status of the installation configuration for install + operationId: getLinuxInstallInstallationStatus + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/types.Status' + description: OK + security: + - bearerauth: [] + summary: Get installation configuration status for install tags: - - install + - linux-install servers: - url: /api diff --git a/api/handlers.go b/api/handlers.go new file mode 100644 index 0000000000..83a12413ae --- /dev/null +++ b/api/handlers.go @@ -0,0 +1,76 @@ +package api + +import ( + "fmt" + + authhandler "github.com/replicatedhq/embedded-cluster/api/internal/handlers/auth" + consolehandler "github.com/replicatedhq/embedded-cluster/api/internal/handlers/console" + healthhandler "github.com/replicatedhq/embedded-cluster/api/internal/handlers/health" + kuberneteshandler "github.com/replicatedhq/embedded-cluster/api/internal/handlers/kubernetes" + linuxhandler "github.com/replicatedhq/embedded-cluster/api/internal/handlers/linux" +) + +type handlers struct { + auth *authhandler.Handler + console *consolehandler.Handler + health *healthhandler.Handler + linux *linuxhandler.Handler + kubernetes *kuberneteshandler.Handler +} + +func (a *API) initHandlers() error { + // Auth handler + authHandler, err := authhandler.New( + a.cfg.Password, + authhandler.WithLogger(a.logger), + authhandler.WithAuthController(a.authController), + ) + if err != nil { + return fmt.Errorf("new auth handler: %w", err) + } + a.handlers.auth = authHandler + + // Console handler + consoleHandler, err := consolehandler.New( + consolehandler.WithLogger(a.logger), + consolehandler.WithConsoleController(a.consoleController), + ) + if err != nil { + return fmt.Errorf("new console handler: %w", err) + } + a.handlers.console = consoleHandler + + // Health handler + healthHandler, err := healthhandler.New( + healthhandler.WithLogger(a.logger), + ) + if err != nil { + return fmt.Errorf("new health handler: %w", err) + } + a.handlers.health = healthHandler + + // Linux handler + linuxHandler, err := linuxhandler.New( + a.cfg, + linuxhandler.WithLogger(a.logger), + linuxhandler.WithMetricsReporter(a.metricsReporter), + linuxhandler.WithInstallController(a.linuxInstallController), + ) + if err != nil { + return fmt.Errorf("new linux handler: %w", err) + } + a.handlers.linux = linuxHandler + + // Kubernetes handler + kubernetesHandler, err := kuberneteshandler.New( + a.cfg, + kuberneteshandler.WithLogger(a.logger), + kuberneteshandler.WithInstallController(a.kubernetesInstallController), + ) + if err != nil { + return fmt.Errorf("new kubernetes handler: %w", err) + } + a.handlers.kubernetes = kubernetesHandler + + return nil +} diff --git a/api/health.go b/api/health.go deleted file mode 100644 index e38709b18e..0000000000 --- a/api/health.go +++ /dev/null @@ -1,22 +0,0 @@ -package api - -import ( - "net/http" - - "github.com/replicatedhq/embedded-cluster/api/types" -) - -// getHealth handler to get the health of the API -// -// @Summary Get the health of the API -// @Description get the health of the API -// @Tags health -// @Produce json -// @Success 200 {object} types.Health -// @Router /health [get] -func (a *API) getHealth(w http.ResponseWriter, r *http.Request) { - response := types.Health{ - Status: types.HealthStatusOK, - } - a.json(w, r, http.StatusOK, response) -} diff --git a/api/install.go b/api/install.go deleted file mode 100644 index 552d341245..0000000000 --- a/api/install.go +++ /dev/null @@ -1,230 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/replicatedhq/embedded-cluster/api/types" -) - -// getInstallInstallationConfig handler to get the installation config -// -// @Summary Get the installation config -// @Description get the installation config -// @Tags install -// @Security bearerauth -// @Produce json -// @Success 200 {object} types.InstallationConfig -// @Router /install/installation/config [get] -func (a *API) getInstallInstallationConfig(w http.ResponseWriter, r *http.Request) { - config, err := a.installController.GetInstallationConfig(r.Context()) - if err != nil { - a.logError(r, err, "failed to get installation config") - a.jsonError(w, r, err) - return - } - - a.json(w, r, http.StatusOK, config) -} - -// postInstallConfigureInstallation handler to configure the installation for install -// -// @Summary Configure the installation for install -// @Description configure the installation for install -// @Tags install -// @Security bearerauth -// @Accept json -// @Produce json -// @Param installationConfig body types.InstallationConfig true "Installation config" -// @Success 200 {object} types.Status -// @Router /install/installation/configure [post] -func (a *API) postInstallConfigureInstallation(w http.ResponseWriter, r *http.Request) { - var config types.InstallationConfig - if err := json.NewDecoder(r.Body).Decode(&config); err != nil { - a.logError(r, err, "failed to decode installation config") - a.jsonError(w, r, types.NewBadRequestError(err)) - return - } - - if err := a.installController.ConfigureInstallation(r.Context(), &config); err != nil { - a.logError(r, err, "failed to set installation config") - a.jsonError(w, r, err) - return - } - - a.getInstallInstallationStatus(w, r) -} - -// getInstallInstallationStatus handler to get the status of the installation configuration for install -// -// @Summary Get installation configuration status for install -// @Description Get the current status of the installation configuration for install -// @Tags install -// @Security bearerauth -// @Produce json -// @Success 200 {object} types.Status -// @Router /install/installation/status [get] -func (a *API) getInstallInstallationStatus(w http.ResponseWriter, r *http.Request) { - status, err := a.installController.GetInstallationStatus(r.Context()) - if err != nil { - a.logError(r, err, "failed to get installation status") - a.jsonError(w, r, err) - return - } - - a.json(w, r, http.StatusOK, status) -} - -// postInstallRunHostPreflights handler to run install host preflight checks -// -// @Summary Run install host preflight checks -// @Description Run install host preflight checks using installation config and client-provided data -// @Tags install -// @Security bearerauth -// @Produce json -// @Success 200 {object} types.InstallHostPreflightsStatusResponse -// @Router /install/host-preflights/run [post] -func (a *API) postInstallRunHostPreflights(w http.ResponseWriter, r *http.Request) { - err := a.installController.RunHostPreflights(r.Context()) - if err != nil { - a.logError(r, err, "failed to run install host preflights") - a.jsonError(w, r, err) - return - } - - a.getInstallHostPreflightsStatus(w, r) -} - -// getInstallHostPreflightsStatus handler to get host preflight status for install -// -// @Summary Get host preflight status for install -// @Description Get the current status and results of host preflight checks for install -// @Tags install -// @Security bearerauth -// @Produce json -// @Success 200 {object} types.InstallHostPreflightsStatusResponse -// @Router /install/host-preflights/status [get] -func (a *API) getInstallHostPreflightsStatus(w http.ResponseWriter, r *http.Request) { - titles, err := a.installController.GetHostPreflightTitles(r.Context()) - if err != nil { - a.logError(r, err, "failed to get install host preflight titles") - a.jsonError(w, r, err) - return - } - - output, err := a.installController.GetHostPreflightOutput(r.Context()) - if err != nil { - a.logError(r, err, "failed to get install host preflight output") - a.jsonError(w, r, err) - return - } - - status, err := a.installController.GetHostPreflightStatus(r.Context()) - if err != nil { - a.logError(r, err, "failed to get install host preflight status") - a.jsonError(w, r, err) - return - } - - response := types.InstallHostPreflightsStatusResponse{ - Titles: titles, - Output: output, - Status: status, - } - - a.json(w, r, http.StatusOK, response) -} - -// postInstallSetupInfra handler to setup infra components -// -// @Summary Setup infra components -// @Description Setup infra components -// @Tags install -// @Security bearerauth -// @Produce json -// @Success 200 {object} types.Infra -// @Router /install/infra/setup [post] -func (a *API) postInstallSetupInfra(w http.ResponseWriter, r *http.Request) { - err := a.installController.SetupInfra(r.Context()) - if err != nil { - a.logError(r, err, "failed to setup infra") - a.jsonError(w, r, err) - return - } - - a.getInstallInfraStatus(w, r) -} - -// getInstallInfraStatus handler to get the status of the infra -// -// @Summary Get the status of the infra -// @Description Get the current status of the infra -// @Tags install -// @Security bearerauth -// @Produce json -// @Success 200 {object} types.Infra -// @Router /install/infra/status [get] -func (a *API) getInstallInfraStatus(w http.ResponseWriter, r *http.Request) { - infra, err := a.installController.GetInfra(r.Context()) - if err != nil { - a.logError(r, err, "failed to get install infra status") - a.jsonError(w, r, err) - return - } - - a.json(w, r, http.StatusOK, infra) -} - -// postInstallSetInstallStatus handler to set the status of the install workflow -// -// @Summary Set the status of the install workflow -// @Description Set the status of the install workflow -// @Tags install -// @Security bearerauth -// @Accept json -// @Produce json -// @Param status body types.Status true "Status" -// @Success 200 {object} types.Status -// @Router /install/status [post] -func (a *API) setInstallStatus(w http.ResponseWriter, r *http.Request) { - var status types.Status - if err := json.NewDecoder(r.Body).Decode(&status); err != nil { - a.logError(r, err, "failed to decode install status") - a.jsonError(w, r, types.NewBadRequestError(err)) - return - } - - if err := types.ValidateStatus(&status); err != nil { - a.logError(r, err, "invalid install status") - a.jsonError(w, r, err) - return - } - - if err := a.installController.SetStatus(r.Context(), &status); err != nil { - a.logError(r, err, "failed to set install status") - a.jsonError(w, r, err) - return - } - - a.getInstallStatus(w, r) -} - -// getInstallStatus handler to get the status of the install workflow -// -// @Summary Get the status of the install workflow -// @Description Get the current status of the install workflow -// @Tags install -// @Security bearerauth -// @Produce json -// @Success 200 {object} types.Status -// @Router /install/status [get] -func (a *API) getInstallStatus(w http.ResponseWriter, r *http.Request) { - status, err := a.installController.GetStatus(r.Context()) - if err != nil { - a.logError(r, err, "failed to get install status") - a.jsonError(w, r, err) - return - } - - a.json(w, r, http.StatusOK, status) -} diff --git a/api/integration/assets/license.yaml b/api/integration/assets/license.yaml new file mode 100644 index 0000000000..ec35c3b0d8 --- /dev/null +++ b/api/integration/assets/license.yaml @@ -0,0 +1,37 @@ +apiVersion: kots.io/v1beta1 +kind: License +metadata: + name: dryrun-install +spec: + appSlug: fake-app-slug + channelID: fake-channel-id + channelName: fake-channel-name + channels: + - channelID: fake-channel-id + channelName: fake-channel-name + channelSlug: fake-channel-slug + endpoint: https://fake-endpoint.com + isDefault: true + replicatedProxyDomain: fake-replicated-proxy.test.net + customerEmail: salah@replicated.com + customerName: Salah EC Dev + endpoint: https://fake-endpoint.com + entitlements: + expires_at: + description: License Expiration + signature: {} + title: Expiration + value: "" + valueType: String + isDisasterRecoverySupported: true + isEmbeddedClusterDownloadEnabled: true + isEmbeddedClusterMultiNodeEnabled: true + isKotsInstallEnabled: true + isNewKotsUiEnabled: true + isSnapshotSupported: true + isSupportBundleUploadSupported: true + licenseID: fake-license-id + licenseSequence: 4 + licenseType: dev + replicatedProxyDomain: fake-replicated-proxy.test.net + signature: ZmFrZS1zaWduYXR1cmU= diff --git a/api/integration/auth_controller_test.go b/api/integration/auth_controller_test.go index a625ba3112..803e6c893f 100644 --- a/api/integration/auth_controller_test.go +++ b/api/integration/auth_controller_test.go @@ -11,25 +11,27 @@ import ( "github.com/replicatedhq/embedded-cluster/api" "github.com/replicatedhq/embedded-cluster/api/client" "github.com/replicatedhq/embedded-cluster/api/controllers/auth" - "github.com/replicatedhq/embedded-cluster/api/controllers/install" - "github.com/replicatedhq/embedded-cluster/api/internal/managers/installation" + linuxinstall "github.com/replicatedhq/embedded-cluster/api/controllers/linux/install" + "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/installation" + "github.com/replicatedhq/embedded-cluster/api/internal/utils" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" - "github.com/replicatedhq/embedded-cluster/api/pkg/utils" "github.com/replicatedhq/embedded-cluster/api/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestAuthLoginAndTokenValidation(t *testing.T) { - password := "test-password" + cfg := types.APIConfig{ + Password: "test-password", + } // Create an auth controller - authController, err := auth.NewAuthController(password) + authController, err := auth.NewAuthController(cfg.Password) require.NoError(t, err) // Create an install controller - installController, err := install.NewInstallController( - install.WithInstallationManager(installation.NewInstallationManager( + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithInstallationManager(installation.NewInstallationManager( installation.WithNetUtils(&utils.MockNetUtils{}), )), ) @@ -37,9 +39,9 @@ func TestAuthLoginAndTokenValidation(t *testing.T) { // Create the API with the auth controller apiInstance, err := api.New( - password, + cfg, api.WithAuthController(authController), - api.WithInstallController(installController), + api.WithLinuxInstallController(installController), api.WithLogger(logger.NewDiscardLogger()), ) require.NoError(t, err) @@ -52,7 +54,7 @@ func TestAuthLoginAndTokenValidation(t *testing.T) { t.Run("successful login", func(t *testing.T) { // Create login request with correct password loginReq := types.AuthRequest{ - Password: password, + Password: cfg.Password, } loginReqJSON, err := json.Marshal(loginReq) require.NoError(t, err) @@ -110,7 +112,7 @@ func TestAuthLoginAndTokenValidation(t *testing.T) { // Test access to protected route without token t.Run("access protected route without token", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/install/installation/config", nil) + req := httptest.NewRequest(http.MethodGet, "/linux/install/installation/config", nil) rec := httptest.NewRecorder() // Serve the request @@ -122,7 +124,7 @@ func TestAuthLoginAndTokenValidation(t *testing.T) { // Test access to protected route with invalid token t.Run("access protected route with invalid token", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/install/installation/config", nil) + req := httptest.NewRequest(http.MethodGet, "/linux/install/installation/config", nil) req.Header.Set("Authorization", "Bearer "+"invalid-token") rec := httptest.NewRecorder() @@ -135,11 +137,13 @@ func TestAuthLoginAndTokenValidation(t *testing.T) { } func TestAPIClientLogin(t *testing.T) { - password := "test-password" + cfg := types.APIConfig{ + Password: "test-password", + } // Create the API with the auth controller apiInstance, err := api.New( - password, + cfg, api.WithLogger(logger.NewDiscardLogger()), ) require.NoError(t, err) @@ -158,13 +162,12 @@ func TestAPIClientLogin(t *testing.T) { c := client.New(server.URL) // Login with the client - err := c.Authenticate(password) + err := c.Authenticate(cfg.Password) require.NoError(t, err, "API client login should succeed with correct password") // Verify we can make authenticated requests after login - status, err := c.GetInstallationStatus() + _, err = c.GetLinuxInstallationStatus() require.NoError(t, err, "API client should be able to get installation status after successful login") - assert.NotNil(t, status, "Installation status should not be nil") }) // Test failed login with incorrect password @@ -182,7 +185,7 @@ func TestAPIClientLogin(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, apiErr.StatusCode, "Error should have Unauthorized status code") // Verify we can't make authenticated requests - _, err = c.GetInstallationStatus() + _, err = c.GetLinuxInstallationStatus() require.Error(t, err, "API client should not be able to get installation status after failed login") }) } diff --git a/api/integration/console_test.go b/api/integration/console_test.go index 169e789110..f16b6b5e09 100644 --- a/api/integration/console_test.go +++ b/api/integration/console_test.go @@ -9,8 +9,8 @@ import ( "github.com/gorilla/mux" "github.com/replicatedhq/embedded-cluster/api" "github.com/replicatedhq/embedded-cluster/api/controllers/console" + "github.com/replicatedhq/embedded-cluster/api/internal/utils" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" - "github.com/replicatedhq/embedded-cluster/api/pkg/utils" "github.com/replicatedhq/embedded-cluster/api/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -28,7 +28,9 @@ func TestConsoleListAvailableNetworkInterfaces(t *testing.T) { // Create the API with the install controller apiInstance, err := api.New( - "password", + types.APIConfig{ + Password: "password", + }, api.WithConsoleController(consoleController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), @@ -74,7 +76,9 @@ func TestConsoleListAvailableNetworkInterfacesUnauthorized(t *testing.T) { // Create the API with the install controller apiInstance, err := api.New( - "password", + types.APIConfig{ + Password: "password", + }, api.WithConsoleController(consoleController), api.WithAuthController(&staticAuthController{"VALID_TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), @@ -116,7 +120,9 @@ func TestConsoleListAvailableNetworkInterfacesError(t *testing.T) { // Create the API with the install controller apiInstance, err := api.New( - "password", + types.APIConfig{ + Password: "password", + }, api.WithConsoleController(consoleController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), diff --git a/api/integration/hostpreflights_test.go b/api/integration/hostpreflights_test.go index c90d8eefcc..63e522eda0 100644 --- a/api/integration/hostpreflights_test.go +++ b/api/integration/hostpreflights_test.go @@ -1,7 +1,7 @@ package integration import ( - "context" + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -10,9 +10,11 @@ import ( "github.com/gorilla/mux" "github.com/replicatedhq/embedded-cluster/api" - "github.com/replicatedhq/embedded-cluster/api/controllers/install" - "github.com/replicatedhq/embedded-cluster/api/internal/managers/installation" - "github.com/replicatedhq/embedded-cluster/api/internal/managers/preflight" + linuxinstall "github.com/replicatedhq/embedded-cluster/api/controllers/linux/install" + linuxinstallation "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/installation" + linuxpreflight "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/preflight" + linuxinstallationstore "github.com/replicatedhq/embedded-cluster/api/internal/store/linux/installation" + linuxpreflightstore "github.com/replicatedhq/embedded-cluster/api/internal/store/linux/preflight" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" @@ -24,6 +26,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "k8s.io/utils/ptr" ) // Test the getHostPreflightsStatus endpoint returns host preflights status correctly @@ -47,25 +50,31 @@ func TestGetHostPreflightsStatus(t *testing.T) { "Some Preflight", "Another Preflight", }, - Status: &types.Status{ + Status: types.Status{ State: types.StateFailed, Description: "A preflight failed", }, } runner := &preflights.MockPreflightRunner{} // Create a host preflights manager - manager := preflight.NewHostPreflightManager( - preflight.WithHostPreflightStore(preflight.NewMemoryStore(&hpf)), - preflight.WithPreflightRunner(runner), + manager := linuxpreflight.NewHostPreflightManager( + linuxpreflight.WithHostPreflightStore( + linuxpreflightstore.NewMemoryStore(linuxpreflightstore.WithHostPreflight(hpf)), + ), + linuxpreflight.WithPreflightRunner(runner), ) // Create an install controller - installController, err := install.NewInstallController(install.WithHostPreflightManager(manager)) + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithHostPreflightManager(manager), + ) require.NoError(t, err) // Create the API with the install controller apiInstance, err := api.New( - "password", - api.WithInstallController(installController), + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), ) @@ -78,7 +87,7 @@ func TestGetHostPreflightsStatus(t *testing.T) { // Test successful get t.Run("Success", func(t *testing.T) { // Create a request - req := httptest.NewRequest(http.MethodGet, "/install/host-preflights/status", nil) + req := httptest.NewRequest(http.MethodGet, "/linux/install/host-preflights/status", nil) req.Header.Set("Authorization", "Bearer TOKEN") rec := httptest.NewRecorder() @@ -86,7 +95,7 @@ func TestGetHostPreflightsStatus(t *testing.T) { router.ServeHTTP(rec, req) // Check the response - assert.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, http.StatusOK, rec.Code, "expected status ok, got %d with body %s", rec.Code, rec.Body.String()) assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) // Parse the response body @@ -103,7 +112,7 @@ func TestGetHostPreflightsStatus(t *testing.T) { // Test authorization t.Run("Authorization error", func(t *testing.T) { // Create a request - req := httptest.NewRequest(http.MethodGet, "/install/host-preflights/status", nil) + req := httptest.NewRequest(http.MethodGet, "/linux/install/host-preflights/status", nil) req.Header.Set("Authorization", "Bearer NOT_A_TOKEN") rec := httptest.NewRecorder() @@ -124,14 +133,17 @@ func TestGetHostPreflightsStatus(t *testing.T) { // Test error handling t.Run("Controller error", func(t *testing.T) { // Create a mock controller that returns an error - mockController := &mockInstallController{ - getHostPreflightStatusError: assert.AnError, - } + mockController := &linuxinstall.MockController{} + mockController.On("GetHostPreflightTitles", mock.Anything).Return([]string{}, nil) + mockController.On("GetHostPreflightOutput", mock.Anything).Return(&types.HostPreflightsOutput{}, nil) + mockController.On("GetHostPreflightStatus", mock.Anything).Return(types.Status{}, assert.AnError) // Create the API with the mock controller apiInstance, err := api.New( - "password", - api.WithInstallController(mockController), + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(mockController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), ) @@ -141,7 +153,7 @@ func TestGetHostPreflightsStatus(t *testing.T) { apiInstance.RegisterRoutes(router) // Create a request - req := httptest.NewRequest(http.MethodGet, "/install/host-preflights/status", nil) + req := httptest.NewRequest(http.MethodGet, "/linux/install/host-preflights/status", nil) req.Header.Set("Authorization", "Bearer TOKEN") rec := httptest.NewRecorder() @@ -157,9 +169,99 @@ func TestGetHostPreflightsStatus(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusInternalServerError, apiError.StatusCode) assert.NotEmpty(t, apiError.Message) + + // Verify mock expectations + mockController.AssertExpectations(t) }) } +// Test the getHostPreflightsStatus endpoint returns AllowIgnoreHostPreflights flag correctly +func TestGetHostPreflightsStatusWithIgnoreFlag(t *testing.T) { + tests := []struct { + name string + allowIgnoreHostPreflights bool + expectedAllowIgnore bool + }{ + { + name: "allow ignore host preflights true", + allowIgnoreHostPreflights: true, + expectedAllowIgnore: true, + }, + { + name: "allow ignore host preflights false", + allowIgnoreHostPreflights: false, + expectedAllowIgnore: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hpf := types.HostPreflights{ + Output: &types.HostPreflightsOutput{ + Pass: []types.HostPreflightsRecord{ + { + Title: "Some Preflight", + Message: "All good", + }, + }, + }, + Titles: []string{"Some Preflight"}, + Status: types.Status{ + State: types.StateSucceeded, + Description: "All preflights passed", + }, + } + runner := &preflights.MockPreflightRunner{} + // Create a host preflights manager + manager := linuxpreflight.NewHostPreflightManager( + linuxpreflight.WithHostPreflightStore(linuxpreflightstore.NewMemoryStore(linuxpreflightstore.WithHostPreflight(hpf))), + linuxpreflight.WithPreflightRunner(runner), + ) + // Create an install controller + installController, err := linuxinstall.NewInstallController(linuxinstall.WithHostPreflightManager(manager)) + require.NoError(t, err) + + // Create the API with allow ignore host preflights flag + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + LinuxConfig: types.LinuxConfig{ + AllowIgnoreHostPreflights: tt.allowIgnoreHostPreflights, + }, + }, + api.WithLinuxInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Create a request + req := httptest.NewRequest(http.MethodGet, "/linux/install/host-preflights/status", nil) + req.Header.Set("Authorization", "Bearer TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + require.Equal(t, http.StatusOK, rec.Code, "expected status ok, got %d with body %s", rec.Code, rec.Body.String()) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + // Parse the response body + var status types.InstallHostPreflightsStatusResponse + err = json.NewDecoder(rec.Body).Decode(&status) + require.NoError(t, err) + + // Verify the flag is present and correctly set by the handler + assert.Equal(t, tt.expectedAllowIgnore, status.AllowIgnoreHostPreflights) + }) + } +} + // Test the postRunHostPreflights endpoint runs host preflights correctly func TestPostRunHostPreflights(t *testing.T) { // Create a runtime config @@ -171,26 +273,27 @@ func TestPostRunHostPreflights(t *testing.T) { runner := &preflights.MockPreflightRunner{} // Creeate the installation struct - inst := types.NewInstallation() + inst := types.LinuxInstallation{} // Create a host preflights manager with the mock runner - pfManager := preflight.NewHostPreflightManager( - preflight.WithRuntimeConfig(rc), - preflight.WithPreflightRunner(runner), + pfManager := linuxpreflight.NewHostPreflightManager( + linuxpreflight.WithPreflightRunner(runner), ) // Create an installation manager - iManager := installation.NewInstallationManager( - installation.WithRuntimeConfig(rc), - installation.WithInstallationStore(installation.NewMemoryStore(inst)), + iManager := linuxinstallation.NewInstallationManager( + linuxinstallation.WithInstallationStore(linuxinstallationstore.NewMemoryStore(linuxinstallationstore.WithInstallation(inst))), ) // Create an install controller with the mocked manager - installController, err := install.NewInstallController( - install.WithHostPreflightManager(pfManager), - install.WithInstallationManager(iManager), + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithStateMachine(linuxinstall.NewStateMachine( + linuxinstall.WithCurrentState(linuxinstall.StateHostConfigured), + )), + linuxinstall.WithHostPreflightManager(pfManager), + linuxinstall.WithInstallationManager(iManager), // Mock the release data used by the preflight runner - install.WithReleaseData(&release.ReleaseData{ + linuxinstall.WithReleaseData(&release.ReleaseData{ EmbeddedClusterConfig: &ecv1beta1.Config{}, ChannelRelease: &release.ChannelRelease{ DefaultDomains: release.Domains{ @@ -199,7 +302,7 @@ func TestPostRunHostPreflights(t *testing.T) { }, }, }), - install.WithRuntimeConfig(rc), + linuxinstall.WithRuntimeConfig(rc), ) require.NoError(t, err) @@ -212,22 +315,29 @@ func TestPostRunHostPreflights(t *testing.T) { mock.InOrder( runner.On("Prepare", mock.Anything, preflights.PrepareOptions{ - K0sDataDir: rc.EmbeddedClusterK0sSubDir(), - OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), - NodeIP: nodeIP, - ReplicatedAppURL: "https://replicated.example.com", - ProxyRegistryURL: "https://some-proxy.example.com", + DataDir: rc.EmbeddedClusterHomeDirectory(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), + OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), + NodeIP: nodeIP, + ReplicatedAppURL: "https://replicated.example.com", + ProxyRegistryURL: "https://some-proxy.example.com", + AdminConsolePort: 30000, + LocalArtifactMirrorPort: 50000, + GlobalCIDR: ptr.To("10.244.0.0/16"), + IsUI: true, }).Return(hpfc, nil), // For a successful run, we expect the runner to return an output without any errors or warnings - runner.On("Run", mock.Anything, hpfc, mock.Anything, rc).Return(&types.HostPreflightsOutput{}, "", nil), + runner.On("Run", mock.Anything, hpfc, rc).Return(&types.HostPreflightsOutput{}, "", nil), runner.On("SaveToDisk", mock.Anything, mock.Anything).Return(nil), runner.On("CopyBundleTo", mock.Anything, mock.Anything).Return(nil), ) // Create the API with the install controller apiInstance, err := api.New( - "password", - api.WithInstallController(installController), + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), ) @@ -238,15 +348,16 @@ func TestPostRunHostPreflights(t *testing.T) { apiInstance.RegisterRoutes(router) // Create a request - req := httptest.NewRequest(http.MethodPost, "/install/host-preflights/run", nil) + req := httptest.NewRequest(http.MethodPost, "/linux/install/host-preflights/run", bytes.NewBuffer([]byte(`{"isUi": true}`))) req.Header.Set("Authorization", "Bearer TOKEN") + req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() // Serve the request router.ServeHTTP(rec, req) // Check the response - assert.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, http.StatusOK, rec.Code, "expected status ok, got %d with body %s", rec.Code, rec.Body.String()) t.Logf("Response body: %s", rec.Body.String()) @@ -255,17 +366,16 @@ func TestPostRunHostPreflights(t *testing.T) { err = json.NewDecoder(rec.Body).Decode(&status) require.NoError(t, err) - // Verify that the status was properly set - assert.Equal(t, types.StateRunning, status.Status.State) - assert.Equal(t, "Running host preflights", status.Status.Description) - - // The status should eventually be set to succeeded in a goroutine - assert.Eventually(t, func() bool { - status, err := installController.GetHostPreflightStatus(context.Background()) - t.Logf("Status: %s, Description: %s", status.State, status.Description) - require.NoError(t, err) - return status.State == types.StateSucceeded - }, 5*time.Second, 100*time.Millisecond) + // The state should eventually be set to succeeded in a goroutine + var preflightsStatus types.Status + if !assert.Eventually(t, func() bool { + preflightsStatus, err = installController.GetHostPreflightStatus(t.Context()) + require.NoError(t, err, "GetHostPreflightStatus should succeed") + return preflightsStatus.State == types.StateSucceeded + }, 1*time.Second, 100*time.Millisecond) { + require.Equal(t, types.StateSucceeded, preflightsStatus.State, + "Preflights not succeeded with state %s and description %s", preflightsStatus.State, preflightsStatus.Description) + } // Verify that the mock expectations were met runner.AssertExpectations(t) @@ -277,25 +387,30 @@ func TestPostRunHostPreflights(t *testing.T) { runner := &preflights.MockPreflightRunner{} // Create a host preflights manager - manager := preflight.NewHostPreflightManager( - preflight.WithPreflightRunner(runner), + manager := linuxpreflight.NewHostPreflightManager( + linuxpreflight.WithPreflightRunner(runner), ) // Create an install controller - installController, err := install.NewInstallController( - install.WithHostPreflightManager(manager), - install.WithReleaseData(&release.ReleaseData{ + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithStateMachine(linuxinstall.NewStateMachine( + linuxinstall.WithCurrentState(linuxinstall.StateHostConfigured), + )), + linuxinstall.WithHostPreflightManager(manager), + linuxinstall.WithReleaseData(&release.ReleaseData{ EmbeddedClusterConfig: &ecv1beta1.Config{}, ChannelRelease: &release.ChannelRelease{}, }), - install.WithRuntimeConfig(rc), + linuxinstall.WithRuntimeConfig(rc), ) require.NoError(t, err) // Create the API with the install controller apiInstance, err := api.New( - "password", - api.WithInstallController(installController), + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), ) @@ -306,8 +421,9 @@ func TestPostRunHostPreflights(t *testing.T) { apiInstance.RegisterRoutes(router) // Create a request - req := httptest.NewRequest(http.MethodPost, "/install/host-preflights/run", nil) + req := httptest.NewRequest(http.MethodPost, "/linux/install/host-preflights/run", bytes.NewBuffer([]byte(`{"isUi": true}`))) req.Header.Set("Authorization", "Bearer NOT_A_TOKEN") + req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() // Serve the request @@ -331,25 +447,30 @@ func TestPostRunHostPreflights(t *testing.T) { runner.On("Prepare", mock.Anything, mock.Anything).Return(nil, assert.AnError) // Create a host preflights manager with the failing mock runner - manager := preflight.NewHostPreflightManager( - preflight.WithPreflightRunner(runner), + manager := linuxpreflight.NewHostPreflightManager( + linuxpreflight.WithPreflightRunner(runner), ) // Create an install controller with the failing manager - installController, err := install.NewInstallController( - install.WithHostPreflightManager(manager), - install.WithReleaseData(&release.ReleaseData{ + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithStateMachine(linuxinstall.NewStateMachine( + linuxinstall.WithCurrentState(linuxinstall.StateHostConfigured), + )), + linuxinstall.WithHostPreflightManager(manager), + linuxinstall.WithReleaseData(&release.ReleaseData{ EmbeddedClusterConfig: &ecv1beta1.Config{}, ChannelRelease: &release.ChannelRelease{}, }), - install.WithRuntimeConfig(rc), + linuxinstall.WithRuntimeConfig(rc), ) require.NoError(t, err) // Create the API with the install controller apiInstance, err := api.New( - "password", - api.WithInstallController(installController), + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), ) @@ -359,8 +480,9 @@ func TestPostRunHostPreflights(t *testing.T) { apiInstance.RegisterRoutes(router) // Create a request - req := httptest.NewRequest(http.MethodPost, "/install/host-preflights/run", nil) + req := httptest.NewRequest(http.MethodPost, "/linux/install/host-preflights/run", bytes.NewBuffer([]byte(`{"isUi": true}`))) req.Header.Set("Authorization", "Bearer TOKEN") + req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() // Serve the request @@ -383,28 +505,33 @@ func TestPostRunHostPreflights(t *testing.T) { runner := &preflights.MockPreflightRunner{} mock.InOrder( runner.On("Prepare", mock.Anything, mock.Anything).Return(hpfc, nil), - runner.On("Run", mock.Anything, hpfc, mock.Anything, mock.Anything).Return(nil, "this is an error", assert.AnError), + runner.On("Run", mock.Anything, hpfc, mock.Anything).Return(nil, "this is an error", assert.AnError), ) // Create a host preflights manager with the failing mock runner - manager := preflight.NewHostPreflightManager( - preflight.WithPreflightRunner(runner), + manager := linuxpreflight.NewHostPreflightManager( + linuxpreflight.WithPreflightRunner(runner), ) // Create an install controller with the failing manager - installController, err := install.NewInstallController( - install.WithHostPreflightManager(manager), - install.WithReleaseData(&release.ReleaseData{ + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithStateMachine(linuxinstall.NewStateMachine( + linuxinstall.WithCurrentState(linuxinstall.StateHostConfigured), + )), + linuxinstall.WithHostPreflightManager(manager), + linuxinstall.WithReleaseData(&release.ReleaseData{ EmbeddedClusterConfig: &ecv1beta1.Config{}, ChannelRelease: &release.ChannelRelease{}, }), - install.WithRuntimeConfig(rc), + linuxinstall.WithRuntimeConfig(rc), ) require.NoError(t, err) // Create the API with the install controller apiInstance, err := api.New( - "password", - api.WithInstallController(installController), + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), ) @@ -414,15 +541,16 @@ func TestPostRunHostPreflights(t *testing.T) { apiInstance.RegisterRoutes(router) // Create a request - req := httptest.NewRequest(http.MethodPost, "/install/host-preflights/run", nil) + req := httptest.NewRequest(http.MethodPost, "/linux/install/host-preflights/run", bytes.NewBuffer([]byte(`{"isUi": true}`))) req.Header.Set("Authorization", "Bearer TOKEN") + req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() // Serve the request router.ServeHTTP(rec, req) // Check the response - assert.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, http.StatusOK, rec.Code, "expected status ok, got %d with body %s", rec.Code, rec.Body.String()) t.Logf("Response body: %s", rec.Body.String()) @@ -431,17 +559,16 @@ func TestPostRunHostPreflights(t *testing.T) { err = json.NewDecoder(rec.Body).Decode(&status) require.NoError(t, err) - // Verify that the status was properly set - assert.Equal(t, types.StateRunning, status.Status.State) - assert.Equal(t, "Running host preflights", status.Status.Description) - - // The status should eventually be set to failed in a goroutine - assert.Eventually(t, func() bool { - status, err := installController.GetHostPreflightStatus(context.Background()) - t.Logf("Status: %s, Description: %s", status.State, status.Description) - require.NoError(t, err) - return status.State == types.StateFailed - }, 5*time.Second, 100*time.Millisecond) + // The state should eventually be set to failed in a goroutine + var preflightsStatus types.Status + if !assert.Eventually(t, func() bool { + preflightsStatus, err = installController.GetHostPreflightStatus(t.Context()) + require.NoError(t, err, "GetHostPreflightStatus should succeed") + return preflightsStatus.State == types.StateFailed + }, 5*time.Second, 100*time.Millisecond) { + require.Equal(t, types.StateFailed, preflightsStatus.State, + "Preflights not failed with state %s and description %s", preflightsStatus.State, preflightsStatus.Description) + } // Verify that the mock expectations were met runner.AssertExpectations(t) @@ -450,30 +577,35 @@ func TestPostRunHostPreflights(t *testing.T) { // Test we get a conflict error if preflights are already running t.Run("Preflights already running errror", func(t *testing.T) { // Create a host preflights manager with the failing mock runner - hp := types.NewHostPreflights() - hp.Status = &types.Status{ + hp := types.HostPreflights{} + hp.Status = types.Status{ State: types.StateRunning, Description: "Preflights running", } - manager := preflight.NewHostPreflightManager( - preflight.WithHostPreflightStore(preflight.NewMemoryStore(hp)), + manager := linuxpreflight.NewHostPreflightManager( + linuxpreflight.WithHostPreflightStore(linuxpreflightstore.NewMemoryStore(linuxpreflightstore.WithHostPreflight(hp))), ) // Create an install controller with the failing manager - installController, err := install.NewInstallController( - install.WithHostPreflightManager(manager), - install.WithReleaseData(&release.ReleaseData{ + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithStateMachine(linuxinstall.NewStateMachine( + linuxinstall.WithCurrentState(linuxinstall.StatePreflightsRunning), + )), + linuxinstall.WithHostPreflightManager(manager), + linuxinstall.WithReleaseData(&release.ReleaseData{ EmbeddedClusterConfig: &ecv1beta1.Config{}, ChannelRelease: &release.ChannelRelease{}, }), - install.WithRuntimeConfig(rc), + linuxinstall.WithRuntimeConfig(rc), ) require.NoError(t, err) // Create the API with the install controller apiInstance, err := api.New( - "password", - api.WithInstallController(installController), + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), ) @@ -483,8 +615,9 @@ func TestPostRunHostPreflights(t *testing.T) { apiInstance.RegisterRoutes(router) // Create a request - req := httptest.NewRequest(http.MethodPost, "/install/host-preflights/run", nil) + req := httptest.NewRequest(http.MethodPost, "/linux/install/host-preflights/run", bytes.NewBuffer([]byte(`{"isUi": true}`))) req.Header.Set("Authorization", "Bearer TOKEN") + req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() // Serve the request diff --git a/api/integration/install_test.go b/api/integration/install_test.go index cbdd878efc..a6510f628f 100644 --- a/api/integration/install_test.go +++ b/api/integration/install_test.go @@ -3,173 +3,239 @@ package integration import ( "bytes" "context" + _ "embed" "encoding/json" + "errors" + "fmt" + "net" "net/http" "net/http/httptest" + "os" "strings" "testing" "time" "github.com/gorilla/mux" + k0sv1beta1 "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" "github.com/replicatedhq/embedded-cluster/api" - "github.com/replicatedhq/embedded-cluster/api/client" - "github.com/replicatedhq/embedded-cluster/api/controllers/install" - "github.com/replicatedhq/embedded-cluster/api/internal/managers/installation" + apiclient "github.com/replicatedhq/embedded-cluster/api/client" + kubernetesinstall "github.com/replicatedhq/embedded-cluster/api/controllers/kubernetes/install" + linuxinstall "github.com/replicatedhq/embedded-cluster/api/controllers/linux/install" + kubernetesinfra "github.com/replicatedhq/embedded-cluster/api/internal/managers/kubernetes/infra" + kubernetesinstallationmanager "github.com/replicatedhq/embedded-cluster/api/internal/managers/kubernetes/installation" + "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/infra" + linuxinfra "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/infra" + linuxinstallationmanager "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/installation" + "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/preflight" + linuxpreflightstore "github.com/replicatedhq/embedded-cluster/api/internal/store/linux/preflight" + "github.com/replicatedhq/embedded-cluster/api/internal/utils" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" - "github.com/replicatedhq/embedded-cluster/api/pkg/utils" "github.com/replicatedhq/embedded-cluster/api/types" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" + "github.com/replicatedhq/embedded-cluster/pkg-new/k0s" + "github.com/replicatedhq/embedded-cluster/pkg/helm" + "github.com/replicatedhq/embedded-cluster/pkg/kubernetesinstallation" + "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" + "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + metadatafake "k8s.io/client-go/metadata/fake" + client "sigs.k8s.io/controller-runtime/pkg/client" + clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" ) -// Mock implementation of the install.Controller interface -type mockInstallController struct { - configureInstallationError error - getInstallationConfigError error - runHostPreflightsError error - getHostPreflightStatusError error - getHostPreflightOutputError error - getHostPreflightTitlesError error - setupInfraError error - getInfraError error - setStatusError error - readStatusError error -} - -func (m *mockInstallController) GetInstallationConfig(ctx context.Context) (*types.InstallationConfig, error) { - if m.getInstallationConfigError != nil { - return nil, m.getInstallationConfigError - } - return &types.InstallationConfig{}, nil -} - -func (m *mockInstallController) ConfigureInstallation(ctx context.Context, config *types.InstallationConfig) error { - return m.configureInstallationError -} - -func (m *mockInstallController) GetInstallationStatus(ctx context.Context) (*types.Status, error) { - if m.readStatusError != nil { - return nil, m.readStatusError - } - return &types.Status{}, nil -} - -func (m *mockInstallController) RunHostPreflights(ctx context.Context) error { - return m.runHostPreflightsError -} - -func (m *mockInstallController) GetHostPreflightStatus(ctx context.Context) (*types.Status, error) { - if m.getHostPreflightStatusError != nil { - return nil, m.getHostPreflightStatusError - } - return &types.Status{}, nil -} - -func (m *mockInstallController) GetHostPreflightOutput(ctx context.Context) (*types.HostPreflightsOutput, error) { - if m.getHostPreflightOutputError != nil { - return nil, m.getHostPreflightOutputError - } - return &types.HostPreflightsOutput{}, nil -} - -func (m *mockInstallController) GetHostPreflightTitles(ctx context.Context) ([]string, error) { - if m.getHostPreflightTitlesError != nil { - return nil, m.getHostPreflightTitlesError - } - return []string{}, nil -} - -func (m *mockInstallController) SetupInfra(ctx context.Context) error { - return m.setupInfraError -} - -func (m *mockInstallController) GetInfra(ctx context.Context) (*types.Infra, error) { - if m.getInfraError != nil { - return nil, m.getInfraError - } - return &types.Infra{}, nil -} - -func (m *mockInstallController) SetStatus(ctx context.Context, status *types.Status) error { - return m.setStatusError -} - -func (m *mockInstallController) GetStatus(ctx context.Context) (*types.Status, error) { - return nil, m.readStatusError -} +var ( + //go:embed assets/license.yaml + licenseData []byte +) -func TestConfigureInstallation(t *testing.T) { +func TestLinuxConfigureInstallation(t *testing.T) { // Test scenarios testCases := []struct { - name string - mockHostUtils *hostutils.MockHostUtils - token string - config types.InstallationConfig - expectedStatus int - expectedError bool + name string + mockHostUtils *hostutils.MockHostUtils + mockNetUtils *utils.MockNetUtils + token string + config types.LinuxInstallationConfig + expectedStatus *types.Status + expectedStatusCode int + expectedError bool + validateRuntimeConfig func(t *testing.T, rc runtimeconfig.RuntimeConfig) }{ { name: "Valid config", mockHostUtils: func() *hostutils.MockHostUtils { mockHostUtils := &hostutils.MockHostUtils{} - mockHostUtils.On("ConfigureHost", mock.Anything, mock.Anything).Return(nil).Once() + mockHostUtils.On("ConfigureHost", mock.Anything, + mock.MatchedBy(func(rc runtimeconfig.RuntimeConfig) bool { + return rc.EmbeddedClusterHomeDirectory() == "/tmp/data" && + rc.AdminConsolePort() == 8000 && + rc.LocalArtifactMirrorPort() == 8081 && + rc.NetworkInterface() == "eth0" && + rc.GlobalCIDR() == "10.0.0.0/16" && + rc.PodCIDR() == "10.0.0.0/17" && + rc.ServiceCIDR() == "10.0.128.0/17" && + rc.NodePortRange() == "80-32767" + }), + mock.Anything, + ).Return(nil).Once() + return mockHostUtils + }(), + mockNetUtils: &utils.MockNetUtils{}, + token: "TOKEN", + config: types.LinuxInstallationConfig{ + DataDirectory: "/tmp/data", + AdminConsolePort: 8000, + LocalArtifactMirrorPort: 8081, + GlobalCIDR: "10.0.0.0/16", + NetworkInterface: "eth0", + }, + expectedStatus: &types.Status{ + State: types.StateSucceeded, + Description: "Installation configured", + }, + expectedStatusCode: http.StatusOK, + expectedError: false, + validateRuntimeConfig: func(t *testing.T, rc runtimeconfig.RuntimeConfig) { + assert.Equal(t, "/tmp/data", rc.EmbeddedClusterHomeDirectory()) + assert.Equal(t, 8000, rc.AdminConsolePort()) + assert.Equal(t, 8081, rc.LocalArtifactMirrorPort()) + assert.Equal(t, ecv1beta1.NetworkSpec{ + NetworkInterface: "eth0", + GlobalCIDR: "10.0.0.0/16", + PodCIDR: "10.0.0.0/17", + ServiceCIDR: "10.0.128.0/17", + NodePortRange: "80-32767", + }, rc.Get().Network) + assert.Nil(t, rc.Get().Proxy) + }, + }, + { + name: "Valid config with proxy", + mockHostUtils: func() *hostutils.MockHostUtils { + mockHostUtils := &hostutils.MockHostUtils{} + mockHostUtils.On("ConfigureHost", mock.Anything, + mock.MatchedBy(func(rc runtimeconfig.RuntimeConfig) bool { + return rc.EmbeddedClusterHomeDirectory() == "/tmp/data" && + rc.AdminConsolePort() == 8000 && + rc.LocalArtifactMirrorPort() == 8081 && + rc.NetworkInterface() == "eth0" && + rc.GlobalCIDR() == "10.0.0.0/16" && + rc.PodCIDR() == "10.0.0.0/17" && + rc.ServiceCIDR() == "10.0.128.0/17" && + rc.NodePortRange() == "80-32767" && + rc.ProxySpec().HTTPProxy == "http://proxy.example.com" && + rc.ProxySpec().HTTPSProxy == "https://proxy.example.com" && + rc.ProxySpec().ProvidedNoProxy == "somecompany.internal,192.168.17.0/24" + }), + mock.Anything, + ).Return(nil).Once() return mockHostUtils }(), + mockNetUtils: func() *utils.MockNetUtils { + mockNetUtils := &utils.MockNetUtils{} + mockNetUtils.On("FirstValidIPNet", "eth0").Return(&net.IPNet{IP: net.ParseIP("192.168.17.12"), Mask: net.CIDRMask(24, 32)}, nil) + return mockNetUtils + }(), token: "TOKEN", - config: types.InstallationConfig{ + config: types.LinuxInstallationConfig{ DataDirectory: "/tmp/data", AdminConsolePort: 8000, LocalArtifactMirrorPort: 8081, GlobalCIDR: "10.0.0.0/16", NetworkInterface: "eth0", + HTTPProxy: "http://proxy.example.com", + HTTPSProxy: "https://proxy.example.com", + NoProxy: "somecompany.internal,192.168.17.0/24", + }, + expectedStatus: &types.Status{ + State: types.StateSucceeded, + Description: "Installation configured", + }, + expectedStatusCode: http.StatusOK, + expectedError: false, + validateRuntimeConfig: func(t *testing.T, rc runtimeconfig.RuntimeConfig) { + assert.Equal(t, "/tmp/data", rc.EmbeddedClusterHomeDirectory()) + assert.Equal(t, 8000, rc.AdminConsolePort()) + assert.Equal(t, 8081, rc.LocalArtifactMirrorPort()) + assert.Equal(t, ecv1beta1.NetworkSpec{ + NetworkInterface: "eth0", + GlobalCIDR: "10.0.0.0/16", + PodCIDR: "10.0.0.0/17", + ServiceCIDR: "10.0.128.0/17", + NodePortRange: "80-32767", + }, rc.Get().Network) + assert.Equal(t, &ecv1beta1.ProxySpec{ + HTTPProxy: "http://proxy.example.com", + HTTPSProxy: "https://proxy.example.com", + NoProxy: "localhost,127.0.0.1,.cluster.local,.svc,169.254.169.254,10.0.0.0/17,10.0.128.0/17,somecompany.internal,192.168.17.0/24", + ProvidedNoProxy: "somecompany.internal,192.168.17.0/24", + }, rc.Get().Proxy) }, - expectedStatus: http.StatusOK, - expectedError: false, }, { name: "Invalid config - port conflict", mockHostUtils: &hostutils.MockHostUtils{}, + mockNetUtils: &utils.MockNetUtils{}, token: "TOKEN", - config: types.InstallationConfig{ + config: types.LinuxInstallationConfig{ DataDirectory: "/tmp/data", AdminConsolePort: 8080, LocalArtifactMirrorPort: 8080, // Same as AdminConsolePort GlobalCIDR: "10.0.0.0/16", NetworkInterface: "eth0", }, - expectedStatus: http.StatusBadRequest, - expectedError: true, + expectedStatus: &types.Status{ + State: types.StateFailed, + Description: "validate: field errors: adminConsolePort and localArtifactMirrorPort cannot be equal", + }, + expectedStatusCode: http.StatusBadRequest, + expectedError: true, }, { - name: "Unauthorized", - mockHostUtils: &hostutils.MockHostUtils{}, - token: "NOT_A_TOKEN", - config: types.InstallationConfig{}, - expectedStatus: http.StatusUnauthorized, - expectedError: true, + name: "Unauthorized", + mockHostUtils: &hostutils.MockHostUtils{}, + mockNetUtils: &utils.MockNetUtils{}, + token: "NOT_A_TOKEN", + config: types.LinuxInstallationConfig{}, + expectedStatusCode: http.StatusUnauthorized, + expectedError: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create a runtime config - rc := runtimeconfig.New(nil) - rc.SetDataDir(t.TempDir()) + rc := runtimeconfig.New(nil, runtimeconfig.WithEnvSetter(&testEnvSetter{})) + // Set the expected data directory to match the test case + if tc.config.DataDirectory != "" { + rc.SetDataDir(tc.config.DataDirectory) + } // Create an install controller with the config manager - installController, err := install.NewInstallController( - install.WithHostUtils(tc.mockHostUtils), - install.WithRuntimeConfig(rc), + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithStateMachine(linuxinstall.NewStateMachine(linuxinstall.WithCurrentState(linuxinstall.StateNew))), + linuxinstall.WithHostUtils(tc.mockHostUtils), + linuxinstall.WithNetUtils(tc.mockNetUtils), ) require.NoError(t, err) // Create the API with the install controller apiInstance, err := api.New( - "password", - api.WithInstallController(installController), + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), ) @@ -184,7 +250,7 @@ func TestConfigureInstallation(t *testing.T) { require.NoError(t, err) // Create a request - req := httptest.NewRequest(http.MethodPost, "/install/installation/configure", bytes.NewReader(configJSON)) + req := httptest.NewRequest(http.MethodPost, "/linux/install/installation/configure", bytes.NewReader(configJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+tc.token) rec := httptest.NewRecorder() @@ -193,7 +259,7 @@ func TestConfigureInstallation(t *testing.T) { router.ServeHTTP(rec, req) // Check the response - assert.Equal(t, tc.expectedStatus, rec.Code) + assert.Equal(t, tc.expectedStatusCode, rec.Code) t.Logf("Response body: %s", rec.Body.String()) @@ -202,32 +268,38 @@ func TestConfigureInstallation(t *testing.T) { var apiError types.APIError err = json.NewDecoder(rec.Body).Decode(&apiError) require.NoError(t, err) - assert.Equal(t, tc.expectedStatus, apiError.StatusCode) + assert.Equal(t, tc.expectedStatusCode, apiError.StatusCode) assert.NotEmpty(t, apiError.Message) } else { var status types.Status err = json.NewDecoder(rec.Body).Decode(&status) require.NoError(t, err) - // Verify that the status is not pending. We cannot check for an end state here because the hots config is async + // Verify that the status is not pending. We cannot check for an end state here because the host config is async // so the state might have moved from running to a final state before we get the response. assert.NotEqual(t, types.StatePending, status.State) } - if !tc.expectedError { - // The status is set to succeeded in a goroutine, so we need to wait for it + // We might not have an expected status if the test is expected to fail before running the controller logic + if tc.expectedStatus != nil { + // The status is set in a goroutine, so we need to wait for it assert.Eventually(t, func() bool { status, err := installController.GetInstallationStatus(t.Context()) require.NoError(t, err) - return status.State == types.StateSucceeded && status.Description == "Installation configured" - }, 1*time.Second, 100*time.Millisecond, "status should eventually be succeeded") + return status.State == tc.expectedStatus.State + }, 1*time.Second, 100*time.Millisecond, fmt.Sprintf("Expected status to be %s", tc.expectedStatus.State)) + + // Get the final status to check the description + finalStatus, err := installController.GetInstallationStatus(t.Context()) + require.NoError(t, err) + assert.Contains(t, finalStatus.Description, tc.expectedStatus.Description) } if !tc.expectedError { // Verify that the config is in the store storedConfig, err := installController.GetInstallationConfig(t.Context()) require.NoError(t, err) - assert.Equal(t, tc.config.DataDirectory, storedConfig.DataDirectory) + assert.Equal(t, rc.EmbeddedClusterHomeDirectory(), storedConfig.DataDirectory) assert.Equal(t, tc.config.AdminConsolePort, storedConfig.AdminConsolePort) // Verify that the runtime config is updated @@ -236,22 +308,35 @@ func TestConfigureInstallation(t *testing.T) { assert.Equal(t, tc.config.LocalArtifactMirrorPort, rc.LocalArtifactMirrorPort()) } - // Verify host confiuration was performed for successful tests + // Verify host configuration was performed for successful tests tc.mockHostUtils.AssertExpectations(t) + tc.mockNetUtils.AssertExpectations(t) + + if tc.validateRuntimeConfig != nil { + tc.validateRuntimeConfig(t, rc) + } }) } } // Test that config validation errors are properly returned -func TestConfigureInstallationValidation(t *testing.T) { +func TestLinuxConfigureInstallationValidation(t *testing.T) { + rc := runtimeconfig.New(nil, runtimeconfig.WithEnvSetter(&testEnvSetter{})) + rc.SetDataDir(t.TempDir()) + // Create an install controller with the config manager - installController, err := install.NewInstallController() + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithStateMachine(linuxinstall.NewStateMachine(linuxinstall.WithCurrentState(linuxinstall.StateNew))), + ) require.NoError(t, err) // Create the API with the install controller apiInstance, err := api.New( - "password", - api.WithInstallController(installController), + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), ) @@ -262,7 +347,7 @@ func TestConfigureInstallationValidation(t *testing.T) { apiInstance.RegisterRoutes(router) // Test a validation error case with mixed CIDR settings - config := types.InstallationConfig{ + config := types.LinuxInstallationConfig{ DataDirectory: "/tmp/data", AdminConsolePort: 8000, LocalArtifactMirrorPort: 8081, @@ -275,7 +360,7 @@ func TestConfigureInstallationValidation(t *testing.T) { require.NoError(t, err) // Create a request - req := httptest.NewRequest(http.MethodPost, "/install/installation/configure", bytes.NewReader(configJSON)) + req := httptest.NewRequest(http.MethodPost, "/linux/install/installation/configure", bytes.NewReader(configJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+"TOKEN") rec := httptest.NewRecorder() @@ -292,20 +377,28 @@ func TestConfigureInstallationValidation(t *testing.T) { var apiError types.APIError err = json.NewDecoder(rec.Body).Decode(&apiError) require.NoError(t, err) - assert.Contains(t, apiError.Error(), "Service CIDR is required when globalCidr is not set") + assert.Contains(t, apiError.Error(), "serviceCidr is required when globalCidr is not set") // Also verify the field name is correct assert.Equal(t, "serviceCidr", apiError.Errors[0].Field) } // Test that the endpoint properly handles malformed JSON -func TestConfigureInstallationBadRequest(t *testing.T) { +func TestLinuxConfigureInstallationBadRequest(t *testing.T) { + rc := runtimeconfig.New(nil, runtimeconfig.WithEnvSetter(&testEnvSetter{})) + rc.SetDataDir(t.TempDir()) + // Create an install controller with the config manager - installController, err := install.NewInstallController() + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithStateMachine(linuxinstall.NewStateMachine(linuxinstall.WithCurrentState(linuxinstall.StateHostConfigured))), + ) require.NoError(t, err) apiInstance, err := api.New( - "password", - api.WithInstallController(installController), + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), ) @@ -315,7 +408,7 @@ func TestConfigureInstallationBadRequest(t *testing.T) { apiInstance.RegisterRoutes(router) // Create a request with invalid JSON - req := httptest.NewRequest(http.MethodPost, "/install/installation/configure", + req := httptest.NewRequest(http.MethodPost, "/linux/install/installation/configure", bytes.NewReader([]byte(`{"dataDirectory": "/tmp/data", "adminConsolePort": "not-a-number"}`))) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+"TOKEN") @@ -331,16 +424,17 @@ func TestConfigureInstallationBadRequest(t *testing.T) { } // Test that the server returns proper errors when the API controller fails -func TestConfigureInstallationControllerError(t *testing.T) { +func TestLinuxConfigureInstallationControllerError(t *testing.T) { // Create a mock controller that returns an error - mockController := &mockInstallController{ - configureInstallationError: assert.AnError, - } + mockController := &linuxinstall.MockController{} + mockController.On("ConfigureInstallation", mock.Anything, mock.Anything).Return(assert.AnError) // Create the API with the mock controller apiInstance, err := api.New( - "password", - api.WithInstallController(mockController), + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(mockController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), ) @@ -350,7 +444,7 @@ func TestConfigureInstallationControllerError(t *testing.T) { apiInstance.RegisterRoutes(router) // Create a valid config request - config := types.InstallationConfig{ + config := types.LinuxInstallationConfig{ DataDirectory: "/tmp/data", AdminConsolePort: 8000, } @@ -358,7 +452,7 @@ func TestConfigureInstallationControllerError(t *testing.T) { require.NoError(t, err) // Create a request - req := httptest.NewRequest(http.MethodPost, "/install/installation/configure", bytes.NewReader(configJSON)) + req := httptest.NewRequest(http.MethodPost, "/linux/install/installation/configure", bytes.NewReader(configJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+"TOKEN") rec := httptest.NewRecorder() @@ -370,22 +464,30 @@ func TestConfigureInstallationControllerError(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, rec.Code) t.Logf("Response body: %s", rec.Body.String()) + + // Verify mock expectations + mockController.AssertExpectations(t) } -// Test the getInstall endpoint returns installation data correctly -func TestGetInstallationConfig(t *testing.T) { +// Test the getInstallationConfig endpoint returns installation data correctly +func TestLinuxGetInstallationConfig(t *testing.T) { + rc := runtimeconfig.New(nil, runtimeconfig.WithEnvSetter(&testEnvSetter{})) + tempDir := t.TempDir() + rc.SetDataDir(tempDir) + // Create a config manager - installationManager := installation.NewInstallationManager() + installationManager := linuxinstallationmanager.NewInstallationManager() // Create an install controller with the config manager - installController, err := install.NewInstallController( - install.WithInstallationManager(installationManager), + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithInstallationManager(installationManager), ) require.NoError(t, err) // Set some initial config - initialConfig := types.InstallationConfig{ - DataDirectory: "/tmp/test-data", + initialConfig := types.LinuxInstallationConfig{ + DataDirectory: rc.EmbeddedClusterHomeDirectory(), AdminConsolePort: 8080, LocalArtifactMirrorPort: 8081, GlobalCIDR: "10.0.0.0/16", @@ -396,8 +498,10 @@ func TestGetInstallationConfig(t *testing.T) { // Create the API with the install controller apiInstance, err := api.New( - "password", - api.WithInstallController(installController), + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), ) @@ -410,7 +514,7 @@ func TestGetInstallationConfig(t *testing.T) { // Test successful get t.Run("Success", func(t *testing.T) { // Create a request - req := httptest.NewRequest(http.MethodGet, "/install/installation/config", nil) + req := httptest.NewRequest(http.MethodGet, "/linux/install/installation/config", nil) req.Header.Set("Authorization", "Bearer "+"TOKEN") rec := httptest.NewRecorder() @@ -422,12 +526,12 @@ func TestGetInstallationConfig(t *testing.T) { assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) // Parse the response body - var config types.InstallationConfig + var config types.LinuxInstallationConfig err = json.NewDecoder(rec.Body).Decode(&config) require.NoError(t, err) // Verify the installation data matches what we expect - assert.Equal(t, initialConfig.DataDirectory, config.DataDirectory) + assert.Equal(t, rc.EmbeddedClusterHomeDirectory(), config.DataDirectory) assert.Equal(t, initialConfig.AdminConsolePort, config.AdminConsolePort) assert.Equal(t, initialConfig.LocalArtifactMirrorPort, config.LocalArtifactMirrorPort) assert.Equal(t, initialConfig.GlobalCIDR, config.GlobalCIDR) @@ -439,21 +543,29 @@ func TestGetInstallationConfig(t *testing.T) { netUtils := &utils.MockNetUtils{} netUtils.On("ListValidNetworkInterfaces").Return([]string{"eth0", "eth1"}, nil).Once() netUtils.On("DetermineBestNetworkInterface").Return("eth0", nil).Once() + + rc := runtimeconfig.New(nil, runtimeconfig.WithEnvSetter(&testEnvSetter{})) + defaultTempDir := t.TempDir() + rc.SetDataDir(defaultTempDir) + // Create a fresh config manager without writing anything - emptyInstallationManager := installation.NewInstallationManager( - installation.WithNetUtils(netUtils), + emptyInstallationManager := linuxinstallationmanager.NewInstallationManager( + linuxinstallationmanager.WithNetUtils(netUtils), ) // Create an install controller with the empty config manager - emptyInstallController, err := install.NewInstallController( - install.WithInstallationManager(emptyInstallationManager), + emptyInstallController, err := linuxinstall.NewInstallController( + linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithInstallationManager(emptyInstallationManager), ) require.NoError(t, err) // Create the API with the install controller emptyAPI, err := api.New( - "password", - api.WithInstallController(emptyInstallController), + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(emptyInstallController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), ) @@ -464,7 +576,7 @@ func TestGetInstallationConfig(t *testing.T) { emptyAPI.RegisterRoutes(emptyRouter) // Create a request - req := httptest.NewRequest(http.MethodGet, "/install/installation/config", nil) + req := httptest.NewRequest(http.MethodGet, "/linux/install/installation/config", nil) req.Header.Set("Authorization", "Bearer "+"TOKEN") rec := httptest.NewRecorder() @@ -476,12 +588,13 @@ func TestGetInstallationConfig(t *testing.T) { assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) // Parse the response body - var config types.InstallationConfig + var config types.LinuxInstallationConfig err = json.NewDecoder(rec.Body).Decode(&config) require.NoError(t, err) // Verify the installation data contains defaults or empty values - assert.Equal(t, "/var/lib/embedded-cluster", config.DataDirectory) + // Note: DataDirectory gets overridden with the temp directory from RuntimeConfig + assert.Equal(t, rc.EmbeddedClusterHomeDirectory(), config.DataDirectory) assert.Equal(t, 30000, config.AdminConsolePort) assert.Equal(t, 50000, config.LocalArtifactMirrorPort) assert.Equal(t, "10.244.0.0/16", config.GlobalCIDR) @@ -491,7 +604,7 @@ func TestGetInstallationConfig(t *testing.T) { // Test authorization t.Run("Authorization error", func(t *testing.T) { // Create a request - req := httptest.NewRequest(http.MethodGet, "/install/installation/config", nil) + req := httptest.NewRequest(http.MethodGet, "/linux/install/installation/config", nil) req.Header.Set("Authorization", "Bearer "+"NOT_A_TOKEN") rec := httptest.NewRecorder() @@ -511,14 +624,15 @@ func TestGetInstallationConfig(t *testing.T) { // Test error handling t.Run("Controller error", func(t *testing.T) { // Create a mock controller that returns an error - mockController := &mockInstallController{ - getInstallationConfigError: assert.AnError, - } + mockController := &linuxinstall.MockController{} + mockController.On("GetInstallationConfig", mock.Anything).Return(types.LinuxInstallationConfig{}, assert.AnError) // Create the API with the mock controller apiInstance, err := api.New( - "password", - api.WithInstallController(mockController), + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(mockController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), ) @@ -528,7 +642,7 @@ func TestGetInstallationConfig(t *testing.T) { apiInstance.RegisterRoutes(router) // Create a request - req := httptest.NewRequest(http.MethodGet, "/install/installation/config", nil) + req := httptest.NewRequest(http.MethodGet, "/linux/install/installation/config", nil) req.Header.Set("Authorization", "Bearer "+"TOKEN") rec := httptest.NewRecorder() @@ -544,155 +658,296 @@ func TestGetInstallationConfig(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusInternalServerError, apiError.StatusCode) assert.NotEmpty(t, apiError.Message) + + // Verify mock expectations + mockController.AssertExpectations(t) }) } -// Test the getInstallStatus endpoint returns install status correctly -func TestGetInstallStatus(t *testing.T) { +// TestLinuxInstallWithAPIClient tests the install endpoints using the API client +func TestLinuxInstallWithAPIClient(t *testing.T) { + password := "test-password" + + // Create a runtimeconfig to be used in the install process + rc := runtimeconfig.New(nil, runtimeconfig.WithEnvSetter(&testEnvSetter{})) + tempDir := t.TempDir() + rc.SetDataDir(tempDir) + + // Create a mock hostutils + mockHostUtils := &hostutils.MockHostUtils{} + mockHostUtils.On("ConfigureHost", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + // Create a config manager + installationManager := linuxinstallationmanager.NewInstallationManager( + linuxinstallationmanager.WithHostUtils(mockHostUtils), + ) + // Create an install controller with the config manager - installController, err := install.NewInstallController() + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithInstallationManager(installationManager), + ) + require.NoError(t, err) + + // Set some initial config + initialConfig := types.LinuxInstallationConfig{ + DataDirectory: rc.EmbeddedClusterHomeDirectory(), + AdminConsolePort: 9080, + LocalArtifactMirrorPort: 9081, + GlobalCIDR: "192.168.0.0/16", + NetworkInterface: "eth1", + } + err = installationManager.SetConfig(initialConfig) require.NoError(t, err) // Set some initial status initialStatus := types.Status{ State: types.StatePending, - Description: "Installation in progress", + Description: "Installation pending", } - err = installController.SetStatus(t.Context(), &initialStatus) + err = installationManager.SetStatus(initialStatus) require.NoError(t, err) - // Create the API with the install controller + // Create the API with controllers apiInstance, err := api.New( - "password", - api.WithInstallController(installController), + types.APIConfig{ + Password: password, + }, api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLinuxInstallController(installController), api.WithLogger(logger.NewDiscardLogger()), ) require.NoError(t, err) // Create a router and register the API routes router := mux.NewRouter() - apiInstance.RegisterRoutes(router) + apiInstance.RegisterRoutes(router.PathPrefix("/api").Subrouter()) - // Test successful get - t.Run("Success", func(t *testing.T) { - // Create a request - req := httptest.NewRequest(http.MethodGet, "/install/status", nil) - req.Header.Set("Authorization", "Bearer "+"TOKEN") - rec := httptest.NewRecorder() + // Create a test server using the router + server := httptest.NewServer(router) + defer server.Close() - // Serve the request - router.ServeHTTP(rec, req) + // Create client with the predefined token + c := apiclient.New(server.URL, apiclient.WithToken("TOKEN")) + require.NoError(t, err, "API client login should succeed") - // Check the response - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + // Test GetLinuxInstallationConfig + t.Run("GetLinuxInstallationConfig", func(t *testing.T) { + config, err := c.GetLinuxInstallationConfig() + require.NoError(t, err, "GetInstallationConfig should succeed") - // Parse the response body - var status types.Status - err = json.NewDecoder(rec.Body).Decode(&status) - require.NoError(t, err) + // Verify values + // Note: DataDirectory gets overridden with the temp directory from RuntimeConfig + assert.Equal(t, rc.EmbeddedClusterHomeDirectory(), config.DataDirectory) + assert.Equal(t, 9080, config.AdminConsolePort) + assert.Equal(t, 9081, config.LocalArtifactMirrorPort) + assert.Equal(t, "192.168.0.0/16", config.GlobalCIDR) + assert.Equal(t, "eth1", config.NetworkInterface) + }) - // Verify the status matches what we expect - assert.Equal(t, initialStatus.State, status.State) - assert.Equal(t, initialStatus.Description, status.Description) + // Test GetLinuxInstallationStatus + t.Run("GetLinuxInstallationStatus", func(t *testing.T) { + status, err := c.GetLinuxInstallationStatus() + require.NoError(t, err, "GetLinuxInstallationStatus should succeed") + assert.Equal(t, types.StatePending, status.State) + assert.Equal(t, "Installation pending", status.Description) }) - // Test authorization - t.Run("Authorization error", func(t *testing.T) { - // Create a request - req := httptest.NewRequest(http.MethodGet, "/install/status", nil) - req.Header.Set("Authorization", "Bearer "+"NOT_A_TOKEN") - rec := httptest.NewRecorder() + // Test ConfigureLinuxInstallation + t.Run("ConfigureLinuxInstallation", func(t *testing.T) { + // Create a valid config + config := types.LinuxInstallationConfig{ + DataDirectory: "/tmp/new-dir", + AdminConsolePort: 8000, + LocalArtifactMirrorPort: 8081, + GlobalCIDR: "10.0.0.0/16", + NetworkInterface: "eth0", + } - // Serve the request - router.ServeHTTP(rec, req) + // Update runtime config to match expected data directory for this test + rc.SetDataDir(config.DataDirectory) - // Check the response - assert.Equal(t, http.StatusUnauthorized, rec.Code) - assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) - // Parse the response body - var apiError types.APIError - err = json.NewDecoder(rec.Body).Decode(&apiError) - require.NoError(t, err) - assert.Equal(t, http.StatusUnauthorized, apiError.StatusCode) - }) + // Configure the installation using the client + _, err = c.ConfigureLinuxInstallation(config) + require.NoError(t, err, "ConfigureLinuxInstallation should succeed with valid config") - // Test error handling - t.Run("Controller error", func(t *testing.T) { - // Create a mock controller that returns an error - mockController := &mockInstallController{ - readStatusError: assert.AnError, + // Verify the status was set correctly + var installStatus types.Status + if !assert.Eventually(t, func() bool { + installStatus, err = c.GetLinuxInstallationStatus() + require.NoError(t, err, "GetLinuxInstallationStatus should succeed") + return installStatus.State == types.StateSucceeded + }, 1*time.Second, 100*time.Millisecond) { + require.Equal(t, types.StateSucceeded, installStatus.State, + "Installation not succeeded with state %s and description %s", installStatus.State, installStatus.Description) } - // Create the API with the mock controller - apiInstance, err := api.New( - "password", - api.WithInstallController(mockController), - api.WithAuthController(&staticAuthController{"TOKEN"}), - api.WithLogger(logger.NewDiscardLogger()), - ) - require.NoError(t, err) - - router := mux.NewRouter() - apiInstance.RegisterRoutes(router) + // Get the config to verify it persisted + newConfig, err := c.GetLinuxInstallationConfig() + require.NoError(t, err, "GetLinuxInstallationConfig should succeed after setting config") + assert.Equal(t, rc.EmbeddedClusterHomeDirectory(), newConfig.DataDirectory) + assert.Equal(t, config.AdminConsolePort, newConfig.AdminConsolePort) + assert.Equal(t, config.NetworkInterface, newConfig.NetworkInterface) - // Create a request - req := httptest.NewRequest(http.MethodGet, "/install/status", nil) - req.Header.Set("Authorization", "Bearer "+"TOKEN") - rec := httptest.NewRecorder() + // Verify host configuration was performed + mockHostUtils.AssertExpectations(t) + }) - // Serve the request - router.ServeHTTP(rec, req) + // Test ConfigureLinuxInstallation validation error + t.Run("ConfigureLinuxInstallation validation error", func(t *testing.T) { + // Create an invalid config (port conflict) + config := types.LinuxInstallationConfig{ + DataDirectory: "/tmp/new-dir", + AdminConsolePort: 8080, + LocalArtifactMirrorPort: 8080, // Same as AdminConsolePort + GlobalCIDR: "10.0.0.0/16", + NetworkInterface: "eth0", + } - // Check the response - assert.Equal(t, http.StatusInternalServerError, rec.Code) + // Configure the installation using the client + _, err := c.ConfigureLinuxInstallation(config) + require.Error(t, err, "ConfigureLinuxInstallation should fail with invalid config") - // Parse the response body - var apiError types.APIError - err = json.NewDecoder(rec.Body).Decode(&apiError) - require.NoError(t, err) - assert.Equal(t, http.StatusInternalServerError, apiError.StatusCode) - assert.NotEmpty(t, apiError.Message) + // Verify the error is of type APIError + apiErr, ok := err.(*types.APIError) + require.True(t, ok, "Error should be of type *types.APIError") + assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode) + // Error message should contain the same port conflict message for both fields + assert.Equal(t, 2, strings.Count(apiErr.Error(), "adminConsolePort and localArtifactMirrorPort cannot be equal")) }) } -// Test the setInstallStatus endpoint sets install status correctly -func TestSetInstallStatus(t *testing.T) { - // Create an install controller with the config manager - installController, err := install.NewInstallController() - require.NoError(t, err) +// Test the linux setupInfra endpoint runs infrastructure setup correctly +func TestLinuxPostSetupInfra(t *testing.T) { + // Create schemes + scheme := runtime.NewScheme() + require.NoError(t, ecv1beta1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, apiextensionsv1.AddToScheme(scheme)) - // Create the API with the install controller - apiInstance, err := api.New( - "password", - api.WithInstallController(installController), - api.WithAuthController(&staticAuthController{"TOKEN"}), - api.WithLogger(logger.NewDiscardLogger()), - ) - require.NoError(t, err) + metascheme := metadatafake.NewTestScheme() + require.NoError(t, metav1.AddMetaToScheme(metascheme)) + require.NoError(t, corev1.AddToScheme(metascheme)) - // Create a router and register the API routes - router := mux.NewRouter() - apiInstance.RegisterRoutes(router) + t.Run("Success", func(t *testing.T) { + // Create mocks + k0sMock := &k0s.MockK0s{} + helmMock := &helm.MockClient{} + hostutilsMock := &hostutils.MockHostUtils{} + fakeKcli := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(testControllerNode(t)). + WithStatusSubresource(&ecv1beta1.Installation{}, &apiextensionsv1.CustomResourceDefinition{}). + WithInterceptorFuncs(testInterceptorFuncs(t)). + Build() + fakeMcli := metadatafake.NewSimpleMetadataClient(metascheme) + + // Create a runtime config + rc := runtimeconfig.New(nil) + rc.SetDataDir(t.TempDir()) + rc.SetNetworkSpec(ecv1beta1.NetworkSpec{ + NetworkInterface: "eth0", + ServiceCIDR: "10.96.0.0/12", + PodCIDR: "10.244.0.0/16", + }) + + // Create host preflights with successful status + hpf := types.HostPreflights{} + hpf.Status = types.Status{ + State: types.StateSucceeded, + Description: "Host preflights succeeded", + } + + // Create host preflights manager + pfManager := preflight.NewHostPreflightManager( + preflight.WithHostPreflightStore(linuxpreflightstore.NewMemoryStore(linuxpreflightstore.WithHostPreflight(hpf))), + ) - t.Run("Valid status is passed", func(t *testing.T) { + // Create infra manager with mocks + infraManager := linuxinfra.NewInfraManager( + linuxinfra.WithK0s(k0sMock), + linuxinfra.WithKubeClient(fakeKcli), + linuxinfra.WithMetadataClient(fakeMcli), + linuxinfra.WithHelmClient(helmMock), + linuxinfra.WithLicense(licenseData), + linuxinfra.WithHostUtils(hostutilsMock), + linuxinfra.WithKotsInstaller(func() error { + return nil + }), + linuxinfra.WithReleaseData(&release.ReleaseData{ + EmbeddedClusterConfig: &ecv1beta1.Config{}, + ChannelRelease: &release.ChannelRelease{ + DefaultDomains: release.Domains{ + ReplicatedAppDomain: "replicated.example.com", + ProxyRegistryDomain: "some-proxy.example.com", + }, + }, + }), + ) - now := time.Now() - status := types.Status{ - State: types.StatePending, - Description: "Install is pending", - LastUpdated: now, + // Setup mock expectations + k0sConfig := &k0sv1beta1.ClusterConfig{ + Spec: &k0sv1beta1.ClusterSpec{ + Network: &k0sv1beta1.Network{ + PodCIDR: "10.244.0.0/16", + ServiceCIDR: "10.96.0.0/12", + }, + }, } + mock.InOrder( + k0sMock.On("IsInstalled").Return(false, nil), + k0sMock.On("WriteK0sConfig", mock.Anything, "eth0", "", "10.244.0.0/16", "10.96.0.0/12", mock.Anything, mock.Anything).Return(k0sConfig, nil), + hostutilsMock.On("CreateSystemdUnitFiles", mock.Anything, mock.Anything, rc, false).Return(nil), + k0sMock.On("Install", rc).Return(nil), + k0sMock.On("WaitForK0s").Return(nil), + hostutilsMock.On("AddInsecureRegistry", mock.Anything).Return(nil), + helmMock.On("Install", mock.Anything, mock.Anything).Times(4).Return(nil, nil), // 4 addons + helmMock.On("Close").Return(nil), + ) - // Serialize the status to JSON - statusJSON, err := json.Marshal(status) + // Create an install controller with the mocked managers + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithStateMachine(linuxinstall.NewStateMachine(linuxinstall.WithCurrentState(linuxinstall.StatePreflightsSucceeded))), + linuxinstall.WithHostPreflightManager(pfManager), + linuxinstall.WithInfraManager(infraManager), + linuxinstall.WithReleaseData(&release.ReleaseData{ + EmbeddedClusterConfig: &ecv1beta1.Config{}, + ChannelRelease: &release.ChannelRelease{ + DefaultDomains: release.Domains{ + ReplicatedAppDomain: "replicated.example.com", + ProxyRegistryDomain: "some-proxy.example.com", + }, + }, + }), + ) require.NoError(t, err) - // Create a request - req := httptest.NewRequest(http.MethodPost, "/install/status", bytes.NewReader(statusJSON)) + // Create the API with the install controller + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Create a request with proper JSON body + requestBody := types.LinuxInfraSetupRequest{ + IgnoreHostPreflights: false, + } + reqBodyBytes, err := json.Marshal(requestBody) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/linux/install/infra/setup", bytes.NewReader(reqBodyBytes)) + req.Header.Set("Authorization", "Bearer TOKEN") req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+"TOKEN") rec := httptest.NewRecorder() // Serve the request @@ -704,56 +959,119 @@ func TestSetInstallStatus(t *testing.T) { t.Logf("Response body: %s", rec.Body.String()) // Parse the response body - var respStatus types.Status - err = json.NewDecoder(rec.Body).Decode(&respStatus) + var infra types.Infra + err = json.NewDecoder(rec.Body).Decode(&infra) require.NoError(t, err) - // Verify that the status was properly set - assert.Equal(t, status.State, respStatus.State) - assert.Equal(t, status.Description, respStatus.Description) - assert.Equal(t, now.Format(time.RFC3339), respStatus.LastUpdated.Format(time.RFC3339)) + // Verify that the status is not pending. We cannot check for an end state here because the hots config is async + // so the state might have moved from running to a final state before we get the response. + assert.NotEqual(t, types.StatePending, infra.Status.State) - // Also verify that the status is in the store - storedStatus, err := installController.GetStatus(t.Context()) - require.NoError(t, err) - assert.Equal(t, status.State, storedStatus.State) - assert.Equal(t, status.Description, storedStatus.Description) - assert.Equal(t, now.Format(time.RFC3339), storedStatus.LastUpdated.Format(time.RFC3339)) - }) + // Helper function to get infra status + getInfraStatus := func() types.Infra { + // Create a request to get infra status + req := httptest.NewRequest(http.MethodGet, "/linux/install/infra/status", nil) + req.Header.Set("Authorization", "Bearer TOKEN") + rec := httptest.NewRecorder() - // Test that the endpoint properly handles validation errors - t.Run("Validation error", func(t *testing.T) { - // Create a request with invalid JSON - req := httptest.NewRequest(http.MethodPost, "/install/status", - bytes.NewReader([]byte(`{"state": "INVALID_STATE"}`))) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+"TOKEN") - rec := httptest.NewRecorder() + // Serve the request + router.ServeHTTP(rec, req) - // Serve the request - router.ServeHTTP(rec, req) + // Check the response + assert.Equal(t, http.StatusOK, rec.Code) - // Check the response - assert.Equal(t, http.StatusBadRequest, rec.Code) + // Parse the response body + var infra types.Infra + err = json.NewDecoder(rec.Body).Decode(&infra) + require.NoError(t, err) - t.Logf("Response body: %s", rec.Body.String()) - }) + // Log the infra status + t.Logf("Infra Status: %s, Description: %s", infra.Status.State, infra.Status.Description) - // Test authorization errors - t.Run("Authorization error", func(t *testing.T) { - // Create a request with invalid JSON - req := httptest.NewRequest(http.MethodPost, "/install/status", - bytes.NewReader([]byte(`{}`))) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+"NOT_A_TOKEN") - rec := httptest.NewRecorder() + return infra + } - // Serve the request - router.ServeHTTP(rec, req) + // The status should eventually be set to succeeded in a goroutine + assert.Eventually(t, func() bool { + infra := getInfraStatus() - // Check the response - assert.Equal(t, http.StatusUnauthorized, rec.Code) + // Fail the test if the status is Failed + if infra.Status.State == types.StateFailed { + t.Fatalf("Infrastructure setup failed: %s", infra.Status.Description) + } + + return infra.Status.State == types.StateSucceeded + }, 30*time.Second, 500*time.Millisecond, "Infrastructure setup did not succeed in time") + + // Verify that the mock expectations were met + k0sMock.AssertExpectations(t) + hostutilsMock.AssertExpectations(t) + helmMock.AssertExpectations(t) + + // Verify installation was created + gotInst, err := kubeutils.GetLatestInstallation(t.Context(), fakeKcli) + require.NoError(t, err) + assert.Equal(t, ecv1beta1.InstallationStateInstalled, gotInst.Status.State) + + // Verify version metadata configmap was created + var gotConfigmap corev1.ConfigMap + err = fakeKcli.Get(t.Context(), client.ObjectKey{Namespace: "embedded-cluster", Name: "version-metadata-0-0-0"}, &gotConfigmap) + require.NoError(t, err) + + // Verify kotsadm namespace and kotsadm-password secret were created + var gotKotsadmNamespace corev1.Namespace + err = fakeKcli.Get(t.Context(), client.ObjectKey{Name: constants.KotsadmNamespace}, &gotKotsadmNamespace) + require.NoError(t, err) + + var gotKotsadmPasswordSecret corev1.Secret + err = fakeKcli.Get(t.Context(), client.ObjectKey{Namespace: constants.KotsadmNamespace, Name: "kotsadm-password"}, &gotKotsadmPasswordSecret) + require.NoError(t, err) + assert.NotEmpty(t, gotKotsadmPasswordSecret.Data["passwordBcrypt"]) + + // Get infra status again and verify more details + infra = getInfraStatus() + assert.Contains(t, infra.Logs, "[k0s]") + assert.Contains(t, infra.Logs, "[metadata]") + assert.Contains(t, infra.Logs, "[addons]") + assert.Contains(t, infra.Logs, "[extensions]") + assert.Len(t, infra.Components, 6) + }) + + // Test authorization + t.Run("Authorization error", func(t *testing.T) { + // Create the API + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Create a request with proper JSON body + requestBody := types.LinuxInfraSetupRequest{ + IgnoreHostPreflights: false, + } + reqBodyBytes, err := json.Marshal(requestBody) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/linux/install/infra/setup", bytes.NewReader(reqBodyBytes)) + req.Header.Set("Authorization", "Bearer NOT_A_TOKEN") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusUnauthorized, rec.Code) assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + // Parse the response body var apiError types.APIError err = json.NewDecoder(rec.Body).Decode(&apiError) @@ -761,17 +1079,34 @@ func TestSetInstallStatus(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, apiError.StatusCode) }) - // Test controller error - t.Run("Controller error", func(t *testing.T) { - // Create a mock controller that returns an error - mockController := &mockInstallController{ - setStatusError: assert.AnError, + // Test preflight bypass with CLI flag allowing it - should succeed + t.Run("Preflight bypass allowed by CLI flag", func(t *testing.T) { + // Create host preflights with failed status + hpf := types.HostPreflights{} + hpf.Status = types.Status{ + State: types.StateFailed, + Description: "Host preflights failed", } - // Create the API with the mock controller + // Create managers + pfManager := preflight.NewHostPreflightManager( + preflight.WithHostPreflightStore(linuxpreflightstore.NewMemoryStore(linuxpreflightstore.WithHostPreflight(hpf))), + ) + + // Create an install controller with CLI flag allowing bypass + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithStateMachine(linuxinstall.NewStateMachine(linuxinstall.WithCurrentState(linuxinstall.StatePreflightsFailed))), + linuxinstall.WithHostPreflightManager(pfManager), + linuxinstall.WithAllowIgnoreHostPreflights(true), // CLI flag allows bypass + ) + require.NoError(t, err) + + // Create the API with the install controller apiInstance, err := api.New( - "password", - api.WithInstallController(mockController), + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), api.WithAuthController(&staticAuthController{"TOKEN"}), api.WithLogger(logger.NewDiscardLogger()), ) @@ -780,187 +1115,1275 @@ func TestSetInstallStatus(t *testing.T) { router := mux.NewRouter() apiInstance.RegisterRoutes(router) - // Create a valid status - status := types.Status{ - State: types.StatePending, - Description: "Installation in progress", + // Create a request with ignoreHostPreflights=true + requestBody := types.LinuxInfraSetupRequest{ + IgnoreHostPreflights: true, } - statusJSON, err := json.Marshal(status) + reqBodyBytes, err := json.Marshal(requestBody) require.NoError(t, err) - // Create a request - req := httptest.NewRequest(http.MethodPost, "/install/status", bytes.NewReader(statusJSON)) + req := httptest.NewRequest(http.MethodPost, "/linux/install/infra/setup", bytes.NewReader(reqBodyBytes)) + req.Header.Set("Authorization", "Bearer TOKEN") req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+"TOKEN") rec := httptest.NewRecorder() // Serve the request router.ServeHTTP(rec, req) - // Check the response - assert.Equal(t, http.StatusInternalServerError, rec.Code) + // Check the response - should succeed because CLI flag allows bypass + assert.Equal(t, http.StatusOK, rec.Code) t.Logf("Response body: %s", rec.Body.String()) }) -} -// TestInstallWithAPIClient tests the install endpoints using the API client -func TestInstallWithAPIClient(t *testing.T) { - password := "test-password" + // Test preflight bypass with CLI flag NOT allowing it - should fail + t.Run("Preflight bypass denied by CLI flag", func(t *testing.T) { + // Create host preflights with failed status + hpf := types.HostPreflights{} + hpf.Status = types.Status{ + State: types.StateFailed, + Description: "Host preflights failed", + } - // Create a runtimeconfig to be used in the install process - rc := runtimeconfig.New(nil) + // Create managers + pfManager := preflight.NewHostPreflightManager( + preflight.WithHostPreflightStore(linuxpreflightstore.NewMemoryStore(linuxpreflightstore.WithHostPreflight(hpf))), + ) - // Create a mock hostutils - mockHostUtils := &hostutils.MockHostUtils{} - mockHostUtils.On("ConfigureHost", mock.Anything, mock.Anything).Return(nil) + // Create an install controller with CLI flag NOT allowing bypass + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithStateMachine(linuxinstall.NewStateMachine(linuxinstall.WithCurrentState(linuxinstall.StatePreflightsFailed))), + linuxinstall.WithHostPreflightManager(pfManager), + linuxinstall.WithAllowIgnoreHostPreflights(false), // CLI flag does NOT allow bypass + ) + require.NoError(t, err) - // Create a config manager - installationManager := installation.NewInstallationManager( - installation.WithRuntimeConfig(rc), - installation.WithHostUtils(mockHostUtils), - ) + // Create the API with the install controller + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) - // Create an install controller with the config manager - installController, err := install.NewInstallController( - install.WithRuntimeConfig(rc), - install.WithInstallationManager(installationManager), - ) - require.NoError(t, err) + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) - // Set some initial config - initialConfig := types.InstallationConfig{ - DataDirectory: "/tmp/test-data-for-client", - AdminConsolePort: 9080, - LocalArtifactMirrorPort: 9081, - GlobalCIDR: "192.168.0.0/16", - NetworkInterface: "eth1", - } - err = installationManager.SetConfig(initialConfig) - require.NoError(t, err) + // Create a request with ignoreHostPreflights=true + requestBody := types.LinuxInfraSetupRequest{ + IgnoreHostPreflights: true, + } + reqBodyBytes, err := json.Marshal(requestBody) + require.NoError(t, err) - // Set some initial status - initialStatus := types.Status{ - State: types.StatePending, - Description: "Installation pending", - } - err = installationManager.SetStatus(initialStatus) - require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/linux/install/infra/setup", bytes.NewReader(reqBodyBytes)) + req.Header.Set("Authorization", "Bearer TOKEN") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() - // Create the API with controllers - apiInstance, err := api.New( - password, - api.WithAuthController(&staticAuthController{"TOKEN"}), - api.WithInstallController(installController), - api.WithLogger(logger.NewDiscardLogger()), - ) - require.NoError(t, err) + // Serve the request + router.ServeHTTP(rec, req) - // Create a router and register the API routes - router := mux.NewRouter() - apiInstance.RegisterRoutes(router.PathPrefix("/api").Subrouter()) + // Check the response - should fail because CLI flag does NOT allow bypass + assert.Equal(t, http.StatusBadRequest, rec.Code) - // Create a test server using the router - server := httptest.NewServer(router) - defer server.Close() + t.Logf("Response body: %s", rec.Body.String()) - // Create client with the predefined token - c := client.New(server.URL, client.WithToken("TOKEN")) - require.NoError(t, err, "API client login should succeed") + // Parse the response body + var apiError types.APIError + err = json.NewDecoder(rec.Body).Decode(&apiError) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, apiError.StatusCode) + assert.Contains(t, apiError.Message, "preflight checks failed") + }) - // Test GetInstallationConfig - t.Run("GetInstallationConfig", func(t *testing.T) { - config, err := c.GetInstallationConfig() - require.NoError(t, err, "GetInstallationConfig should succeed") - assert.NotNil(t, config, "InstallationConfig should not be nil") + // Test client not requesting bypass but preflights failed - should fail + t.Run("Client not requesting bypass with failed preflights", func(t *testing.T) { + // Create host preflights with failed status + hpf := types.HostPreflights{} + hpf.Status = types.Status{ + State: types.StateFailed, + Description: "Host preflights failed", + } - // Verify values - assert.Equal(t, "/tmp/test-data-for-client", config.DataDirectory) - assert.Equal(t, 9080, config.AdminConsolePort) - assert.Equal(t, 9081, config.LocalArtifactMirrorPort) - assert.Equal(t, "192.168.0.0/16", config.GlobalCIDR) - assert.Equal(t, "eth1", config.NetworkInterface) - }) + // Create managers + pfManager := preflight.NewHostPreflightManager( + preflight.WithHostPreflightStore(linuxpreflightstore.NewMemoryStore(linuxpreflightstore.WithHostPreflight(hpf))), + ) - // Test GetInstallationStatus - t.Run("GetInstallationStatus", func(t *testing.T) { - status, err := c.GetInstallationStatus() - require.NoError(t, err, "GetInstallationStatus should succeed") - assert.NotNil(t, status, "InstallationStatus should not be nil") - assert.Equal(t, types.StatePending, status.State) - assert.Equal(t, "Installation pending", status.Description) + // Create an install controller with CLI flag allowing bypass + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithStateMachine(linuxinstall.NewStateMachine(linuxinstall.WithCurrentState(linuxinstall.StatePreflightsFailed))), + linuxinstall.WithHostPreflightManager(pfManager), + linuxinstall.WithAllowIgnoreHostPreflights(true), // CLI flag allows bypass + ) + require.NoError(t, err) + + // Create the API with the install controller + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) + + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Create a request with ignoreHostPreflights=false (client not requesting bypass) + requestBody := types.LinuxInfraSetupRequest{ + IgnoreHostPreflights: false, + } + reqBodyBytes, err := json.Marshal(requestBody) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/linux/install/infra/setup", bytes.NewReader(reqBodyBytes)) + req.Header.Set("Authorization", "Bearer TOKEN") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response - should fail because client is not requesting bypass + assert.Equal(t, http.StatusBadRequest, rec.Code) + + t.Logf("Response body: %s", rec.Body.String()) + + // Parse the response body + var apiError types.APIError + err = json.NewDecoder(rec.Body).Decode(&apiError) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, apiError.StatusCode) + assert.Contains(t, apiError.Message, "preflight checks failed") }) - // Test ConfigureInstallation - t.Run("ConfigureInstallation", func(t *testing.T) { - // Create a valid config - config := types.InstallationConfig{ - DataDirectory: "/tmp/new-dir", - AdminConsolePort: 8000, - LocalArtifactMirrorPort: 8081, - GlobalCIDR: "10.0.0.0/16", - NetworkInterface: "eth0", + // Test preflight checks not completed + t.Run("Preflight checks not completed", func(t *testing.T) { + // Create host preflights with running status (not completed) + hpf := types.HostPreflights{} + hpf.Status = types.Status{ + State: types.StateRunning, + Description: "Host preflights running", } - // Configure the installation using the client - status, err := c.ConfigureInstallation(&config) - require.NoError(t, err, "ConfigureInstallation should succeed with valid config") - assert.NotNil(t, status, "Status should not be nil") + // Create managers + pfManager := preflight.NewHostPreflightManager( + preflight.WithHostPreflightStore(linuxpreflightstore.NewMemoryStore(linuxpreflightstore.WithHostPreflight(hpf))), + ) - // Verify the status was set correctly - assert.Equal(t, types.StateRunning, status.State) - assert.Equal(t, "Configuring installation", status.Description) + // Create an install controller + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithStateMachine(linuxinstall.NewStateMachine(linuxinstall.WithCurrentState(linuxinstall.StatePreflightsRunning))), + linuxinstall.WithHostPreflightManager(pfManager), + ) + require.NoError(t, err) - // Get the config to verify it persisted - newConfig, err := c.GetInstallationConfig() - require.NoError(t, err, "GetInstallationConfig should succeed after setting config") - assert.Equal(t, config.DataDirectory, newConfig.DataDirectory) - assert.Equal(t, config.AdminConsolePort, newConfig.AdminConsolePort) - assert.Equal(t, config.NetworkInterface, newConfig.NetworkInterface) + // Create the API with the install controller + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) - // Verify host configuration was performed - mockHostUtils.AssertExpectations(t) + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Create a request with proper JSON body + requestBody := types.LinuxInfraSetupRequest{ + IgnoreHostPreflights: false, + } + reqBodyBytes, err := json.Marshal(requestBody) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/linux/install/infra/setup", bytes.NewReader(reqBodyBytes)) + req.Header.Set("Authorization", "Bearer TOKEN") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusConflict, rec.Code) + assert.Contains(t, rec.Body.String(), "invalid transition") }) - // Test ConfigureInstallation validation error - t.Run("ConfigureInstallation validation error", func(t *testing.T) { - // Create an invalid config (port conflict) - config := &types.InstallationConfig{ - DataDirectory: "/tmp/new-dir", - AdminConsolePort: 8080, - LocalArtifactMirrorPort: 8080, // Same as AdminConsolePort - GlobalCIDR: "10.0.0.0/16", - NetworkInterface: "eth0", + // Test k0s already installed error + t.Run("K0s already installed", func(t *testing.T) { + // Create a runtime config + rc := runtimeconfig.New(nil) + rc.SetDataDir(t.TempDir()) + rc.SetNetworkSpec(ecv1beta1.NetworkSpec{ + NetworkInterface: "eth0", + }) + + // Create host preflights with successful status + hpf := types.HostPreflights{} + hpf.Status = types.Status{ + State: types.StateSucceeded, + Description: "Host preflights succeeded", } - // Configure the installation using the client - _, err := c.ConfigureInstallation(config) - require.Error(t, err, "ConfigureInstallation should fail with invalid config") + // Create managers + pfManager := preflight.NewHostPreflightManager( + preflight.WithHostPreflightStore(linuxpreflightstore.NewMemoryStore(linuxpreflightstore.WithHostPreflight(hpf))), + ) - // Verify the error is of type APIError - apiErr, ok := err.(*types.APIError) - require.True(t, ok, "Error should be of type *types.APIError") - assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode) - // Error message should contain both variants of the port conflict message - assert.True(t, - strings.Contains(apiErr.Error(), "Admin Console Port and localArtifactMirrorPort cannot be equal") && - strings.Contains(apiErr.Error(), "adminConsolePort and Local Artifact Mirror Port cannot be equal"), - "Error message should contain both variants of the port conflict message", + // Create an install controller + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithStateMachine(linuxinstall.NewStateMachine(linuxinstall.WithCurrentState(linuxinstall.StateSucceeded))), + linuxinstall.WithHostPreflightManager(pfManager), + linuxinstall.WithReleaseData(&release.ReleaseData{ + EmbeddedClusterConfig: &ecv1beta1.Config{}, + ChannelRelease: &release.ChannelRelease{}, + }), + ) + require.NoError(t, err) + + // Create the API with the install controller + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), ) + require.NoError(t, err) + + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Create a request with proper JSON body + requestBody := types.LinuxInfraSetupRequest{ + IgnoreHostPreflights: false, + } + reqBodyBytes, err := json.Marshal(requestBody) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/linux/install/infra/setup", bytes.NewReader(reqBodyBytes)) + req.Header.Set("Authorization", "Bearer TOKEN") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusConflict, rec.Code) + assert.Contains(t, rec.Body.String(), "invalid transition") }) - // Test SetInstallStatus - t.Run("SetInstallStatus", func(t *testing.T) { - // Create a status - status := &types.Status{ - State: types.StateFailed, - Description: "Installation failed", + // Test k0s install error + t.Run("K0s install error", func(t *testing.T) { + // Create mocks + k0sMock := &k0s.MockK0s{} + hostutilsMock := &hostutils.MockHostUtils{} + + // Create a runtime config + rc := runtimeconfig.New(nil) + rc.SetDataDir(t.TempDir()) + rc.SetNetworkSpec(ecv1beta1.NetworkSpec{ + NetworkInterface: "eth0", + ServiceCIDR: "10.96.0.0/12", + PodCIDR: "10.244.0.0/16", + }) + + // Create host preflights with successful status + hpf := types.HostPreflights{} + hpf.Status = types.Status{ + State: types.StateSucceeded, + Description: "Host preflights succeeded", } - // Set the status using the client - newStatus, err := c.SetInstallStatus(status) - require.NoError(t, err, "SetInstallStatus should succeed") - assert.NotNil(t, newStatus, "Install should not be nil") - assert.Equal(t, status, newStatus, "Install status should match the one set") + // Create managers + pfManager := preflight.NewHostPreflightManager( + preflight.WithHostPreflightStore(linuxpreflightstore.NewMemoryStore(linuxpreflightstore.WithHostPreflight(hpf))), + ) + infraManager := infra.NewInfraManager( + infra.WithK0s(k0sMock), + infra.WithHostUtils(hostutilsMock), + infra.WithLicense(licenseData), + ) + + // Setup k0s mock expectations with failure + k0sConfig := &k0sv1beta1.ClusterConfig{} + mock.InOrder( + k0sMock.On("IsInstalled").Return(false, nil), + k0sMock.On("WriteK0sConfig", mock.Anything, "eth0", "", "10.244.0.0/16", "10.96.0.0/12", mock.Anything, mock.Anything).Return(k0sConfig, nil), + hostutilsMock.On("CreateSystemdUnitFiles", mock.Anything, mock.Anything, rc, false).Return(nil), + k0sMock.On("Install", mock.Anything).Return(errors.New("failed to install k0s")), + ) + + // Create an install controller + installController, err := linuxinstall.NewInstallController( + linuxinstall.WithHostPreflightManager(pfManager), + linuxinstall.WithInfraManager(infraManager), + linuxinstall.WithReleaseData(&release.ReleaseData{ + EmbeddedClusterConfig: &ecv1beta1.Config{}, + ChannelRelease: &release.ChannelRelease{}, + }), + linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithStateMachine(linuxinstall.NewStateMachine(linuxinstall.WithCurrentState(linuxinstall.StatePreflightsSucceeded))), + ) + require.NoError(t, err) + + // Create the API with the install controller + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithLinuxInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) + + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Create a request with proper JSON body + requestBody := types.LinuxInfraSetupRequest{ + IgnoreHostPreflights: false, + } + reqBodyBytes, err := json.Marshal(requestBody) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/linux/install/infra/setup", bytes.NewReader(reqBodyBytes)) + req.Header.Set("Authorization", "Bearer TOKEN") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusOK, rec.Code) + + // The status should eventually be set to failed due to k0s install error + assert.Eventually(t, func() bool { + // Create a request to get infra status + req := httptest.NewRequest(http.MethodGet, "/linux/install/infra/status", nil) + req.Header.Set("Authorization", "Bearer TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusOK, rec.Code) + + // Parse the response body + var infra types.Infra + err = json.NewDecoder(rec.Body).Decode(&infra) + require.NoError(t, err) + + t.Logf("Infra Status: %s, Description: %s", infra.Status.State, infra.Status.Description) + return infra.Status.State == types.StateFailed && strings.Contains(infra.Status.Description, "failed to install k0s") + }, 10*time.Second, 100*time.Millisecond, "Infrastructure setup did not fail in time") + + // Verify that the mock expectations were met + k0sMock.AssertExpectations(t) + hostutilsMock.AssertExpectations(t) + }) +} + +func testControllerNode(t *testing.T) *corev1.Node { + hostname, err := os.Hostname() + require.NoError(t, err) + return &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: strings.ToLower(hostname), + Labels: map[string]string{ + "node-role.kubernetes.io/control-plane": "", + }, + }, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + { + Type: corev1.NodeReady, + Status: corev1.ConditionTrue, + }, + }, + }, + } +} + +func testInterceptorFuncs(t *testing.T) interceptor.Funcs { + return interceptor.Funcs{ + Create: func(ctx context.Context, cli client.WithWatch, obj client.Object, opts ...client.CreateOption) error { + if crd, ok := obj.(*apiextensionsv1.CustomResourceDefinition); ok { + err := cli.Create(ctx, obj, opts...) + if err != nil { + return err + } + // Update status to ready after creation + crd.Status.Conditions = []apiextensionsv1.CustomResourceDefinitionCondition{ + {Type: apiextensionsv1.Established, Status: apiextensionsv1.ConditionTrue}, + {Type: apiextensionsv1.NamesAccepted, Status: apiextensionsv1.ConditionTrue}, + } + return cli.Status().Update(ctx, crd) + } + return cli.Create(ctx, obj, opts...) + }, + } +} + +type testEnvSetter struct { + env map[string]string +} + +func (e *testEnvSetter) Setenv(key string, val string) error { + if e.env == nil { + e.env = make(map[string]string) + } + e.env[key] = val + return nil +} + +func TestKubernetesConfigureInstallation(t *testing.T) { + // Test scenarios + testCases := []struct { + name string + token string + config types.KubernetesInstallationConfig + expectedStatus *types.Status + expectedStatusCode int + expectedError bool + validateInstallation func(t *testing.T, ki kubernetesinstallation.Installation) + }{ + { + name: "Valid config", + token: "TOKEN", + config: types.KubernetesInstallationConfig{ + AdminConsolePort: 9000, + HTTPProxy: "http://proxy.example.com", + HTTPSProxy: "https://proxy.example.com", + NoProxy: "somecompany.internal,192.168.17.0/24", + }, + expectedStatus: &types.Status{ + State: types.StateSucceeded, + Description: "Installation configured", + }, + expectedStatusCode: http.StatusOK, + expectedError: false, + validateInstallation: func(t *testing.T, ki kubernetesinstallation.Installation) { + assert.Equal(t, 9000, ki.AdminConsolePort()) + assert.Equal(t, &ecv1beta1.ProxySpec{ + HTTPProxy: "http://proxy.example.com", + HTTPSProxy: "https://proxy.example.com", + NoProxy: "somecompany.internal,192.168.17.0/24", + ProvidedNoProxy: "somecompany.internal,192.168.17.0/24", + }, ki.ProxySpec()) + }, + }, + { + name: "Valid config with default admin console port", + token: "TOKEN", + config: types.KubernetesInstallationConfig{ + AdminConsolePort: 30000, // Use the default value explicitly + HTTPProxy: "http://proxy.example.com", + HTTPSProxy: "https://proxy.example.com", + NoProxy: "somecompany.internal,192.168.17.0/24", + }, + expectedStatus: &types.Status{ + State: types.StateSucceeded, + Description: "Installation configured", + }, + expectedStatusCode: http.StatusOK, + expectedError: false, + validateInstallation: func(t *testing.T, ki kubernetesinstallation.Installation) { + assert.Equal(t, ecv1beta1.DefaultAdminConsolePort, ki.AdminConsolePort()) + assert.Equal(t, &ecv1beta1.ProxySpec{ + HTTPProxy: "http://proxy.example.com", + HTTPSProxy: "https://proxy.example.com", + NoProxy: "somecompany.internal,192.168.17.0/24", + ProvidedNoProxy: "somecompany.internal,192.168.17.0/24", + }, ki.ProxySpec()) + }, + }, + { + name: "Invalid config - port conflict with manager", + token: "TOKEN", + config: types.KubernetesInstallationConfig{ + AdminConsolePort: 30080, // Same as DefaultManagerPort + HTTPProxy: "http://proxy.example.com", + HTTPSProxy: "https://proxy.example.com", + NoProxy: "somecompany.internal,192.168.17.0/24", + }, + expectedStatus: &types.Status{ + State: types.StateFailed, + Description: "validate: field errors: adminConsolePort cannot be the same as the manager port", + }, + expectedStatusCode: http.StatusBadRequest, + expectedError: true, + }, + { + name: "Invalid config - missing admin console port", + token: "TOKEN", + config: types.KubernetesInstallationConfig{ + AdminConsolePort: 0, // Missing port + HTTPProxy: "http://proxy.example.com", + HTTPSProxy: "https://proxy.example.com", + NoProxy: "somecompany.internal,192.168.17.0/24", + }, + expectedStatus: &types.Status{ + State: types.StateFailed, + Description: "validate: field errors: adminConsolePort is required", + }, + expectedStatusCode: http.StatusBadRequest, + expectedError: true, + }, + { + name: "Unauthorized", + token: "NOT_A_TOKEN", + config: types.KubernetesInstallationConfig{}, + expectedStatusCode: http.StatusUnauthorized, + expectedError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ki := kubernetesinstallation.New(nil) + + // Create an install controller with the mock installation + installController, err := kubernetesinstall.NewInstallController( + kubernetesinstall.WithInstallation(ki), + kubernetesinstall.WithStateMachine(kubernetesinstall.NewStateMachine(kubernetesinstall.WithCurrentState(kubernetesinstall.StateNew))), + ) + require.NoError(t, err) + + // Create the API with the install controller + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithKubernetesInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Serialize the config to JSON + configJSON, err := json.Marshal(tc.config) + require.NoError(t, err) + + // Create a request + req := httptest.NewRequest(http.MethodPost, "/kubernetes/install/installation/configure", bytes.NewReader(configJSON)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+tc.token) + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, tc.expectedStatusCode, rec.Code) + + t.Logf("Response body: %s", rec.Body.String()) + + // Parse the response body + if tc.expectedError { + var apiError types.APIError + err = json.NewDecoder(rec.Body).Decode(&apiError) + require.NoError(t, err) + assert.Equal(t, tc.expectedStatusCode, apiError.StatusCode) + assert.NotEmpty(t, apiError.Message) + } else { + var status types.Status + err = json.NewDecoder(rec.Body).Decode(&status) + require.NoError(t, err) + + // Verify that the status is not pending + assert.NotEqual(t, types.StatePending, status.State) + } + + // We might not have an expected status if the test is expected to fail before running the controller logic + if tc.expectedStatus != nil { + // The status is set in a goroutine, so we need to wait for it + assert.Eventually(t, func() bool { + status, err := installController.GetInstallationStatus(t.Context()) + require.NoError(t, err) + return status.State == tc.expectedStatus.State + }, 1*time.Second, 100*time.Millisecond, fmt.Sprintf("Expected status to be %s", tc.expectedStatus.State)) + + // Get the final status to check the description + finalStatus, err := installController.GetInstallationStatus(t.Context()) + require.NoError(t, err) + assert.Contains(t, finalStatus.Description, tc.expectedStatus.Description) + } + + if !tc.expectedError { + // Verify that the config is in the store + storedConfig, err := installController.GetInstallationConfig(t.Context()) + require.NoError(t, err) + assert.Equal(t, tc.config.AdminConsolePort, storedConfig.AdminConsolePort) + assert.Equal(t, tc.config.HTTPProxy, storedConfig.HTTPProxy) + assert.Equal(t, tc.config.HTTPSProxy, storedConfig.HTTPSProxy) + assert.Equal(t, tc.config.NoProxy, storedConfig.NoProxy) + + // Verify that the installation was updated + if tc.validateInstallation != nil { + tc.validateInstallation(t, ki) + } + } + }) + } +} + +// Test that config validation errors are properly returned for Kubernetes installation +func TestKubernetesConfigureInstallationValidation(t *testing.T) { + ki := kubernetesinstallation.New(nil) + ki.SetManagerPort(9001) + + // Create an install controller with the mock installation + installController, err := kubernetesinstall.NewInstallController( + kubernetesinstall.WithInstallation(ki), + kubernetesinstall.WithStateMachine(kubernetesinstall.NewStateMachine(kubernetesinstall.WithCurrentState(kubernetesinstall.StateNew))), + ) + require.NoError(t, err) + + // Create the API with the install controller + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithKubernetesInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Test a validation error case with port conflict + config := types.KubernetesInstallationConfig{ + AdminConsolePort: 9001, // Same as ManagerPort + HTTPProxy: "http://proxy.example.com", + HTTPSProxy: "https://proxy.example.com", + NoProxy: "somecompany.internal,192.168.17.0/24", + } + + // Serialize the config to JSON + configJSON, err := json.Marshal(config) + require.NoError(t, err) + + // Create a request + req := httptest.NewRequest(http.MethodPost, "/kubernetes/install/installation/configure", bytes.NewReader(configJSON)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+"TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusBadRequest, rec.Code) + + t.Logf("Response body: %s", rec.Body.String()) + + // We expect a ValidationError with specific error about port conflict + var apiError types.APIError + err = json.NewDecoder(rec.Body).Decode(&apiError) + require.NoError(t, err) + assert.Contains(t, apiError.Error(), "adminConsolePort cannot be the same as the manager port") + // Also verify the field name is correct + assert.Equal(t, "adminConsolePort", apiError.Errors[0].Field) +} + +// Test that the endpoint properly handles malformed JSON for Kubernetes installation +func TestKubernetesConfigureInstallationBadRequest(t *testing.T) { + ki := kubernetesinstallation.New(nil) + + // Create an install controller with the mock installation + installController, err := kubernetesinstall.NewInstallController( + kubernetesinstall.WithInstallation(ki), + kubernetesinstall.WithStateMachine(kubernetesinstall.NewStateMachine(kubernetesinstall.WithCurrentState(kubernetesinstall.StateNew))), + ) + require.NoError(t, err) + + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithKubernetesInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) + + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Create a request with invalid JSON + req := httptest.NewRequest(http.MethodPost, "/kubernetes/install/installation/configure", + bytes.NewReader([]byte(`{"adminConsolePort": "not-a-number"}`))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+"TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusBadRequest, rec.Code) + + t.Logf("Response body: %s", rec.Body.String()) +} + +// Test that the server returns proper errors when the API controller fails for Kubernetes installation +func TestKubernetesConfigureInstallationControllerError(t *testing.T) { + // Create a mock controller that returns an error + mockController := &kubernetesinstall.MockController{} + mockController.On("ConfigureInstallation", mock.Anything, mock.Anything).Return(assert.AnError) + + // Create the API with the mock controller + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithKubernetesInstallController(mockController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) + + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Create a valid config request + config := types.KubernetesInstallationConfig{ + AdminConsolePort: 9000, + HTTPProxy: "http://proxy.example.com", + HTTPSProxy: "https://proxy.example.com", + NoProxy: "somecompany.internal,192.168.17.0/24", + } + configJSON, err := json.Marshal(config) + require.NoError(t, err) + + // Create a request + req := httptest.NewRequest(http.MethodPost, "/kubernetes/install/installation/configure", bytes.NewReader(configJSON)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+"TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusInternalServerError, rec.Code) + + t.Logf("Response body: %s", rec.Body.String()) + + // Verify mock expectations + mockController.AssertExpectations(t) +} + +// Test the getInstall endpoint returns installation data correctly for Kubernetes +func TestKubernetesGetInstallationConfig(t *testing.T) { + ki := kubernetesinstallation.New(nil) + + // Create a config manager + installationManager := kubernetesinstallationmanager.NewInstallationManager() + + // Create an install controller with the config manager + installController, err := kubernetesinstall.NewInstallController( + kubernetesinstall.WithInstallation(ki), + kubernetesinstall.WithInstallationManager(installationManager), + ) + require.NoError(t, err) + + // Set some initial config + initialConfig := types.KubernetesInstallationConfig{ + AdminConsolePort: 8800, + HTTPProxy: "http://proxy.example.com", + HTTPSProxy: "https://proxy.example.com", + NoProxy: "somecompany.internal,192.168.17.0/24", + } + err = installationManager.SetConfig(initialConfig) + require.NoError(t, err) + + // Create the API with the install controller + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithKubernetesInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Test successful get + t.Run("Success", func(t *testing.T) { + // Create a request + req := httptest.NewRequest(http.MethodGet, "/kubernetes/install/installation/config", nil) + req.Header.Set("Authorization", "Bearer "+"TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + // Parse the response body + var config types.KubernetesInstallationConfig + err = json.NewDecoder(rec.Body).Decode(&config) + require.NoError(t, err) + + // Verify the installation data matches what we expect + assert.Equal(t, initialConfig.AdminConsolePort, config.AdminConsolePort) + assert.Equal(t, initialConfig.HTTPProxy, config.HTTPProxy) + assert.Equal(t, initialConfig.HTTPSProxy, config.HTTPSProxy) + assert.Equal(t, initialConfig.NoProxy, config.NoProxy) + }) + + // Test get with default/empty configuration + t.Run("Default configuration", func(t *testing.T) { + ki := kubernetesinstallation.New(nil) + + // Create a fresh config manager without writing anything + emptyInstallationManager := kubernetesinstallationmanager.NewInstallationManager() + + // Create an install controller with the empty config manager + emptyInstallController, err := kubernetesinstall.NewInstallController( + kubernetesinstall.WithInstallation(ki), + kubernetesinstall.WithInstallationManager(emptyInstallationManager), + ) + require.NoError(t, err) + + // Create the API with the install controller + emptyAPI, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithKubernetesInstallController(emptyInstallController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + emptyRouter := mux.NewRouter() + emptyAPI.RegisterRoutes(emptyRouter) + + // Create a request + req := httptest.NewRequest(http.MethodGet, "/kubernetes/install/installation/config", nil) + req.Header.Set("Authorization", "Bearer "+"TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + emptyRouter.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + // Parse the response body + var config types.KubernetesInstallationConfig + err = json.NewDecoder(rec.Body).Decode(&config) + require.NoError(t, err) + + // Verify the installation data contains defaults or empty values + assert.Equal(t, ecv1beta1.DefaultAdminConsolePort, config.AdminConsolePort) + assert.Equal(t, "", config.HTTPProxy) + assert.Equal(t, "", config.HTTPSProxy) + assert.Equal(t, "", config.NoProxy) + }) + + // Test authorization + t.Run("Authorization error", func(t *testing.T) { + // Create a request + req := httptest.NewRequest(http.MethodGet, "/kubernetes/install/installation/config", nil) + req.Header.Set("Authorization", "Bearer "+"NOT_A_TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + // Parse the response body + var apiError types.APIError + err = json.NewDecoder(rec.Body).Decode(&apiError) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, apiError.StatusCode) + }) + + // Test error handling + t.Run("Controller error", func(t *testing.T) { + // Create a mock controller that returns an error + mockController := &kubernetesinstall.MockController{} + mockController.On("GetInstallationConfig", mock.Anything).Return(types.KubernetesInstallationConfig{}, assert.AnError) + + // Create the API with the mock controller + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithKubernetesInstallController(mockController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) + + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Create a request + req := httptest.NewRequest(http.MethodGet, "/kubernetes/install/installation/config", nil) + req.Header.Set("Authorization", "Bearer "+"TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusInternalServerError, rec.Code) + + // Parse the response body + var apiError types.APIError + err = json.NewDecoder(rec.Body).Decode(&apiError) + require.NoError(t, err) + assert.Equal(t, http.StatusInternalServerError, apiError.StatusCode) + assert.NotEmpty(t, apiError.Message) + + // Verify mock expectations + mockController.AssertExpectations(t) + }) +} + +// Test the kubernetes setupInfra endpoint runs infrastructure setup correctly +func TestKubernetesPostSetupInfra(t *testing.T) { + // Create schemes + scheme := runtime.NewScheme() + require.NoError(t, ecv1beta1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, apiextensionsv1.AddToScheme(scheme)) + + metascheme := metadatafake.NewTestScheme() + require.NoError(t, metav1.AddMetaToScheme(metascheme)) + require.NoError(t, corev1.AddToScheme(metascheme)) + + t.Run("Success", func(t *testing.T) { + // Create mocks + helmMock := &helm.MockClient{} + fakeKcli := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(testControllerNode(t)). + WithStatusSubresource(&ecv1beta1.Installation{}, &apiextensionsv1.CustomResourceDefinition{}). + WithInterceptorFuncs(testInterceptorFuncs(t)). + Build() + fakeMcli := metadatafake.NewSimpleMetadataClient(metascheme) + + // Create a runtime config + ki := kubernetesinstallation.New(nil) + + // Create infra manager with mocks + infraManager := kubernetesinfra.NewInfraManager( + kubernetesinfra.WithKubeClient(fakeKcli), + kubernetesinfra.WithMetadataClient(fakeMcli), + kubernetesinfra.WithHelmClient(helmMock), + kubernetesinfra.WithLicense(licenseData), + kubernetesinfra.WithKotsInstaller(func() error { + return nil + }), + kubernetesinfra.WithReleaseData(&release.ReleaseData{ + EmbeddedClusterConfig: &ecv1beta1.Config{}, + ChannelRelease: &release.ChannelRelease{ + DefaultDomains: release.Domains{ + ReplicatedAppDomain: "replicated.example.com", + ProxyRegistryDomain: "some-proxy.example.com", + }, + }, + }), + ) + + mock.InOrder( + helmMock.On("Install", mock.Anything, mock.Anything).Times(1).Return(nil, nil), // 1 addon + helmMock.On("Close").Return(nil), + ) + + // Create an install controller with the mocked managers + installController, err := kubernetesinstall.NewInstallController( + kubernetesinstall.WithInstallation(ki), + kubernetesinstall.WithStateMachine(kubernetesinstall.NewStateMachine(kubernetesinstall.WithCurrentState(kubernetesinstall.StateInstallationConfigured))), + kubernetesinstall.WithInfraManager(infraManager), + kubernetesinstall.WithReleaseData(&release.ReleaseData{ + EmbeddedClusterConfig: &ecv1beta1.Config{}, + ChannelRelease: &release.ChannelRelease{ + DefaultDomains: release.Domains{ + ReplicatedAppDomain: "replicated.example.com", + ProxyRegistryDomain: "some-proxy.example.com", + }, + }, + }), + ) + require.NoError(t, err) + + // Create the API with the install controller + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithKubernetesInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + req := httptest.NewRequest(http.MethodPost, "/kubernetes/install/infra/setup", nil) + req.Header.Set("Authorization", "Bearer TOKEN") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusOK, rec.Code) + + t.Logf("Response body: %s", rec.Body.String()) + + // Parse the response body + var infra types.Infra + err = json.NewDecoder(rec.Body).Decode(&infra) + require.NoError(t, err) + + // Verify that the status is not pending. We cannot check for an end state here because the hots config is async + // so the state might have moved from running to a final state before we get the response. + assert.NotEqual(t, types.StatePending, infra.Status.State) + + // Helper function to get infra status + getInfraStatus := func() types.Infra { + // Create a request to get infra status + req := httptest.NewRequest(http.MethodGet, "/kubernetes/install/infra/status", nil) + req.Header.Set("Authorization", "Bearer TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusOK, rec.Code) + + // Parse the response body + var infra types.Infra + err = json.NewDecoder(rec.Body).Decode(&infra) + require.NoError(t, err) + + // Log the infra status + t.Logf("Infra Status: %s, Description: %s", infra.Status.State, infra.Status.Description) + + return infra + } + + // The status should eventually be set to succeeded in a goroutine + assert.Eventually(t, func() bool { + infra := getInfraStatus() + + // Fail the test if the status is Failed + if infra.Status.State == types.StateFailed { + t.Fatalf("Infrastructure setup failed: %s", infra.Status.Description) + } + + return infra.Status.State == types.StateSucceeded + }, 30*time.Second, 500*time.Millisecond, "Infrastructure setup did not succeed in time") + + // Verify that the mock expectations were met + helmMock.AssertExpectations(t) + + // Verify kotsadm namespace and kotsadm-password secret were created + var gotKotsadmNamespace corev1.Namespace + err = fakeKcli.Get(t.Context(), client.ObjectKey{Name: constants.KotsadmNamespace}, &gotKotsadmNamespace) + require.NoError(t, err) + + var gotKotsadmPasswordSecret corev1.Secret + err = fakeKcli.Get(t.Context(), client.ObjectKey{Namespace: constants.KotsadmNamespace, Name: "kotsadm-password"}, &gotKotsadmPasswordSecret) + require.NoError(t, err) + assert.NotEmpty(t, gotKotsadmPasswordSecret.Data["passwordBcrypt"]) + + // Get infra status again and verify more details + infra = getInfraStatus() + // assert.Contains(t, infra.Logs, "[metadata]") // record installation + assert.Contains(t, infra.Logs, "[addons]") + assert.Len(t, infra.Components, 1) // admin console addon + }) + + // Test authorization + t.Run("Authorization error", func(t *testing.T) { + // Create the API + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + req := httptest.NewRequest(http.MethodPost, "/kubernetes/install/infra/setup", nil) + req.Header.Set("Authorization", "Bearer NOT_A_TOKEN") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + // Parse the response body + var apiError types.APIError + err = json.NewDecoder(rec.Body).Decode(&apiError) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, apiError.StatusCode) + }) + + // Addon install error + t.Run("addon install error", func(t *testing.T) { + // Create mocks + helmMock := &helm.MockClient{} + fakeKcli := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(testControllerNode(t)). + WithStatusSubresource(&ecv1beta1.Installation{}, &apiextensionsv1.CustomResourceDefinition{}). + WithInterceptorFuncs(testInterceptorFuncs(t)). + Build() + fakeMcli := metadatafake.NewSimpleMetadataClient(metascheme) + + // Create a runtime config + ki := kubernetesinstallation.New(nil) + + // Create infra manager with mocks + infraManager := kubernetesinfra.NewInfraManager( + kubernetesinfra.WithKubeClient(fakeKcli), + kubernetesinfra.WithMetadataClient(fakeMcli), + kubernetesinfra.WithHelmClient(helmMock), + kubernetesinfra.WithLicense(licenseData), + kubernetesinfra.WithKotsInstaller(func() error { + return nil + }), + kubernetesinfra.WithReleaseData(&release.ReleaseData{ + EmbeddedClusterConfig: &ecv1beta1.Config{}, + ChannelRelease: &release.ChannelRelease{ + DefaultDomains: release.Domains{ + ReplicatedAppDomain: "replicated.example.com", + ProxyRegistryDomain: "some-proxy.example.com", + }, + }, + }), + ) + + mock.InOrder( + helmMock.On("Install", mock.Anything, mock.Anything).Times(1).Return(nil, assert.AnError), // 1 addon + helmMock.On("Close").Return(nil), + ) + + // Create an install controller with the mocked managers + installController, err := kubernetesinstall.NewInstallController( + kubernetesinstall.WithInstallation(ki), + kubernetesinstall.WithStateMachine(kubernetesinstall.NewStateMachine(kubernetesinstall.WithCurrentState(kubernetesinstall.StateInstallationConfigured))), + kubernetesinstall.WithInfraManager(infraManager), + kubernetesinstall.WithReleaseData(&release.ReleaseData{ + EmbeddedClusterConfig: &ecv1beta1.Config{}, + ChannelRelease: &release.ChannelRelease{ + DefaultDomains: release.Domains{ + ReplicatedAppDomain: "replicated.example.com", + ProxyRegistryDomain: "some-proxy.example.com", + }, + }, + }), + ) + require.NoError(t, err) + + // Create the API with the install controller + apiInstance, err := api.New( + types.APIConfig{ + Password: "password", + }, + api.WithKubernetesInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(logger.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + req := httptest.NewRequest(http.MethodPost, "/kubernetes/install/infra/setup", nil) + req.Header.Set("Authorization", "Bearer TOKEN") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusOK, rec.Code) + + // The status should eventually be set to failed due to k0s install error + assert.Eventually(t, func() bool { + // Create a request to get infra status + req := httptest.NewRequest(http.MethodGet, "/kubernetes/install/infra/status", nil) + req.Header.Set("Authorization", "Bearer TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusOK, rec.Code) + + // Parse the response body + var infra types.Infra + err = json.NewDecoder(rec.Body).Decode(&infra) + require.NoError(t, err) + + t.Logf("Infra Status: %s, Description: %s", infra.Status.State, infra.Status.Description) + return infra.Status.State == types.StateFailed && strings.Contains(infra.Status.Description, assert.AnError.Error()) + }, 10*time.Second, 100*time.Millisecond, "Infrastructure setup did not fail in time") + + // Verify that the mock expectations were met + helmMock.AssertExpectations(t) }) } diff --git a/api/internal/handlers/auth/auth.go b/api/internal/handlers/auth/auth.go new file mode 100644 index 0000000000..5f1cc8568a --- /dev/null +++ b/api/internal/handlers/auth/auth.go @@ -0,0 +1,85 @@ +package auth + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/replicatedhq/embedded-cluster/api/controllers/auth" + "github.com/replicatedhq/embedded-cluster/api/internal/handlers/utils" + "github.com/replicatedhq/embedded-cluster/api/pkg/logger" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/sirupsen/logrus" +) + +type Handler struct { + logger logrus.FieldLogger + authController auth.Controller +} + +type Option func(*Handler) + +func WithAuthController(controller auth.Controller) Option { + return func(h *Handler) { + h.authController = controller + } +} + +func WithLogger(logger logrus.FieldLogger) Option { + return func(h *Handler) { + h.logger = logger + } +} + +func New(password string, opts ...Option) (*Handler, error) { + h := &Handler{} + + for _, opt := range opts { + opt(h) + } + + if h.logger == nil { + h.logger = logger.NewDiscardLogger() + } + + if h.authController == nil { + authController, err := auth.NewAuthController(password) + if err != nil { + return nil, fmt.Errorf("new auth controller: %w", err) + } + h.authController = authController + } + + return h, nil +} + +func (h *Handler) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + if token == "" { + err := errors.New("authorization header is required") + utils.LogError(r, err, h.logger, "failed to authenticate") + utils.JSONError(w, r, types.NewUnauthorizedError(err), h.logger) + return + } + + if !strings.HasPrefix(token, "Bearer ") { + err := errors.New("authorization header must start with Bearer ") + utils.LogError(r, err, h.logger, "failed to authenticate") + utils.JSONError(w, r, types.NewUnauthorizedError(err), h.logger) + return + } + + token = token[len("Bearer "):] + + err := h.authController.ValidateToken(r.Context(), token) + if err != nil { + utils.LogError(r, err, h.logger, "failed to validate token") + utils.JSONError(w, r, types.NewUnauthorizedError(err), h.logger) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/api/internal/handlers/auth/login.go b/api/internal/handlers/auth/login.go new file mode 100644 index 0000000000..34e5e3f17c --- /dev/null +++ b/api/internal/handlers/auth/login.go @@ -0,0 +1,47 @@ +package auth + +import ( + "errors" + "net/http" + + "github.com/replicatedhq/embedded-cluster/api/controllers/auth" + "github.com/replicatedhq/embedded-cluster/api/internal/handlers/utils" + "github.com/replicatedhq/embedded-cluster/api/types" +) + +// PostLogin handler to authenticate a user +// +// @ID postAuthLogin +// @Summary Authenticate a user +// @Description Authenticate a user +// @Tags auth +// @Accept json +// @Produce json +// @Param request body types.AuthRequest true "Auth Request" +// @Success 200 {object} types.AuthResponse +// @Failure 401 {object} types.APIError +// @Router /auth/login [post] +func (h *Handler) PostLogin(w http.ResponseWriter, r *http.Request) { + var request types.AuthRequest + if err := utils.BindJSON(w, r, &request, h.logger); err != nil { + return + } + + token, err := h.authController.Authenticate(r.Context(), request.Password) + if errors.Is(err, auth.ErrInvalidPassword) { + utils.JSONError(w, r, types.NewUnauthorizedError(err), h.logger) + return + } + + if err != nil { + utils.LogError(r, err, h.logger, "failed to authenticate") + utils.JSONError(w, r, types.NewInternalServerError(err), h.logger) + return + } + + response := types.AuthResponse{ + Token: token, + } + + utils.JSON(w, r, http.StatusOK, response, h.logger) +} diff --git a/api/internal/handlers/console/console.go b/api/internal/handlers/console/console.go new file mode 100644 index 0000000000..c9035a25c7 --- /dev/null +++ b/api/internal/handlers/console/console.go @@ -0,0 +1,81 @@ +package console + +import ( + "fmt" + "net/http" + + "github.com/replicatedhq/embedded-cluster/api/controllers/console" + "github.com/replicatedhq/embedded-cluster/api/internal/handlers/utils" + "github.com/replicatedhq/embedded-cluster/api/pkg/logger" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/sirupsen/logrus" +) + +type Handler struct { + logger logrus.FieldLogger + consoleController console.Controller +} + +type Option func(*Handler) + +func WithConsoleController(controller console.Controller) Option { + return func(h *Handler) { + h.consoleController = controller + } +} + +func WithLogger(logger logrus.FieldLogger) Option { + return func(h *Handler) { + h.logger = logger + } +} + +func New(opts ...Option) (*Handler, error) { + h := &Handler{} + + for _, opt := range opts { + opt(h) + } + + if h.logger == nil { + h.logger = logger.NewDiscardLogger() + } + + if h.consoleController == nil { + consoleController, err := console.NewConsoleController() + if err != nil { + return nil, fmt.Errorf("new console controller: %w", err) + } + h.consoleController = consoleController + } + + return h, nil +} + +// GetListAvailableNetworkInterfaces handler to list available network interfaces +// +// @ID getConsoleListAvailableNetworkInterfaces +// @Summary List available network interfaces +// @Description List available network interfaces +// @Tags console +// @Produce json +// @Success 200 {object} types.GetListAvailableNetworkInterfacesResponse +// @Router /console/available-network-interfaces [get] +func (h *Handler) GetListAvailableNetworkInterfaces(w http.ResponseWriter, r *http.Request) { + interfaces, err := h.consoleController.ListAvailableNetworkInterfaces() + if err != nil { + utils.LogError(r, err, h.logger, "failed to list available network interfaces") + utils.JSONError(w, r, err, h.logger) + return + } + + h.logger.WithFields(utils.LogrusFieldsFromRequest(r)). + WithField("interfaces", interfaces). + Info("got available network interfaces") + + response := types.GetListAvailableNetworkInterfacesResponse{ + NetworkInterfaces: interfaces, + } + + utils.JSON(w, r, http.StatusOK, response, h.logger) +} diff --git a/api/internal/handlers/health/health.go b/api/internal/handlers/health/health.go new file mode 100644 index 0000000000..7f033c7672 --- /dev/null +++ b/api/internal/handlers/health/health.go @@ -0,0 +1,52 @@ +package health + +import ( + "net/http" + + "github.com/replicatedhq/embedded-cluster/api/internal/handlers/utils" + "github.com/replicatedhq/embedded-cluster/api/pkg/logger" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/sirupsen/logrus" +) + +type Handler struct { + logger logrus.FieldLogger +} + +type Option func(*Handler) + +func WithLogger(logger logrus.FieldLogger) Option { + return func(h *Handler) { + h.logger = logger + } +} + +func New(opts ...Option) (*Handler, error) { + h := &Handler{} + + for _, opt := range opts { + opt(h) + } + + if h.logger == nil { + h.logger = logger.NewDiscardLogger() + } + + return h, nil +} + +// GetHealth handler to get the health of the API +// +// @ID getHealth +// @Summary Get the health of the API +// @Description get the health of the API +// @Tags health +// @Produce json +// @Success 200 {object} types.Health +// @Router /health [get] +func (h *Handler) GetHealth(w http.ResponseWriter, r *http.Request) { + response := types.Health{ + Status: types.HealthStatusOK, + } + utils.JSON(w, r, http.StatusOK, response, h.logger) +} diff --git a/api/internal/handlers/kubernetes/install.go b/api/internal/handlers/kubernetes/install.go new file mode 100644 index 0000000000..48a432d8e4 --- /dev/null +++ b/api/internal/handlers/kubernetes/install.go @@ -0,0 +1,120 @@ +package kubernetes + +import ( + "net/http" + + "github.com/replicatedhq/embedded-cluster/api/internal/handlers/utils" + "github.com/replicatedhq/embedded-cluster/api/types" +) + +// GetInstallationConfig handler to get the Kubernetes installation config +// +// @ID getKubernetesInstallInstallationConfig +// @Summary Get the Kubernetes installation config +// @Description get the Kubernetes installation config +// @Tags kubernetes-install +// @Security bearerauth +// @Produce json +// @Success 200 {object} types.KubernetesInstallationConfig +// @Router /kubernetes/install/installation/config [get] +func (h *Handler) GetInstallationConfig(w http.ResponseWriter, r *http.Request) { + config, err := h.installController.GetInstallationConfig(r.Context()) + if err != nil { + utils.LogError(r, err, h.logger, "failed to get installation config") + utils.JSONError(w, r, err, h.logger) + return + } + + utils.JSON(w, r, http.StatusOK, config, h.logger) +} + +// PostConfigureInstallation handler to configure the Kubernetes installation for install +// +// @ID postKubernetesInstallConfigureInstallation +// @Summary Configure the Kubernetes installation for install +// @Description configure the Kubernetes installation for install +// @Tags kubernetes-install +// @Security bearerauth +// @Accept json +// @Produce json +// @Param installationConfig body types.KubernetesInstallationConfig true "Installation config" +// @Success 200 {object} types.Status +// @Router /kubernetes/install/installation/configure [post] +func (h *Handler) PostConfigureInstallation(w http.ResponseWriter, r *http.Request) { + var config types.KubernetesInstallationConfig + if err := utils.BindJSON(w, r, &config, h.logger); err != nil { + return + } + + if err := h.installController.ConfigureInstallation(r.Context(), config); err != nil { + utils.LogError(r, err, h.logger, "failed to set installation config") + utils.JSONError(w, r, err, h.logger) + return + } + + h.GetInstallationStatus(w, r) +} + +// GetInstallationStatus handler to get the status of the installation configuration for install +// +// @ID getKubernetesInstallInstallationStatus +// @Summary Get installation configuration status for install +// @Description Get the current status of the installation configuration for install +// @Tags kubernetes-install +// @Security bearerauth +// @Produce json +// @Success 200 {object} types.Status +// @Router /kubernetes/install/installation/status [get] +func (h *Handler) GetInstallationStatus(w http.ResponseWriter, r *http.Request) { + status, err := h.installController.GetInstallationStatus(r.Context()) + if err != nil { + utils.LogError(r, err, h.logger, "failed to get installation status") + utils.JSONError(w, r, err, h.logger) + return + } + + utils.JSON(w, r, http.StatusOK, status, h.logger) +} + +// PostSetupInfra handler to setup infra components +// +// @ID postKubernetesInstallSetupInfra +// @Summary Setup infra components +// @Description Setup infra components +// @Tags kubernetes-install +// @Security bearerauth +// @Accept json +// @Produce json +// @Success 200 {object} types.Infra +// @Router /kubernetes/install/infra/setup [post] +func (h *Handler) PostSetupInfra(w http.ResponseWriter, r *http.Request) { + err := h.installController.SetupInfra(r.Context()) + if err != nil { + utils.LogError(r, err, h.logger, "failed to setup infra") + utils.JSONError(w, r, err, h.logger) + return + } + + h.GetInfraStatus(w, r) +} + +// GetInfraStatus handler to get the status of the infra +// +// @ID getKubernetesInstallInfraStatus +// @Summary Get the status of the infra +// @Description Get the current status of the infra +// @Tags kubernetes-install +// @Security bearerauth +// @Produce json +// @Success 200 {object} types.Infra +// @Router /kubernetes/install/infra/status [get] +func (h *Handler) GetInfraStatus(w http.ResponseWriter, r *http.Request) { + infra, err := h.installController.GetInfra(r.Context()) + if err != nil { + utils.LogError(r, err, h.logger, "failed to get install infra status") + utils.JSONError(w, r, err, h.logger) + return + } + + utils.JSON(w, r, http.StatusOK, infra, h.logger) +} diff --git a/api/internal/handlers/kubernetes/kubernetes.go b/api/internal/handlers/kubernetes/kubernetes.go new file mode 100644 index 0000000000..fe4db989d2 --- /dev/null +++ b/api/internal/handlers/kubernetes/kubernetes.go @@ -0,0 +1,71 @@ +package kubernetes + +import ( + "fmt" + + "github.com/replicatedhq/embedded-cluster/api/controllers/kubernetes/install" + "github.com/replicatedhq/embedded-cluster/api/pkg/logger" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/metrics" + "github.com/sirupsen/logrus" +) + +type Handler struct { + cfg types.APIConfig + installController install.Controller + logger logrus.FieldLogger + metricsReporter metrics.ReporterInterface +} + +type Option func(*Handler) + +func WithInstallController(controller install.Controller) Option { + return func(h *Handler) { + h.installController = controller + } +} + +func WithLogger(logger logrus.FieldLogger) Option { + return func(h *Handler) { + h.logger = logger + } +} + +func WithMetricsReporter(metricsReporter metrics.ReporterInterface) Option { + return func(h *Handler) { + h.metricsReporter = metricsReporter + } +} + +func New(cfg types.APIConfig, opts ...Option) (*Handler, error) { + h := &Handler{ + cfg: cfg, + } + + for _, opt := range opts { + opt(h) + } + + if h.logger == nil { + h.logger = logger.NewDiscardLogger() + } + + // TODO (@team): discuss which of these should / should not be pointers + if h.installController == nil { + installController, err := install.NewInstallController( + install.WithLogger(h.logger), + install.WithMetricsReporter(h.metricsReporter), + install.WithRESTClientGetterFactory(h.cfg.RESTClientGetterFactory), + install.WithReleaseData(h.cfg.ReleaseData), + install.WithEndUserConfig(h.cfg.EndUserConfig), + install.WithPassword(h.cfg.Password), + install.WithInstallation(h.cfg.KubernetesConfig.Installation), + ) + if err != nil { + return nil, fmt.Errorf("new install controller: %w", err) + } + h.installController = installController + } + + return h, nil +} diff --git a/api/internal/handlers/linux/install.go b/api/internal/handlers/linux/install.go new file mode 100644 index 0000000000..1b3a81175c --- /dev/null +++ b/api/internal/handlers/linux/install.go @@ -0,0 +1,199 @@ +package linux + +import ( + "net/http" + + "github.com/replicatedhq/embedded-cluster/api/controllers/linux/install" + "github.com/replicatedhq/embedded-cluster/api/internal/handlers/utils" + "github.com/replicatedhq/embedded-cluster/api/types" +) + +// GetInstallationConfig handler to get the installation config +// +// @ID getLinuxInstallInstallationConfig +// @Summary Get the installation config +// @Description get the installation config +// @Tags linux-install +// @Security bearerauth +// @Produce json +// @Success 200 {object} types.LinuxInstallationConfig +// @Router /linux/install/installation/config [get] +func (h *Handler) GetInstallationConfig(w http.ResponseWriter, r *http.Request) { + config, err := h.installController.GetInstallationConfig(r.Context()) + if err != nil { + utils.LogError(r, err, h.logger, "failed to get installation config") + utils.JSONError(w, r, err, h.logger) + return + } + + utils.JSON(w, r, http.StatusOK, config, h.logger) +} + +// PostConfigureInstallation handler to configure the installation for install +// +// @ID postLinuxInstallConfigureInstallation +// @Summary Configure the installation for install +// @Description configure the installation for install +// @Tags linux-install +// @Security bearerauth +// @Accept json +// @Produce json +// @Param installationConfig body types.LinuxInstallationConfig true "Installation config" +// @Success 200 {object} types.Status +// @Router /linux/install/installation/configure [post] +func (h *Handler) PostConfigureInstallation(w http.ResponseWriter, r *http.Request) { + var config types.LinuxInstallationConfig + if err := utils.BindJSON(w, r, &config, h.logger); err != nil { + return + } + + if err := h.installController.ConfigureInstallation(r.Context(), config); err != nil { + utils.LogError(r, err, h.logger, "failed to set installation config") + utils.JSONError(w, r, err, h.logger) + return + } + + h.GetInstallationStatus(w, r) +} + +// GetInstallationStatus handler to get the status of the installation configuration for install +// +// @ID getLinuxInstallInstallationStatus +// @Summary Get installation configuration status for install +// @Description Get the current status of the installation configuration for install +// @Tags linux-install +// @Security bearerauth +// @Produce json +// @Success 200 {object} types.Status +// @Router /linux/install/installation/status [get] +func (h *Handler) GetInstallationStatus(w http.ResponseWriter, r *http.Request) { + status, err := h.installController.GetInstallationStatus(r.Context()) + if err != nil { + utils.LogError(r, err, h.logger, "failed to get installation status") + utils.JSONError(w, r, err, h.logger) + return + } + + utils.JSON(w, r, http.StatusOK, status, h.logger) +} + +// PostRunHostPreflights handler to run install host preflight checks +// +// @ID postLinuxInstallRunHostPreflights +// @Summary Run install host preflight checks +// @Description Run install host preflight checks using installation config and client-provided data +// @Tags linux-install +// @Security bearerauth +// @Accept json +// @Produce json +// @Param request body types.PostInstallRunHostPreflightsRequest true "Post Install Run Host Preflights Request" +// @Success 200 {object} types.InstallHostPreflightsStatusResponse +// @Router /linux/install/host-preflights/run [post] +func (h *Handler) PostRunHostPreflights(w http.ResponseWriter, r *http.Request) { + var req types.PostInstallRunHostPreflightsRequest + if err := utils.BindJSON(w, r, &req, h.logger); err != nil { + return + } + + err := h.installController.RunHostPreflights(r.Context(), install.RunHostPreflightsOptions{ + IsUI: req.IsUI, + }) + if err != nil { + utils.LogError(r, err, h.logger, "failed to run install host preflights") + utils.JSONError(w, r, err, h.logger) + return + } + + h.GetHostPreflightsStatus(w, r) +} + +// GetHostPreflightsStatus handler to get host preflight status for install +// +// @ID getLinuxInstallHostPreflightsStatus +// @Summary Get host preflight status for install +// @Description Get the current status and results of host preflight checks for install +// @Tags linux-install +// @Security bearerauth +// @Produce json +// @Success 200 {object} types.InstallHostPreflightsStatusResponse +// @Router /linux/install/host-preflights/status [get] +func (h *Handler) GetHostPreflightsStatus(w http.ResponseWriter, r *http.Request) { + titles, err := h.installController.GetHostPreflightTitles(r.Context()) + if err != nil { + utils.LogError(r, err, h.logger, "failed to get install host preflight titles") + utils.JSONError(w, r, err, h.logger) + return + } + + output, err := h.installController.GetHostPreflightOutput(r.Context()) + if err != nil { + utils.LogError(r, err, h.logger, "failed to get install host preflight output") + utils.JSONError(w, r, err, h.logger) + return + } + + status, err := h.installController.GetHostPreflightStatus(r.Context()) + if err != nil { + utils.LogError(r, err, h.logger, "failed to get install host preflight status") + utils.JSONError(w, r, err, h.logger) + return + } + + response := types.InstallHostPreflightsStatusResponse{ + Titles: titles, + Output: output, + Status: status, + AllowIgnoreHostPreflights: h.cfg.AllowIgnoreHostPreflights, + } + + utils.JSON(w, r, http.StatusOK, response, h.logger) +} + +// PostSetupInfra handler to setup infra components +// +// @ID postLinuxInstallSetupInfra +// @Summary Setup infra components +// @Description Setup infra components +// @Tags linux-install +// @Security bearerauth +// @Accept json +// @Produce json +// @Param request body types.LinuxInfraSetupRequest true "Infra Setup Request" +// @Success 200 {object} types.Infra +// @Router /linux/install/infra/setup [post] +func (h *Handler) PostSetupInfra(w http.ResponseWriter, r *http.Request) { + var req types.LinuxInfraSetupRequest + if err := utils.BindJSON(w, r, &req, h.logger); err != nil { + return + } + + err := h.installController.SetupInfra(r.Context(), req.IgnoreHostPreflights) + if err != nil { + utils.LogError(r, err, h.logger, "failed to setup infra") + utils.JSONError(w, r, err, h.logger) + return + } + + h.GetInfraStatus(w, r) +} + +// GetInfraStatus handler to get the status of the infra +// +// @ID getLinuxInstallInfraStatus +// @Summary Get the status of the infra +// @Description Get the current status of the infra +// @Tags linux-install +// @Security bearerauth +// @Produce json +// @Success 200 {object} types.Infra +// @Router /linux/install/infra/status [get] +func (h *Handler) GetInfraStatus(w http.ResponseWriter, r *http.Request) { + infra, err := h.installController.GetInfra(r.Context()) + if err != nil { + utils.LogError(r, err, h.logger, "failed to get install infra status") + utils.JSONError(w, r, err, h.logger) + return + } + + utils.JSON(w, r, http.StatusOK, infra, h.logger) +} diff --git a/api/internal/handlers/linux/linux.go b/api/internal/handlers/linux/linux.go new file mode 100644 index 0000000000..cfc721d3cf --- /dev/null +++ b/api/internal/handlers/linux/linux.go @@ -0,0 +1,91 @@ +package linux + +import ( + "fmt" + + "github.com/replicatedhq/embedded-cluster/api/controllers/linux/install" + "github.com/replicatedhq/embedded-cluster/api/pkg/logger" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" + "github.com/replicatedhq/embedded-cluster/pkg/metrics" + "github.com/sirupsen/logrus" +) + +type Handler struct { + cfg types.APIConfig + installController install.Controller + logger logrus.FieldLogger + hostUtils hostutils.HostUtilsInterface + metricsReporter metrics.ReporterInterface +} + +type Option func(*Handler) + +func WithInstallController(controller install.Controller) Option { + return func(h *Handler) { + h.installController = controller + } +} + +func WithLogger(logger logrus.FieldLogger) Option { + return func(h *Handler) { + h.logger = logger + } +} + +func WithHostUtils(hostUtils hostutils.HostUtilsInterface) Option { + return func(h *Handler) { + h.hostUtils = hostUtils + } +} + +func WithMetricsReporter(metricsReporter metrics.ReporterInterface) Option { + return func(h *Handler) { + h.metricsReporter = metricsReporter + } +} + +func New(cfg types.APIConfig, opts ...Option) (*Handler, error) { + h := &Handler{ + cfg: cfg, + } + + for _, opt := range opts { + opt(h) + } + + if h.logger == nil { + h.logger = logger.NewDiscardLogger() + } + + if h.hostUtils == nil { + h.hostUtils = hostutils.New( + hostutils.WithLogger(h.logger), + ) + } + + // TODO (@team): discuss which of these should / should not be pointers + if h.installController == nil { + installController, err := install.NewInstallController( + install.WithRuntimeConfig(h.cfg.RuntimeConfig), + install.WithLogger(h.logger), + install.WithHostUtils(h.hostUtils), + install.WithMetricsReporter(h.metricsReporter), + install.WithReleaseData(h.cfg.ReleaseData), + install.WithPassword(h.cfg.Password), + install.WithTLSConfig(h.cfg.TLSConfig), + install.WithLicense(h.cfg.License), + install.WithAirgapBundle(h.cfg.AirgapBundle), + install.WithConfigValues(h.cfg.ConfigValues), + install.WithEndUserConfig(h.cfg.EndUserConfig), + install.WithClusterID(h.cfg.ClusterID), + install.WithAllowIgnoreHostPreflights(h.cfg.AllowIgnoreHostPreflights), + ) + if err != nil { + return nil, fmt.Errorf("new install controller: %w", err) + } + h.installController = installController + } + + return h, nil +} diff --git a/api/internal/handlers/utils/utils.go b/api/internal/handlers/utils/utils.go new file mode 100644 index 0000000000..57fc071a55 --- /dev/null +++ b/api/internal/handlers/utils/utils.go @@ -0,0 +1,62 @@ +package utils + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/sirupsen/logrus" +) + +// Shared helper functions for all handler packages + +func BindJSON(w http.ResponseWriter, r *http.Request, v any, logger logrus.FieldLogger) error { + if err := json.NewDecoder(r.Body).Decode(v); err != nil { + LogError(r, err, logger, fmt.Sprintf("failed to decode %s %s request", strings.ToLower(r.Method), r.URL.Path)) + JSONError(w, r, types.NewBadRequestError(err), logger) + return err + } + return nil +} + +func JSON(w http.ResponseWriter, r *http.Request, code int, payload any, logger logrus.FieldLogger) { + response, err := json.Marshal(payload) + if err != nil { + LogError(r, err, logger, "failed to encode response") + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _, _ = w.Write(response) +} + +func JSONError(w http.ResponseWriter, r *http.Request, err error, logger logrus.FieldLogger) { + var apiErr *types.APIError + if !errors.As(err, &apiErr) { + apiErr = types.NewInternalServerError(err) + } + response, err := json.Marshal(apiErr) + if err != nil { + LogError(r, err, logger, "failed to encode response") + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(apiErr.StatusCode) + _, _ = w.Write(response) +} + +func LogError(r *http.Request, err error, logger logrus.FieldLogger, args ...any) { + logger.WithFields(LogrusFieldsFromRequest(r)).WithError(err).Error(args...) +} + +func LogrusFieldsFromRequest(r *http.Request) logrus.Fields { + return logrus.Fields{ + "method": r.Method, + "path": r.URL.Path, + } +} diff --git a/api/api_test.go b/api/internal/handlers/utils/utils_test.go similarity index 94% rename from api/api_test.go rename to api/internal/handlers/utils/utils_test.go index 8a35376cc5..3d6e26ca2c 100644 --- a/api/api_test.go +++ b/api/internal/handlers/utils/utils_test.go @@ -1,4 +1,4 @@ -package api +package utils import ( "encoding/json" @@ -84,10 +84,7 @@ func TestAPI_jsonError(t *testing.T) { rec := httptest.NewRecorder() // Call the JSON method - api := &API{ - logger: logger.NewDiscardLogger(), - } - api.jsonError(rec, httptest.NewRequest("GET", "/api/test", nil), tt.apiErr) + JSONError(rec, httptest.NewRequest("GET", "/api/test", nil), tt.apiErr, logger.NewDiscardLogger()) // Check status code assert.Equal(t, tt.wantCode, rec.Code, "Status code should match") diff --git a/api/internal/managers/infra/status.go b/api/internal/managers/infra/status.go deleted file mode 100644 index 05db00980f..0000000000 --- a/api/internal/managers/infra/status.go +++ /dev/null @@ -1,55 +0,0 @@ -package infra - -import ( - "fmt" - "time" - - "github.com/replicatedhq/embedded-cluster/api/types" -) - -func (m *infraManager) GetStatus() (*types.Status, error) { - return m.infraStore.GetStatus() -} - -func (m *infraManager) SetStatus(status types.Status) error { - return m.infraStore.SetStatus(status) -} - -func (m *infraManager) installDidRun() (bool, error) { - currStatus, err := m.GetStatus() - if err != nil { - return false, fmt.Errorf("get status: %w", err) - } - if currStatus == nil { - return false, nil - } - if currStatus.State == "" { - return false, nil - } - if currStatus.State == types.StatePending { - return false, nil - } - return true, nil -} - -func (m *infraManager) setStatus(state types.State, description string) error { - return m.SetStatus(types.Status{ - State: state, - Description: description, - LastUpdated: time.Now(), - }) -} - -func (m *infraManager) setComponentStatus(name string, state types.State, description string) error { - if state == types.StateRunning { - // update the overall status to reflect the current component - if err := m.setStatus(types.StateRunning, fmt.Sprintf("%s %s", description, name)); err != nil { - m.logger.Errorf("Failed to set status: %v", err) - } - } - return m.infraStore.SetComponentStatus(name, &types.Status{ - State: state, - Description: description, - LastUpdated: time.Now(), - }) -} diff --git a/api/internal/managers/infra/status_test.go b/api/internal/managers/infra/status_test.go deleted file mode 100644 index a6a4b72916..0000000000 --- a/api/internal/managers/infra/status_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package infra - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - - "github.com/replicatedhq/embedded-cluster/api/types" -) - -func TestStatusSetAndGet(t *testing.T) { - manager := NewInfraManager() - - // Test writing a status - statusToWrite := types.Status{ - State: types.StateRunning, - Description: "Installation in progress", - LastUpdated: time.Now().UTC().Truncate(time.Second), // Truncate to avoid time precision issues - } - - err := manager.SetStatus(statusToWrite) - assert.NoError(t, err) - - // Test reading it back - readStatus, err := manager.GetStatus() - assert.NoError(t, err) - assert.NotNil(t, readStatus) - - // Verify the values match - assert.Equal(t, statusToWrite.State, readStatus.State) - assert.Equal(t, statusToWrite.Description, readStatus.Description) - - // Compare time with string format to avoid precision issues - expectedTime := statusToWrite.LastUpdated.Format(time.RFC3339) - actualTime := readStatus.LastUpdated.Format(time.RFC3339) - assert.Equal(t, expectedTime, actualTime) -} - -func TestInstallDidRun(t *testing.T) { - tests := []struct { - name string - currentStatus *types.Status - expectedResult bool - expectedErr bool - }{ - { - name: "nil status", - currentStatus: nil, - expectedResult: false, - expectedErr: false, - }, - { - name: "empty state", - currentStatus: &types.Status{ - State: "", - }, - expectedResult: false, - expectedErr: false, - }, - { - name: "pending state", - currentStatus: &types.Status{ - State: types.StatePending, - }, - expectedResult: false, - expectedErr: false, - }, - { - name: "running state", - currentStatus: &types.Status{ - State: types.StateRunning, - }, - expectedResult: true, - expectedErr: false, - }, - { - name: "failed state", - currentStatus: &types.Status{ - State: types.StateFailed, - }, - expectedResult: true, - expectedErr: false, - }, - { - name: "succeeded state", - currentStatus: &types.Status{ - State: types.StateSucceeded, - }, - expectedResult: true, - expectedErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - manager := NewInfraManager() - if tt.currentStatus != nil { - manager.SetStatus(*tt.currentStatus) - } - result, err := manager.installDidRun() - - if tt.expectedErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expectedResult, result) - } - }) - } -} diff --git a/api/internal/managers/infra/store.go b/api/internal/managers/infra/store.go deleted file mode 100644 index fb4de7af9e..0000000000 --- a/api/internal/managers/infra/store.go +++ /dev/null @@ -1,80 +0,0 @@ -package infra - -import ( - "fmt" - "sync" - "time" - - "github.com/replicatedhq/embedded-cluster/api/types" -) - -// Store provides methods for storing and retrieving infrastructure state -type Store interface { - Get() (*types.Infra, error) - GetStatus() (*types.Status, error) - SetStatus(status types.Status) error - RegisterComponent(name string) error - SetComponentStatus(name string, status *types.Status) error -} - -// memoryStore is an in-memory implementation of Store -type memoryStore struct { - infra *types.Infra - mu sync.RWMutex -} - -// NewMemoryStore creates a new memory store -func NewMemoryStore(infra *types.Infra) Store { - return &memoryStore{ - infra: infra, - } -} - -func (s *memoryStore) Get() (*types.Infra, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.infra, nil -} - -func (s *memoryStore) GetStatus() (*types.Status, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.infra.Status, nil -} - -func (s *memoryStore) SetStatus(status types.Status) error { - s.mu.Lock() - defer s.mu.Unlock() - s.infra.Status = &status - return nil -} - -func (s *memoryStore) RegisterComponent(name string) error { - s.mu.Lock() - defer s.mu.Unlock() - - s.infra.Components = append(s.infra.Components, types.InfraComponent{ - Name: name, - Status: &types.Status{ - State: types.StatePending, - Description: "", - LastUpdated: time.Now(), - }, - }) - - return nil -} - -func (s *memoryStore) SetComponentStatus(name string, status *types.Status) error { - s.mu.Lock() - defer s.mu.Unlock() - - for i, component := range s.infra.Components { - if component.Name == name { - s.infra.Components[i].Status = status - return nil - } - } - - return fmt.Errorf("component %s not found", name) -} diff --git a/api/internal/managers/infra/store_mock.go b/api/internal/managers/infra/store_mock.go deleted file mode 100644 index 4c03580bef..0000000000 --- a/api/internal/managers/infra/store_mock.go +++ /dev/null @@ -1,44 +0,0 @@ -package infra - -import ( - "github.com/replicatedhq/embedded-cluster/api/types" - "github.com/stretchr/testify/mock" -) - -var _ Store = (*MockStore)(nil) - -// MockStore is a mock implementation of Store -type MockStore struct { - mock.Mock -} - -func (m *MockStore) Get() (*types.Infra, error) { - args := m.Called() - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*types.Infra), args.Error(1) -} - -func (m *MockStore) GetStatus() (*types.Status, error) { - args := m.Called() - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*types.Status), args.Error(1) -} - -func (m *MockStore) SetStatus(status types.Status) error { - args := m.Called(status) - return args.Error(0) -} - -func (m *MockStore) RegisterComponent(name string) error { - args := m.Called(name) - return args.Error(0) -} - -func (m *MockStore) SetComponentStatus(name string, status *types.Status) error { - args := m.Called(name, status) - return args.Error(0) -} diff --git a/api/internal/managers/infra/util.go b/api/internal/managers/infra/util.go deleted file mode 100644 index 5e8405c5d6..0000000000 --- a/api/internal/managers/infra/util.go +++ /dev/null @@ -1,56 +0,0 @@ -package infra - -import ( - "context" - "fmt" - "os" - "strings" - - ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" - "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" - "github.com/replicatedhq/embedded-cluster/pkg/versions" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -func (m *infraManager) waitForNode(ctx context.Context, kcli client.Client) error { - hostname, err := os.Hostname() - if err != nil { - return fmt.Errorf("get hostname: %w", err) - } - nodename := strings.ToLower(hostname) - if err := kubeutils.WaitForNode(ctx, kcli, nodename, false); err != nil { - return err - } - return nil -} - -func (m *infraManager) getHelmClient() (helm.Client, error) { - airgapChartsPath := "" - if m.airgapBundle != "" { - airgapChartsPath = m.rc.EmbeddedClusterChartsSubDir() - } - hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: m.rc.PathToKubeConfig(), - K0sVersion: versions.K0sVersion, - AirgapPath: airgapChartsPath, - }) - if err != nil { - return nil, fmt.Errorf("create helm client: %w", err) - } - return hcli, nil -} - -func (m *infraManager) getECConfigSpec() *ecv1beta1.ConfigSpec { - if m.releaseData == nil || m.releaseData.EmbeddedClusterConfig == nil { - return nil - } - return &m.releaseData.EmbeddedClusterConfig.Spec -} - -func (m *infraManager) getEndUserConfigSpec() *ecv1beta1.ConfigSpec { - if m.endUserConfig == nil { - return nil - } - return &m.endUserConfig.Spec -} diff --git a/api/internal/managers/installation/store.go b/api/internal/managers/installation/store.go deleted file mode 100644 index 7159ed6e69..0000000000 --- a/api/internal/managers/installation/store.go +++ /dev/null @@ -1,58 +0,0 @@ -package installation - -import ( - "sync" - - "github.com/replicatedhq/embedded-cluster/api/types" -) - -// TODO (@team): discuss the idea of having a generic store interface that can be used for all stores -type InstallationStore interface { - GetConfig() (*types.InstallationConfig, error) - SetConfig(cfg types.InstallationConfig) error - GetStatus() (*types.Status, error) - SetStatus(status types.Status) error -} - -var _ InstallationStore = &MemoryStore{} - -type MemoryStore struct { - mu sync.RWMutex - installation *types.Installation -} - -func NewMemoryStore(installation *types.Installation) *MemoryStore { - return &MemoryStore{ - installation: installation, - } -} - -func (s *MemoryStore) GetConfig() (*types.InstallationConfig, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.installation.Config, nil -} - -func (s *MemoryStore) SetConfig(cfg types.InstallationConfig) error { - s.mu.Lock() - defer s.mu.Unlock() - s.installation.Config = &cfg - - return nil -} - -func (s *MemoryStore) GetStatus() (*types.Status, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.installation.Status, nil -} - -func (s *MemoryStore) SetStatus(status types.Status) error { - s.mu.Lock() - defer s.mu.Unlock() - s.installation.Status = &status - - return nil -} diff --git a/api/internal/managers/installation/store_mock.go b/api/internal/managers/installation/store_mock.go deleted file mode 100644 index 871e151928..0000000000 --- a/api/internal/managers/installation/store_mock.go +++ /dev/null @@ -1,43 +0,0 @@ -package installation - -import ( - "github.com/replicatedhq/embedded-cluster/api/types" - "github.com/stretchr/testify/mock" -) - -var _ InstallationStore = (*MockInstallationStore)(nil) - -// MockInstallationStore is a mock implementation of the InstallationStore interface -type MockInstallationStore struct { - mock.Mock -} - -// GetConfig mocks the GetConfig method -func (m *MockInstallationStore) GetConfig() (*types.InstallationConfig, error) { - args := m.Called() - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*types.InstallationConfig), args.Error(1) -} - -// SetConfig mocks the SetConfig method -func (m *MockInstallationStore) SetConfig(cfg types.InstallationConfig) error { - args := m.Called(cfg) - return args.Error(0) -} - -// GetStatus mocks the GetStatus method -func (m *MockInstallationStore) GetStatus() (*types.Status, error) { - args := m.Called() - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*types.Status), args.Error(1) -} - -// SetStatus mocks the SetStatus method -func (m *MockInstallationStore) SetStatus(status types.Status) error { - args := m.Called(status) - return args.Error(0) -} diff --git a/api/internal/managers/kubernetes/infra/install.go b/api/internal/managers/kubernetes/infra/install.go new file mode 100644 index 0000000000..b63502f30a --- /dev/null +++ b/api/internal/managers/kubernetes/infra/install.go @@ -0,0 +1,181 @@ +package infra + +import ( + "context" + "fmt" + "runtime/debug" + + "github.com/replicatedhq/embedded-cluster/api/internal/utils" + "github.com/replicatedhq/embedded-cluster/api/types" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/addons" + addontypes "github.com/replicatedhq/embedded-cluster/pkg/addons/types" + "github.com/replicatedhq/embedded-cluster/pkg/helm" + "github.com/replicatedhq/embedded-cluster/pkg/kubernetesinstallation" + "github.com/replicatedhq/embedded-cluster/pkg/support" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/sirupsen/logrus" + "k8s.io/client-go/metadata" + "sigs.k8s.io/controller-runtime/pkg/client" + kyaml "sigs.k8s.io/yaml" +) + +func (m *infraManager) Install(ctx context.Context, ki kubernetesinstallation.Installation) (finalErr error) { + // TODO: check if kots is already installed + + if err := m.setStatus(types.StateRunning, ""); err != nil { + return fmt.Errorf("set status: %w", err) + } + + defer func() { + if r := recover(); r != nil { + finalErr = fmt.Errorf("panic: %v: %s", r, string(debug.Stack())) + } + if finalErr != nil { + if err := m.setStatus(types.StateFailed, finalErr.Error()); err != nil { + m.logger.WithField("error", err).Error("set failed status") + } + } else { + if err := m.setStatus(types.StateSucceeded, "Installation complete"); err != nil { + m.logger.WithField("error", err).Error("set succeeded status") + } + } + }() + + if err := m.install(ctx, ki); err != nil { + return err + } + + return nil +} + +func (m *infraManager) initComponentsList(license *kotsv1beta1.License, ki kubernetesinstallation.Installation) error { + components := []types.InfraComponent{} + + addOns := addons.GetAddOnsForKubernetesInstall(m.getAddonInstallOpts(license, ki)) + for _, addOn := range addOns { + components = append(components, types.InfraComponent{Name: addOn.Name()}) + } + + for _, component := range components { + if err := m.infraStore.RegisterComponent(component.Name); err != nil { + return fmt.Errorf("register component: %w", err) + } + } + return nil +} + +func (m *infraManager) install(ctx context.Context, ki kubernetesinstallation.Installation) error { + license := &kotsv1beta1.License{} + if err := kyaml.Unmarshal(m.license, license); err != nil { + return fmt.Errorf("parse license: %w", err) + } + + if err := m.initComponentsList(license, ki); err != nil { + return fmt.Errorf("init components: %w", err) + } + + kcli, err := m.kubeClient() + if err != nil { + return fmt.Errorf("create kube client: %w", err) + } + + mcli, err := m.metadataClient() + if err != nil { + return fmt.Errorf("create metadata client: %w", err) + } + + hcli, err := m.helmClient(ki) + if err != nil { + return fmt.Errorf("create helm client: %w", err) + } + defer hcli.Close() + + _, err = m.recordInstallation(ctx, kcli, license, ki) + if err != nil { + return fmt.Errorf("record installation: %w", err) + } + + if err := m.installAddOns(ctx, kcli, mcli, hcli, license, ki); err != nil { + return fmt.Errorf("install addons: %w", err) + } + + // TODO: we may need this later + // if err := kubeutils.SetInstallationState(ctx, kcli, in, ecv1beta1.InstallationStateInstalled, "Installed"); err != nil { + // return fmt.Errorf("update installation: %w", err) + // } + + if err = support.CreateHostSupportBundle(ctx, kcli); err != nil { + m.logger.Warnf("Unable to create host support bundle: %v", err) + } + + return nil +} + +func (m *infraManager) recordInstallation(ctx context.Context, kcli client.Client, license *kotsv1beta1.License, ki kubernetesinstallation.Installation) (*ecv1beta1.Installation, error) { + // TODO: we may need this later + + return nil, nil +} + +func (m *infraManager) installAddOns(ctx context.Context, kcli client.Client, mcli metadata.Interface, hcli helm.Client, license *kotsv1beta1.License, ki kubernetesinstallation.Installation) error { + progressChan := make(chan addontypes.AddOnProgress) + defer close(progressChan) + + go func() { + for progress := range progressChan { + // capture progress in debug logs + m.logger.WithFields(logrus.Fields{"addon": progress.Name, "state": progress.Status.State, "description": progress.Status.Description}).Debugf("addon progress") + + // if in progress, update the overall status to reflect the current component + if progress.Status.State == types.StateRunning { + m.setStatusDesc(fmt.Sprintf("%s %s", progress.Status.Description, progress.Name)) + } + + // update the status for the current component + if err := m.setComponentStatus(progress.Name, progress.Status.State, progress.Status.Description); err != nil { + m.logger.Errorf("Failed to update addon status: %v", err) + } + } + }() + + logFn := m.logFn("addons") + + addOns := addons.New( + addons.WithLogFunc(logFn), + addons.WithKubernetesClient(kcli), + addons.WithMetadataClient(mcli), + addons.WithHelmClient(hcli), + addons.WithDomains(utils.GetDomains(m.releaseData)), + addons.WithProgressChannel(progressChan), + ) + + opts := m.getAddonInstallOpts(license, ki) + + logFn("installing addons") + if err := addOns.InstallKubernetes(ctx, opts); err != nil { + return err + } + + return nil +} + +func (m *infraManager) getAddonInstallOpts(license *kotsv1beta1.License, ki kubernetesinstallation.Installation) addons.KubernetesInstallOptions { + opts := addons.KubernetesInstallOptions{ + AdminConsolePwd: m.password, + AdminConsolePort: ki.AdminConsolePort(), + License: license, + IsAirgap: m.airgapBundle != "", + TLSCertBytes: m.tlsConfig.CertBytes, + TLSKeyBytes: m.tlsConfig.KeyBytes, + Hostname: m.tlsConfig.Hostname, + IsMultiNodeEnabled: license.Spec.IsEmbeddedClusterMultiNodeEnabled, + EmbeddedConfigSpec: m.getECConfigSpec(), + EndUserConfigSpec: m.getEndUserConfigSpec(), + ProxySpec: ki.ProxySpec(), + } + + // TODO: no kots app install for now + + return opts +} diff --git a/api/internal/managers/kubernetes/infra/manager.go b/api/internal/managers/kubernetes/infra/manager.go new file mode 100644 index 0000000000..f4eb62eec1 --- /dev/null +++ b/api/internal/managers/kubernetes/infra/manager.go @@ -0,0 +1,154 @@ +package infra + +import ( + "context" + "sync" + + infrastore "github.com/replicatedhq/embedded-cluster/api/internal/store/infra" + "github.com/replicatedhq/embedded-cluster/api/pkg/logger" + "github.com/replicatedhq/embedded-cluster/api/types" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/helm" + "github.com/replicatedhq/embedded-cluster/pkg/kubernetesinstallation" + "github.com/replicatedhq/embedded-cluster/pkg/release" + "github.com/sirupsen/logrus" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/metadata" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ InfraManager = &infraManager{} + +// InfraManager provides methods for managing infrastructure setup +type InfraManager interface { + Get() (types.Infra, error) + Install(ctx context.Context, ki kubernetesinstallation.Installation) error +} + +// infraManager is an implementation of the InfraManager interface +type infraManager struct { + infraStore infrastore.Store + password string + tlsConfig types.TLSConfig + license []byte + airgapBundle string + configValues string + releaseData *release.ReleaseData + endUserConfig *ecv1beta1.Config + logger logrus.FieldLogger + kcli client.Client + mcli metadata.Interface + hcli helm.Client + restClientGetterFactory func(namespace string) genericclioptions.RESTClientGetter + kotsInstaller func() error + mu sync.RWMutex +} + +type InfraManagerOption func(*infraManager) + +func WithLogger(logger logrus.FieldLogger) InfraManagerOption { + return func(c *infraManager) { + c.logger = logger + } +} + +func WithInfraStore(store infrastore.Store) InfraManagerOption { + return func(c *infraManager) { + c.infraStore = store + } +} + +func WithPassword(password string) InfraManagerOption { + return func(c *infraManager) { + c.password = password + } +} + +func WithTLSConfig(tlsConfig types.TLSConfig) InfraManagerOption { + return func(c *infraManager) { + c.tlsConfig = tlsConfig + } +} + +func WithLicense(license []byte) InfraManagerOption { + return func(c *infraManager) { + c.license = license + } +} + +func WithAirgapBundle(airgapBundle string) InfraManagerOption { + return func(c *infraManager) { + c.airgapBundle = airgapBundle + } +} + +func WithConfigValues(configValues string) InfraManagerOption { + return func(c *infraManager) { + c.configValues = configValues + } +} + +func WithReleaseData(releaseData *release.ReleaseData) InfraManagerOption { + return func(c *infraManager) { + c.releaseData = releaseData + } +} + +func WithEndUserConfig(endUserConfig *ecv1beta1.Config) InfraManagerOption { + return func(c *infraManager) { + c.endUserConfig = endUserConfig + } +} + +func WithKubeClient(kcli client.Client) InfraManagerOption { + return func(c *infraManager) { + c.kcli = kcli + } +} + +func WithMetadataClient(mcli metadata.Interface) InfraManagerOption { + return func(c *infraManager) { + c.mcli = mcli + } +} + +func WithHelmClient(hcli helm.Client) InfraManagerOption { + return func(c *infraManager) { + c.hcli = hcli + } +} + +func WithRESTClientGetterFactory(restClientGetterFactory func(namespace string) genericclioptions.RESTClientGetter) InfraManagerOption { + return func(c *infraManager) { + c.restClientGetterFactory = restClientGetterFactory + } +} + +func WithKotsInstaller(kotsInstaller func() error) InfraManagerOption { + return func(c *infraManager) { + c.kotsInstaller = kotsInstaller + } +} + +// NewInfraManager creates a new InfraManager with the provided options +func NewInfraManager(opts ...InfraManagerOption) *infraManager { + manager := &infraManager{} + + for _, opt := range opts { + opt(manager) + } + + if manager.logger == nil { + manager.logger = logger.NewDiscardLogger() + } + + if manager.infraStore == nil { + manager.infraStore = infrastore.NewMemoryStore() + } + + return manager +} + +func (m *infraManager) Get() (types.Infra, error) { + return m.infraStore.Get() +} diff --git a/api/internal/managers/kubernetes/infra/manager_mock.go b/api/internal/managers/kubernetes/infra/manager_mock.go new file mode 100644 index 0000000000..a09c0618c2 --- /dev/null +++ b/api/internal/managers/kubernetes/infra/manager_mock.go @@ -0,0 +1,29 @@ +package infra + +import ( + "context" + + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/kubernetesinstallation" + "github.com/stretchr/testify/mock" +) + +var _ InfraManager = (*MockInfraManager)(nil) + +// MockInfraManager is a mock implementation of InfraManager +type MockInfraManager struct { + mock.Mock +} + +func (m *MockInfraManager) Install(ctx context.Context, ki kubernetesinstallation.Installation) error { + args := m.Called(ctx, ki) + return args.Error(0) +} + +func (m *MockInfraManager) Get() (types.Infra, error) { + args := m.Called() + if args.Get(0) == nil { + return types.Infra{}, args.Error(1) + } + return args.Get(0).(types.Infra), args.Error(1) +} diff --git a/api/internal/managers/kubernetes/infra/status.go b/api/internal/managers/kubernetes/infra/status.go new file mode 100644 index 0000000000..a7b303b1fc --- /dev/null +++ b/api/internal/managers/kubernetes/infra/status.go @@ -0,0 +1,35 @@ +package infra + +import ( + "time" + + "github.com/replicatedhq/embedded-cluster/api/types" +) + +func (m *infraManager) GetStatus() (types.Status, error) { + return m.infraStore.GetStatus() +} + +func (m *infraManager) SetStatus(status types.Status) error { + return m.infraStore.SetStatus(status) +} + +func (m *infraManager) setStatus(state types.State, description string) error { + return m.SetStatus(types.Status{ + State: state, + Description: description, + LastUpdated: time.Now(), + }) +} + +func (m *infraManager) setStatusDesc(description string) error { + return m.infraStore.SetStatusDesc(description) +} + +func (m *infraManager) setComponentStatus(name string, state types.State, description string) error { + return m.infraStore.SetComponentStatus(name, types.Status{ + State: state, + Description: description, + LastUpdated: time.Now(), + }) +} diff --git a/api/internal/managers/kubernetes/infra/status_test.go b/api/internal/managers/kubernetes/infra/status_test.go new file mode 100644 index 0000000000..c003433a82 --- /dev/null +++ b/api/internal/managers/kubernetes/infra/status_test.go @@ -0,0 +1,22 @@ +package infra + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInfraWithLogs(t *testing.T) { + manager := NewInfraManager() + + // Add some logs through the internal logging mechanism + logFn := manager.logFn("test") + logFn("Test log message") + logFn("Another log message with arg: %s", "value") + + // Get the infra and verify logs are included + infra, err := manager.Get() + assert.NoError(t, err) + assert.Contains(t, infra.Logs, "[test] Test log message") + assert.Contains(t, infra.Logs, "[test] Another log message with arg: value") +} diff --git a/api/internal/managers/kubernetes/infra/util.go b/api/internal/managers/kubernetes/infra/util.go new file mode 100644 index 0000000000..69520d36a0 --- /dev/null +++ b/api/internal/managers/kubernetes/infra/util.go @@ -0,0 +1,103 @@ +package infra + +import ( + "context" + "fmt" + "os" + "strings" + + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/helm" + "github.com/replicatedhq/embedded-cluster/pkg/kubernetesinstallation" + "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" + "github.com/replicatedhq/embedded-cluster/pkg/versions" + "k8s.io/client-go/metadata" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func (m *infraManager) waitForNode(ctx context.Context, kcli client.Client) error { + hostname, err := os.Hostname() + if err != nil { + return fmt.Errorf("get hostname: %w", err) + } + nodename := strings.ToLower(hostname) + if err := kubeutils.WaitForNode(ctx, kcli, nodename, false); err != nil { + return err + } + return nil +} + +func (m *infraManager) kubeClient() (client.Client, error) { + if m.kcli != nil { + return m.kcli, nil + } + kcli, err := kubeutils.KubeClient() + if err != nil { + return nil, fmt.Errorf("create kube client: %w", err) + } + return kcli, nil +} + +func (m *infraManager) metadataClient() (metadata.Interface, error) { + if m.mcli != nil { + return m.mcli, nil + } + mcli, err := kubeutils.MetadataClient() + if err != nil { + return nil, fmt.Errorf("create metadata client: %w", err) + } + return mcli, nil +} + +func (m *infraManager) helmClient(_ kubernetesinstallation.Installation) (helm.Client, error) { + if m.hcli != nil { + return m.hcli, nil + } + + airgapChartsPath := "" + if m.airgapBundle != "" { + // TODO: how can we support airgap? + airgapChartsPath = "" // rc.EmbeddedClusterChartsSubDir() + } + hcli, err := helm.NewClient(helm.HelmOptions{ + RESTClientGetterFactory: m.restClientGetterFactory, + K0sVersion: versions.K0sVersion, + AirgapPath: airgapChartsPath, + LogFn: m.logFn("helm"), + }) + if err != nil { + return nil, fmt.Errorf("create helm client: %w", err) + } + return hcli, nil +} + +func (m *infraManager) getECConfigSpec() *ecv1beta1.ConfigSpec { + if m.releaseData == nil || m.releaseData.EmbeddedClusterConfig == nil { + return nil + } + return &m.releaseData.EmbeddedClusterConfig.Spec +} + +func (m *infraManager) getEndUserConfigSpec() *ecv1beta1.ConfigSpec { + if m.endUserConfig == nil { + return nil + } + return &m.endUserConfig.Spec +} + +// logFn creates a component-specific logging function that tags log entries with the +// component name and persists them to the infra store for client retrieval, +// as well as logs them to the structured logger. +func (m *infraManager) logFn(component string) func(format string, v ...interface{}) { + return func(format string, v ...interface{}) { + m.logger.WithField("component", component).Debugf(format, v...) + m.addLogs(component, format, v...) + } +} + +func (m *infraManager) addLogs(component string, format string, v ...interface{}) { + msg := fmt.Sprintf("[%s] %s", component, fmt.Sprintf(format, v...)) + if err := m.infraStore.AddLogs(msg); err != nil { + m.logger.WithField("error", err).Error("add log") + } +} diff --git a/api/internal/managers/kubernetes/infra/util_test.go b/api/internal/managers/kubernetes/infra/util_test.go new file mode 100644 index 0000000000..b7f3d396df --- /dev/null +++ b/api/internal/managers/kubernetes/infra/util_test.go @@ -0,0 +1,85 @@ +package infra + +import ( + "testing" + + infrastore "github.com/replicatedhq/embedded-cluster/api/internal/store/infra" + "github.com/replicatedhq/embedded-cluster/api/pkg/logger" + "github.com/stretchr/testify/assert" +) + +func TestInfraManager_logFn(t *testing.T) { + tests := []struct { + name string + component string + format string + args []interface{} + expected string + }{ + { + name: "simple log message", + component: "k0s", + format: "installing component", + args: []interface{}{}, + expected: "[k0s] installing component", + }, + { + name: "log message with arguments", + component: "addons", + format: "installing %s version %s", + args: []interface{}{"helm", "v3.12.0"}, + expected: "[addons] installing helm version v3.12.0", + }, + { + name: "log message with multiple arguments", + component: "helm", + format: "chart %s installed in namespace %s with values %v", + args: []interface{}{"test-chart", "default", map[string]string{"key": "value"}}, + expected: "[helm] chart test-chart installed in namespace default with values map[key:value]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock store + mockStore := &infrastore.MockStore{} + mockStore.On("AddLogs", tt.expected).Return(nil) + + // Create a manager with the mock store + manager := &infraManager{ + infraStore: mockStore, + logger: logger.NewDiscardLogger(), + } + + // Call logFn and execute the returned function + logFunc := manager.logFn(tt.component) + logFunc(tt.format, tt.args...) + + // Verify the mock was called with expected arguments + mockStore.AssertExpectations(t) + }) + } +} + +func TestInfraManager_logFn_StoreError(t *testing.T) { + // Create a mock store that returns an error + mockStore := &infrastore.MockStore{} + mockStore.On("AddLogs", "[test] error message").Return(assert.AnError) + + // Create a manager with the mock store + manager := &infraManager{ + infraStore: mockStore, + logger: logger.NewDiscardLogger(), + } + + // Call logFn and execute the returned function + logFunc := manager.logFn("test") + + // This should not panic even if AddLogs returns an error + assert.NotPanics(t, func() { + logFunc("error message") + }) + + // Verify the mock was called + mockStore.AssertExpectations(t) +} diff --git a/api/internal/managers/kubernetes/installation/config.go b/api/internal/managers/kubernetes/installation/config.go new file mode 100644 index 0000000000..2174f63b4f --- /dev/null +++ b/api/internal/managers/kubernetes/installation/config.go @@ -0,0 +1,96 @@ +package installation + +import ( + "context" + "errors" + "fmt" + "runtime/debug" + + "github.com/replicatedhq/embedded-cluster/api/types" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/kubernetesinstallation" +) + +func (m *installationManager) GetConfig() (types.KubernetesInstallationConfig, error) { + return m.installationStore.GetConfig() +} + +func (m *installationManager) SetConfig(config types.KubernetesInstallationConfig) error { + return m.installationStore.SetConfig(config) +} + +func (m *installationManager) ValidateConfig(config types.KubernetesInstallationConfig, managerPort int) error { + var ve *types.APIError + + if err := m.validateAdminConsolePort(config, managerPort); err != nil { + ve = types.AppendFieldError(ve, "adminConsolePort", err) + } + + return ve.ErrorOrNil() +} + +func (m *installationManager) validateAdminConsolePort(config types.KubernetesInstallationConfig, managerPort int) error { + if config.AdminConsolePort == 0 { + return errors.New("adminConsolePort is required") + } + + if config.AdminConsolePort == managerPort { + return errors.New("adminConsolePort cannot be the same as the manager port") + } + + return nil +} + +// SetConfigDefaults sets default values for the installation configuration +func (m *installationManager) SetConfigDefaults(config *types.KubernetesInstallationConfig) error { + if config.AdminConsolePort == 0 { + config.AdminConsolePort = ecv1beta1.DefaultAdminConsolePort + } + + return nil +} + +func (m *installationManager) ConfigureInstallation(ctx context.Context, ki kubernetesinstallation.Installation, config types.KubernetesInstallationConfig) (finalErr error) { + if err := m.setStatus(types.StateRunning, ""); err != nil { + return fmt.Errorf("set status: %w", err) + } + + defer func() { + if r := recover(); r != nil { + finalErr = fmt.Errorf("panic: %v: %s", r, string(debug.Stack())) + } + if finalErr != nil { + if err := m.setStatus(types.StateFailed, finalErr.Error()); err != nil { + m.logger.WithField("error", err).Error("set failed status") + } + } else { + if err := m.setStatus(types.StateSucceeded, "Installation configured"); err != nil { + m.logger.WithField("error", err).Error("set succeeded status") + } + } + }() + + if err := m.ValidateConfig(config, ki.ManagerPort()); err != nil { + return fmt.Errorf("validate: %w", err) + } + + if err := m.SetConfig(config); err != nil { + return fmt.Errorf("write: %w", err) + } + + // update the kubernetes installation + ki.SetAdminConsolePort(config.AdminConsolePort) + + if config.HTTPProxy != "" || config.HTTPSProxy != "" || config.NoProxy != "" { + ki.SetProxySpec(&ecv1beta1.ProxySpec{ + HTTPProxy: config.HTTPProxy, + HTTPSProxy: config.HTTPSProxy, + NoProxy: config.NoProxy, + ProvidedNoProxy: config.NoProxy, + }) + } else { + ki.SetProxySpec(nil) + } + + return nil +} diff --git a/api/internal/managers/kubernetes/installation/config_test.go b/api/internal/managers/kubernetes/installation/config_test.go new file mode 100644 index 0000000000..e0b4c357bb --- /dev/null +++ b/api/internal/managers/kubernetes/installation/config_test.go @@ -0,0 +1,186 @@ +package installation + +import ( + "testing" + + "github.com/replicatedhq/embedded-cluster/api/types" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/stretchr/testify/assert" +) + +func TestValidateConfig(t *testing.T) { + // Create test cases for validation + tests := []struct { + name string + managerPort int + config types.KubernetesInstallationConfig + expectedErr bool + }{ + { + name: "valid config with admin console port", + managerPort: 8080, + config: types.KubernetesInstallationConfig{ + AdminConsolePort: 8800, + }, + expectedErr: false, + }, + { + name: "missing admin console port", + managerPort: 8080, + config: types.KubernetesInstallationConfig{}, + expectedErr: true, + }, + { + name: "admin console port is zero", + managerPort: 8080, + config: types.KubernetesInstallationConfig{ + AdminConsolePort: 0, + }, + expectedErr: true, + }, + { + name: "same ports for admin console and manager", + managerPort: 8800, + config: types.KubernetesInstallationConfig{ + AdminConsolePort: 8800, + }, + expectedErr: true, + }, + { + name: "valid config with proxy settings", + managerPort: 8080, + config: types.KubernetesInstallationConfig{ + AdminConsolePort: 8800, + HTTPProxy: "http://proxy.example.com:3128", + HTTPSProxy: "https://proxy.example.com:3128", + NoProxy: "localhost,127.0.0.1", + }, + expectedErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := NewInstallationManager() + + err := manager.ValidateConfig(tt.config, tt.managerPort) + + if tt.expectedErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestSetConfigDefaults(t *testing.T) { + tests := []struct { + name string + inputConfig types.KubernetesInstallationConfig + expectedConfig types.KubernetesInstallationConfig + }{ + { + name: "empty config", + inputConfig: types.KubernetesInstallationConfig{}, + expectedConfig: types.KubernetesInstallationConfig{ + AdminConsolePort: ecv1beta1.DefaultAdminConsolePort, + }, + }, + { + name: "partial config with admin console port", + inputConfig: types.KubernetesInstallationConfig{ + AdminConsolePort: 9000, + }, + expectedConfig: types.KubernetesInstallationConfig{ + AdminConsolePort: 9000, + }, + }, + { + name: "config with proxy settings", + inputConfig: types.KubernetesInstallationConfig{ + HTTPProxy: "http://proxy.example.com:3128", + HTTPSProxy: "https://proxy.example.com:3128", + }, + expectedConfig: types.KubernetesInstallationConfig{ + AdminConsolePort: ecv1beta1.DefaultAdminConsolePort, + HTTPProxy: "http://proxy.example.com:3128", + HTTPSProxy: "https://proxy.example.com:3128", + }, + }, + { + name: "config with all proxy settings", + inputConfig: types.KubernetesInstallationConfig{ + HTTPProxy: "http://proxy.example.com:3128", + HTTPSProxy: "https://proxy.example.com:3128", + NoProxy: "localhost,127.0.0.1", + }, + expectedConfig: types.KubernetesInstallationConfig{ + AdminConsolePort: ecv1beta1.DefaultAdminConsolePort, + HTTPProxy: "http://proxy.example.com:3128", + HTTPSProxy: "https://proxy.example.com:3128", + NoProxy: "localhost,127.0.0.1", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := NewInstallationManager() + + err := manager.SetConfigDefaults(&tt.inputConfig) + assert.NoError(t, err) + assert.Equal(t, tt.expectedConfig, tt.inputConfig) + }) + } +} + +func TestSetConfigDefaultsNoEnvProxy(t *testing.T) { + // Set proxy environment variables to simulate a proxy-enabled environment + t.Setenv("HTTP_PROXY", "http://env-proxy.example.com:8080") + t.Setenv("HTTPS_PROXY", "https://env-proxy.example.com:8080") + t.Setenv("NO_PROXY", "localhost,127.0.0.1,.env-example.com") + + manager := NewInstallationManager() + + // Test with empty config - should only set admin console port default + config := types.KubernetesInstallationConfig{} + err := manager.SetConfigDefaults(&config) + assert.NoError(t, err) + + // Verify that only the admin console port is set as default + expectedConfig := types.KubernetesInstallationConfig{ + AdminConsolePort: ecv1beta1.DefaultAdminConsolePort, + } + assert.Equal(t, expectedConfig, config) + + // Verify that proxy fields remain empty even though environment variables are set + assert.Empty(t, config.HTTPProxy, "HTTPProxy should not be set from environment variable") + assert.Empty(t, config.HTTPSProxy, "HTTPSProxy should not be set from environment variable") + assert.Empty(t, config.NoProxy, "NoProxy should not be set from environment variable") +} + +func TestConfigSetAndGet(t *testing.T) { + manager := NewInstallationManager() + + // Test writing a config + configToWrite := types.KubernetesInstallationConfig{ + AdminConsolePort: 8800, + HTTPProxy: "http://proxy.example.com:3128", + HTTPSProxy: "https://proxy.example.com:3128", + NoProxy: "localhost,127.0.0.1", + } + + err := manager.SetConfig(configToWrite) + assert.NoError(t, err) + + // Test reading it back + readConfig, err := manager.GetConfig() + assert.NoError(t, err) + + // Verify the values match + assert.Equal(t, configToWrite.AdminConsolePort, readConfig.AdminConsolePort) + assert.Equal(t, configToWrite.HTTPProxy, readConfig.HTTPProxy) + assert.Equal(t, configToWrite.HTTPSProxy, readConfig.HTTPSProxy) + assert.Equal(t, configToWrite.NoProxy, readConfig.NoProxy) +} diff --git a/api/internal/managers/kubernetes/installation/manager.go b/api/internal/managers/kubernetes/installation/manager.go new file mode 100644 index 0000000000..a43e7b76fb --- /dev/null +++ b/api/internal/managers/kubernetes/installation/manager.go @@ -0,0 +1,63 @@ +package installation + +import ( + "context" + + "github.com/replicatedhq/embedded-cluster/api/internal/store/kubernetes/installation" + "github.com/replicatedhq/embedded-cluster/api/pkg/logger" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/kubernetesinstallation" + "github.com/sirupsen/logrus" +) + +var _ InstallationManager = &installationManager{} + +// InstallationManager provides methods for validating and setting defaults for installation configuration +type InstallationManager interface { + GetConfig() (types.KubernetesInstallationConfig, error) + SetConfig(config types.KubernetesInstallationConfig) error + GetStatus() (types.Status, error) + SetStatus(status types.Status) error + ValidateConfig(config types.KubernetesInstallationConfig, managerPort int) error + SetConfigDefaults(config *types.KubernetesInstallationConfig) error + ConfigureInstallation(ctx context.Context, ki kubernetesinstallation.Installation, config types.KubernetesInstallationConfig) error +} + +// installationManager is an implementation of the InstallationManager interface +type installationManager struct { + installationStore installation.Store + logger logrus.FieldLogger +} + +type InstallationManagerOption func(*installationManager) + +func WithLogger(logger logrus.FieldLogger) InstallationManagerOption { + return func(c *installationManager) { + c.logger = logger + } +} + +func WithInstallationStore(installationStore installation.Store) InstallationManagerOption { + return func(c *installationManager) { + c.installationStore = installationStore + } +} + +// NewInstallationManager creates a new InstallationManager with the provided options +func NewInstallationManager(opts ...InstallationManagerOption) *installationManager { + manager := &installationManager{} + + for _, opt := range opts { + opt(manager) + } + + if manager.logger == nil { + manager.logger = logger.NewDiscardLogger() + } + + if manager.installationStore == nil { + manager.installationStore = installation.NewMemoryStore() + } + + return manager +} diff --git a/api/internal/managers/kubernetes/installation/manager_mock.go b/api/internal/managers/kubernetes/installation/manager_mock.go new file mode 100644 index 0000000000..aff69cae94 --- /dev/null +++ b/api/internal/managers/kubernetes/installation/manager_mock.go @@ -0,0 +1,64 @@ +package installation + +import ( + "context" + + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/kubernetesinstallation" + "github.com/stretchr/testify/mock" +) + +var _ InstallationManager = (*MockInstallationManager)(nil) + +// MockInstallationManager is a mock implementation of the InstallationManager interface +type MockInstallationManager struct { + mock.Mock +} + +// GetConfig mocks the GetConfig method +func (m *MockInstallationManager) GetConfig() (types.KubernetesInstallationConfig, error) { + args := m.Called() + if args.Get(0) == nil { + return types.KubernetesInstallationConfig{}, args.Error(1) + } + return args.Get(0).(types.KubernetesInstallationConfig), args.Error(1) +} + +// SetConfig mocks the SetConfig method +func (m *MockInstallationManager) SetConfig(config types.KubernetesInstallationConfig) error { + args := m.Called(config) + return args.Error(0) +} + +// GetStatus mocks the GetStatus method +func (m *MockInstallationManager) GetStatus() (types.Status, error) { + args := m.Called() + if args.Get(0) == nil { + return types.Status{}, args.Error(1) + } + return args.Get(0).(types.Status), args.Error(1) +} + +// SetStatus mocks the SetStatus method +func (m *MockInstallationManager) SetStatus(status types.Status) error { + args := m.Called(status) + return args.Error(0) +} + +// ValidateConfig mocks the ValidateConfig method +func (m *MockInstallationManager) ValidateConfig(config types.KubernetesInstallationConfig, managerPort int) error { + args := m.Called(config, managerPort) + return args.Error(0) +} + +// SetConfigDefaults mocks the SetConfigDefaults method +func (m *MockInstallationManager) SetConfigDefaults(config *types.KubernetesInstallationConfig) error { + args := m.Called(config) + return args.Error(0) +} + +// ConfigureInstallation mocks the ConfigureInstallation method +func (m *MockInstallationManager) ConfigureInstallation(ctx context.Context, ki kubernetesinstallation.Installation, config types.KubernetesInstallationConfig) (finalErr error) { + args := m.Called(ctx, ki, config) + return args.Error(0) +} diff --git a/api/internal/managers/kubernetes/installation/status.go b/api/internal/managers/kubernetes/installation/status.go new file mode 100644 index 0000000000..b9b359d688 --- /dev/null +++ b/api/internal/managers/kubernetes/installation/status.go @@ -0,0 +1,23 @@ +package installation + +import ( + "time" + + "github.com/replicatedhq/embedded-cluster/api/types" +) + +func (m *installationManager) GetStatus() (types.Status, error) { + return m.installationStore.GetStatus() +} + +func (m *installationManager) SetStatus(status types.Status) error { + return m.installationStore.SetStatus(status) +} + +func (m *installationManager) setStatus(state types.State, description string) error { + return m.SetStatus(types.Status{ + State: state, + Description: description, + LastUpdated: time.Now(), + }) +} diff --git a/api/internal/managers/kubernetes/installation/status_test.go b/api/internal/managers/kubernetes/installation/status_test.go new file mode 100644 index 0000000000..198f25ae9e --- /dev/null +++ b/api/internal/managers/kubernetes/installation/status_test.go @@ -0,0 +1,37 @@ +package installation + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/replicatedhq/embedded-cluster/api/types" +) + +func TestStatusSetAndGet(t *testing.T) { + manager := NewInstallationManager() + + // Test writing a status + statusToWrite := types.Status{ + State: types.StateRunning, + Description: "Installation in progress", + LastUpdated: time.Now().UTC().Truncate(time.Second), // Truncate to avoid time precision issues + } + + err := manager.SetStatus(statusToWrite) + assert.NoError(t, err) + + // Test reading it back + readStatus, err := manager.GetStatus() + assert.NoError(t, err) + + // Verify the values match + assert.Equal(t, statusToWrite.State, readStatus.State) + assert.Equal(t, statusToWrite.Description, readStatus.Description) + + // Compare time with string format to avoid precision issues + expectedTime := statusToWrite.LastUpdated.Format(time.RFC3339) + actualTime := readStatus.LastUpdated.Format(time.RFC3339) + assert.Equal(t, expectedTime, actualTime) +} diff --git a/api/internal/managers/infra/install.go b/api/internal/managers/linux/infra/install.go similarity index 64% rename from api/internal/managers/infra/install.go rename to api/internal/managers/linux/infra/install.go index 921b08cd58..0473a42a2a 100644 --- a/api/internal/managers/infra/install.go +++ b/api/internal/managers/linux/infra/install.go @@ -6,27 +6,26 @@ import ( "runtime/debug" k0sv1beta1 "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" - "github.com/replicatedhq/embedded-cluster/api/pkg/utils" + "github.com/replicatedhq/embedded-cluster/api/internal/utils" "github.com/replicatedhq/embedded-cluster/api/types" "github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" - "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" - "github.com/replicatedhq/embedded-cluster/pkg-new/k0s" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" ecmetadata "github.com/replicatedhq/embedded-cluster/pkg-new/metadata" "github.com/replicatedhq/embedded-cluster/pkg/addons" "github.com/replicatedhq/embedded-cluster/pkg/addons/registry" addontypes "github.com/replicatedhq/embedded-cluster/pkg/addons/types" - "github.com/replicatedhq/embedded-cluster/pkg/airgap" "github.com/replicatedhq/embedded-cluster/pkg/extensions" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" "github.com/replicatedhq/embedded-cluster/pkg/netutils" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/pkg/support" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/sirupsen/logrus" "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" + kyaml "sigs.k8s.io/yaml" ) const K0sComponentName = "Runtime" @@ -34,70 +33,49 @@ const K0sComponentName = "Runtime" func AlreadyInstalledError() error { return fmt.Errorf( "\nAn installation is detected on this machine.\nTo install, you must first remove the existing installation.\nYou can do this by running the following command:\n\n sudo ./%s reset\n", - runtimeconfig.BinaryName(), + runtimeconfig.AppSlug(), ) } -func (m *infraManager) Install(ctx context.Context, config *types.InstallationConfig) (finalErr error) { - m.mu.Lock() - defer m.mu.Unlock() - - installed, err := k0s.IsInstalled() +func (m *infraManager) Install(ctx context.Context, rc runtimeconfig.RuntimeConfig) (finalErr error) { + installed, err := m.k0scli.IsInstalled() if err != nil { - return err + return fmt.Errorf("check if k0s is installed: %w", err) } if installed { return AlreadyInstalledError() } - didRun, err := m.installDidRun() - if err != nil { - return fmt.Errorf("check if install did run: %w", err) - } - if didRun { - return fmt.Errorf("install can only be run once") - } - - if config == nil { - return fmt.Errorf("installation config is required") + if err := m.setStatus(types.StateRunning, ""); err != nil { + return fmt.Errorf("set status: %w", err) } - // Build proxy spec - var proxy *ecv1beta1.ProxySpec - if config.HTTPProxy != "" || config.HTTPSProxy != "" || config.NoProxy != "" { - proxy = &ecv1beta1.ProxySpec{ - HTTPProxy: config.HTTPProxy, - HTTPSProxy: config.HTTPSProxy, - NoProxy: config.NoProxy, + defer func() { + if r := recover(); r != nil { + finalErr = fmt.Errorf("panic: %v: %s", r, string(debug.Stack())) } - } - - license, err := helpers.ParseLicense(m.licenseFile) - if err != nil { - return fmt.Errorf("parse license: %w", err) - } - - if err := m.initComponentsList(license); err != nil { - return fmt.Errorf("init components: %w", err) - } + if finalErr != nil { + if err := m.setStatus(types.StateFailed, finalErr.Error()); err != nil { + m.logger.WithField("error", err).Error("set failed status") + } + } else { + if err := m.setStatus(types.StateSucceeded, "Installation complete"); err != nil { + m.logger.WithField("error", err).Error("set succeeded status") + } + } + }() - if err := m.setStatus(types.StateRunning, ""); err != nil { - return fmt.Errorf("set status: %w", err) + if err := m.install(ctx, rc); err != nil { + return err } - // Run install in background - go m.install(context.Background(), config, proxy, license) - return nil } -func (m *infraManager) initComponentsList(license *kotsv1beta1.License) error { +func (m *infraManager) initComponentsList(license *kotsv1beta1.License, rc runtimeconfig.RuntimeConfig) error { components := []types.InfraComponent{{Name: K0sComponentName}} - addOns := addons.GetAddOnsForInstall(addons.InstallOptions{ - IsAirgap: m.airgapBundle != "", - DisasterRecoveryEnabled: license.Spec.IsDisasterRecoverySupported, - }) + addOns := addons.GetAddOnsForInstall(m.getAddonInstallOpts(license, rc)) for _, addOn := range addOns { components = append(components, types.InfraComponent{Name: addOn.Name()}) } @@ -112,49 +90,43 @@ func (m *infraManager) initComponentsList(license *kotsv1beta1.License) error { return nil } -func (m *infraManager) install(ctx context.Context, config *types.InstallationConfig, proxy *ecv1beta1.ProxySpec, license *kotsv1beta1.License) (finalErr error) { - defer func() { - if r := recover(); r != nil { - finalErr = fmt.Errorf("panic: %v: %s", r, string(debug.Stack())) - } - if finalErr != nil { - if err := m.setStatus(types.StateFailed, finalErr.Error()); err != nil { - m.logger.WithField("error", err).Error("set failed status") - } - } else { - if err := m.setStatus(types.StateSucceeded, "Installation complete"); err != nil { - m.logger.WithField("error", err).Error("set succeeded status") - } - } - }() +func (m *infraManager) install(ctx context.Context, rc runtimeconfig.RuntimeConfig) error { + license := &kotsv1beta1.License{} + if err := kyaml.Unmarshal(m.license, license); err != nil { + return fmt.Errorf("parse license: %w", err) + } + + if err := m.initComponentsList(license, rc); err != nil { + return fmt.Errorf("init components: %w", err) + } - k0sCfg, err := m.installK0s(ctx, config, proxy) + _, err := m.installK0s(ctx, rc) if err != nil { return fmt.Errorf("install k0s: %w", err) } - kcli, err := kubeutils.KubeClient() + kcli, err := m.kubeClient() if err != nil { return fmt.Errorf("create kube client: %w", err) } - mcli, err := kubeutils.MetadataClient() + mcli, err := m.metadataClient() if err != nil { return fmt.Errorf("create metadata client: %w", err) } - hcli, err := m.getHelmClient() + hcli, err := m.helmClient(rc) if err != nil { return fmt.Errorf("create helm client: %w", err) } defer hcli.Close() - in, err := m.recordInstallation(ctx, kcli, proxy, license, k0sCfg) + in, err := m.recordInstallation(ctx, kcli, license, rc) if err != nil { return fmt.Errorf("record installation: %w", err) } - if err := m.installAddOns(ctx, config, proxy, license, kcli, mcli, hcli); err != nil { + if err := m.installAddOns(ctx, kcli, mcli, hcli, license, rc); err != nil { return fmt.Errorf("install addons: %w", err) } @@ -166,14 +138,14 @@ func (m *infraManager) install(ctx context.Context, config *types.InstallationCo return fmt.Errorf("update installation: %w", err) } - if err = support.CreateHostSupportBundle(); err != nil { + if err = support.CreateHostSupportBundle(ctx, kcli); err != nil { m.logger.Warnf("Unable to create host support bundle: %v", err) } return nil } -func (m *infraManager) installK0s(ctx context.Context, config *types.InstallationConfig, proxy *ecv1beta1.ProxySpec) (k0sCfg *k0sv1beta1.ClusterConfig, finalErr error) { +func (m *infraManager) installK0s(ctx context.Context, rc runtimeconfig.RuntimeConfig) (k0sCfg *k0sv1beta1.ClusterConfig, finalErr error) { componentName := K0sComponentName if err := m.setComponentStatus(componentName, types.StateRunning, "Installing"); err != nil { @@ -195,69 +167,77 @@ func (m *infraManager) installK0s(ctx context.Context, config *types.Installatio } }() - m.logger.Debug("creating k0s configuration file") - k0sCfg, err := k0s.WriteK0sConfig(ctx, config.NetworkInterface, m.airgapBundle, config.PodCIDR, config.ServiceCIDR, m.endUserConfig, nil) + m.setStatusDesc(fmt.Sprintf("Installing %s", componentName)) + + logFn := m.logFn("k0s") + + logFn("creating k0s configuration file") + k0sCfg, err := m.k0scli.WriteK0sConfig(ctx, rc.NetworkInterface(), m.airgapBundle, rc.PodCIDR(), rc.ServiceCIDR(), m.endUserConfig, nil) if err != nil { return nil, fmt.Errorf("create config file: %w", err) } - m.logger.Debug("creating systemd unit files") - if err := hostutils.CreateSystemdUnitFiles(ctx, m.logger, m.rc, false, proxy); err != nil { + logFn("creating systemd unit files") + if err := m.hostUtils.CreateSystemdUnitFiles(ctx, m.logger, rc, false); err != nil { return nil, fmt.Errorf("create systemd unit files: %w", err) } - m.logger.Debug("installing k0s") - if err := k0s.Install(m.rc, config.NetworkInterface); err != nil { + logFn("installing k0s") + if err := m.k0scli.Install(rc); err != nil { return nil, fmt.Errorf("install cluster: %w", err) } - m.logger.Debug("waiting for k0s to be ready") - if err := k0s.WaitForK0s(); err != nil { + logFn("waiting for k0s to be ready") + if err := m.k0scli.WaitForK0s(); err != nil { return nil, fmt.Errorf("wait for k0s: %w", err) } - kcli, err := kubeutils.KubeClient() + kcli, err := m.kubeClient() if err != nil { return nil, fmt.Errorf("create kube client: %w", err) } - m.logger.Debug("waiting for node to be ready") + m.setStatusDesc(fmt.Sprintf("Waiting for %s", componentName)) + + logFn("waiting for node to be ready") if err := m.waitForNode(ctx, kcli); err != nil { return nil, fmt.Errorf("wait for node: %w", err) } - m.logger.Debugf("adding insecure registry") - registryIP, err := registry.GetRegistryClusterIP(config.ServiceCIDR) + logFn("adding registry to containerd") + registryIP, err := registry.GetRegistryClusterIP(rc.ServiceCIDR()) if err != nil { return nil, fmt.Errorf("get registry cluster IP: %w", err) } - if err := airgap.AddInsecureRegistry(fmt.Sprintf("%s:5000", registryIP)); err != nil { + if err := m.hostUtils.AddInsecureRegistry(fmt.Sprintf("%s:5000", registryIP)); err != nil { return nil, fmt.Errorf("add insecure registry: %w", err) } return k0sCfg, nil } -func (m *infraManager) recordInstallation(ctx context.Context, kcli client.Client, proxy *ecv1beta1.ProxySpec, license *kotsv1beta1.License, k0sCfg *k0sv1beta1.ClusterConfig) (*ecv1beta1.Installation, error) { +func (m *infraManager) recordInstallation(ctx context.Context, kcli client.Client, license *kotsv1beta1.License, rc runtimeconfig.RuntimeConfig) (*ecv1beta1.Installation, error) { + logFn := m.logFn("metadata") + // get the configured custom domains ecDomains := utils.GetDomains(m.releaseData) // record the installation - m.logger.Debugf("recording installation") + logFn("recording installation") in, err := kubeutils.RecordInstallation(ctx, kcli, kubeutils.RecordInstallationOptions{ + ClusterID: m.clusterID, IsAirgap: m.airgapBundle != "", - Proxy: proxy, - K0sConfig: k0sCfg, License: license, ConfigSpec: m.getECConfigSpec(), MetricsBaseURL: netutils.MaybeAddHTTPS(ecDomains.ReplicatedAppDomain), - RuntimeConfig: m.rc.Get(), + RuntimeConfig: rc.Get(), EndUserConfig: m.endUserConfig, }) if err != nil { return nil, fmt.Errorf("record installation: %w", err) } + logFn("creating version metadata configmap") if err := ecmetadata.CreateVersionMetadataConfigmap(ctx, kcli); err != nil { return nil, fmt.Errorf("create version metadata configmap: %w", err) } @@ -265,58 +245,82 @@ func (m *infraManager) recordInstallation(ctx context.Context, kcli client.Clien return in, nil } -func (m *infraManager) installAddOns( - ctx context.Context, - config *types.InstallationConfig, - proxy *ecv1beta1.ProxySpec, - license *kotsv1beta1.License, - kcli client.Client, - mcli metadata.Interface, - hcli helm.Client, -) error { - // get the configured custom domains - ecDomains := utils.GetDomains(m.releaseData) - +func (m *infraManager) installAddOns(ctx context.Context, kcli client.Client, mcli metadata.Interface, hcli helm.Client, license *kotsv1beta1.License, rc runtimeconfig.RuntimeConfig) error { progressChan := make(chan addontypes.AddOnProgress) defer close(progressChan) go func() { for progress := range progressChan { + // capture progress in debug logs + m.logger.WithFields(logrus.Fields{"addon": progress.Name, "state": progress.Status.State, "description": progress.Status.Description}).Debugf("addon progress") + + // if in progress, update the overall status to reflect the current component + if progress.Status.State == types.StateRunning { + m.setStatusDesc(fmt.Sprintf("%s %s", progress.Status.Description, progress.Name)) + } + + // update the status for the current component if err := m.setComponentStatus(progress.Name, progress.Status.State, progress.Status.Description); err != nil { m.logger.Errorf("Failed to update addon status: %v", err) } } }() + logFn := m.logFn("addons") + addOns := addons.New( - addons.WithLogFunc(m.logger.Debugf), + addons.WithLogFunc(logFn), addons.WithKubernetesClient(kcli), addons.WithMetadataClient(mcli), addons.WithHelmClient(hcli), - addons.WithRuntimeConfig(m.rc), + addons.WithDomains(utils.GetDomains(m.releaseData)), addons.WithProgressChannel(progressChan), ) - m.logger.Debugf("installing addons") - if err := addOns.Install(ctx, addons.InstallOptions{ + opts := m.getAddonInstallOpts(license, rc) + + logFn("installing addons") + if err := addOns.Install(ctx, opts); err != nil { + return err + } + + return nil +} + +func (m *infraManager) getAddonInstallOpts(license *kotsv1beta1.License, rc runtimeconfig.RuntimeConfig) addons.InstallOptions { + ecDomains := utils.GetDomains(m.releaseData) + + opts := addons.InstallOptions{ + ClusterID: m.clusterID, AdminConsolePwd: m.password, + AdminConsolePort: rc.AdminConsolePort(), License: license, IsAirgap: m.airgapBundle != "", - Proxy: proxy, TLSCertBytes: m.tlsConfig.CertBytes, TLSKeyBytes: m.tlsConfig.KeyBytes, Hostname: m.tlsConfig.Hostname, - ServiceCIDR: config.ServiceCIDR, DisasterRecoveryEnabled: license.Spec.IsDisasterRecoverySupported, IsMultiNodeEnabled: license.Spec.IsEmbeddedClusterMultiNodeEnabled, EmbeddedConfigSpec: m.getECConfigSpec(), EndUserConfigSpec: m.getEndUserConfigSpec(), - KotsInstaller: func() error { + ProxySpec: rc.ProxySpec(), + HostCABundlePath: rc.HostCABundlePath(), + DataDir: rc.EmbeddedClusterHomeDirectory(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), + OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), + ServiceCIDR: rc.ServiceCIDR(), + } + + if m.kotsInstaller != nil { // used for testing + opts.KotsInstaller = m.kotsInstaller + } else { + opts.KotsInstaller = func() error { opts := kotscli.InstallOptions{ - RuntimeConfig: m.rc, + RuntimeConfig: rc, AppSlug: license.Spec.AppSlug, - LicenseFile: m.licenseFile, - Namespace: runtimeconfig.KotsadmNamespace, + License: m.license, + Namespace: constants.KotsadmNamespace, + ClusterID: m.clusterID, AirgapBundle: m.airgapBundle, ConfigValuesFile: m.configValues, ReplicatedAppEndpoint: netutils.MaybeAddHTTPS(ecDomains.ReplicatedAppDomain), @@ -324,12 +328,10 @@ func (m *infraManager) installAddOns( // Stdout: stdout, } return kotscli.Install(opts) - }, - }); err != nil { - return err + } } - return nil + return opts } func (m *infraManager) installExtensions(ctx context.Context, hcli helm.Client) (finalErr error) { @@ -354,7 +356,10 @@ func (m *infraManager) installExtensions(ctx context.Context, hcli helm.Client) } }() - m.logger.Debugf("installing extensions") + m.setStatusDesc(fmt.Sprintf("Installing %s", componentName)) + + logFn := m.logFn("extensions") + logFn("installing extensions") if err := extensions.Install(ctx, hcli, nil); err != nil { return fmt.Errorf("install extensions: %w", err) } diff --git a/api/internal/managers/infra/manager.go b/api/internal/managers/linux/infra/manager.go similarity index 56% rename from api/internal/managers/infra/manager.go rename to api/internal/managers/linux/infra/manager.go index 7d673ff6a5..c4f2734008 100644 --- a/api/internal/managers/infra/manager.go +++ b/api/internal/managers/linux/infra/manager.go @@ -4,59 +4,58 @@ import ( "context" "sync" + infrastore "github.com/replicatedhq/embedded-cluster/api/internal/store/infra" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" + "github.com/replicatedhq/embedded-cluster/pkg-new/k0s" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/sirupsen/logrus" + "k8s.io/client-go/metadata" + "sigs.k8s.io/controller-runtime/pkg/client" ) var _ InfraManager = &infraManager{} // InfraManager provides methods for managing infrastructure setup type InfraManager interface { - Get() (*types.Infra, error) - Install(ctx context.Context, config *types.InstallationConfig) error + Get() (types.Infra, error) + Install(ctx context.Context, rc runtimeconfig.RuntimeConfig) error } // infraManager is an implementation of the InfraManager interface type infraManager struct { - infra *types.Infra - infraStore Store - rc runtimeconfig.RuntimeConfig + infraStore infrastore.Store password string tlsConfig types.TLSConfig - licenseFile string + license []byte airgapBundle string configValues string releaseData *release.ReleaseData endUserConfig *ecv1beta1.Config + clusterID string logger logrus.FieldLogger + k0scli k0s.K0sInterface + kcli client.Client + mcli metadata.Interface + hcli helm.Client + hostUtils hostutils.HostUtilsInterface + kotsInstaller func() error mu sync.RWMutex } type InfraManagerOption func(*infraManager) -func WithRuntimeConfig(rc runtimeconfig.RuntimeConfig) InfraManagerOption { - return func(c *infraManager) { - c.rc = rc - } -} - func WithLogger(logger logrus.FieldLogger) InfraManagerOption { return func(c *infraManager) { c.logger = logger } } -func WithInfra(infra *types.Infra) InfraManagerOption { - return func(c *infraManager) { - c.infra = infra - } -} - -func WithInfraStore(store Store) InfraManagerOption { +func WithInfraStore(store infrastore.Store) InfraManagerOption { return func(c *infraManager) { c.infraStore = store } @@ -74,9 +73,9 @@ func WithTLSConfig(tlsConfig types.TLSConfig) InfraManagerOption { } } -func WithLicenseFile(licenseFile string) InfraManagerOption { +func WithLicense(license []byte) InfraManagerOption { return func(c *infraManager) { - c.licenseFile = licenseFile + c.license = license } } @@ -104,6 +103,48 @@ func WithEndUserConfig(endUserConfig *ecv1beta1.Config) InfraManagerOption { } } +func WithClusterID(clusterID string) InfraManagerOption { + return func(c *infraManager) { + c.clusterID = clusterID + } +} + +func WithK0s(k0s k0s.K0sInterface) InfraManagerOption { + return func(c *infraManager) { + c.k0scli = k0s + } +} + +func WithKubeClient(kcli client.Client) InfraManagerOption { + return func(c *infraManager) { + c.kcli = kcli + } +} + +func WithMetadataClient(mcli metadata.Interface) InfraManagerOption { + return func(c *infraManager) { + c.mcli = mcli + } +} + +func WithHelmClient(hcli helm.Client) InfraManagerOption { + return func(c *infraManager) { + c.hcli = hcli + } +} + +func WithHostUtils(hostUtils hostutils.HostUtilsInterface) InfraManagerOption { + return func(c *infraManager) { + c.hostUtils = hostUtils + } +} + +func WithKotsInstaller(kotsInstaller func() error) InfraManagerOption { + return func(c *infraManager) { + c.kotsInstaller = kotsInstaller + } +} + // NewInfraManager creates a new InfraManager with the provided options func NewInfraManager(opts ...InfraManagerOption) *infraManager { manager := &infraManager{} @@ -112,25 +153,25 @@ func NewInfraManager(opts ...InfraManagerOption) *infraManager { opt(manager) } - if manager.rc == nil { - manager.rc = runtimeconfig.New(nil) - } - if manager.logger == nil { manager.logger = logger.NewDiscardLogger() } - if manager.infra == nil { - manager.infra = &types.Infra{} + if manager.infraStore == nil { + manager.infraStore = infrastore.NewMemoryStore() } - if manager.infraStore == nil { - manager.infraStore = NewMemoryStore(manager.infra) + if manager.k0scli == nil { + manager.k0scli = k0s.New() + } + + if manager.hostUtils == nil { + manager.hostUtils = hostutils.New() } return manager } -func (m *infraManager) Get() (*types.Infra, error) { +func (m *infraManager) Get() (types.Infra, error) { return m.infraStore.Get() } diff --git a/api/internal/managers/infra/manager_mock.go b/api/internal/managers/linux/infra/manager_mock.go similarity index 52% rename from api/internal/managers/infra/manager_mock.go rename to api/internal/managers/linux/infra/manager_mock.go index 426a49500f..12345c5db0 100644 --- a/api/internal/managers/infra/manager_mock.go +++ b/api/internal/managers/linux/infra/manager_mock.go @@ -4,6 +4,7 @@ import ( "context" "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/stretchr/testify/mock" ) @@ -14,15 +15,15 @@ type MockInfraManager struct { mock.Mock } -func (m *MockInfraManager) Install(ctx context.Context, config *types.InstallationConfig) error { - args := m.Called(ctx, config) +func (m *MockInfraManager) Install(ctx context.Context, rc runtimeconfig.RuntimeConfig) error { + args := m.Called(ctx, rc) return args.Error(0) } -func (m *MockInfraManager) Get() (*types.Infra, error) { +func (m *MockInfraManager) Get() (types.Infra, error) { args := m.Called() if args.Get(0) == nil { - return nil, args.Error(1) + return types.Infra{}, args.Error(1) } - return args.Get(0).(*types.Infra), args.Error(1) + return args.Get(0).(types.Infra), args.Error(1) } diff --git a/api/internal/managers/linux/infra/status.go b/api/internal/managers/linux/infra/status.go new file mode 100644 index 0000000000..a7b303b1fc --- /dev/null +++ b/api/internal/managers/linux/infra/status.go @@ -0,0 +1,35 @@ +package infra + +import ( + "time" + + "github.com/replicatedhq/embedded-cluster/api/types" +) + +func (m *infraManager) GetStatus() (types.Status, error) { + return m.infraStore.GetStatus() +} + +func (m *infraManager) SetStatus(status types.Status) error { + return m.infraStore.SetStatus(status) +} + +func (m *infraManager) setStatus(state types.State, description string) error { + return m.SetStatus(types.Status{ + State: state, + Description: description, + LastUpdated: time.Now(), + }) +} + +func (m *infraManager) setStatusDesc(description string) error { + return m.infraStore.SetStatusDesc(description) +} + +func (m *infraManager) setComponentStatus(name string, state types.State, description string) error { + return m.infraStore.SetComponentStatus(name, types.Status{ + State: state, + Description: description, + LastUpdated: time.Now(), + }) +} diff --git a/api/internal/managers/linux/infra/status_test.go b/api/internal/managers/linux/infra/status_test.go new file mode 100644 index 0000000000..c003433a82 --- /dev/null +++ b/api/internal/managers/linux/infra/status_test.go @@ -0,0 +1,22 @@ +package infra + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInfraWithLogs(t *testing.T) { + manager := NewInfraManager() + + // Add some logs through the internal logging mechanism + logFn := manager.logFn("test") + logFn("Test log message") + logFn("Another log message with arg: %s", "value") + + // Get the infra and verify logs are included + infra, err := manager.Get() + assert.NoError(t, err) + assert.Contains(t, infra.Logs, "[test] Test log message") + assert.Contains(t, infra.Logs, "[test] Another log message with arg: value") +} diff --git a/api/internal/managers/linux/infra/util.go b/api/internal/managers/linux/infra/util.go new file mode 100644 index 0000000000..46e2b8ca56 --- /dev/null +++ b/api/internal/managers/linux/infra/util.go @@ -0,0 +1,102 @@ +package infra + +import ( + "context" + "fmt" + "os" + "strings" + + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/helm" + "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + "github.com/replicatedhq/embedded-cluster/pkg/versions" + "k8s.io/client-go/metadata" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func (m *infraManager) waitForNode(ctx context.Context, kcli client.Client) error { + hostname, err := os.Hostname() + if err != nil { + return fmt.Errorf("get hostname: %w", err) + } + nodename := strings.ToLower(hostname) + if err := kubeutils.WaitForNode(ctx, kcli, nodename, false); err != nil { + return err + } + return nil +} + +func (m *infraManager) kubeClient() (client.Client, error) { + if m.kcli != nil { + return m.kcli, nil + } + kcli, err := kubeutils.KubeClient() + if err != nil { + return nil, fmt.Errorf("create kube client: %w", err) + } + return kcli, nil +} + +func (m *infraManager) metadataClient() (metadata.Interface, error) { + if m.mcli != nil { + return m.mcli, nil + } + mcli, err := kubeutils.MetadataClient() + if err != nil { + return nil, fmt.Errorf("create metadata client: %w", err) + } + return mcli, nil +} + +func (m *infraManager) helmClient(rc runtimeconfig.RuntimeConfig) (helm.Client, error) { + if m.hcli != nil { + return m.hcli, nil + } + + airgapChartsPath := "" + if m.airgapBundle != "" { + airgapChartsPath = rc.EmbeddedClusterChartsSubDir() + } + hcli, err := helm.NewClient(helm.HelmOptions{ + KubeConfig: rc.PathToKubeConfig(), + K0sVersion: versions.K0sVersion, + AirgapPath: airgapChartsPath, + LogFn: m.logFn("helm"), + }) + if err != nil { + return nil, fmt.Errorf("create helm client: %w", err) + } + return hcli, nil +} + +func (m *infraManager) getECConfigSpec() *ecv1beta1.ConfigSpec { + if m.releaseData == nil || m.releaseData.EmbeddedClusterConfig == nil { + return nil + } + return &m.releaseData.EmbeddedClusterConfig.Spec +} + +func (m *infraManager) getEndUserConfigSpec() *ecv1beta1.ConfigSpec { + if m.endUserConfig == nil { + return nil + } + return &m.endUserConfig.Spec +} + +// logFn creates a component-specific logging function that tags log entries with the +// component name and persists them to the infra store for client retrieval, +// as well as logs them to the structured logger. +func (m *infraManager) logFn(component string) func(format string, v ...interface{}) { + return func(format string, v ...interface{}) { + m.logger.WithField("component", component).Debugf(format, v...) + m.addLogs(component, format, v...) + } +} + +func (m *infraManager) addLogs(component string, format string, v ...interface{}) { + msg := fmt.Sprintf("[%s] %s", component, fmt.Sprintf(format, v...)) + if err := m.infraStore.AddLogs(msg); err != nil { + m.logger.WithField("error", err).Error("add log") + } +} diff --git a/api/internal/managers/linux/infra/util_test.go b/api/internal/managers/linux/infra/util_test.go new file mode 100644 index 0000000000..b7f3d396df --- /dev/null +++ b/api/internal/managers/linux/infra/util_test.go @@ -0,0 +1,85 @@ +package infra + +import ( + "testing" + + infrastore "github.com/replicatedhq/embedded-cluster/api/internal/store/infra" + "github.com/replicatedhq/embedded-cluster/api/pkg/logger" + "github.com/stretchr/testify/assert" +) + +func TestInfraManager_logFn(t *testing.T) { + tests := []struct { + name string + component string + format string + args []interface{} + expected string + }{ + { + name: "simple log message", + component: "k0s", + format: "installing component", + args: []interface{}{}, + expected: "[k0s] installing component", + }, + { + name: "log message with arguments", + component: "addons", + format: "installing %s version %s", + args: []interface{}{"helm", "v3.12.0"}, + expected: "[addons] installing helm version v3.12.0", + }, + { + name: "log message with multiple arguments", + component: "helm", + format: "chart %s installed in namespace %s with values %v", + args: []interface{}{"test-chart", "default", map[string]string{"key": "value"}}, + expected: "[helm] chart test-chart installed in namespace default with values map[key:value]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock store + mockStore := &infrastore.MockStore{} + mockStore.On("AddLogs", tt.expected).Return(nil) + + // Create a manager with the mock store + manager := &infraManager{ + infraStore: mockStore, + logger: logger.NewDiscardLogger(), + } + + // Call logFn and execute the returned function + logFunc := manager.logFn(tt.component) + logFunc(tt.format, tt.args...) + + // Verify the mock was called with expected arguments + mockStore.AssertExpectations(t) + }) + } +} + +func TestInfraManager_logFn_StoreError(t *testing.T) { + // Create a mock store that returns an error + mockStore := &infrastore.MockStore{} + mockStore.On("AddLogs", "[test] error message").Return(assert.AnError) + + // Create a manager with the mock store + manager := &infraManager{ + infraStore: mockStore, + logger: logger.NewDiscardLogger(), + } + + // Call logFn and execute the returned function + logFunc := manager.logFn("test") + + // This should not panic even if AddLogs returns an error + assert.NotPanics(t, func() { + logFunc("error message") + }) + + // Verify the mock was called + mockStore.AssertExpectations(t) +} diff --git a/api/internal/managers/installation/config.go b/api/internal/managers/linux/installation/config.go similarity index 72% rename from api/internal/managers/installation/config.go rename to api/internal/managers/linux/installation/config.go index 1eccef204a..394022eb94 100644 --- a/api/internal/managers/installation/config.go +++ b/api/internal/managers/linux/installation/config.go @@ -11,17 +11,18 @@ import ( newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" "github.com/replicatedhq/embedded-cluster/pkg/netutils" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" ) -func (m *installationManager) GetConfig() (*types.InstallationConfig, error) { +func (m *installationManager) GetConfig() (types.LinuxInstallationConfig, error) { return m.installationStore.GetConfig() } -func (m *installationManager) SetConfig(config types.InstallationConfig) error { +func (m *installationManager) SetConfig(config types.LinuxInstallationConfig) error { return m.installationStore.SetConfig(config) } -func (m *installationManager) ValidateConfig(config *types.InstallationConfig) error { +func (m *installationManager) ValidateConfig(config types.LinuxInstallationConfig, managerPort int) error { var ve *types.APIError if err := m.validateGlobalCIDR(config); err != nil { @@ -40,11 +41,11 @@ func (m *installationManager) ValidateConfig(config *types.InstallationConfig) e ve = types.AppendFieldError(ve, "networkInterface", err) } - if err := m.validateAdminConsolePort(config); err != nil { + if err := m.validateAdminConsolePort(config, managerPort); err != nil { ve = types.AppendFieldError(ve, "adminConsolePort", err) } - if err := m.validateLocalArtifactMirrorPort(config); err != nil { + if err := m.validateLocalArtifactMirrorPort(config, managerPort); err != nil { ve = types.AppendFieldError(ve, "localArtifactMirrorPort", err) } @@ -55,7 +56,7 @@ func (m *installationManager) ValidateConfig(config *types.InstallationConfig) e return ve.ErrorOrNil() } -func (m *installationManager) validateGlobalCIDR(config *types.InstallationConfig) error { +func (m *installationManager) validateGlobalCIDR(config types.LinuxInstallationConfig) error { if config.GlobalCIDR != "" { if err := netutils.ValidateCIDR(config.GlobalCIDR, 16, true); err != nil { return err @@ -68,7 +69,7 @@ func (m *installationManager) validateGlobalCIDR(config *types.InstallationConfi return nil } -func (m *installationManager) validatePodCIDR(config *types.InstallationConfig) error { +func (m *installationManager) validatePodCIDR(config types.LinuxInstallationConfig) error { if config.GlobalCIDR != "" { return nil } @@ -78,7 +79,7 @@ func (m *installationManager) validatePodCIDR(config *types.InstallationConfig) return nil } -func (m *installationManager) validateServiceCIDR(config *types.InstallationConfig) error { +func (m *installationManager) validateServiceCIDR(config types.LinuxInstallationConfig) error { if config.GlobalCIDR != "" { return nil } @@ -88,7 +89,7 @@ func (m *installationManager) validateServiceCIDR(config *types.InstallationConf return nil } -func (m *installationManager) validateNetworkInterface(config *types.InstallationConfig) error { +func (m *installationManager) validateNetworkInterface(config types.LinuxInstallationConfig) error { if config.NetworkInterface == "" { return errors.New("networkInterface is required") } @@ -97,7 +98,7 @@ func (m *installationManager) validateNetworkInterface(config *types.Installatio return nil } -func (m *installationManager) validateAdminConsolePort(config *types.InstallationConfig) error { +func (m *installationManager) validateAdminConsolePort(config types.LinuxInstallationConfig, managerPort int) error { if config.AdminConsolePort == 0 { return errors.New("adminConsolePort is required") } @@ -111,14 +112,14 @@ func (m *installationManager) validateAdminConsolePort(config *types.Installatio return errors.New("adminConsolePort and localArtifactMirrorPort cannot be equal") } - if config.AdminConsolePort == m.rc.ManagerPort() { + if config.AdminConsolePort == managerPort { return errors.New("adminConsolePort cannot be the same as the manager port") } return nil } -func (m *installationManager) validateLocalArtifactMirrorPort(config *types.InstallationConfig) error { +func (m *installationManager) validateLocalArtifactMirrorPort(config types.LinuxInstallationConfig, managerPort int) error { if config.LocalArtifactMirrorPort == 0 { return errors.New("localArtifactMirrorPort is required") } @@ -132,14 +133,14 @@ func (m *installationManager) validateLocalArtifactMirrorPort(config *types.Inst return errors.New("adminConsolePort and localArtifactMirrorPort cannot be equal") } - if config.LocalArtifactMirrorPort == m.rc.ManagerPort() { + if config.LocalArtifactMirrorPort == managerPort { return errors.New("localArtifactMirrorPort cannot be the same as the manager port") } return nil } -func (m *installationManager) validateDataDirectory(config *types.InstallationConfig) error { +func (m *installationManager) validateDataDirectory(config types.LinuxInstallationConfig) error { if config.DataDirectory == "" { return errors.New("dataDirectory is required") } @@ -148,13 +149,13 @@ func (m *installationManager) validateDataDirectory(config *types.InstallationCo } // SetConfigDefaults sets default values for the installation configuration -func (m *installationManager) SetConfigDefaults(config *types.InstallationConfig) error { +func (m *installationManager) SetConfigDefaults(config *types.LinuxInstallationConfig, rc runtimeconfig.RuntimeConfig) error { if config.AdminConsolePort == 0 { config.AdminConsolePort = ecv1beta1.DefaultAdminConsolePort } if config.DataDirectory == "" { - config.DataDirectory = ecv1beta1.DefaultDataDir + config.DataDirectory = rc.EmbeddedClusterHomeDirectory() } if config.LocalArtifactMirrorPort == 0 { @@ -178,7 +179,7 @@ func (m *installationManager) SetConfigDefaults(config *types.InstallationConfig return nil } -func (m *installationManager) setProxyDefaults(config *types.InstallationConfig) { +func (m *installationManager) setProxyDefaults(config *types.LinuxInstallationConfig) { proxy := &ecv1beta1.ProxySpec{ HTTPProxy: config.HTTPProxy, HTTPSProxy: config.HTTPSProxy, @@ -191,7 +192,7 @@ func (m *installationManager) setProxyDefaults(config *types.InstallationConfig) config.NoProxy = proxy.ProvidedNoProxy } -func (m *installationManager) setCIDRDefaults(config *types.InstallationConfig) error { +func (m *installationManager) setCIDRDefaults(config *types.LinuxInstallationConfig) error { // if the client has not explicitly set / used pod/service cidrs, we assume the client is using the global cidr // and only popluate the default for the global cidr. // we don't populate pod/service cidrs defaults because the client would have to explicitly @@ -202,28 +203,11 @@ func (m *installationManager) setCIDRDefaults(config *types.InstallationConfig) return nil } -func (m *installationManager) ConfigureHost(ctx context.Context, config *types.InstallationConfig) error { - m.mu.Lock() - defer m.mu.Unlock() - - running, err := m.isRunning() - if err != nil { - return fmt.Errorf("check if installation is running: %w", err) - } - if running { - return fmt.Errorf("installation configuration is already running") - } - +func (m *installationManager) ConfigureHost(ctx context.Context, rc runtimeconfig.RuntimeConfig) (finalErr error) { if err := m.setRunningStatus("Configuring installation"); err != nil { return fmt.Errorf("set running status: %w", err) } - go m.configureHost(context.Background(), config) - - return nil -} - -func (m *installationManager) configureHost(ctx context.Context, config *types.InstallationConfig) (finalErr error) { defer func() { if r := recover(); r != nil { finalErr = fmt.Errorf("panic: %v: %s", r, string(debug.Stack())) @@ -240,13 +224,11 @@ func (m *installationManager) configureHost(ctx context.Context, config *types.I }() opts := hostutils.InitForInstallOptions{ - LicenseFile: m.licenseFile, + License: m.license, AirgapBundle: m.airgapBundle, - PodCIDR: config.PodCIDR, - ServiceCIDR: config.ServiceCIDR, } - if err := m.hostUtils.ConfigureHost(ctx, m.rc, opts); err != nil { - return fmt.Errorf("configure installation: %w", err) + if err := m.hostUtils.ConfigureHost(ctx, rc, opts); err != nil { + return fmt.Errorf("configure host: %w", err) } return nil diff --git a/api/internal/managers/installation/config_test.go b/api/internal/managers/linux/installation/config_test.go similarity index 69% rename from api/internal/managers/installation/config_test.go rename to api/internal/managers/linux/installation/config_test.go index 104cefaaf0..7babb46d71 100644 --- a/api/internal/managers/installation/config_test.go +++ b/api/internal/managers/linux/installation/config_test.go @@ -4,12 +4,12 @@ import ( "context" "errors" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" - "github.com/replicatedhq/embedded-cluster/api/pkg/utils" + "github.com/replicatedhq/embedded-cluster/api/internal/store/linux/installation" + "github.com/replicatedhq/embedded-cluster/api/internal/utils" "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" @@ -21,12 +21,12 @@ func TestValidateConfig(t *testing.T) { tests := []struct { name string rc runtimeconfig.RuntimeConfig - config *types.InstallationConfig + config types.LinuxInstallationConfig expectedErr bool }{ { name: "valid config with global CIDR", - config: &types.InstallationConfig{ + config: types.LinuxInstallationConfig{ GlobalCIDR: "10.0.0.0/16", NetworkInterface: "eth0", AdminConsolePort: 8800, @@ -37,7 +37,7 @@ func TestValidateConfig(t *testing.T) { }, { name: "valid config with pod and service CIDRs", - config: &types.InstallationConfig{ + config: types.LinuxInstallationConfig{ PodCIDR: "10.0.0.0/17", ServiceCIDR: "10.0.128.0/17", NetworkInterface: "eth0", @@ -49,7 +49,7 @@ func TestValidateConfig(t *testing.T) { }, { name: "missing network interface", - config: &types.InstallationConfig{ + config: types.LinuxInstallationConfig{ GlobalCIDR: "10.0.0.0/16", AdminConsolePort: 8800, LocalArtifactMirrorPort: 8888, @@ -59,7 +59,7 @@ func TestValidateConfig(t *testing.T) { }, { name: "missing global CIDR and pod/service CIDRs", - config: &types.InstallationConfig{ + config: types.LinuxInstallationConfig{ NetworkInterface: "eth0", AdminConsolePort: 8800, LocalArtifactMirrorPort: 8888, @@ -69,7 +69,7 @@ func TestValidateConfig(t *testing.T) { }, { name: "missing pod CIDR when no global CIDR", - config: &types.InstallationConfig{ + config: types.LinuxInstallationConfig{ ServiceCIDR: "10.0.128.0/17", NetworkInterface: "eth0", AdminConsolePort: 8800, @@ -80,7 +80,7 @@ func TestValidateConfig(t *testing.T) { }, { name: "missing service CIDR when no global CIDR", - config: &types.InstallationConfig{ + config: types.LinuxInstallationConfig{ PodCIDR: "10.0.0.0/17", NetworkInterface: "eth0", AdminConsolePort: 8800, @@ -91,7 +91,7 @@ func TestValidateConfig(t *testing.T) { }, { name: "invalid global CIDR", - config: &types.InstallationConfig{ + config: types.LinuxInstallationConfig{ GlobalCIDR: "10.0.0.0/24", // Not a /16 NetworkInterface: "eth0", AdminConsolePort: 8800, @@ -102,7 +102,7 @@ func TestValidateConfig(t *testing.T) { }, { name: "missing admin console port", - config: &types.InstallationConfig{ + config: types.LinuxInstallationConfig{ GlobalCIDR: "10.0.0.0/16", NetworkInterface: "eth0", LocalArtifactMirrorPort: 8888, @@ -112,7 +112,7 @@ func TestValidateConfig(t *testing.T) { }, { name: "missing local artifact mirror port", - config: &types.InstallationConfig{ + config: types.LinuxInstallationConfig{ GlobalCIDR: "10.0.0.0/16", NetworkInterface: "eth0", AdminConsolePort: 8800, @@ -122,7 +122,7 @@ func TestValidateConfig(t *testing.T) { }, { name: "missing data directory", - config: &types.InstallationConfig{ + config: types.LinuxInstallationConfig{ GlobalCIDR: "10.0.0.0/16", NetworkInterface: "eth0", AdminConsolePort: 8800, @@ -132,7 +132,7 @@ func TestValidateConfig(t *testing.T) { }, { name: "same ports for admin console and artifact mirror", - config: &types.InstallationConfig{ + config: types.LinuxInstallationConfig{ GlobalCIDR: "10.0.0.0/16", NetworkInterface: "eth0", AdminConsolePort: 8800, @@ -148,7 +148,7 @@ func TestValidateConfig(t *testing.T) { rc.SetManagerPort(8800) return rc }(), - config: &types.InstallationConfig{ + config: types.LinuxInstallationConfig{ GlobalCIDR: "10.0.0.0/16", NetworkInterface: "eth0", AdminConsolePort: 8800, @@ -164,7 +164,7 @@ func TestValidateConfig(t *testing.T) { rc.SetManagerPort(8888) return rc }(), - config: &types.InstallationConfig{ + config: types.LinuxInstallationConfig{ GlobalCIDR: "10.0.0.0/16", NetworkInterface: "eth0", AdminConsolePort: 8800, @@ -185,9 +185,9 @@ func TestValidateConfig(t *testing.T) { } rc.SetDataDir(t.TempDir()) - manager := NewInstallationManager(WithRuntimeConfig(rc)) + manager := NewInstallationManager() - err := manager.ValidateConfig(tt.config) + err := manager.ValidateConfig(tt.config, rc.ManagerPort()) if tt.expectedErr { assert.Error(t, err) @@ -203,17 +203,22 @@ func TestSetConfigDefaults(t *testing.T) { mockNetUtils := &utils.MockNetUtils{} mockNetUtils.On("DetermineBestNetworkInterface").Return("eth0", nil) + // Create a mock RuntimeConfig + mockRC := &runtimeconfig.MockRuntimeConfig{} + testDataDir := "/test/data/dir" + mockRC.On("EmbeddedClusterHomeDirectory").Return(testDataDir) + tests := []struct { name string - inputConfig *types.InstallationConfig - expectedConfig *types.InstallationConfig + inputConfig types.LinuxInstallationConfig + expectedConfig types.LinuxInstallationConfig }{ { name: "empty config", - inputConfig: &types.InstallationConfig{}, - expectedConfig: &types.InstallationConfig{ + inputConfig: types.LinuxInstallationConfig{}, + expectedConfig: types.LinuxInstallationConfig{ AdminConsolePort: ecv1beta1.DefaultAdminConsolePort, - DataDirectory: ecv1beta1.DefaultDataDir, + DataDirectory: testDataDir, LocalArtifactMirrorPort: ecv1beta1.DefaultLocalArtifactMirrorPort, NetworkInterface: "eth0", GlobalCIDR: ecv1beta1.DefaultNetworkCIDR, @@ -221,11 +226,11 @@ func TestSetConfigDefaults(t *testing.T) { }, { name: "partial config", - inputConfig: &types.InstallationConfig{ + inputConfig: types.LinuxInstallationConfig{ AdminConsolePort: 9000, DataDirectory: "/custom/dir", }, - expectedConfig: &types.InstallationConfig{ + expectedConfig: types.LinuxInstallationConfig{ AdminConsolePort: 9000, DataDirectory: "/custom/dir", LocalArtifactMirrorPort: ecv1beta1.DefaultLocalArtifactMirrorPort, @@ -235,13 +240,13 @@ func TestSetConfigDefaults(t *testing.T) { }, { name: "config with pod and service CIDRs", - inputConfig: &types.InstallationConfig{ + inputConfig: types.LinuxInstallationConfig{ PodCIDR: "10.1.0.0/17", ServiceCIDR: "10.1.128.0/17", }, - expectedConfig: &types.InstallationConfig{ + expectedConfig: types.LinuxInstallationConfig{ AdminConsolePort: ecv1beta1.DefaultAdminConsolePort, - DataDirectory: ecv1beta1.DefaultDataDir, + DataDirectory: testDataDir, LocalArtifactMirrorPort: ecv1beta1.DefaultLocalArtifactMirrorPort, NetworkInterface: "eth0", PodCIDR: "10.1.0.0/17", @@ -250,12 +255,12 @@ func TestSetConfigDefaults(t *testing.T) { }, { name: "config with global CIDR", - inputConfig: &types.InstallationConfig{ + inputConfig: types.LinuxInstallationConfig{ GlobalCIDR: "192.168.0.0/16", }, - expectedConfig: &types.InstallationConfig{ + expectedConfig: types.LinuxInstallationConfig{ AdminConsolePort: ecv1beta1.DefaultAdminConsolePort, - DataDirectory: ecv1beta1.DefaultDataDir, + DataDirectory: testDataDir, LocalArtifactMirrorPort: ecv1beta1.DefaultLocalArtifactMirrorPort, NetworkInterface: "eth0", GlobalCIDR: "192.168.0.0/16", @@ -263,13 +268,13 @@ func TestSetConfigDefaults(t *testing.T) { }, { name: "config with proxy settings", - inputConfig: &types.InstallationConfig{ + inputConfig: types.LinuxInstallationConfig{ HTTPProxy: "http://proxy.example.com:3128", HTTPSProxy: "https://proxy.example.com:3128", }, - expectedConfig: &types.InstallationConfig{ + expectedConfig: types.LinuxInstallationConfig{ AdminConsolePort: ecv1beta1.DefaultAdminConsolePort, - DataDirectory: ecv1beta1.DefaultDataDir, + DataDirectory: testDataDir, LocalArtifactMirrorPort: ecv1beta1.DefaultLocalArtifactMirrorPort, NetworkInterface: "eth0", GlobalCIDR: ecv1beta1.DefaultNetworkCIDR, @@ -277,16 +282,27 @@ func TestSetConfigDefaults(t *testing.T) { HTTPSProxy: "https://proxy.example.com:3128", }, }, + { + name: "config with existing data directory should preserve it", + inputConfig: types.LinuxInstallationConfig{ + DataDirectory: "/existing/custom/path", + }, + expectedConfig: types.LinuxInstallationConfig{ + AdminConsolePort: ecv1beta1.DefaultAdminConsolePort, + DataDirectory: "/existing/custom/path", + LocalArtifactMirrorPort: ecv1beta1.DefaultLocalArtifactMirrorPort, + NetworkInterface: "eth0", + GlobalCIDR: ecv1beta1.DefaultNetworkCIDR, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { manager := NewInstallationManager(WithNetUtils(mockNetUtils)) - err := manager.SetConfigDefaults(tt.inputConfig) + err := manager.SetConfigDefaults(&tt.inputConfig, mockRC) assert.NoError(t, err) - - assert.NotNil(t, tt.inputConfig) assert.Equal(t, tt.expectedConfig, tt.inputConfig) }) } @@ -298,12 +314,14 @@ func TestSetConfigDefaults(t *testing.T) { manager := NewInstallationManager(WithNetUtils(failingMockNetUtils)) - config := &types.InstallationConfig{} - err := manager.SetConfigDefaults(config) + config := types.LinuxInstallationConfig{} + err := manager.SetConfigDefaults(&config, mockRC) assert.NoError(t, err) // Network interface should remain empty when detection fails assert.Empty(t, config.NetworkInterface) + // DataDirectory should still be set from RuntimeConfig + assert.Equal(t, testDataDir, config.DataDirectory) }) } @@ -311,7 +329,7 @@ func TestConfigSetAndGet(t *testing.T) { manager := NewInstallationManager() // Test writing a config - configToWrite := types.InstallationConfig{ + configToWrite := types.LinuxInstallationConfig{ AdminConsolePort: 8800, DataDirectory: "/var/lib/embedded-cluster", LocalArtifactMirrorPort: 8888, @@ -325,7 +343,6 @@ func TestConfigSetAndGet(t *testing.T) { // Test reading it back readConfig, err := manager.GetConfig() assert.NoError(t, err) - assert.NotNil(t, readConfig) // Verify the values match assert.Equal(t, configToWrite.AdminConsolePort, readConfig.AdminConsolePort) @@ -338,68 +355,75 @@ func TestConfigSetAndGet(t *testing.T) { func TestConfigureHost(t *testing.T) { tests := []struct { name string - config *types.InstallationConfig - setupMocks func(*hostutils.MockHostUtils, *MockInstallationStore) + rc runtimeconfig.RuntimeConfig + setupMocks func(*hostutils.MockHostUtils, *installation.MockStore) expectedErr bool }{ { name: "successful configuration", - config: &types.InstallationConfig{ - DataDirectory: "/var/lib/embedded-cluster", - PodCIDR: "10.0.0.0/16", - ServiceCIDR: "10.1.0.0/16", - }, - setupMocks: func(hum *hostutils.MockHostUtils, im *MockInstallationStore) { + rc: func() runtimeconfig.RuntimeConfig { + rc := runtimeconfig.New(&ecv1beta1.RuntimeConfigSpec{ + DataDir: "/var/lib/embedded-cluster", + Network: ecv1beta1.NetworkSpec{ + PodCIDR: "10.0.0.0/16", + ServiceCIDR: "10.1.0.0/16", + }, + }) + return rc + }(), + setupMocks: func(hum *hostutils.MockHostUtils, im *installation.MockStore) { mock.InOrder( - im.On("GetStatus").Return(&types.Status{State: types.StatePending}, nil), im.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { return status.State == types.StateRunning })).Return(nil), - hum.On("ConfigureHost", mock.Anything, hostutils.InitForInstallOptions{ - LicenseFile: "license.yaml", - AirgapBundle: "bundle.tar", - PodCIDR: "10.0.0.0/16", - ServiceCIDR: "10.1.0.0/16", - }).Return(nil), + hum.On("ConfigureHost", mock.Anything, + mock.MatchedBy(func(rc runtimeconfig.RuntimeConfig) bool { + return rc.EmbeddedClusterHomeDirectory() == "/var/lib/embedded-cluster" && + rc.PodCIDR() == "10.0.0.0/16" && + rc.ServiceCIDR() == "10.1.0.0/16" + }), + hostutils.InitForInstallOptions{ + License: []byte("metadata:\n name: test-license"), + AirgapBundle: "bundle.tar", + }).Return(nil), im.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { return status.State == types.StateSucceeded })).Return(nil), ) }, expectedErr: false, }, - { - name: "already running", - config: &types.InstallationConfig{ - DataDirectory: "/var/lib/embedded-cluster", - }, - setupMocks: func(hum *hostutils.MockHostUtils, im *MockInstallationStore) { - im.On("GetStatus").Return(&types.Status{State: types.StateRunning}, nil) - }, - expectedErr: true, - }, { name: "configure installation fails", - config: &types.InstallationConfig{ - DataDirectory: "/var/lib/embedded-cluster", - }, - setupMocks: func(hum *hostutils.MockHostUtils, im *MockInstallationStore) { + rc: func() runtimeconfig.RuntimeConfig { + rc := runtimeconfig.New(&ecv1beta1.RuntimeConfigSpec{ + DataDir: "/var/lib/embedded-cluster", + }) + return rc + }(), + setupMocks: func(hum *hostutils.MockHostUtils, im *installation.MockStore) { mock.InOrder( - im.On("GetStatus").Return(&types.Status{State: types.StatePending}, nil), im.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { return status.State == types.StateRunning })).Return(nil), - hum.On("ConfigureHost", mock.Anything, hostutils.InitForInstallOptions{ - LicenseFile: "license.yaml", - AirgapBundle: "bundle.tar", - }).Return(errors.New("configuration failed")), + hum.On("ConfigureHost", mock.Anything, + mock.MatchedBy(func(rc runtimeconfig.RuntimeConfig) bool { + return rc.EmbeddedClusterHomeDirectory() == "/var/lib/embedded-cluster" + }), + hostutils.InitForInstallOptions{ + License: []byte("metadata:\n name: test-license"), + AirgapBundle: "bundle.tar", + }, + ).Return(errors.New("configuration failed")), im.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { return status.State == types.StateFailed })).Return(nil), ) }, - expectedErr: false, + expectedErr: true, }, { name: "set running status fails", - config: &types.InstallationConfig{ - DataDirectory: "/var/lib/embedded-cluster", - }, - setupMocks: func(hum *hostutils.MockHostUtils, im *MockInstallationStore) { + rc: func() runtimeconfig.RuntimeConfig { + rc := runtimeconfig.New(&ecv1beta1.RuntimeConfigSpec{ + DataDir: "/var/lib/embedded-cluster", + }) + return rc + }(), + setupMocks: func(hum *hostutils.MockHostUtils, im *installation.MockStore) { mock.InOrder( - im.On("GetStatus").Return(&types.Status{State: types.StatePending}, nil), im.On("SetStatus", mock.Anything).Return(errors.New("failed to set status")), ) }, @@ -409,9 +433,11 @@ func TestConfigureHost(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + rc := tt.rc + // Create mocks mockHostUtils := &hostutils.MockHostUtils{} - mockStore := &MockInstallationStore{} + mockStore := &installation.MockStore{} // Setup mocks tt.setupMocks(mockHostUtils, mockStore) @@ -420,21 +446,18 @@ func TestConfigureHost(t *testing.T) { manager := NewInstallationManager( WithHostUtils(mockHostUtils), WithInstallationStore(mockStore), - WithLicenseFile("license.yaml"), + WithLicense([]byte("metadata:\n name: test-license")), WithAirgapBundle("bundle.tar"), ) // Run the test - err := manager.ConfigureHost(context.Background(), tt.config) + err := manager.ConfigureHost(context.Background(), rc) // Assertions if tt.expectedErr { assert.Error(t, err) } else { assert.NoError(t, err) - - // Wait a bit for the goroutine to complete - time.Sleep(200 * time.Millisecond) } // Verify all mock expectations were met diff --git a/api/internal/managers/installation/manager.go b/api/internal/managers/linux/installation/manager.go similarity index 62% rename from api/internal/managers/installation/manager.go rename to api/internal/managers/linux/installation/manager.go index 7b1842f8ae..42720e0505 100644 --- a/api/internal/managers/installation/manager.go +++ b/api/internal/managers/linux/installation/manager.go @@ -2,10 +2,10 @@ package installation import ( "context" - "sync" + "github.com/replicatedhq/embedded-cluster/api/internal/store/linux/installation" + "github.com/replicatedhq/embedded-cluster/api/internal/utils" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" - "github.com/replicatedhq/embedded-cluster/api/pkg/utils" "github.com/replicatedhq/embedded-cluster/api/types" "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" @@ -16,57 +16,42 @@ var _ InstallationManager = &installationManager{} // InstallationManager provides methods for validating and setting defaults for installation configuration type InstallationManager interface { - GetConfig() (*types.InstallationConfig, error) - SetConfig(config types.InstallationConfig) error - GetStatus() (*types.Status, error) + GetConfig() (types.LinuxInstallationConfig, error) + SetConfig(config types.LinuxInstallationConfig) error + GetStatus() (types.Status, error) SetStatus(status types.Status) error - ValidateConfig(config *types.InstallationConfig) error - SetConfigDefaults(config *types.InstallationConfig) error - ConfigureHost(ctx context.Context, config *types.InstallationConfig) error + ValidateConfig(config types.LinuxInstallationConfig, managerPort int) error + SetConfigDefaults(config *types.LinuxInstallationConfig, rc runtimeconfig.RuntimeConfig) error + ConfigureHost(ctx context.Context, rc runtimeconfig.RuntimeConfig) error } // installationManager is an implementation of the InstallationManager interface type installationManager struct { - installation *types.Installation - installationStore InstallationStore - rc runtimeconfig.RuntimeConfig - licenseFile string + installationStore installation.Store + license []byte airgapBundle string netUtils utils.NetUtils hostUtils hostutils.HostUtilsInterface logger logrus.FieldLogger - mu sync.RWMutex } type InstallationManagerOption func(*installationManager) -func WithRuntimeConfig(rc runtimeconfig.RuntimeConfig) InstallationManagerOption { - return func(c *installationManager) { - c.rc = rc - } -} - func WithLogger(logger logrus.FieldLogger) InstallationManagerOption { return func(c *installationManager) { c.logger = logger } } -func WithInstallation(installation *types.Installation) InstallationManagerOption { - return func(c *installationManager) { - c.installation = installation - } -} - -func WithInstallationStore(installationStore InstallationStore) InstallationManagerOption { +func WithInstallationStore(installationStore installation.Store) InstallationManagerOption { return func(c *installationManager) { c.installationStore = installationStore } } -func WithLicenseFile(licenseFile string) InstallationManagerOption { +func WithLicense(license []byte) InstallationManagerOption { return func(c *installationManager) { - c.licenseFile = licenseFile + c.license = license } } @@ -96,20 +81,12 @@ func NewInstallationManager(opts ...InstallationManagerOption) *installationMana opt(manager) } - if manager.rc == nil { - manager.rc = runtimeconfig.New(nil) - } - if manager.logger == nil { manager.logger = logger.NewDiscardLogger() } - if manager.installation == nil { - manager.installation = types.NewInstallation() - } - if manager.installationStore == nil { - manager.installationStore = NewMemoryStore(manager.installation) + manager.installationStore = installation.NewMemoryStore() } if manager.netUtils == nil { diff --git a/api/internal/managers/installation/manager_mock.go b/api/internal/managers/linux/installation/manager_mock.go similarity index 57% rename from api/internal/managers/installation/manager_mock.go rename to api/internal/managers/linux/installation/manager_mock.go index 5d1f3eafa6..5bf43eea8d 100644 --- a/api/internal/managers/installation/manager_mock.go +++ b/api/internal/managers/linux/installation/manager_mock.go @@ -4,6 +4,7 @@ import ( "context" "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/stretchr/testify/mock" ) @@ -15,27 +16,27 @@ type MockInstallationManager struct { } // GetConfig mocks the GetConfig method -func (m *MockInstallationManager) GetConfig() (*types.InstallationConfig, error) { +func (m *MockInstallationManager) GetConfig() (types.LinuxInstallationConfig, error) { args := m.Called() if args.Get(0) == nil { - return nil, args.Error(1) + return types.LinuxInstallationConfig{}, args.Error(1) } - return args.Get(0).(*types.InstallationConfig), args.Error(1) + return args.Get(0).(types.LinuxInstallationConfig), args.Error(1) } // SetConfig mocks the SetConfig method -func (m *MockInstallationManager) SetConfig(config types.InstallationConfig) error { +func (m *MockInstallationManager) SetConfig(config types.LinuxInstallationConfig) error { args := m.Called(config) return args.Error(0) } // GetStatus mocks the GetStatus method -func (m *MockInstallationManager) GetStatus() (*types.Status, error) { +func (m *MockInstallationManager) GetStatus() (types.Status, error) { args := m.Called() if args.Get(0) == nil { - return nil, args.Error(1) + return types.Status{}, args.Error(1) } - return args.Get(0).(*types.Status), args.Error(1) + return args.Get(0).(types.Status), args.Error(1) } // SetStatus mocks the SetStatus method @@ -45,19 +46,19 @@ func (m *MockInstallationManager) SetStatus(status types.Status) error { } // ValidateConfig mocks the ValidateConfig method -func (m *MockInstallationManager) ValidateConfig(config *types.InstallationConfig) error { - args := m.Called(config) +func (m *MockInstallationManager) ValidateConfig(config types.LinuxInstallationConfig, managerPort int) error { + args := m.Called(config, managerPort) return args.Error(0) } // SetConfigDefaults mocks the SetConfigDefaults method -func (m *MockInstallationManager) SetConfigDefaults(config *types.InstallationConfig) error { - args := m.Called(config) +func (m *MockInstallationManager) SetConfigDefaults(config *types.LinuxInstallationConfig, rc runtimeconfig.RuntimeConfig) error { + args := m.Called(config, rc) return args.Error(0) } // ConfigureHost mocks the ConfigureHost method -func (m *MockInstallationManager) ConfigureHost(ctx context.Context, config *types.InstallationConfig) error { - args := m.Called(ctx, config) +func (m *MockInstallationManager) ConfigureHost(ctx context.Context, rc runtimeconfig.RuntimeConfig) error { + args := m.Called(ctx, rc) return args.Error(0) } diff --git a/api/internal/managers/installation/status.go b/api/internal/managers/linux/installation/status.go similarity index 78% rename from api/internal/managers/installation/status.go rename to api/internal/managers/linux/installation/status.go index 9557a29a30..684b8aeaed 100644 --- a/api/internal/managers/installation/status.go +++ b/api/internal/managers/linux/installation/status.go @@ -6,7 +6,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/types" ) -func (m *installationManager) GetStatus() (*types.Status, error) { +func (m *installationManager) GetStatus() (types.Status, error) { return m.installationStore.GetStatus() } @@ -14,14 +14,6 @@ func (m *installationManager) SetStatus(status types.Status) error { return m.installationStore.SetStatus(status) } -func (m *installationManager) isRunning() (bool, error) { - status, err := m.GetStatus() - if err != nil { - return false, err - } - return status.State == types.StateRunning, nil -} - func (m *installationManager) setRunningStatus(description string) error { return m.SetStatus(types.Status{ State: types.StateRunning, diff --git a/api/internal/managers/installation/status_test.go b/api/internal/managers/linux/installation/status_test.go similarity index 95% rename from api/internal/managers/installation/status_test.go rename to api/internal/managers/linux/installation/status_test.go index aee4f8a74d..d799de72d2 100644 --- a/api/internal/managers/installation/status_test.go +++ b/api/internal/managers/linux/installation/status_test.go @@ -25,7 +25,6 @@ func TestStatusSetAndGet(t *testing.T) { // Test reading it back readStatus, err := manager.GetStatus() assert.NoError(t, err) - assert.NotNil(t, readStatus) // Verify the values match assert.Equal(t, statusToWrite.State, readStatus.State) @@ -46,7 +45,6 @@ func TestSetRunningStatus(t *testing.T) { status, err := manager.GetStatus() assert.NoError(t, err) - assert.NotNil(t, status) assert.Equal(t, types.StateRunning, status.State) assert.Equal(t, description, status.Description) @@ -62,7 +60,6 @@ func TestSetFailedStatus(t *testing.T) { status, err := manager.GetStatus() assert.NoError(t, err) - assert.NotNil(t, status) assert.Equal(t, types.StateFailed, status.State) assert.Equal(t, description, status.Description) @@ -96,7 +93,6 @@ func TestSetCompletedStatus(t *testing.T) { status, err := manager.GetStatus() assert.NoError(t, err) - assert.NotNil(t, status) assert.Equal(t, tt.state, status.State) assert.Equal(t, tt.description, status.Description) diff --git a/api/internal/managers/preflight/hostpreflight.go b/api/internal/managers/linux/preflight/hostpreflight.go similarity index 59% rename from api/internal/managers/preflight/hostpreflight.go rename to api/internal/managers/linux/preflight/hostpreflight.go index 47194c5d5f..001ee33221 100644 --- a/api/internal/managers/preflight/hostpreflight.go +++ b/api/internal/managers/linux/preflight/hostpreflight.go @@ -9,12 +9,12 @@ import ( "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/preflights" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" troubleshootanalyze "github.com/replicatedhq/troubleshoot/pkg/analyze" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" ) type PrepareHostPreflightOptions struct { - InstallationConfig *types.InstallationConfig ReplicatedAppURL string ProxyRegistryURL string HostPreflightSpec *troubleshootv1beta2.HostPreflightSpec @@ -22,151 +22,114 @@ type PrepareHostPreflightOptions struct { TCPConnectionsRequired []string IsAirgap bool IsJoin bool + IsUI bool } type RunHostPreflightOptions struct { HostPreflightSpec *troubleshootv1beta2.HostPreflightSpec - Proxy *ecv1beta1.ProxySpec } -func (m *hostPreflightManager) PrepareHostPreflights(ctx context.Context, opts PrepareHostPreflightOptions) (*troubleshootv1beta2.HostPreflightSpec, *ecv1beta1.ProxySpec, error) { - hpf, proxy, err := m.prepareHostPreflights(ctx, opts) - if err != nil { - return nil, nil, err - } - return hpf, proxy, nil -} - -func (m *hostPreflightManager) RunHostPreflights(ctx context.Context, opts RunHostPreflightOptions) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.hostPreflightStore.IsRunning() { - return types.NewConflictError(fmt.Errorf("host preflights are already running")) - } - - if err := m.setRunningStatus(opts.HostPreflightSpec); err != nil { - return fmt.Errorf("set running status: %w", err) - } - - // Run preflights in background - go m.runHostPreflights(context.Background(), opts) - - return nil -} - -func (m *hostPreflightManager) GetHostPreflightStatus(ctx context.Context) (*types.Status, error) { - return m.hostPreflightStore.GetStatus() -} - -func (m *hostPreflightManager) GetHostPreflightOutput(ctx context.Context) (*types.HostPreflightsOutput, error) { - return m.hostPreflightStore.GetOutput() -} - -func (m *hostPreflightManager) GetHostPreflightTitles(ctx context.Context) ([]string, error) { - return m.hostPreflightStore.GetTitles() -} - -func (m *hostPreflightManager) prepareHostPreflights(ctx context.Context, opts PrepareHostPreflightOptions) (*troubleshootv1beta2.HostPreflightSpec, *ecv1beta1.ProxySpec, error) { - // Use provided installation config - config := opts.InstallationConfig - if config == nil { - return nil, nil, fmt.Errorf("installation config is required") - } - +func (m *hostPreflightManager) PrepareHostPreflights(ctx context.Context, rc runtimeconfig.RuntimeConfig, opts PrepareHostPreflightOptions) (*troubleshootv1beta2.HostPreflightSpec, error) { // Get node IP - nodeIP, err := m.netUtils.FirstValidAddress(config.NetworkInterface) + nodeIP, err := m.netUtils.FirstValidAddress(rc.NetworkInterface()) if err != nil { - return nil, nil, fmt.Errorf("determine node ip: %w", err) - } - - // Build proxy spec - var proxy *ecv1beta1.ProxySpec - if config.HTTPProxy != "" || config.HTTPSProxy != "" || config.NoProxy != "" { - proxy = &ecv1beta1.ProxySpec{ - HTTPProxy: config.HTTPProxy, - HTTPSProxy: config.HTTPSProxy, - NoProxy: config.NoProxy, - } - } - - var globalCIDR *string - if config.GlobalCIDR != "" { - globalCIDR = &config.GlobalCIDR + return nil, fmt.Errorf("determine node ip: %w", err) } // Use the shared Prepare function to prepare host preflights - hpf, err := m.runner.Prepare(ctx, preflights.PrepareOptions{ + prepareOpts := preflights.PrepareOptions{ HostPreflightSpec: opts.HostPreflightSpec, ReplicatedAppURL: opts.ReplicatedAppURL, ProxyRegistryURL: opts.ProxyRegistryURL, - AdminConsolePort: opts.InstallationConfig.AdminConsolePort, - LocalArtifactMirrorPort: opts.InstallationConfig.LocalArtifactMirrorPort, - DataDir: opts.InstallationConfig.DataDirectory, - K0sDataDir: m.rc.EmbeddedClusterK0sSubDir(), - OpenEBSDataDir: m.rc.EmbeddedClusterOpenEBSLocalSubDir(), - Proxy: proxy, - PodCIDR: config.PodCIDR, - ServiceCIDR: config.ServiceCIDR, - GlobalCIDR: globalCIDR, + AdminConsolePort: rc.AdminConsolePort(), + LocalArtifactMirrorPort: rc.LocalArtifactMirrorPort(), + DataDir: rc.EmbeddedClusterHomeDirectory(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), + OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), + Proxy: rc.ProxySpec(), + PodCIDR: rc.PodCIDR(), + ServiceCIDR: rc.ServiceCIDR(), NodeIP: nodeIP, IsAirgap: opts.IsAirgap, TCPConnectionsRequired: opts.TCPConnectionsRequired, IsJoin: opts.IsJoin, - }) + IsUI: opts.IsUI, + } + if cidr := rc.GlobalCIDR(); cidr != "" { + prepareOpts.GlobalCIDR = &cidr + } + + // Use the shared Prepare function to prepare host preflights + hpf, err := m.runner.Prepare(ctx, prepareOpts) if err != nil { - return nil, nil, fmt.Errorf("prepare host preflights: %w", err) + return nil, fmt.Errorf("prepare host preflights: %w", err) } - return hpf, proxy, nil + return hpf, nil } -func (m *hostPreflightManager) runHostPreflights(ctx context.Context, opts RunHostPreflightOptions) { +func (m *hostPreflightManager) RunHostPreflights(ctx context.Context, rc runtimeconfig.RuntimeConfig, opts RunHostPreflightOptions) (finalErr error) { defer func() { if r := recover(); r != nil { - if err := m.setFailedStatus(fmt.Sprintf("panic: %v: %s", r, string(debug.Stack()))); err != nil { + finalErr = fmt.Errorf("panic: %v: %s", r, string(debug.Stack())) + + if err := m.setFailedStatus("Host preflights failed to run: panic"); err != nil { m.logger.WithField("error", err).Error("set failed status") } } }() + if err := m.setRunningStatus(opts.HostPreflightSpec); err != nil { + return fmt.Errorf("set running status: %w", err) + } + // Run the preflights using the shared core function - output, stderr, err := m.runner.Run(ctx, opts.HostPreflightSpec, opts.Proxy, m.rc) + output, stderr, err := m.runner.Run(ctx, opts.HostPreflightSpec, rc) if err != nil { errMsg := fmt.Sprintf("Host preflights failed to run: %v", err) if stderr != "" { errMsg += fmt.Sprintf(" (stderr: %s)", stderr) } + m.logger.Error(errMsg) if err := m.setFailedStatus(errMsg); err != nil { - m.logger.WithField("error", err).Error("set failed status") + return fmt.Errorf("set failed status: %w", err) } return } - if err := m.runner.SaveToDisk(output, m.rc.PathToEmbeddedClusterSupportFile("host-preflight-results.json")); err != nil { + if err := m.runner.SaveToDisk(output, rc.PathToEmbeddedClusterSupportFile("host-preflight-results.json")); err != nil { m.logger.WithField("error", err).Warn("save preflights output") } - if err := m.runner.CopyBundleTo(m.rc.PathToEmbeddedClusterSupportFile("preflight-bundle.tar.gz")); err != nil { + if err := m.runner.CopyBundleTo(rc.PathToEmbeddedClusterSupportFile("preflight-bundle.tar.gz")); err != nil { m.logger.WithField("error", err).Warn("copy preflight bundle to embedded-cluster support dir") } - if output.HasFail() || output.HasWarn() { - if m.metricsReporter != nil { - m.metricsReporter.ReportPreflightsFailed(ctx, output) - } - } - // Set final status based on results + // TODO @jgantunes: we're currently not handling warnings in the output. if output.HasFail() { if err := m.setCompletedStatus(types.StateFailed, "Host preflights failed", output); err != nil { - m.logger.WithField("error", err).Error("set failed status") + return fmt.Errorf("set failed status: %w", err) } } else { if err := m.setCompletedStatus(types.StateSucceeded, "Host preflights passed", output); err != nil { - m.logger.WithField("error", err).Error("set succeeded status") + return fmt.Errorf("set succeeded status: %w", err) } } + + return nil +} + +func (m *hostPreflightManager) GetHostPreflightStatus(ctx context.Context) (types.Status, error) { + return m.hostPreflightStore.GetStatus() +} + +func (m *hostPreflightManager) GetHostPreflightOutput(ctx context.Context) (*types.HostPreflightsOutput, error) { + return m.hostPreflightStore.GetOutput() +} + +func (m *hostPreflightManager) GetHostPreflightTitles(ctx context.Context) ([]string, error) { + return m.hostPreflightStore.GetTitles() } func (m *hostPreflightManager) setRunningStatus(hpf *troubleshootv1beta2.HostPreflightSpec) error { @@ -183,7 +146,7 @@ func (m *hostPreflightManager) setRunningStatus(hpf *troubleshootv1beta2.HostPre return fmt.Errorf("reset output: %w", err) } - if err := m.hostPreflightStore.SetStatus(&types.Status{ + if err := m.hostPreflightStore.SetStatus(types.Status{ State: types.StateRunning, Description: "Running host preflights", LastUpdated: time.Now(), @@ -195,9 +158,7 @@ func (m *hostPreflightManager) setRunningStatus(hpf *troubleshootv1beta2.HostPre } func (m *hostPreflightManager) setFailedStatus(description string) error { - m.logger.Error(description) - - return m.hostPreflightStore.SetStatus(&types.Status{ + return m.hostPreflightStore.SetStatus(types.Status{ State: types.StateFailed, Description: description, LastUpdated: time.Now(), @@ -209,7 +170,7 @@ func (m *hostPreflightManager) setCompletedStatus(state types.State, description return fmt.Errorf("set output: %w", err) } - return m.hostPreflightStore.SetStatus(&types.Status{ + return m.hostPreflightStore.SetStatus(types.Status{ State: state, Description: description, LastUpdated: time.Now(), diff --git a/api/internal/managers/preflight/hostpreflight_test.go b/api/internal/managers/linux/preflight/hostpreflight_test.go similarity index 65% rename from api/internal/managers/preflight/hostpreflight_test.go rename to api/internal/managers/linux/preflight/hostpreflight_test.go index 674932b5f4..0a520329f7 100644 --- a/api/internal/managers/preflight/hostpreflight_test.go +++ b/api/internal/managers/linux/preflight/hostpreflight_test.go @@ -10,12 +10,12 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + preflightstore "github.com/replicatedhq/embedded-cluster/api/internal/store/linux/preflight" + "github.com/replicatedhq/embedded-cluster/api/internal/utils" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" - "github.com/replicatedhq/embedded-cluster/api/pkg/utils" "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/preflights" - "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" ) @@ -27,25 +27,12 @@ func TestHostPreflightManager_PrepareHostPreflights(t *testing.T) { runtimeSpec *ecv1beta1.RuntimeConfigSpec setupMocks func(*preflights.MockPreflightRunner, *utils.MockNetUtils) expectedHPF *troubleshootv1beta2.HostPreflightSpec - expectedProxy *ecv1beta1.ProxySpec expectedError string - assertResult func(t *testing.T, hpf *troubleshootv1beta2.HostPreflightSpec, proxy *ecv1beta1.ProxySpec) + assertResult func(t *testing.T, hpf *troubleshootv1beta2.HostPreflightSpec) }{ { name: "success with proxy configuration", opts: PrepareHostPreflightOptions{ - InstallationConfig: &types.InstallationConfig{ - NetworkInterface: "eth0", - HTTPProxy: "http://proxy:8080", - HTTPSProxy: "https://proxy:8080", - NoProxy: "localhost,127.0.0.1", - AdminConsolePort: 30000, - LocalArtifactMirrorPort: 50000, - DataDirectory: "/var/lib/embedded-cluster", - PodCIDR: "10.244.0.0/16", - ServiceCIDR: "10.96.0.0/12", - GlobalCIDR: "10.128.0.0/16", - }, ReplicatedAppURL: "https://replicated.app", ProxyRegistryURL: "proxy.registry.url", HostPreflightSpec: &troubleshootv1beta2.HostPreflightSpec{}, @@ -56,6 +43,24 @@ func TestHostPreflightManager_PrepareHostPreflights(t *testing.T) { }, runtimeSpec: &ecv1beta1.RuntimeConfigSpec{ DataDir: "/var/lib/embedded-cluster", + AdminConsole: ecv1beta1.AdminConsoleSpec{ + Port: 30000, + }, + LocalArtifactMirror: ecv1beta1.LocalArtifactMirrorSpec{ + Port: 50000, + }, + Network: ecv1beta1.NetworkSpec{ + NetworkInterface: "eth0", + PodCIDR: "10.244.0.0/16", + ServiceCIDR: "10.96.0.0/12", + GlobalCIDR: "10.128.0.0/16", + NodePortRange: "80-32767", + }, + Proxy: &ecv1beta1.ProxySpec{ + HTTPProxy: "http://proxy:8080", + HTTPSProxy: "https://proxy:8080", + NoProxy: "localhost,127.0.0.1", + }, }, setupMocks: func(runner *preflights.MockPreflightRunner, netUtils *utils.MockNetUtils) { netUtils.On("FirstValidAddress", "eth0").Return("192.0.100.1", nil) @@ -75,30 +80,31 @@ func TestHostPreflightManager_PrepareHostPreflights(t *testing.T) { })).Return(&troubleshootv1beta2.HostPreflightSpec{}, nil) }, expectedHPF: &troubleshootv1beta2.HostPreflightSpec{}, - expectedProxy: &ecv1beta1.ProxySpec{ - HTTPProxy: "http://proxy:8080", - HTTPSProxy: "https://proxy:8080", - NoProxy: "localhost,127.0.0.1", - }, }, { name: "success without proxy configuration", opts: PrepareHostPreflightOptions{ - InstallationConfig: &types.InstallationConfig{ - NetworkInterface: "eth0", - AdminConsolePort: 30000, - LocalArtifactMirrorPort: 50000, - DataDirectory: "/var/lib/embedded-cluster", - PodCIDR: "10.244.0.0/16", - ServiceCIDR: "10.96.0.0/12", - }, ReplicatedAppURL: "https://replicated.app", HostPreflightSpec: &troubleshootv1beta2.HostPreflightSpec{}, TCPConnectionsRequired: []string{"6443"}, IsAirgap: true, IsJoin: true, }, - runtimeSpec: nil, // Use defaults + runtimeSpec: &ecv1beta1.RuntimeConfigSpec{ + DataDir: "/var/lib/embedded-cluster", + AdminConsole: ecv1beta1.AdminConsoleSpec{ + Port: 30000, + }, + LocalArtifactMirror: ecv1beta1.LocalArtifactMirrorSpec{ + Port: 50000, + }, + Network: ecv1beta1.NetworkSpec{ + NetworkInterface: "eth0", + PodCIDR: "10.244.0.0/16", + ServiceCIDR: "10.96.0.0/12", + NodePortRange: "80-32767", + }, + }, setupMocks: func(runner *preflights.MockPreflightRunner, netUtils *utils.MockNetUtils) { netUtils.On("FirstValidAddress", "eth0").Return("192.0.100.1", nil) runner.On("Prepare", mock.Anything, mock.MatchedBy(func(opts preflights.PrepareOptions) bool { @@ -108,20 +114,11 @@ func TestHostPreflightManager_PrepareHostPreflights(t *testing.T) { opts.IsJoin })).Return(&troubleshootv1beta2.HostPreflightSpec{}, nil) }, - expectedHPF: &troubleshootv1beta2.HostPreflightSpec{}, - expectedProxy: nil, + expectedHPF: &troubleshootv1beta2.HostPreflightSpec{}, }, { name: "success with custom k0s and openebs data dirs", opts: PrepareHostPreflightOptions{ - InstallationConfig: &types.InstallationConfig{ - NetworkInterface: "eth0", - AdminConsolePort: 30000, - LocalArtifactMirrorPort: 50000, - DataDirectory: "/custom/data", - PodCIDR: "10.244.0.0/16", - ServiceCIDR: "10.96.0.0/12", - }, ReplicatedAppURL: "https://replicated.app", HostPreflightSpec: &troubleshootv1beta2.HostPreflightSpec{}, }, @@ -129,6 +126,18 @@ func TestHostPreflightManager_PrepareHostPreflights(t *testing.T) { DataDir: "/custom/data", K0sDataDirOverride: "/custom/k0s", OpenEBSDataDirOverride: "/custom/openebs", + AdminConsole: ecv1beta1.AdminConsoleSpec{ + Port: 30000, + }, + LocalArtifactMirror: ecv1beta1.LocalArtifactMirrorSpec{ + Port: 50000, + }, + Network: ecv1beta1.NetworkSpec{ + NetworkInterface: "eth0", + PodCIDR: "10.244.0.0/16", + ServiceCIDR: "10.96.0.0/12", + NodePortRange: "80-32767", + }, }, setupMocks: func(runner *preflights.MockPreflightRunner, netUtils *utils.MockNetUtils) { netUtils.On("FirstValidAddress", "eth0").Return("192.0.100.1", nil) @@ -138,31 +147,26 @@ func TestHostPreflightManager_PrepareHostPreflights(t *testing.T) { opts.OpenEBSDataDir == "/custom/openebs" })).Return(&troubleshootv1beta2.HostPreflightSpec{}, nil) }, - expectedHPF: &troubleshootv1beta2.HostPreflightSpec{}, - expectedProxy: nil, - }, - { - name: "error when installation config is nil", - opts: PrepareHostPreflightOptions{ - InstallationConfig: nil, - }, - runtimeSpec: nil, - setupMocks: func(*preflights.MockPreflightRunner, *utils.MockNetUtils) {}, - expectedError: "installation config is required", + expectedHPF: &troubleshootv1beta2.HostPreflightSpec{}, }, { name: "error when runner prepare fails", - opts: PrepareHostPreflightOptions{ - InstallationConfig: &types.InstallationConfig{ - NetworkInterface: "eth0", - AdminConsolePort: 30000, - LocalArtifactMirrorPort: 50000, - DataDirectory: "/var/lib/embedded-cluster", - PodCIDR: "10.244.0.0/16", - ServiceCIDR: "10.96.0.0/12", + opts: PrepareHostPreflightOptions{}, + runtimeSpec: &ecv1beta1.RuntimeConfigSpec{ + DataDir: "/var/lib/embedded-cluster", + AdminConsole: ecv1beta1.AdminConsoleSpec{ + Port: 30000, + }, + LocalArtifactMirror: ecv1beta1.LocalArtifactMirrorSpec{ + Port: 50000, + }, + Network: ecv1beta1.NetworkSpec{ + NetworkInterface: "eth0", + PodCIDR: "10.244.0.0/16", + ServiceCIDR: "10.96.0.0/12", + NodePortRange: "80-32767", }, }, - runtimeSpec: nil, setupMocks: func(runner *preflights.MockPreflightRunner, netUtils *utils.MockNetUtils) { netUtils.On("FirstValidAddress", "eth0").Return("192.0.100.1", nil) runner.On("Prepare", mock.Anything, mock.Anything).Return(nil, fmt.Errorf("prepare failed")) @@ -171,17 +175,22 @@ func TestHostPreflightManager_PrepareHostPreflights(t *testing.T) { }, { name: "error when determining the node IP fails", - opts: PrepareHostPreflightOptions{ - InstallationConfig: &types.InstallationConfig{ - NetworkInterface: "eth0", - AdminConsolePort: 30000, - LocalArtifactMirrorPort: 50000, - DataDirectory: "/var/lib/embedded-cluster", - PodCIDR: "10.244.0.0/16", - ServiceCIDR: "10.96.0.0/12", + opts: PrepareHostPreflightOptions{}, + runtimeSpec: &ecv1beta1.RuntimeConfigSpec{ + DataDir: "/var/lib/embedded-cluster", + AdminConsole: ecv1beta1.AdminConsoleSpec{ + Port: 30000, + }, + LocalArtifactMirror: ecv1beta1.LocalArtifactMirrorSpec{ + Port: 50000, + }, + Network: ecv1beta1.NetworkSpec{ + NetworkInterface: "eth0", + PodCIDR: "10.244.0.0/16", + ServiceCIDR: "10.96.0.0/12", + NodePortRange: "80-32767", }, }, - runtimeSpec: nil, setupMocks: func(runner *preflights.MockPreflightRunner, netUtils *utils.MockNetUtils) { netUtils.On("FirstValidAddress", "eth0").Return("", assert.AnError) }, @@ -193,8 +202,7 @@ func TestHostPreflightManager_PrepareHostPreflights(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Setup mocks mockRunner := &preflights.MockPreflightRunner{} - mockStore := &MockHostPreflightStore{} - mockMetrics := &metrics.MockReporter{} + mockStore := &preflightstore.MockStore{} mockNetUtils := &utils.MockNetUtils{} // Create real runtime config @@ -206,28 +214,24 @@ func TestHostPreflightManager_PrepareHostPreflights(t *testing.T) { manager := NewHostPreflightManager( WithPreflightRunner(mockRunner), WithHostPreflightStore(mockStore), - WithRuntimeConfig(rc), WithLogger(logger.NewDiscardLogger()), - WithMetricsReporter(mockMetrics), WithNetUtils(mockNetUtils), ) // Execute - hpf, proxy, err := manager.PrepareHostPreflights(context.Background(), tt.opts) + hpf, err := manager.PrepareHostPreflights(context.Background(), rc, tt.opts) // Assert if tt.expectedError != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.expectedError) assert.Nil(t, hpf) - assert.Nil(t, proxy) } else { require.NoError(t, err) assert.Equal(t, tt.expectedHPF, hpf) - assert.Equal(t, tt.expectedProxy, proxy) if tt.assertResult != nil { - tt.assertResult(t, hpf, proxy) + tt.assertResult(t, hpf) } } @@ -241,8 +245,8 @@ func TestHostPreflightManager_RunHostPreflights(t *testing.T) { tests := []struct { name string opts RunHostPreflightOptions - initialState *types.HostPreflights - setupMocks func(*preflights.MockPreflightRunner, *metrics.MockReporter, runtimeconfig.RuntimeConfig) + initialState types.HostPreflights + setupMocks func(*preflights.MockPreflightRunner, runtimeconfig.RuntimeConfig) expectedFinalState types.State // This is the expected error message returned by the RunHostPreflights method, synchronously expectedError string @@ -251,17 +255,16 @@ func TestHostPreflightManager_RunHostPreflights(t *testing.T) { name: "successful execution with no failures or warnings", opts: RunHostPreflightOptions{ HostPreflightSpec: &troubleshootv1beta2.HostPreflightSpec{}, - Proxy: nil, }, - initialState: &types.HostPreflights{ - Status: &types.Status{ + initialState: types.HostPreflights{ + Status: types.Status{ State: types.StatePending, }, }, - setupMocks: func(runner *preflights.MockPreflightRunner, metricsReporter *metrics.MockReporter, rc runtimeconfig.RuntimeConfig) { + setupMocks: func(runner *preflights.MockPreflightRunner, rc runtimeconfig.RuntimeConfig) { // Mock successful preflight execution output := &types.HostPreflightsOutput{} - runner.On("Run", mock.Anything, mock.Anything, mock.Anything, rc).Return(output, "", nil) + runner.On("Run", mock.Anything, mock.Anything, rc).Return(output, "", nil) // Mock save operations in order runner.On("SaveToDisk", output, rc.PathToEmbeddedClusterSupportFile("host-preflight-results.json")).Return(nil) @@ -273,14 +276,13 @@ func TestHostPreflightManager_RunHostPreflights(t *testing.T) { name: "execution with preflight failures", opts: RunHostPreflightOptions{ HostPreflightSpec: &troubleshootv1beta2.HostPreflightSpec{}, - Proxy: nil, }, - initialState: &types.HostPreflights{ - Status: &types.Status{ + initialState: types.HostPreflights{ + Status: types.Status{ State: types.StatePending, }, }, - setupMocks: func(runner *preflights.MockPreflightRunner, metricsReporter *metrics.MockReporter, rc runtimeconfig.RuntimeConfig) { + setupMocks: func(runner *preflights.MockPreflightRunner, rc runtimeconfig.RuntimeConfig) { // Mock failed preflight execution output := &types.HostPreflightsOutput{ Fail: []types.HostPreflightsRecord{{ @@ -289,29 +291,25 @@ func TestHostPreflightManager_RunHostPreflights(t *testing.T) { }}, } - runner.On("Run", mock.Anything, mock.Anything, mock.Anything, rc).Return(output, "", nil) + runner.On("Run", mock.Anything, mock.Anything, rc).Return(output, "", nil) // Mock save operations runner.On("SaveToDisk", output, rc.PathToEmbeddedClusterSupportFile("host-preflight-results.json")).Return(nil) runner.On("CopyBundleTo", rc.PathToEmbeddedClusterSupportFile("preflight-bundle.tar.gz")).Return(nil) - - // Mock metrics reporting - metricsReporter.On("ReportPreflightsFailed", mock.Anything, output).Return() }, expectedFinalState: types.StateFailed, }, { name: "execution with preflight warnings", - initialState: &types.HostPreflights{ - Status: &types.Status{ + initialState: types.HostPreflights{ + Status: types.Status{ State: types.StatePending, }, }, opts: RunHostPreflightOptions{ HostPreflightSpec: &troubleshootv1beta2.HostPreflightSpec{}, - Proxy: nil, }, - setupMocks: func(runner *preflights.MockPreflightRunner, metricsReporter *metrics.MockReporter, rc runtimeconfig.RuntimeConfig) { + setupMocks: func(runner *preflights.MockPreflightRunner, rc runtimeconfig.RuntimeConfig) { // Mock preflight execution with warnings output := &types.HostPreflightsOutput{ Warn: []types.HostPreflightsRecord{{ @@ -319,29 +317,25 @@ func TestHostPreflightManager_RunHostPreflights(t *testing.T) { Message: "This is a sample warning message.", }}, } - runner.On("Run", mock.Anything, mock.Anything, mock.Anything, rc).Return(output, "", nil) + runner.On("Run", mock.Anything, mock.Anything, rc).Return(output, "", nil) // Mock save operations runner.On("SaveToDisk", output, rc.PathToEmbeddedClusterSupportFile("host-preflight-results.json")).Return(nil) runner.On("CopyBundleTo", rc.PathToEmbeddedClusterSupportFile("preflight-bundle.tar.gz")).Return(nil) - - // Mock metrics reporting - metricsReporter.On("ReportPreflightsFailed", mock.Anything, output).Return() }, expectedFinalState: types.StateSucceeded, }, { name: "execution with both failures and warnings", - initialState: &types.HostPreflights{ - Status: &types.Status{ + initialState: types.HostPreflights{ + Status: types.Status{ State: types.StatePending, }, }, opts: RunHostPreflightOptions{ HostPreflightSpec: &troubleshootv1beta2.HostPreflightSpec{}, - Proxy: nil, }, - setupMocks: func(runner *preflights.MockPreflightRunner, metricsReporter *metrics.MockReporter, rc runtimeconfig.RuntimeConfig) { + setupMocks: func(runner *preflights.MockPreflightRunner, rc runtimeconfig.RuntimeConfig) { // Mock preflight execution with both failures and warnings output := &types.HostPreflightsOutput{ Fail: []types.HostPreflightsRecord{{ @@ -353,49 +347,44 @@ func TestHostPreflightManager_RunHostPreflights(t *testing.T) { Message: "This is a sample warning message.", }}, } - runner.On("Run", mock.Anything, mock.Anything, mock.Anything, rc).Return(output, "", nil) + runner.On("Run", mock.Anything, mock.Anything, rc).Return(output, "", nil) // Mock save operations runner.On("SaveToDisk", output, rc.PathToEmbeddedClusterSupportFile("host-preflight-results.json")).Return(nil) runner.On("CopyBundleTo", rc.PathToEmbeddedClusterSupportFile("preflight-bundle.tar.gz")).Return(nil) - - // Mock metrics reporting - metricsReporter.On("ReportPreflightsFailed", mock.Anything, output).Return() }, expectedFinalState: types.StateFailed, }, { name: "runner execution fails", - initialState: &types.HostPreflights{ - Status: &types.Status{ + initialState: types.HostPreflights{ + Status: types.Status{ State: types.StatePending, }, }, opts: RunHostPreflightOptions{ HostPreflightSpec: &troubleshootv1beta2.HostPreflightSpec{}, - Proxy: nil, }, - setupMocks: func(runner *preflights.MockPreflightRunner, metricsReporter *metrics.MockReporter, rc runtimeconfig.RuntimeConfig) { + setupMocks: func(runner *preflights.MockPreflightRunner, rc runtimeconfig.RuntimeConfig) { // Mock runner failure - runner.On("Run", mock.Anything, mock.Anything, mock.Anything, rc).Return(nil, "stderr output", assert.AnError) + runner.On("Run", mock.Anything, mock.Anything, rc).Return(nil, "stderr output", assert.AnError) }, expectedFinalState: types.StateFailed, }, { name: "SaveToDisk fails but execution continues", - initialState: &types.HostPreflights{ - Status: &types.Status{ + initialState: types.HostPreflights{ + Status: types.Status{ State: types.StatePending, }, }, opts: RunHostPreflightOptions{ HostPreflightSpec: &troubleshootv1beta2.HostPreflightSpec{}, - Proxy: nil, }, - setupMocks: func(runner *preflights.MockPreflightRunner, metricsReporter *metrics.MockReporter, rc runtimeconfig.RuntimeConfig) { + setupMocks: func(runner *preflights.MockPreflightRunner, rc runtimeconfig.RuntimeConfig) { // Mock successful preflight execution output := &types.HostPreflightsOutput{} - runner.On("Run", mock.Anything, mock.Anything, mock.Anything, rc).Return(output, "", nil) + runner.On("Run", mock.Anything, mock.Anything, rc).Return(output, "", nil) // Mock save operations - SaveToDisk fails but execution continues runner.On("SaveToDisk", output, rc.PathToEmbeddedClusterSupportFile("host-preflight-results.json")).Return(assert.AnError) @@ -405,19 +394,18 @@ func TestHostPreflightManager_RunHostPreflights(t *testing.T) { }, { name: "CopyBundleTo fails but execution continues", - initialState: &types.HostPreflights{ - Status: &types.Status{ + initialState: types.HostPreflights{ + Status: types.Status{ State: types.StatePending, }, }, opts: RunHostPreflightOptions{ HostPreflightSpec: &troubleshootv1beta2.HostPreflightSpec{}, - Proxy: nil, }, - setupMocks: func(runner *preflights.MockPreflightRunner, metricsReporter *metrics.MockReporter, rc runtimeconfig.RuntimeConfig) { + setupMocks: func(runner *preflights.MockPreflightRunner, rc runtimeconfig.RuntimeConfig) { // Mock successful preflight execution output := &types.HostPreflightsOutput{} - runner.On("Run", mock.Anything, mock.Anything, mock.Anything, rc).Return(output, "", nil) + runner.On("Run", mock.Anything, mock.Anything, rc).Return(output, "", nil) // Mock save operations - CopyBundleTo fails but execution continues runner.On("SaveToDisk", output, rc.PathToEmbeddedClusterSupportFile("host-preflight-results.json")).Return(nil) @@ -425,66 +413,42 @@ func TestHostPreflightManager_RunHostPreflights(t *testing.T) { }, expectedFinalState: types.StateSucceeded, }, - { - name: "error - preflights already running", - initialState: &types.HostPreflights{ - Status: &types.Status{ - State: types.StateRunning, - }, - }, - opts: RunHostPreflightOptions{ - HostPreflightSpec: &troubleshootv1beta2.HostPreflightSpec{}, - }, - setupMocks: func(runner *preflights.MockPreflightRunner, metricsReporter *metrics.MockReporter, rc runtimeconfig.RuntimeConfig) { - }, - expectedError: "host preflights are already running", - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Setup mocks mockRunner := &preflights.MockPreflightRunner{} - mockMetrics := &metrics.MockReporter{} // Create runtime config rc := runtimeconfig.New(nil) rc.SetDataDir(t.TempDir()) - tt.setupMocks(mockRunner, mockMetrics, rc) + tt.setupMocks(mockRunner, rc) // Create manager using builder pattern manager := NewHostPreflightManager( WithPreflightRunner(mockRunner), - WithHostPreflightStore(NewMemoryStore(tt.initialState)), - WithRuntimeConfig(rc), + WithHostPreflightStore(preflightstore.NewMemoryStore(preflightstore.WithHostPreflight(tt.initialState))), WithLogger(logger.NewDiscardLogger()), - WithMetricsReporter(mockMetrics), ) // Execute - err := manager.RunHostPreflights(context.Background(), tt.opts) + err := manager.RunHostPreflights(context.Background(), rc, tt.opts) // If there's an error we don't need to wait for async execution if tt.expectedError != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.expectedError) - mockRunner.AssertExpectations(t) - mockMetrics.AssertExpectations(t) - return } else { require.NoError(t, err) } - // Use assert.Eventually to wait for async execution to complete - assert.Eventually(t, func() bool { - status, err := manager.GetHostPreflightStatus(t.Context()) - require.NoError(t, err) - return tt.expectedFinalState == status.State - }, 2*time.Second, 50*time.Millisecond, "Async execution should complete within timeout") + status, err := manager.GetHostPreflightStatus(t.Context()) + require.NoError(t, err) + assert.Equal(t, tt.expectedFinalState, status.State) // Additional verification that calls were made in the correct order mockRunner.AssertExpectations(t) - mockMetrics.AssertExpectations(t) }) } } @@ -492,27 +456,27 @@ func TestHostPreflightManager_RunHostPreflights(t *testing.T) { func TestHostPreflightManager_GetHostPreflightStatus(t *testing.T) { tests := []struct { name string - setupMocks func(*MockHostPreflightStore) - expectedStatus *types.Status + setupMocks func(*preflightstore.MockStore) + expectedStatus types.Status expectedError string }{ { name: "success", - setupMocks: func(store *MockHostPreflightStore) { - store.On("GetStatus").Return(&types.Status{ + setupMocks: func(store *preflightstore.MockStore) { + store.On("GetStatus").Return(types.Status{ State: types.StateSucceeded, Description: "Host preflights passed", LastUpdated: time.Now(), }, nil) }, - expectedStatus: &types.Status{ + expectedStatus: types.Status{ State: types.StateSucceeded, Description: "Host preflights passed", }, }, { name: "error from store", - setupMocks: func(store *MockHostPreflightStore) { + setupMocks: func(store *preflightstore.MockStore) { store.On("GetStatus").Return(nil, fmt.Errorf("store error")) }, expectedError: "store error", @@ -522,7 +486,7 @@ func TestHostPreflightManager_GetHostPreflightStatus(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Setup mocks - mockStore := &MockHostPreflightStore{} + mockStore := &preflightstore.MockStore{} tt.setupMocks(mockStore) // Create manager using builder pattern @@ -537,7 +501,7 @@ func TestHostPreflightManager_GetHostPreflightStatus(t *testing.T) { if tt.expectedError != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.expectedError) - assert.Nil(t, status) + assert.Equal(t, types.Status{}, status) } else { require.NoError(t, err) assert.Equal(t, tt.expectedStatus.State, status.State) @@ -553,13 +517,13 @@ func TestHostPreflightManager_GetHostPreflightStatus(t *testing.T) { func TestHostPreflightManager_GetHostPreflightOutput(t *testing.T) { tests := []struct { name string - setupMocks func(*MockHostPreflightStore) + setupMocks func(*preflightstore.MockStore) expectedOutput *types.HostPreflightsOutput expectedError string }{ { name: "success", - setupMocks: func(store *MockHostPreflightStore) { + setupMocks: func(store *preflightstore.MockStore) { output := &types.HostPreflightsOutput{} store.On("GetOutput").Return(output, nil) }, @@ -567,7 +531,7 @@ func TestHostPreflightManager_GetHostPreflightOutput(t *testing.T) { }, { name: "error from store", - setupMocks: func(store *MockHostPreflightStore) { + setupMocks: func(store *preflightstore.MockStore) { store.On("GetOutput").Return(nil, fmt.Errorf("store error")) }, expectedError: "store error", @@ -577,7 +541,7 @@ func TestHostPreflightManager_GetHostPreflightOutput(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Setup mocks - mockStore := &MockHostPreflightStore{} + mockStore := &preflightstore.MockStore{} tt.setupMocks(mockStore) // Create manager using builder pattern @@ -607,13 +571,13 @@ func TestHostPreflightManager_GetHostPreflightOutput(t *testing.T) { func TestHostPreflightManager_GetHostPreflightTitles(t *testing.T) { tests := []struct { name string - setupMocks func(*MockHostPreflightStore) + setupMocks func(*preflightstore.MockStore) expectedTitles []string expectedError string }{ { name: "success", - setupMocks: func(store *MockHostPreflightStore) { + setupMocks: func(store *preflightstore.MockStore) { titles := []string{"Memory Check", "Disk Space Check", "Network Check"} store.On("GetTitles").Return(titles, nil) }, @@ -621,14 +585,14 @@ func TestHostPreflightManager_GetHostPreflightTitles(t *testing.T) { }, { name: "success with empty titles", - setupMocks: func(store *MockHostPreflightStore) { + setupMocks: func(store *preflightstore.MockStore) { store.On("GetTitles").Return([]string{}, nil) }, expectedTitles: []string{}, }, { name: "error from store", - setupMocks: func(store *MockHostPreflightStore) { + setupMocks: func(store *preflightstore.MockStore) { store.On("GetTitles").Return(nil, fmt.Errorf("store error")) }, expectedError: "store error", @@ -638,7 +602,7 @@ func TestHostPreflightManager_GetHostPreflightTitles(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Setup mocks - mockStore := &MockHostPreflightStore{} + mockStore := &preflightstore.MockStore{} tt.setupMocks(mockStore) // Create manager using builder pattern diff --git a/api/internal/managers/preflight/manager.go b/api/internal/managers/linux/preflight/manager.go similarity index 61% rename from api/internal/managers/preflight/manager.go rename to api/internal/managers/linux/preflight/manager.go index 7b350748af..27074d2cef 100644 --- a/api/internal/managers/preflight/manager.go +++ b/api/internal/managers/linux/preflight/manager.go @@ -2,14 +2,12 @@ package preflight import ( "context" - "sync" + "github.com/replicatedhq/embedded-cluster/api/internal/store/linux/preflight" + "github.com/replicatedhq/embedded-cluster/api/internal/utils" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" - "github.com/replicatedhq/embedded-cluster/api/pkg/utils" "github.com/replicatedhq/embedded-cluster/api/types" - ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/preflights" - "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" "github.com/sirupsen/logrus" @@ -17,44 +15,29 @@ import ( // HostPreflightManager provides methods for running host preflights type HostPreflightManager interface { - PrepareHostPreflights(ctx context.Context, opts PrepareHostPreflightOptions) (*troubleshootv1beta2.HostPreflightSpec, *ecv1beta1.ProxySpec, error) - RunHostPreflights(ctx context.Context, opts RunHostPreflightOptions) error - GetHostPreflightStatus(ctx context.Context) (*types.Status, error) + PrepareHostPreflights(ctx context.Context, rc runtimeconfig.RuntimeConfig, opts PrepareHostPreflightOptions) (*troubleshootv1beta2.HostPreflightSpec, error) + RunHostPreflights(ctx context.Context, rc runtimeconfig.RuntimeConfig, opts RunHostPreflightOptions) error + GetHostPreflightStatus(ctx context.Context) (types.Status, error) GetHostPreflightOutput(ctx context.Context) (*types.HostPreflightsOutput, error) GetHostPreflightTitles(ctx context.Context) ([]string, error) } type hostPreflightManager struct { - hostPreflightStore HostPreflightStore + hostPreflightStore preflight.Store runner preflights.PreflightsRunnerInterface netUtils utils.NetUtils - rc runtimeconfig.RuntimeConfig logger logrus.FieldLogger - metricsReporter metrics.ReporterInterface - mu sync.RWMutex } type HostPreflightManagerOption func(*hostPreflightManager) -func WithRuntimeConfig(rc runtimeconfig.RuntimeConfig) HostPreflightManagerOption { - return func(m *hostPreflightManager) { - m.rc = rc - } -} - func WithLogger(logger logrus.FieldLogger) HostPreflightManagerOption { return func(m *hostPreflightManager) { m.logger = logger } } -func WithMetricsReporter(metricsReporter metrics.ReporterInterface) HostPreflightManagerOption { - return func(m *hostPreflightManager) { - m.metricsReporter = metricsReporter - } -} - -func WithHostPreflightStore(hostPreflightStore HostPreflightStore) HostPreflightManagerOption { +func WithHostPreflightStore(hostPreflightStore preflight.Store) HostPreflightManagerOption { return func(m *hostPreflightManager) { m.hostPreflightStore = hostPreflightStore } @@ -80,16 +63,12 @@ func NewHostPreflightManager(opts ...HostPreflightManagerOption) HostPreflightMa opt(manager) } - if manager.rc == nil { - manager.rc = runtimeconfig.New(nil) - } - if manager.logger == nil { manager.logger = logger.NewDiscardLogger() } if manager.hostPreflightStore == nil { - manager.hostPreflightStore = NewMemoryStore(types.NewHostPreflights()) + manager.hostPreflightStore = preflight.NewMemoryStore() } if manager.runner == nil { diff --git a/api/internal/managers/preflight/manager_mock.go b/api/internal/managers/linux/preflight/manager_mock.go similarity index 73% rename from api/internal/managers/preflight/manager_mock.go rename to api/internal/managers/linux/preflight/manager_mock.go index 35cc408c9a..6d659ccfa3 100644 --- a/api/internal/managers/preflight/manager_mock.go +++ b/api/internal/managers/linux/preflight/manager_mock.go @@ -4,7 +4,7 @@ import ( "context" "github.com/replicatedhq/embedded-cluster/api/types" - ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" "github.com/stretchr/testify/mock" ) @@ -17,30 +17,27 @@ type MockHostPreflightManager struct { } // PrepareHostPreflights mocks the PrepareHostPreflights method -func (m *MockHostPreflightManager) PrepareHostPreflights(ctx context.Context, opts PrepareHostPreflightOptions) (*troubleshootv1beta2.HostPreflightSpec, *ecv1beta1.ProxySpec, error) { - args := m.Called(ctx, opts) +func (m *MockHostPreflightManager) PrepareHostPreflights(ctx context.Context, rc runtimeconfig.RuntimeConfig, opts PrepareHostPreflightOptions) (*troubleshootv1beta2.HostPreflightSpec, error) { + args := m.Called(ctx, rc, opts) if args.Get(0) == nil { - return nil, nil, args.Error(2) - } - if args.Get(1) == nil { - return args.Get(0).(*troubleshootv1beta2.HostPreflightSpec), nil, args.Error(2) + return nil, args.Error(1) } - return args.Get(0).(*troubleshootv1beta2.HostPreflightSpec), args.Get(1).(*ecv1beta1.ProxySpec), args.Error(2) + return args.Get(0).(*troubleshootv1beta2.HostPreflightSpec), args.Error(1) } // RunHostPreflights mocks the RunHostPreflights method -func (m *MockHostPreflightManager) RunHostPreflights(ctx context.Context, opts RunHostPreflightOptions) error { - args := m.Called(ctx, opts) +func (m *MockHostPreflightManager) RunHostPreflights(ctx context.Context, rc runtimeconfig.RuntimeConfig, opts RunHostPreflightOptions) error { + args := m.Called(ctx, rc, opts) return args.Error(0) } // GetHostPreflightStatus mocks the GetHostPreflightStatus method -func (m *MockHostPreflightManager) GetHostPreflightStatus(ctx context.Context) (*types.Status, error) { +func (m *MockHostPreflightManager) GetHostPreflightStatus(ctx context.Context) (types.Status, error) { args := m.Called(ctx) if args.Get(0) == nil { - return nil, args.Error(1) + return types.Status{}, args.Error(1) } - return args.Get(0).(*types.Status), args.Error(1) + return args.Get(0).(types.Status), args.Error(1) } // GetHostPreflightOutput mocks the GetHostPreflightOutput method diff --git a/api/internal/managers/preflight/store.go b/api/internal/managers/preflight/store.go deleted file mode 100644 index 7f10bd5e78..0000000000 --- a/api/internal/managers/preflight/store.go +++ /dev/null @@ -1,82 +0,0 @@ -package preflight - -import ( - "sync" - - "github.com/replicatedhq/embedded-cluster/api/types" -) - -type HostPreflightStore interface { - GetTitles() ([]string, error) - SetTitles(titles []string) error - GetOutput() (*types.HostPreflightsOutput, error) - SetOutput(output *types.HostPreflightsOutput) error - GetStatus() (*types.Status, error) - SetStatus(status *types.Status) error - IsRunning() bool -} - -var _ HostPreflightStore = &MemoryStore{} - -type MemoryStore struct { - mu sync.RWMutex - hostPreflight *types.HostPreflights -} - -func NewMemoryStore(hostPreflight *types.HostPreflights) *MemoryStore { - return &MemoryStore{ - hostPreflight: hostPreflight, - } -} - -func (s *MemoryStore) GetTitles() ([]string, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.hostPreflight.Titles, nil -} - -func (s *MemoryStore) SetTitles(titles []string) error { - s.mu.Lock() - defer s.mu.Unlock() - s.hostPreflight.Titles = titles - - return nil -} - -func (s *MemoryStore) GetOutput() (*types.HostPreflightsOutput, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.hostPreflight.Output, nil -} - -func (s *MemoryStore) SetOutput(output *types.HostPreflightsOutput) error { - s.mu.Lock() - defer s.mu.Unlock() - s.hostPreflight.Output = output - - return nil -} - -func (s *MemoryStore) GetStatus() (*types.Status, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.hostPreflight.Status, nil -} - -func (s *MemoryStore) SetStatus(status *types.Status) error { - s.mu.Lock() - defer s.mu.Unlock() - s.hostPreflight.Status = status - - return nil -} - -func (s *MemoryStore) IsRunning() bool { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.hostPreflight.Status.State == types.StateRunning -} diff --git a/api/internal/statemachine/event_handler.go b/api/internal/statemachine/event_handler.go new file mode 100644 index 0000000000..d616bfbea1 --- /dev/null +++ b/api/internal/statemachine/event_handler.go @@ -0,0 +1,78 @@ +package statemachine + +import ( + "context" + "fmt" + "runtime/debug" + "time" +) + +var ( + _ EventHandler = &eventHandler{} +) + +// EventHandler is an interface for handling state transition events in the state machine. +type EventHandler interface { + // TriggerHandler triggers the event handler for a state transition. + TriggerHandler(ctx context.Context, fromState, toState State) error +} + +// EventHandlerFunc is a function that handles state transition events. Used to report state changes. +type EventHandlerFunc func(ctx context.Context, fromState, toState State) + +// EventHandlerOption is a configurable state machine option. +type EventHandlerOption func(*eventHandler) + +// WithHandlerTimeout sets the timeout for the event handler to complete. +func WithHandlerTimeout(timeout time.Duration) EventHandlerOption { + return func(eh *eventHandler) { + eh.timeout = timeout + } +} + +// NewEventHandler creates a new event handler with the provided function and options. +func NewEventHandler(handler EventHandlerFunc, options ...EventHandlerOption) EventHandler { + eh := &eventHandler{ + handler: handler, + timeout: 5 * time.Second, // Default timeout + } + + for _, option := range options { + option(eh) + } + + return eh +} + +// eventHandler is a struct that implements the EventHandler interface. It contains a handler function that is called when a state transition occurs, and it supports a timeout for the handler to complete. +type eventHandler struct { + handler EventHandlerFunc + timeout time.Duration // Timeout for the handler to complete, default is 5 seconds +} + +// TriggerHandler triggers the event handler for a state transition. The trigger is blocking and will wait for the handler to complete or timeout. +func (eh *eventHandler) TriggerHandler(ctx context.Context, fromState, toState State) error { + ctx, cancel := context.WithTimeout(ctx, eh.timeout) + defer cancel() + done := make(chan error, 1) + + go func() { + defer func() { + if r := recover(); r != nil { + // Capture panic but don't affect the transition + err := fmt.Errorf("event handler panic from %s to %s: %v: %s\n", fromState, toState, r, debug.Stack()) + done <- err + } + close(done) + }() + eh.handler(ctx, fromState, toState) + }() + + select { + case err := <-done: + return err + case <-ctx.Done(): + err := fmt.Errorf("event handler for transition from %s to %s timed out after %s", fromState, toState, eh.timeout) + return err + } +} diff --git a/api/internal/statemachine/interface.go b/api/internal/statemachine/interface.go new file mode 100644 index 0000000000..93e749483f --- /dev/null +++ b/api/internal/statemachine/interface.go @@ -0,0 +1,34 @@ +package statemachine + +// State represents the possible states of the install process +type State string + +var ( + _ Interface = &stateMachine{} +) + +// Interface is the interface for the state machine +type Interface interface { + // CurrentState returns the current state + CurrentState() State + // IsFinalState checks if the current state is a final state + IsFinalState() bool + // ValidateTransition checks if a transition from the current state to a new state is valid + ValidateTransition(lock Lock, newState State) error + // Transition attempts to transition to a new state and returns an error if the transition is + // invalid. + Transition(lock Lock, nextState State) error + // AcquireLock acquires a lock on the state machine. + AcquireLock() (Lock, error) + // IsLockAcquired checks if a lock already exists on the state machine. + IsLockAcquired() bool + // RegisterEventHandler registers a blocking event handler for reporting events in the state machine. + RegisterEventHandler(targetState State, handler EventHandlerFunc, options ...EventHandlerOption) + // UnregisterEventHandler unregisters a blocking event handler for reporting events in the state machine. + UnregisterEventHandler(targetState State) +} + +type Lock interface { + // Release releases the lock. + Release() +} diff --git a/api/internal/statemachine/statemachine.go b/api/internal/statemachine/statemachine.go new file mode 100644 index 0000000000..312393975d --- /dev/null +++ b/api/internal/statemachine/statemachine.go @@ -0,0 +1,182 @@ +package statemachine + +import ( + "context" + "fmt" + "slices" + "sync" + + "github.com/replicatedhq/embedded-cluster/api/pkg/logger" + "github.com/sirupsen/logrus" +) + +// stateMachine manages the state transitions for the install process +type stateMachine struct { + currentState State + validStateTransitions map[State][]State + lock *lock + mu sync.RWMutex + eventHandlers map[State][]EventHandler + logger logrus.FieldLogger +} + +// StateMachineOption is a configurable state machine option. +type StateMachineOption func(*stateMachine) + +// New creates a new state machine starting in the given state with the given valid state +// transitions and options. +func New(currentState State, validStateTransitions map[State][]State, opts ...StateMachineOption) *stateMachine { + sm := &stateMachine{ + currentState: currentState, + validStateTransitions: validStateTransitions, + logger: logger.NewDiscardLogger(), + eventHandlers: make(map[State][]EventHandler), + } + + for _, opt := range opts { + opt(sm) + } + + return sm +} + +func WithLogger(logger logrus.FieldLogger) StateMachineOption { + return func(sm *stateMachine) { + sm.logger = logger + } +} + +func (sm *stateMachine) CurrentState() State { + sm.mu.RLock() + defer sm.mu.RUnlock() + + return sm.currentState +} + +func (sm *stateMachine) IsFinalState() bool { + sm.mu.RLock() + defer sm.mu.RUnlock() + + return len(sm.validStateTransitions[sm.currentState]) == 0 +} + +func (sm *stateMachine) AcquireLock() (Lock, error) { + sm.mu.Lock() + defer sm.mu.Unlock() + + if sm.lock != nil { + return nil, fmt.Errorf("lock already acquired") + } + + sm.lock = &lock{ + release: func() { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.lock = nil + }, + } + + return sm.lock, nil +} + +func (sm *stateMachine) IsLockAcquired() bool { + sm.mu.RLock() + defer sm.mu.RUnlock() + + return sm.lock != nil +} + +func (sm *stateMachine) ValidateTransition(lock Lock, nextState State) error { + sm.mu.RLock() + defer sm.mu.RUnlock() + + if sm.lock == nil { + return fmt.Errorf("lock not acquired") + } else if sm.lock != lock { + return fmt.Errorf("lock mismatch") + } + + if !sm.isValidTransition(sm.currentState, nextState) { + return fmt.Errorf("invalid transition from %s to %s", sm.currentState, nextState) + } + + return nil +} + +func (sm *stateMachine) Transition(lock Lock, nextState State) (finalError error) { + sm.mu.Lock() + defer func() { + if finalError != nil { + sm.mu.Unlock() + } + }() + + if sm.lock == nil { + return fmt.Errorf("lock not acquired") + } else if sm.lock != lock { + return fmt.Errorf("lock mismatch") + } + + if !sm.isValidTransition(sm.currentState, nextState) { + return fmt.Errorf("invalid transition from %s to %s", sm.currentState, nextState) + } + + fromState := sm.currentState + sm.currentState = nextState + + // Trigger event handlers after successful transition + handlers, exists := sm.eventHandlers[nextState] + safeHandlers := make([]EventHandler, len(handlers)) + copy(safeHandlers, handlers) // Copy to avoid holding the lock while calling handlers + + // We can release the lock here since the transition is successful and there will be no further operations to the state machine internal state + sm.mu.Unlock() + + if !exists || len(safeHandlers) == 0 { + return nil + } + + for _, handler := range safeHandlers { + err := handler.TriggerHandler(context.Background(), fromState, nextState) + if err != nil { + sm.logger.WithFields(logrus.Fields{"fromState": fromState, "toState": nextState}).Errorf("event handler error: %v", err) + } + } + + return nil +} + +func (sm *stateMachine) RegisterEventHandler(targetState State, handler EventHandlerFunc, options ...EventHandlerOption) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.eventHandlers[targetState] = append(sm.eventHandlers[targetState], NewEventHandler(handler, options...)) +} + +func (sm *stateMachine) UnregisterEventHandler(targetState State) { + sm.mu.Lock() + defer sm.mu.Unlock() + delete(sm.eventHandlers, targetState) +} + +func (sm *stateMachine) isValidTransition(currentState State, newState State) bool { + validTransitions, ok := sm.validStateTransitions[currentState] + if !ok { + return false + } + return slices.Contains(validTransitions, newState) +} + +type lock struct { + release func() + mu sync.Mutex +} + +func (l *lock) Release() { + l.mu.Lock() + defer l.mu.Unlock() + + if l.release != nil { + l.release() + l.release = nil + } +} diff --git a/api/internal/statemachine/statemachine_test.go b/api/internal/statemachine/statemachine_test.go new file mode 100644 index 0000000000..4365cafab5 --- /dev/null +++ b/api/internal/statemachine/statemachine_test.go @@ -0,0 +1,824 @@ +package statemachine + +import ( + "context" + "slices" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +const ( + // StateNew is the initial state of the install process + StateNew State = "New" + // StateInstallationConfigured is the state of the install process when the installation is configured + StateInstallationConfigured State = "InstallationConfigured" + // StatePreflightsRunning is the state of the install process when the preflights are running + StatePreflightsRunning State = "PreflightsRunning" + // StatePreflightsSucceeded is the state of the install process when the preflights have succeeded + StatePreflightsSucceeded State = "PreflightsSucceeded" + // StatePreflightsFailed is the state of the install process when the preflights have failed + StatePreflightsFailed State = "PreflightsFailed" + // StatePreflightsFailedBypassed is the state of the install process when the preflights have failed bypassed + StatePreflightsFailedBypassed State = "PreflightsFailedBypassed" + // StateInfrastructureInstalling is the state of the install process when the infrastructure is being installed + StateInfrastructureInstalling State = "InfrastructureInstalling" + // StateSucceeded is the final state of the install process when the install has succeeded + StateSucceeded State = "Succeeded" + // StateFailed is the final state of the install process when the install has failed + StateFailed State = "Failed" +) + +var validStateTransitions = map[State][]State{ + StateNew: {StateInstallationConfigured}, + StateInstallationConfigured: {StatePreflightsRunning}, + StatePreflightsRunning: {StatePreflightsSucceeded, StatePreflightsFailed}, + StatePreflightsSucceeded: {StateInfrastructureInstalling, StatePreflightsRunning, StateInstallationConfigured}, + StatePreflightsFailed: {StatePreflightsFailedBypassed, StatePreflightsRunning, StateInstallationConfigured}, + StatePreflightsFailedBypassed: {StateInfrastructureInstalling, StatePreflightsRunning, StateInstallationConfigured}, + StateInfrastructureInstalling: {StateSucceeded, StateFailed}, + StateSucceeded: {}, + StateFailed: {}, +} + +func TestLockAcquisitionAndRelease(t *testing.T) { + sm := New(StateNew, validStateTransitions) + assert.False(t, sm.IsLockAcquired()) + + // Test valid lock acquisition + lock, err := sm.AcquireLock() + assert.NoError(t, err) + assert.NotNil(t, lock) + assert.True(t, sm.IsLockAcquired()) + + // Test transition with lock + err = sm.Transition(lock, StateInstallationConfigured) + assert.NoError(t, err) + assert.Equal(t, StateInstallationConfigured, sm.CurrentState()) + + // Release lock + lock.Release() + assert.False(t, sm.IsLockAcquired()) + + // Test double lock acquisition + lock, err = sm.AcquireLock() + assert.NoError(t, err) + assert.NotNil(t, lock) + assert.True(t, sm.IsLockAcquired()) + + err = sm.Transition(lock, StatePreflightsRunning) + assert.NoError(t, err) + + // Release lock + lock.Release() + assert.Equal(t, StatePreflightsRunning, sm.CurrentState()) + assert.False(t, sm.IsLockAcquired()) +} + +func TestDoubleLockAcquisition(t *testing.T) { + sm := New(StateNew, validStateTransitions) + assert.False(t, sm.IsLockAcquired()) + + lock1, err := sm.AcquireLock() + assert.NoError(t, err) + assert.True(t, sm.IsLockAcquired()) + + // Try to acquire second lock while first is held + lock2, err := sm.AcquireLock() + assert.Error(t, err, "second lock acquisition should fail while first is held") + assert.Nil(t, lock2) + assert.Contains(t, err.Error(), "lock already acquired") + assert.True(t, sm.IsLockAcquired()) + + // Release first lock + lock1.Release() + assert.False(t, sm.IsLockAcquired()) + + // Now second lock should work + lock2, err = sm.AcquireLock() + assert.NoError(t, err) + assert.NotNil(t, lock2) + assert.True(t, sm.IsLockAcquired()) + + // Release second lock + lock2.Release() + assert.False(t, sm.IsLockAcquired()) +} + +func TestLockReleaseAfterTransition(t *testing.T) { + sm := New(StateNew, validStateTransitions) + assert.False(t, sm.IsLockAcquired()) + + lock, err := sm.AcquireLock() + assert.NoError(t, err) + assert.True(t, sm.IsLockAcquired()) + + err = sm.Transition(lock, StateInstallationConfigured) + assert.NoError(t, err) + assert.True(t, sm.IsLockAcquired()) + + // Release lock after transition + lock.Release() + assert.False(t, sm.IsLockAcquired()) + + // State should remain changed + assert.Equal(t, StateInstallationConfigured, sm.CurrentState()) +} + +func TestDoubleLockRelease(t *testing.T) { + sm := New(StateNew, validStateTransitions) + assert.False(t, sm.IsLockAcquired()) + + lock, err := sm.AcquireLock() + assert.NoError(t, err) + assert.True(t, sm.IsLockAcquired()) + + // Release lock + lock.Release() + assert.False(t, sm.IsLockAcquired()) + + // Acquire another lock + lock2, err := sm.AcquireLock() + assert.NoError(t, err) + assert.NotNil(t, lock2) + assert.True(t, sm.IsLockAcquired()) + + // Second release should not actually do anything + lock.Release() + assert.True(t, sm.IsLockAcquired()) + + // Should not be able to acquire lock after as the other lock is still held + nilLock, err := sm.AcquireLock() + assert.Error(t, err, "should not be able to acquire lock after as the other lock is still held") + assert.Nil(t, nilLock) + assert.True(t, sm.IsLockAcquired()) + + // Release the second lock + lock2.Release() + assert.False(t, sm.IsLockAcquired()) + + // Should be able to acquire lock after the other lock is released + lock3, err := sm.AcquireLock() + assert.NoError(t, err) + assert.NotNil(t, lock3) + assert.True(t, sm.IsLockAcquired()) + + lock3.Release() + assert.False(t, sm.IsLockAcquired()) +} + +func TestRaceConditionMultipleGoroutines(t *testing.T) { + sm := New(StateNew, validStateTransitions) + assert.False(t, sm.IsLockAcquired()) + + var wg sync.WaitGroup + successCount := 0 + var mu sync.Mutex + + // Start multiple goroutines trying to acquire lock simultaneously + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + + lock, err := sm.AcquireLock() + if err == nil && lock != nil { + err = sm.Transition(lock, StateInstallationConfigured) + if err == nil { + mu.Lock() + successCount++ + mu.Unlock() + + // Release the lock + lock.Release() + } else { + lock.Release() + } + } + }() + } + + wg.Wait() + + // Only one transition should succeed + assert.Equal(t, 1, successCount, "only one transition should succeed") + assert.Equal(t, StateInstallationConfigured, sm.CurrentState()) + // There should be no lock acquired at the end + assert.False(t, sm.IsLockAcquired()) +} + +func TestRaceConditionReadWrite(t *testing.T) { + sm := New(StateNew, validStateTransitions) + assert.False(t, sm.IsLockAcquired()) + + var wg sync.WaitGroup + + // Start a goroutine that continuously reads the current state + readDone := make(chan bool) + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 1000; i++ { + _ = sm.CurrentState() + _ = sm.IsFinalState() + } + readDone <- true + }() + + // Start a goroutine that performs transitions + wg.Add(1) + go func() { + defer wg.Done() + + // Wait for reads to start + <-readDone + + lock, err := sm.AcquireLock() + if err == nil && lock != nil { + err = sm.Transition(lock, StateInstallationConfigured) + if err == nil { + lock.Release() + } else { + lock.Release() + } + } + + lock, err = sm.AcquireLock() + if err == nil && lock != nil { + err = sm.Transition(lock, StatePreflightsRunning) + if err == nil { + lock.Release() + } else { + lock.Release() + } + } + }() + + wg.Wait() + + // Final state should be consistent + finalState := sm.CurrentState() + assert.True(t, finalState == StateInstallationConfigured || finalState == StatePreflightsRunning, + "final state should be one of the expected states") + // There should be no lock acquired at the end + assert.False(t, sm.IsLockAcquired()) +} + +func TestIsFinalState(t *testing.T) { + finalStates := []State{ + StateSucceeded, + StateFailed, + } + + for state := range validStateTransitions { + var isFinal bool + if slices.Contains(finalStates, state) { + isFinal = true + } + + sm := New(state, validStateTransitions) + + if isFinal { + assert.True(t, sm.IsFinalState(), "expected state %s to be final", state) + } else { + assert.False(t, sm.IsFinalState(), "expected state %s to not be final", state) + } + } +} + +func TestFinalStateTransitionBlocking(t *testing.T) { + finalStates := []State{StateSucceeded, StateFailed} + + for _, finalState := range finalStates { + t.Run(string(finalState), func(t *testing.T) { + sm := New(finalState, validStateTransitions) + + // Try to transition from final state + lock, err := sm.AcquireLock() + if err != nil { + t.Fatalf("failed to acquire lock: %v", err) + } + + err = sm.Transition(lock, StateNew) + assert.Error(t, err, "should not be able to transition from final state %s", finalState) + assert.Contains(t, err.Error(), "invalid transition") + + // Release the lock + lock.Release() + + // State should remain unchanged + assert.Equal(t, finalState, sm.CurrentState()) + }) + } +} + +func TestMultiStateTransitionWithLock(t *testing.T) { + sm := New(StateNew, validStateTransitions) + assert.False(t, sm.IsLockAcquired()) + + // Acquire lock and transition through multiple states + lock, err := sm.AcquireLock() + assert.NoError(t, err) + assert.NotNil(t, lock) + assert.True(t, sm.IsLockAcquired()) + + // Transition 1: New -> StateInstallationConfigured + err = sm.Transition(lock, StateInstallationConfigured) + assert.NoError(t, err) + assert.Equal(t, StateInstallationConfigured, sm.CurrentState()) + + // Transition 2: StateInstallationConfigured -> StatePreflightsRunning + err = sm.Transition(lock, StatePreflightsRunning) + assert.NoError(t, err) + assert.Equal(t, StatePreflightsRunning, sm.CurrentState()) + + // Transition 3: StatePreflightsRunning -> StatePreflightsSucceeded + err = sm.Transition(lock, StatePreflightsSucceeded) + assert.NoError(t, err) + assert.Equal(t, StatePreflightsSucceeded, sm.CurrentState()) + + // Transition 4: StatePreflightsSucceeded -> StateInfrastructureInstalling + err = sm.Transition(lock, StateInfrastructureInstalling) + assert.NoError(t, err) + assert.Equal(t, StateInfrastructureInstalling, sm.CurrentState()) + + assert.True(t, sm.IsLockAcquired()) + // Release the lock + lock.Release() + assert.False(t, sm.IsLockAcquired()) + + // State should be the final state in the transition chain + assert.Equal(t, StateInfrastructureInstalling, sm.CurrentState(), "state should be the final transitioned state after lock release") +} + +func TestInvalidTransition(t *testing.T) { + sm := New(StateNew, validStateTransitions) + assert.False(t, sm.IsLockAcquired()) + + lock, err := sm.AcquireLock() + assert.NoError(t, err) + assert.True(t, sm.IsLockAcquired()) + + // Try invalid transition + err = sm.Transition(lock, StateSucceeded) + assert.Error(t, err, "should not be able to transition directly from New to Succeeded") + assert.Contains(t, err.Error(), "invalid transition") + + // State should remain unchanged + assert.Equal(t, StateNew, sm.CurrentState()) + + assert.True(t, sm.IsLockAcquired()) + lock.Release() + assert.False(t, sm.IsLockAcquired()) +} + +func TestTransitionWithoutLock(t *testing.T) { + sm := New(StateNew, validStateTransitions) + + assert.False(t, sm.IsLockAcquired()) + err := sm.Transition(nil, StateInstallationConfigured) + assert.Error(t, err, "transition should be invalid") + assert.Contains(t, err.Error(), "lock not acquired") +} + +func TestValidateTransitionWithoutLock(t *testing.T) { + sm := New(StateNew, validStateTransitions) + + assert.False(t, sm.IsLockAcquired()) + err := sm.ValidateTransition(nil, StateInstallationConfigured) + assert.Error(t, err, "transition should be invalid") + assert.Contains(t, err.Error(), "lock not acquired") +} + +func TestTransitionWithNilLock(t *testing.T) { + sm := New(StateNew, validStateTransitions) + + lock, err := sm.AcquireLock() + assert.NoError(t, err) + + err = sm.Transition(nil, StateInstallationConfigured) + assert.Error(t, err, "transition should be invalid") + assert.Contains(t, err.Error(), "lock mismatch") + + lock.Release() +} + +func TestValidateTransitionWithNilLock(t *testing.T) { + sm := New(StateNew, validStateTransitions) + + lock, err := sm.AcquireLock() + assert.NoError(t, err) + + err = sm.ValidateTransition(nil, StateInstallationConfigured) + assert.Error(t, err, "transition should be invalid") + assert.Contains(t, err.Error(), "lock mismatch") + + lock.Release() +} + +func TestTransitionWithWrongLock(t *testing.T) { + sm := New(StateNew, validStateTransitions) + + lock, err := sm.AcquireLock() + assert.NoError(t, err) + lock.Release() + + lock2, err := sm.AcquireLock() + assert.NoError(t, err) + + err = sm.Transition(lock, StateInstallationConfigured) + assert.Error(t, err, "transition should be invalid") + assert.Contains(t, err.Error(), "lock mismatch") + + lock2.Release() +} + +func TestValidateTransitionWithWrongLock(t *testing.T) { + sm := New(StateNew, validStateTransitions) + + lock, err := sm.AcquireLock() + assert.NoError(t, err) + lock.Release() + + lock2, err := sm.AcquireLock() + assert.NoError(t, err) + + err = sm.ValidateTransition(lock, StateInstallationConfigured) + assert.Error(t, err, "transition should be invalid") + assert.Contains(t, err.Error(), "lock mismatch") + + lock2.Release() +} + +func TestValidateTransitionWithNonExistentState(t *testing.T) { + sm := New(StateNew, validStateTransitions) + lock, err := sm.AcquireLock() + assert.NoError(t, err) + + // Test with a state that doesn't exist in the transition map + nonExistentState := State("NonExistentState") + err = sm.ValidateTransition(lock, nonExistentState) + assert.Error(t, err, "transition to non-existent state should be invalid") + assert.Contains(t, err.Error(), "invalid transition") + assert.Contains(t, err.Error(), string(StateNew)) + assert.Contains(t, err.Error(), string(nonExistentState)) + + lock.Release() +} + +func TestValidateTransitionStateConsistency(t *testing.T) { + sm := New(StateNew, validStateTransitions) + lock, err := sm.AcquireLock() + assert.NoError(t, err) + + // Validate a transition + err = sm.ValidateTransition(lock, StateInstallationConfigured) + assert.NoError(t, err, "transition should be valid") + + // State should remain unchanged after validation + assert.Equal(t, StateNew, sm.CurrentState(), "state should not change after validation") + + // Actually perform the transition + err = sm.Transition(lock, StateInstallationConfigured) + assert.NoError(t, err, "transition should succeed") + assert.Equal(t, StateInstallationConfigured, sm.CurrentState(), "state should change after transition") + + lock.Release() +} + +func TestValidateTransitionEdgeCases(t *testing.T) { + // Test with empty transition map + emptyTransitions := make(map[State][]State) + sm := New(StateNew, emptyTransitions) + lock, err := sm.AcquireLock() + assert.NoError(t, err) + + // Any transition should be invalid with empty transition map + err = sm.ValidateTransition(lock, StateInstallationConfigured) + assert.Error(t, err, "transition should be invalid with empty transition map") + assert.Contains(t, err.Error(), "invalid transition") + + lock.Release() + + // Test with state that has no valid transitions (final state) + finalStateTransitions := map[State][]State{ + StateSucceeded: {}, + StateFailed: {}, + } + sm = New(StateSucceeded, finalStateTransitions) + lock, err = sm.AcquireLock() + assert.NoError(t, err) + + // Any transition from final state should be invalid + err = sm.ValidateTransition(lock, StateNew) + assert.Error(t, err, "transition from final state should be invalid") + assert.Contains(t, err.Error(), "invalid transition") + + lock.Release() +} + +func TestEventHandlerRegistrationAndTriggering(t *testing.T) { + sm := New(StateNew, validStateTransitions) + + // Create a mock handler + mockHandler := &MockEventHandler{} + mockHandler.On("Handle", mock.Anything, StateNew, StateInstallationConfigured).Return() + + // Register event handler + handler := func(ctx context.Context, from, to State) { + mockHandler.Handle(ctx, from, to) + } + + sm.RegisterEventHandler(StateInstallationConfigured, handler) + + // Perform transition + lock, err := sm.AcquireLock() + assert.NoError(t, err) + + err = sm.Transition(lock, StateInstallationConfigured) + assert.NoError(t, err) + + lock.Release() + + // Use assert.Eventually to verify handler was called with correct parameters + assert.Eventually(t, func() bool { + return mockHandler.AssertCalled(t, "Handle", mock.Anything, StateNew, StateInstallationConfigured) + }, time.Second, time.Millisecond*50) + + mockHandler.AssertExpectations(t) +} + +func TestEventHandlerMultipleHandlers(t *testing.T) { + sm := New(StateNew, validStateTransitions) + + // Create mock handlers + mockHandler1 := &MockEventHandler{} + mockHandler1.On("Handle", mock.Anything, StateNew, StateInstallationConfigured).Return() + + mockHandler2 := &MockEventHandler{} + mockHandler2.On("Handle", mock.Anything, StateNew, StateInstallationConfigured).Return() + + // Register multiple handlers for the same state + handler1 := func(ctx context.Context, from, to State) { + mockHandler1.Handle(ctx, from, to) + } + + handler2 := func(ctx context.Context, from, to State) { + mockHandler2.Handle(ctx, from, to) + } + + sm.RegisterEventHandler(StateInstallationConfigured, handler1) + sm.RegisterEventHandler(StateInstallationConfigured, handler2) + + // Perform transition + lock, err := sm.AcquireLock() + assert.NoError(t, err) + + err = sm.Transition(lock, StateInstallationConfigured) + assert.NoError(t, err) + + lock.Release() + + // Use assert.Eventually to verify handler was called with correct parameters + assert.Eventually(t, func() bool { + return mockHandler1.AssertCalled(t, "Handle", mock.Anything, StateNew, StateInstallationConfigured) + }, time.Second, time.Millisecond*50, "mockHandler1 was not called") + + assert.Eventually(t, func() bool { + return mockHandler2.AssertCalled(t, "Handle", mock.Anything, StateNew, StateInstallationConfigured) + }, time.Second, time.Millisecond*50, "mockHandler2 was not called") + + mockHandler1.AssertExpectations(t) + mockHandler2.AssertExpectations(t) +} + +func TestEventHandlerUnregistration(t *testing.T) { + sm := New(StateNew, validStateTransitions) + + mockHandler := &MockEventHandler{} + + handler := func(ctx context.Context, from, to State) { + mockHandler.Handle(ctx, from, to) + } + + sm.RegisterEventHandler(StateInstallationConfigured, handler) + + // Unregister handlers + sm.UnregisterEventHandler(StateInstallationConfigured) + + // Perform transition + lock, err := sm.AcquireLock() + assert.NoError(t, err) + + err = sm.Transition(lock, StateInstallationConfigured) + assert.NoError(t, err) + + lock.Release() + + // Use assert.Eventually to wait for the state to change + assert.Eventually(t, func() bool { + return sm.currentState == StateInstallationConfigured + }, time.Second, time.Millisecond*50, "failed to transition to StateInstallationConfigured") + // Verify that the handler was not called + mockHandler.AssertNotCalled(t, "Handle", mock.Anything, StateNew, StateInstallationConfigured) + mockHandler.AssertExpectations(t) +} + +func TestEventHandlerPanicRecovery(t *testing.T) { + sm := New(StateNew, validStateTransitions) + + mockPanicHandler := &MockEventHandler{} + mockPanicHandler.On("Handle", mock.Anything, StateNew, StateInstallationConfigured).Return() + + // Register panicking handler + panicHandler := func(ctx context.Context, from, to State) { + mockPanicHandler.Handle(ctx, from, to) + panic("test panic") + } + + mockNormalHandler := &MockEventHandler{} + mockNormalHandler.On("Handle", mock.Anything, StateNew, StateInstallationConfigured).Return() + + // Register normal handler + normalHandler := func(ctx context.Context, from, to State) { + mockNormalHandler.Handle(ctx, from, to) + } + + sm.RegisterEventHandler(StateInstallationConfigured, panicHandler) + sm.RegisterEventHandler(StateInstallationConfigured, normalHandler) + + // Perform transition + lock, err := sm.AcquireLock() + assert.NoError(t, err) + + err = sm.Transition(lock, StateInstallationConfigured) + assert.NoError(t, err) + + lock.Release() + + // Use assert.Eventually to verify handler was called with correct parameters + assert.Eventually(t, func() bool { + return mockPanicHandler.AssertCalled(t, "Handle", mock.Anything, StateNew, StateInstallationConfigured) + }, time.Second, time.Millisecond*50, "mockPanicHandler was not called") + + assert.Eventually(t, func() bool { + return mockNormalHandler.AssertCalled(t, "Handle", mock.Anything, StateNew, StateInstallationConfigured) + }, time.Second, time.Millisecond*50, "mockNormalHandler was not called") + + mockPanicHandler.AssertExpectations(t) + mockNormalHandler.AssertExpectations(t) + // Verify state machine is still in correct state + assert.Equal(t, StateInstallationConfigured, sm.CurrentState()) +} + +func TestEventHandlerContextTimeout(t *testing.T) { + sm := New(StateNew, validStateTransitions) + + mockHandler := &MockEventHandler{} + mockHandler.On("Handle", mock.Anything, StateNew, StateInstallationConfigured).Return() + + handler := func(ctx context.Context, from, to State) { + mockHandler.Handle(ctx, from, to) + } + + sm.RegisterEventHandler(StateInstallationConfigured, handler, WithHandlerTimeout(time.Millisecond)) + + // Perform transition + lock, err := sm.AcquireLock() + assert.NoError(t, err) + + err = sm.Transition(lock, StateInstallationConfigured) + assert.NoError(t, err) + + lock.Release() + + // Verify handler was called and context was cancelled + assert.Eventually(t, func() bool { + return mockHandler.AssertCalled(t, "Handle", mock.Anything, StateNew, StateInstallationConfigured) + }, time.Second, time.Millisecond*50, "mockHandler was not called") + + mockHandler.AssertExpectations(t) + // State machine correctly transitioned + assert.Equal(t, StateInstallationConfigured, sm.CurrentState()) +} + +func TestEventHandlerDifferentStates(t *testing.T) { + tests := []struct { + name string + registerState State + transitionToState State + shouldTrigger bool + }{ + { + name: "handler for target state should trigger", + registerState: StateInstallationConfigured, + transitionToState: StateInstallationConfigured, + shouldTrigger: true, + }, + { + name: "handler for different state should not trigger", + registerState: StatePreflightsRunning, + transitionToState: StateInstallationConfigured, + shouldTrigger: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sm := New(StateNew, validStateTransitions) + + mockHandler := &MockEventHandler{} + if tt.shouldTrigger { + mockHandler.On("Handle", mock.Anything, StateNew, tt.transitionToState).Return() + } + + handler := func(ctx context.Context, from, to State) { + mockHandler.Handle(ctx, from, to) + } + + sm.RegisterEventHandler(tt.registerState, handler) + + // Perform transition + lock, err := sm.AcquireLock() + assert.NoError(t, err) + + err = sm.Transition(lock, tt.transitionToState) + assert.NoError(t, err) + + lock.Release() + + if tt.shouldTrigger { + assert.Eventually(t, func() bool { + return mockHandler.AssertCalled(t, "Handle", mock.Anything, StateNew, tt.transitionToState) + }, time.Second, time.Millisecond*50, "mockHandler was not called") + mockHandler.AssertExpectations(t) + } else { + // Use assert.Eventually to wait for the state to change, then verify no calls + assert.Eventually(t, func() bool { + return sm.CurrentState() == tt.transitionToState + }, time.Second, time.Millisecond*50, "failed to transition to target state") + mockHandler.AssertNotCalled(t, "Handle", mock.Anything, StateNew, tt.transitionToState) + mockHandler.AssertExpectations(t) + } + }) + } +} + +func TestEventHandlerConcurrentRegistration(t *testing.T) { + sm := New(StateNew, validStateTransitions) + + numHandlers := 10 + mockHandlers := make([]*MockEventHandler, numHandlers) + var wg sync.WaitGroup + wg.Add(numHandlers) + + // Initialize mock handlers + for i := 0; i < numHandlers; i++ { + mockHandlers[i] = &MockEventHandler{} + mockHandlers[i].On("Handle", mock.Anything, StateNew, StateInstallationConfigured).Return() + } + + // Register handlers concurrently + for i := 0; i < numHandlers; i++ { + i := i // capture loop variable + go func() { + defer wg.Done() + handler := func(ctx context.Context, from, to State) { + mockHandlers[i].Handle(ctx, from, to) + } + sm.RegisterEventHandler(StateInstallationConfigured, handler) + }() + } + + wg.Wait() + + // Perform transition + lock, err := sm.AcquireLock() + assert.NoError(t, err) + + err = sm.Transition(lock, StateInstallationConfigured) + assert.NoError(t, err) + + lock.Release() + + // Verify all handlers were called using assert.Eventually + for i := 0; i < numHandlers; i++ { + i := i // capture loop variable + assert.Eventually(t, func() bool { + return mockHandlers[i].AssertCalled(t, "Handle", mock.Anything, StateNew, StateInstallationConfigured) + }, time.Second, time.Millisecond*50, "mockHandler %d was not called", i) + mockHandlers[i].AssertExpectations(t) + } +} + +// MockEventHandler is a mock for event handler testing +type MockEventHandler struct { + mock.Mock +} + +func (m *MockEventHandler) Handle(ctx context.Context, from, to State) { + m.Called(ctx, from, to) +} diff --git a/api/internal/store/infra/store.go b/api/internal/store/infra/store.go new file mode 100644 index 0000000000..4f70322d3f --- /dev/null +++ b/api/internal/store/infra/store.go @@ -0,0 +1,142 @@ +package infra + +import ( + "fmt" + "sync" + "time" + + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/tiendc/go-deepcopy" +) + +const maxLogSize = 100 * 1024 // 100KB total log size + +var _ Store = &memoryStore{} + +// Store provides methods for storing and retrieving infrastructure state +type Store interface { + Get() (types.Infra, error) + GetStatus() (types.Status, error) + SetStatus(status types.Status) error + SetStatusDesc(desc string) error + RegisterComponent(name string) error + SetComponentStatus(name string, status types.Status) error + AddLogs(logs string) error + GetLogs() (string, error) +} + +// memoryStore is an in-memory implementation of Store +type memoryStore struct { + infra types.Infra + mu sync.RWMutex +} + +type StoreOption func(*memoryStore) + +func WithInfra(infra types.Infra) StoreOption { + return func(s *memoryStore) { + s.infra = infra + } +} + +// NewMemoryStore creates a new memory store +func NewMemoryStore(opts ...StoreOption) Store { + s := &memoryStore{} + + for _, opt := range opts { + opt(s) + } + + return s +} + +func (s *memoryStore) Get() (types.Infra, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var infra types.Infra + if err := deepcopy.Copy(&infra, &s.infra); err != nil { + return types.Infra{}, err + } + + return infra, nil +} + +func (s *memoryStore) GetStatus() (types.Status, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var status types.Status + if err := deepcopy.Copy(&status, &s.infra.Status); err != nil { + return types.Status{}, err + } + + return status, nil +} + +func (s *memoryStore) SetStatus(status types.Status) error { + s.mu.Lock() + defer s.mu.Unlock() + s.infra.Status = status + return nil +} + +func (s *memoryStore) SetStatusDesc(desc string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.infra.Status.State == "" { + return fmt.Errorf("state not set") + } + + s.infra.Status.Description = desc + return nil +} + +func (s *memoryStore) RegisterComponent(name string) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.infra.Components = append(s.infra.Components, types.InfraComponent{ + Name: name, + Status: types.Status{ + State: types.StatePending, + Description: "", + LastUpdated: time.Now(), + }, + }) + + return nil +} + +func (s *memoryStore) SetComponentStatus(name string, status types.Status) error { + s.mu.Lock() + defer s.mu.Unlock() + + for i, component := range s.infra.Components { + if component.Name == name { + s.infra.Components[i].Status = status + return nil + } + } + + return fmt.Errorf("component %s not found", name) +} + +func (s *memoryStore) AddLogs(logs string) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.infra.Logs += logs + "\n" + if len(s.infra.Logs) > maxLogSize { + s.infra.Logs = "... (truncated) " + s.infra.Logs[len(s.infra.Logs)-maxLogSize:] + } + + return nil +} + +func (s *memoryStore) GetLogs() (string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.infra.Logs, nil +} diff --git a/api/internal/store/infra/store_mock.go b/api/internal/store/infra/store_mock.go new file mode 100644 index 0000000000..ac0717f5fd --- /dev/null +++ b/api/internal/store/infra/store_mock.go @@ -0,0 +1,59 @@ +package infra + +import ( + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/stretchr/testify/mock" +) + +var _ Store = (*MockStore)(nil) + +// MockStore is a mock implementation of Store +type MockStore struct { + mock.Mock +} + +func (m *MockStore) Get() (types.Infra, error) { + args := m.Called() + if args.Get(0) == nil { + return types.Infra{}, args.Error(1) + } + return args.Get(0).(types.Infra), args.Error(1) +} + +func (m *MockStore) GetStatus() (types.Status, error) { + args := m.Called() + if args.Get(0) == nil { + return types.Status{}, args.Error(1) + } + return args.Get(0).(types.Status), args.Error(1) +} + +func (m *MockStore) SetStatus(status types.Status) error { + args := m.Called(status) + return args.Error(0) +} + +func (m *MockStore) SetStatusDesc(desc string) error { + args := m.Called(desc) + return args.Error(0) +} + +func (m *MockStore) RegisterComponent(name string) error { + args := m.Called(name) + return args.Error(0) +} + +func (m *MockStore) SetComponentStatus(name string, status types.Status) error { + args := m.Called(name, status) + return args.Error(0) +} + +func (m *MockStore) AddLogs(logs string) error { + args := m.Called(logs) + return args.Error(0) +} + +func (m *MockStore) GetLogs() (string, error) { + args := m.Called() + return args.Get(0).(string), args.Error(1) +} diff --git a/api/internal/store/infra/store_test.go b/api/internal/store/infra/store_test.go new file mode 100644 index 0000000000..d4591da81c --- /dev/null +++ b/api/internal/store/infra/store_test.go @@ -0,0 +1,300 @@ +package infra + +import ( + "strings" + "sync" + "testing" + "time" + + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newMemoryStore() Store { + infra := types.Infra{ + Status: types.Status{ + State: types.StatePending, + }, + Components: []types.InfraComponent{}, + Logs: "", + } + return NewMemoryStore(WithInfra(infra)) +} + +func TestNewMemoryStore(t *testing.T) { + store := newMemoryStore() + + assert.NotNil(t, store) + infra, err := store.Get() + require.NoError(t, err) + assert.Equal(t, types.StatePending, infra.Status.State) +} + +func TestMemoryStore_GetAndSetStatus(t *testing.T) { + store := newMemoryStore() + + // Test initial status + status, err := store.GetStatus() + require.NoError(t, err) + assert.Equal(t, types.StatePending, status.State) + + // Test setting status + newStatus := types.Status{ + State: types.StateRunning, + Description: "Installing components", + } + err = store.SetStatus(newStatus) + require.NoError(t, err) + + // Test getting updated status + status, err = store.GetStatus() + require.NoError(t, err) + assert.Equal(t, types.StateRunning, status.State) + assert.Equal(t, "Installing components", status.Description) +} + +func TestMemoryStore_SetStatusDesc(t *testing.T) { + store := newMemoryStore() + + // Test setting status description + err := store.SetStatusDesc("New description") + require.NoError(t, err) + + // Verify the description was updated + status, err := store.GetStatus() + require.NoError(t, err) + assert.Equal(t, "New description", status.Description) + assert.Equal(t, types.StatePending, status.State) // State should remain unchanged +} + +func TestMemoryStore_RegisterComponent(t *testing.T) { + store := newMemoryStore() + + // Test registering a component + err := store.RegisterComponent("k0s") + require.NoError(t, err) + + // Verify component was added + infra, err := store.Get() + require.NoError(t, err) + assert.Len(t, infra.Components, 1) + assert.Equal(t, "k0s", infra.Components[0].Name) + assert.Equal(t, types.StatePending, infra.Components[0].Status.State) + + // Test registering another component + err = store.RegisterComponent("addons") + require.NoError(t, err) + + infra, err = store.Get() + require.NoError(t, err) + assert.Len(t, infra.Components, 2) +} + +func TestMemoryStore_SetComponentStatus(t *testing.T) { + store := newMemoryStore() + + // Register a component first + err := store.RegisterComponent("k0s") + require.NoError(t, err) + + // Test setting component status + now := time.Now() + componentStatus := types.Status{ + State: types.StateRunning, + Description: "Installing k0s", + LastUpdated: now, + } + err = store.SetComponentStatus("k0s", componentStatus) + require.NoError(t, err) + + // Verify the component status was updated + infra, err := store.Get() + require.NoError(t, err) + assert.Len(t, infra.Components, 1) + assert.Equal(t, types.StateRunning, infra.Components[0].Status.State) + assert.Equal(t, "Installing k0s", infra.Components[0].Status.Description) + assert.Equal(t, now, infra.Components[0].Status.LastUpdated) + + // Test setting status for non-existent component + err = store.SetComponentStatus("nonexistent", componentStatus) + assert.Error(t, err) + assert.Contains(t, err.Error(), "component nonexistent not found") +} + +func TestMemoryStore_AddLogs(t *testing.T) { + store := newMemoryStore() + + // Test adding logs + err := store.AddLogs("First log entry") + require.NoError(t, err) + + logs, err := store.GetLogs() + require.NoError(t, err) + assert.Equal(t, "First log entry\n", logs) + + // Test adding more logs + err = store.AddLogs("Second log entry") + require.NoError(t, err) + + logs, err = store.GetLogs() + require.NoError(t, err) + assert.Equal(t, "First log entry\nSecond log entry\n", logs) +} + +func TestMemoryStore_LogTruncation(t *testing.T) { + store := newMemoryStore() + + // Create a large log entry that exceeds maxLogSize + largeLog := strings.Repeat("a", maxLogSize+1000) + err := store.AddLogs(largeLog) + require.NoError(t, err) + + logs, err := store.GetLogs() + require.NoError(t, err) + + // Should be truncated and contain the truncation message + assert.True(t, len(logs) <= maxLogSize+50) // Allow some buffer for truncation message + assert.Contains(t, logs, "... (truncated)") +} + +func TestMemoryStore_GetLogs(t *testing.T) { + store := newMemoryStore() + + // Test getting logs when empty + logs, err := store.GetLogs() + require.NoError(t, err) + assert.Empty(t, logs) + + // Add some logs and test retrieval + err = store.AddLogs("Test log 1") + require.NoError(t, err) + err = store.AddLogs("Test log 2") + require.NoError(t, err) + + logs, err = store.GetLogs() + require.NoError(t, err) + assert.Equal(t, "Test log 1\nTest log 2\n", logs) +} + +func TestMemoryStore_Get(t *testing.T) { + store := newMemoryStore() + + // Test getting infra + infra, err := store.Get() + require.NoError(t, err) + assert.Empty(t, infra.Components) + assert.Empty(t, infra.Logs) + + // Register a component and add logs + err = store.RegisterComponent("k0s") + require.NoError(t, err) + err = store.AddLogs("Test log") + require.NoError(t, err) + + // Test getting updated infra + infra, err = store.Get() + require.NoError(t, err) + assert.Len(t, infra.Components, 1) + assert.Equal(t, "Test log\n", infra.Logs) +} + +// Test concurrent access to ensure thread safety +func TestMemoryStore_ConcurrentAccess(t *testing.T) { + store := newMemoryStore() + var wg sync.WaitGroup + + // Register a component first + err := store.RegisterComponent("k0s") + require.NoError(t, err) + + numGoroutines := 10 + numOperations := 50 + + // Concurrent status operations + wg.Add(numGoroutines * 2) + for i := 0; i < numGoroutines; i++ { + // Concurrent status writes + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + status := types.Status{ + State: types.StateRunning, + Description: "Concurrent test", + } + err := store.SetStatus(status) + assert.NoError(t, err) + } + }(i) + + // Concurrent status reads + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + _, err := store.GetStatus() + assert.NoError(t, err) + } + }(i) + } + + // Concurrent log operations + wg.Add(numGoroutines * 2) + for i := 0; i < numGoroutines; i++ { + // Concurrent log writes + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + err := store.AddLogs("Concurrent log") + assert.NoError(t, err) + } + }(i) + + // Concurrent log reads + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + _, err := store.GetLogs() + assert.NoError(t, err) + } + }(i) + } + + // Concurrent component operations + wg.Add(numGoroutines * 2) + for i := 0; i < numGoroutines; i++ { + // Concurrent component status writes + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + status := types.Status{ + State: types.StateRunning, + Description: "Concurrent component test", + } + err := store.SetComponentStatus("k0s", status) + assert.NoError(t, err) + } + }(i) + + // Concurrent infra reads + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + _, err := store.Get() + assert.NoError(t, err) + } + }(i) + } + + wg.Wait() +} + +func TestMemoryStore_StatusDescWithoutStatus(t *testing.T) { + store := &memoryStore{ + infra: types.Infra{}, + } + + // Test setting status description when status is nil + err := store.SetStatusDesc("Should fail") + assert.Error(t, err) + assert.Contains(t, err.Error(), "state not set") +} diff --git a/api/internal/store/kubernetes/installation/store.go b/api/internal/store/kubernetes/installation/store.go new file mode 100644 index 0000000000..f6e11ceb25 --- /dev/null +++ b/api/internal/store/kubernetes/installation/store.go @@ -0,0 +1,80 @@ +package installation + +import ( + "sync" + + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/tiendc/go-deepcopy" +) + +var _ Store = &memoryStore{} + +type Store interface { + GetConfig() (types.KubernetesInstallationConfig, error) + SetConfig(cfg types.KubernetesInstallationConfig) error + GetStatus() (types.Status, error) + SetStatus(status types.Status) error +} + +type memoryStore struct { + mu sync.RWMutex + installation types.KubernetesInstallation +} + +type StoreOption func(*memoryStore) + +func WithInstallation(installation types.KubernetesInstallation) StoreOption { + return func(s *memoryStore) { + s.installation = installation + } +} + +func NewMemoryStore(opts ...StoreOption) *memoryStore { + s := &memoryStore{} + + for _, opt := range opts { + opt(s) + } + + return s +} + +func (s *memoryStore) GetConfig() (types.KubernetesInstallationConfig, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var config types.KubernetesInstallationConfig + if err := deepcopy.Copy(&config, &s.installation.Config); err != nil { + return types.KubernetesInstallationConfig{}, err + } + + return config, nil +} + +func (s *memoryStore) SetConfig(cfg types.KubernetesInstallationConfig) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.installation.Config = cfg + return nil +} + +func (s *memoryStore) GetStatus() (types.Status, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var status types.Status + if err := deepcopy.Copy(&status, &s.installation.Status); err != nil { + return types.Status{}, err + } + + return status, nil +} + +func (s *memoryStore) SetStatus(status types.Status) error { + s.mu.Lock() + defer s.mu.Unlock() + s.installation.Status = status + + return nil +} diff --git a/api/internal/store/kubernetes/installation/store_mock.go b/api/internal/store/kubernetes/installation/store_mock.go new file mode 100644 index 0000000000..e4eeead5b4 --- /dev/null +++ b/api/internal/store/kubernetes/installation/store_mock.go @@ -0,0 +1,43 @@ +package installation + +import ( + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/stretchr/testify/mock" +) + +var _ Store = (*MockStore)(nil) + +// MockStore is a mock implementation of the InstallationStore interface +type MockStore struct { + mock.Mock +} + +// GetConfig mocks the GetConfig method +func (m *MockStore) GetConfig() (types.KubernetesInstallationConfig, error) { + args := m.Called() + if args.Get(0) == nil { + return types.KubernetesInstallationConfig{}, args.Error(1) + } + return args.Get(0).(types.KubernetesInstallationConfig), args.Error(1) +} + +// SetConfig mocks the SetConfig method +func (m *MockStore) SetConfig(cfg types.KubernetesInstallationConfig) error { + args := m.Called(cfg) + return args.Error(0) +} + +// GetStatus mocks the GetStatus method +func (m *MockStore) GetStatus() (types.Status, error) { + args := m.Called() + if args.Get(0) == nil { + return types.Status{}, args.Error(1) + } + return args.Get(0).(types.Status), args.Error(1) +} + +// SetStatus mocks the SetStatus method +func (m *MockStore) SetStatus(status types.Status) error { + args := m.Called(status) + return args.Error(0) +} diff --git a/api/internal/store/kubernetes/installation/store_test.go b/api/internal/store/kubernetes/installation/store_test.go new file mode 100644 index 0000000000..5e28977dbc --- /dev/null +++ b/api/internal/store/kubernetes/installation/store_test.go @@ -0,0 +1,159 @@ +package installation + +import ( + "sync" + "testing" + + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewMemoryStore(t *testing.T) { + inst := types.KubernetesInstallation{} + store := NewMemoryStore(WithInstallation(inst)) + + assert.NotNil(t, store) + assert.Equal(t, inst, store.installation) +} + +func TestMemoryStore_GetConfig(t *testing.T) { + inst := types.KubernetesInstallation{ + Config: types.KubernetesInstallationConfig{ + AdminConsolePort: 8080, + }, + } + store := NewMemoryStore(WithInstallation(inst)) + + config, err := store.GetConfig() + + require.NoError(t, err) + assert.Equal(t, types.KubernetesInstallationConfig{ + AdminConsolePort: 8080, + }, config) +} + +func TestMemoryStore_SetConfig(t *testing.T) { + inst := types.KubernetesInstallation{ + Config: types.KubernetesInstallationConfig{ + AdminConsolePort: 1000, + }, + } + store := NewMemoryStore(WithInstallation(inst)) + expectedConfig := types.KubernetesInstallationConfig{ + AdminConsolePort: 8080, + } + + err := store.SetConfig(expectedConfig) + + require.NoError(t, err) + + // Verify the config was stored + actualConfig, err := store.GetConfig() + require.NoError(t, err) + assert.Equal(t, expectedConfig, actualConfig) +} + +func TestMemoryStore_GetStatus(t *testing.T) { + inst := types.KubernetesInstallation{ + Status: types.Status{ + State: "failed", + Description: "Failure", + }, + } + store := NewMemoryStore(WithInstallation(inst)) + + status, err := store.GetStatus() + + require.NoError(t, err) + assert.Equal(t, types.Status{ + State: "failed", + Description: "Failure", + }, status) +} +func TestMemoryStore_SetStatus(t *testing.T) { + inst := types.KubernetesInstallation{ + Status: types.Status{ + State: "failed", + Description: "Failure", + }, + } + store := NewMemoryStore(WithInstallation(inst)) + expectedStatus := types.Status{ + State: "running", + Description: "Running", + } + + err := store.SetStatus(expectedStatus) + + require.NoError(t, err) + + // Verify the status was stored + actualStatus, err := store.GetStatus() + require.NoError(t, err) + assert.Equal(t, expectedStatus, actualStatus) +} + +// Useful to test concurrent access with -race flag +func TestMemoryStore_ConcurrentAccess(t *testing.T) { + inst := types.KubernetesInstallation{} + store := NewMemoryStore(WithInstallation(inst)) + var wg sync.WaitGroup + + // Test concurrent reads and writes + numGoroutines := 10 + numOperations := 50 + + // Concurrent config operations + wg.Add(numGoroutines * 2) + for i := 0; i < numGoroutines; i++ { + // Concurrent writes + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + config := types.KubernetesInstallationConfig{ + AdminConsolePort: 8080, + } + err := store.SetConfig(config) + assert.NoError(t, err) + } + }(i) + + // Concurrent reads + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + _, err := store.GetConfig() + assert.NoError(t, err) + } + }(i) + } + + // Concurrent status operations + wg.Add(numGoroutines * 2) + for i := 0; i < numGoroutines; i++ { + // Concurrent writes + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + status := types.Status{ + State: "pending", + Description: "Pending", + } + err := store.SetStatus(status) + assert.NoError(t, err) + } + }(i) + + // Concurrent reads + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + _, err := store.GetStatus() + assert.NoError(t, err) + } + }(i) + } + + wg.Wait() +} diff --git a/api/internal/store/linux/installation/store.go b/api/internal/store/linux/installation/store.go new file mode 100644 index 0000000000..fbd23c16a1 --- /dev/null +++ b/api/internal/store/linux/installation/store.go @@ -0,0 +1,80 @@ +package installation + +import ( + "sync" + + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/tiendc/go-deepcopy" +) + +var _ Store = &memoryStore{} + +type Store interface { + GetConfig() (types.LinuxInstallationConfig, error) + SetConfig(cfg types.LinuxInstallationConfig) error + GetStatus() (types.Status, error) + SetStatus(status types.Status) error +} + +type memoryStore struct { + mu sync.RWMutex + installation types.LinuxInstallation +} + +type StoreOption func(*memoryStore) + +func WithInstallation(installation types.LinuxInstallation) StoreOption { + return func(s *memoryStore) { + s.installation = installation + } +} + +func NewMemoryStore(opts ...StoreOption) *memoryStore { + s := &memoryStore{} + + for _, opt := range opts { + opt(s) + } + + return s +} + +func (s *memoryStore) GetConfig() (types.LinuxInstallationConfig, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var config types.LinuxInstallationConfig + if err := deepcopy.Copy(&config, &s.installation.Config); err != nil { + return types.LinuxInstallationConfig{}, err + } + + return config, nil +} + +func (s *memoryStore) SetConfig(cfg types.LinuxInstallationConfig) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.installation.Config = cfg + return nil +} + +func (s *memoryStore) GetStatus() (types.Status, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var status types.Status + if err := deepcopy.Copy(&status, &s.installation.Status); err != nil { + return types.Status{}, err + } + + return status, nil +} + +func (s *memoryStore) SetStatus(status types.Status) error { + s.mu.Lock() + defer s.mu.Unlock() + s.installation.Status = status + + return nil +} diff --git a/api/internal/store/linux/installation/store_mock.go b/api/internal/store/linux/installation/store_mock.go new file mode 100644 index 0000000000..884ae5c891 --- /dev/null +++ b/api/internal/store/linux/installation/store_mock.go @@ -0,0 +1,43 @@ +package installation + +import ( + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/stretchr/testify/mock" +) + +var _ Store = (*MockStore)(nil) + +// MockStore is a mock implementation of the InstallationStore interface +type MockStore struct { + mock.Mock +} + +// GetConfig mocks the GetConfig method +func (m *MockStore) GetConfig() (types.LinuxInstallationConfig, error) { + args := m.Called() + if args.Get(0) == nil { + return types.LinuxInstallationConfig{}, args.Error(1) + } + return args.Get(0).(types.LinuxInstallationConfig), args.Error(1) +} + +// SetConfig mocks the SetConfig method +func (m *MockStore) SetConfig(cfg types.LinuxInstallationConfig) error { + args := m.Called(cfg) + return args.Error(0) +} + +// GetStatus mocks the GetStatus method +func (m *MockStore) GetStatus() (types.Status, error) { + args := m.Called() + if args.Get(0) == nil { + return types.Status{}, args.Error(1) + } + return args.Get(0).(types.Status), args.Error(1) +} + +// SetStatus mocks the SetStatus method +func (m *MockStore) SetStatus(status types.Status) error { + args := m.Called(status) + return args.Error(0) +} diff --git a/api/internal/managers/installation/store_test.go b/api/internal/store/linux/installation/store_test.go similarity index 75% rename from api/internal/managers/installation/store_test.go rename to api/internal/store/linux/installation/store_test.go index 3eca603d04..5f7ce3bd98 100644 --- a/api/internal/managers/installation/store_test.go +++ b/api/internal/store/linux/installation/store_test.go @@ -10,42 +10,40 @@ import ( ) func TestNewMemoryStore(t *testing.T) { - inst := types.NewInstallation() - store := NewMemoryStore(inst) + inst := types.LinuxInstallation{} + store := NewMemoryStore(WithInstallation(inst)) assert.NotNil(t, store) - assert.NotNil(t, store.installation) assert.Equal(t, inst, store.installation) } func TestMemoryStore_GetConfig(t *testing.T) { - inst := &types.Installation{ - Config: &types.InstallationConfig{ + inst := types.LinuxInstallation{ + Config: types.LinuxInstallationConfig{ AdminConsolePort: 8080, DataDirectory: "/some/dir", }, } - store := NewMemoryStore(inst) + store := NewMemoryStore(WithInstallation(inst)) config, err := store.GetConfig() require.NoError(t, err) - assert.NotNil(t, config) - assert.Equal(t, &types.InstallationConfig{ + assert.Equal(t, types.LinuxInstallationConfig{ AdminConsolePort: 8080, DataDirectory: "/some/dir", }, config) } func TestMemoryStore_SetConfig(t *testing.T) { - inst := &types.Installation{ - Config: &types.InstallationConfig{ + inst := types.LinuxInstallation{ + Config: types.LinuxInstallationConfig{ AdminConsolePort: 1000, DataDirectory: "/a/different/dir", }, } - store := NewMemoryStore(inst) - expectedConfig := types.InstallationConfig{ + store := NewMemoryStore(WithInstallation(inst)) + expectedConfig := types.LinuxInstallationConfig{ AdminConsolePort: 8080, DataDirectory: "/some/dir", } @@ -57,35 +55,34 @@ func TestMemoryStore_SetConfig(t *testing.T) { // Verify the config was stored actualConfig, err := store.GetConfig() require.NoError(t, err) - assert.Equal(t, &expectedConfig, actualConfig) + assert.Equal(t, expectedConfig, actualConfig) } func TestMemoryStore_GetStatus(t *testing.T) { - inst := &types.Installation{ - Status: &types.Status{ + inst := types.LinuxInstallation{ + Status: types.Status{ State: "failed", Description: "Failure", }, } - store := NewMemoryStore(inst) + store := NewMemoryStore(WithInstallation(inst)) status, err := store.GetStatus() require.NoError(t, err) - assert.NotNil(t, status) - assert.Equal(t, &types.Status{ + assert.Equal(t, types.Status{ State: "failed", Description: "Failure", }, status) } func TestMemoryStore_SetStatus(t *testing.T) { - inst := &types.Installation{ - Status: &types.Status{ + inst := types.LinuxInstallation{ + Status: types.Status{ State: "failed", Description: "Failure", }, } - store := NewMemoryStore(inst) + store := NewMemoryStore(WithInstallation(inst)) expectedStatus := types.Status{ State: "running", Description: "Running", @@ -98,13 +95,13 @@ func TestMemoryStore_SetStatus(t *testing.T) { // Verify the status was stored actualStatus, err := store.GetStatus() require.NoError(t, err) - assert.Equal(t, &expectedStatus, actualStatus) + assert.Equal(t, expectedStatus, actualStatus) } // Useful to test concurrent access with -race flag func TestMemoryStore_ConcurrentAccess(t *testing.T) { - inst := types.NewInstallation() - store := NewMemoryStore(inst) + inst := types.LinuxInstallation{} + store := NewMemoryStore(WithInstallation(inst)) var wg sync.WaitGroup // Test concurrent reads and writes @@ -118,7 +115,7 @@ func TestMemoryStore_ConcurrentAccess(t *testing.T) { go func(id int) { defer wg.Done() for j := 0; j < numOperations; j++ { - config := types.InstallationConfig{ + config := types.LinuxInstallationConfig{ LocalArtifactMirrorPort: 8080, DataDirectory: "/some/other/dir", } diff --git a/api/internal/store/linux/preflight/store.go b/api/internal/store/linux/preflight/store.go new file mode 100644 index 0000000000..16b246773e --- /dev/null +++ b/api/internal/store/linux/preflight/store.go @@ -0,0 +1,106 @@ +package preflight + +import ( + "sync" + + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/tiendc/go-deepcopy" +) + +var _ Store = &memoryStore{} + +type Store interface { + GetTitles() ([]string, error) + SetTitles(titles []string) error + GetOutput() (*types.HostPreflightsOutput, error) + SetOutput(output *types.HostPreflightsOutput) error + GetStatus() (types.Status, error) + SetStatus(status types.Status) error +} + +type memoryStore struct { + mu sync.RWMutex + hostPreflight types.HostPreflights +} + +type StoreOption func(*memoryStore) + +func WithHostPreflight(hostPreflight types.HostPreflights) StoreOption { + return func(s *memoryStore) { + s.hostPreflight = hostPreflight + } +} + +func NewMemoryStore(opts ...StoreOption) *memoryStore { + s := &memoryStore{} + + for _, opt := range opts { + opt(s) + } + + return s +} + +func (s *memoryStore) GetTitles() ([]string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var titles []string + if err := deepcopy.Copy(&titles, &s.hostPreflight.Titles); err != nil { + return nil, err + } + + return titles, nil +} + +func (s *memoryStore) SetTitles(titles []string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.hostPreflight.Titles = titles + + return nil +} + +func (s *memoryStore) GetOutput() (*types.HostPreflightsOutput, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.hostPreflight.Output == nil { + return nil, nil + } + + var output *types.HostPreflightsOutput + if err := deepcopy.Copy(&output, &s.hostPreflight.Output); err != nil { + return nil, err + } + + return output, nil +} + +func (s *memoryStore) SetOutput(output *types.HostPreflightsOutput) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.hostPreflight.Output = output + return nil +} + +func (s *memoryStore) GetStatus() (types.Status, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var status types.Status + if err := deepcopy.Copy(&status, &s.hostPreflight.Status); err != nil { + return types.Status{}, err + } + + return status, nil +} + +func (s *memoryStore) SetStatus(status types.Status) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.hostPreflight.Status = status + return nil +} diff --git a/api/internal/managers/preflight/store_mock.go b/api/internal/store/linux/preflight/store_mock.go similarity index 50% rename from api/internal/managers/preflight/store_mock.go rename to api/internal/store/linux/preflight/store_mock.go index fc4ec22ac2..2790e89007 100644 --- a/api/internal/managers/preflight/store_mock.go +++ b/api/internal/store/linux/preflight/store_mock.go @@ -5,15 +5,15 @@ import ( "github.com/stretchr/testify/mock" ) -var _ HostPreflightStore = (*MockHostPreflightStore)(nil) +var _ Store = (*MockStore)(nil) -// MockHostPreflightStore is a mock implementation of the HostPreflightStore interface -type MockHostPreflightStore struct { +// MockStore is a mock implementation of the Store interface +type MockStore struct { mock.Mock } // GetTitles mocks the GetTitles method -func (m *MockHostPreflightStore) GetTitles() ([]string, error) { +func (m *MockStore) GetTitles() ([]string, error) { args := m.Called() if args.Get(0) == nil { return nil, args.Error(1) @@ -22,13 +22,13 @@ func (m *MockHostPreflightStore) GetTitles() ([]string, error) { } // SetTitles mocks the SetTitles method -func (m *MockHostPreflightStore) SetTitles(titles []string) error { +func (m *MockStore) SetTitles(titles []string) error { args := m.Called(titles) return args.Error(0) } // GetOutput mocks the GetOutput method -func (m *MockHostPreflightStore) GetOutput() (*types.HostPreflightsOutput, error) { +func (m *MockStore) GetOutput() (*types.HostPreflightsOutput, error) { args := m.Called() if args.Get(0) == nil { return nil, args.Error(1) @@ -37,28 +37,22 @@ func (m *MockHostPreflightStore) GetOutput() (*types.HostPreflightsOutput, error } // SetOutput mocks the SetOutput method -func (m *MockHostPreflightStore) SetOutput(output *types.HostPreflightsOutput) error { +func (m *MockStore) SetOutput(output *types.HostPreflightsOutput) error { args := m.Called(output) return args.Error(0) } // GetStatus mocks the GetStatus method -func (m *MockHostPreflightStore) GetStatus() (*types.Status, error) { +func (m *MockStore) GetStatus() (types.Status, error) { args := m.Called() if args.Get(0) == nil { - return nil, args.Error(1) + return types.Status{}, args.Error(1) } - return args.Get(0).(*types.Status), args.Error(1) + return args.Get(0).(types.Status), args.Error(1) } // SetStatus mocks the SetStatus method -func (m *MockHostPreflightStore) SetStatus(status *types.Status) error { +func (m *MockStore) SetStatus(status types.Status) error { args := m.Called(status) return args.Error(0) } - -// IsRunning mocks the IsRunning method -func (m *MockHostPreflightStore) IsRunning() bool { - args := m.Called() - return args.Bool(0) -} diff --git a/api/internal/managers/preflight/store_test.go b/api/internal/store/linux/preflight/store_test.go similarity index 65% rename from api/internal/managers/preflight/store_test.go rename to api/internal/store/linux/preflight/store_test.go index f3f37e5f99..d8d6f4c38c 100644 --- a/api/internal/managers/preflight/store_test.go +++ b/api/internal/store/linux/preflight/store_test.go @@ -11,19 +11,18 @@ import ( ) func TestNewMemoryStore(t *testing.T) { - hostPreflight := types.NewHostPreflights() - store := NewMemoryStore(hostPreflight) + hostPreflight := types.HostPreflights{} + store := NewMemoryStore(WithHostPreflight(hostPreflight)) assert.NotNil(t, store) - assert.NotNil(t, store.hostPreflight) assert.Equal(t, hostPreflight, store.hostPreflight) } func TestMemoryStore_GetTitles(t *testing.T) { - hostPreflight := &types.HostPreflights{ + hostPreflight := types.HostPreflights{ Titles: []string{"Memory Check", "Disk Space Check", "Network Check"}, } - store := NewMemoryStore(hostPreflight) + store := NewMemoryStore(WithHostPreflight(hostPreflight)) titles, err := store.GetTitles() @@ -33,10 +32,10 @@ func TestMemoryStore_GetTitles(t *testing.T) { } func TestMemoryStore_GetTitles_Empty(t *testing.T) { - hostPreflight := &types.HostPreflights{ + hostPreflight := types.HostPreflights{ Titles: []string{}, } - store := NewMemoryStore(hostPreflight) + store := NewMemoryStore(WithHostPreflight(hostPreflight)) titles, err := store.GetTitles() @@ -46,10 +45,10 @@ func TestMemoryStore_GetTitles_Empty(t *testing.T) { } func TestMemoryStore_SetTitles(t *testing.T) { - hostPreflight := &types.HostPreflights{ + hostPreflight := types.HostPreflights{ Titles: []string{"Old Title"}, } - store := NewMemoryStore(hostPreflight) + store := NewMemoryStore(WithHostPreflight(hostPreflight)) expectedTitles := []string{"CPU Check", "RAM Check", "Storage Check"} err := store.SetTitles(expectedTitles) @@ -64,10 +63,10 @@ func TestMemoryStore_SetTitles(t *testing.T) { func TestMemoryStore_GetOutput(t *testing.T) { output := &types.HostPreflightsOutput{} - hostPreflight := &types.HostPreflights{ + hostPreflight := types.HostPreflights{ Output: output, } - store := NewMemoryStore(hostPreflight) + store := NewMemoryStore(WithHostPreflight(hostPreflight)) result, err := store.GetOutput() @@ -76,10 +75,10 @@ func TestMemoryStore_GetOutput(t *testing.T) { } func TestMemoryStore_GetOutput_Nil(t *testing.T) { - hostPreflight := &types.HostPreflights{ + hostPreflight := types.HostPreflights{ Output: nil, } - store := NewMemoryStore(hostPreflight) + store := NewMemoryStore(WithHostPreflight(hostPreflight)) result, err := store.GetOutput() @@ -88,10 +87,10 @@ func TestMemoryStore_GetOutput_Nil(t *testing.T) { } func TestMemoryStore_SetOutput(t *testing.T) { - hostPreflight := &types.HostPreflights{ + hostPreflight := types.HostPreflights{ Output: nil, } - store := NewMemoryStore(hostPreflight) + store := NewMemoryStore(WithHostPreflight(hostPreflight)) expectedOutput := &types.HostPreflightsOutput{} err := store.SetOutput(expectedOutput) @@ -105,10 +104,10 @@ func TestMemoryStore_SetOutput(t *testing.T) { } func TestMemoryStore_SetOutput_Nil(t *testing.T) { - hostPreflight := &types.HostPreflights{ + hostPreflight := types.HostPreflights{ Output: &types.HostPreflightsOutput{}, } - store := NewMemoryStore(hostPreflight) + store := NewMemoryStore(WithHostPreflight(hostPreflight)) err := store.SetOutput(nil) @@ -121,32 +120,31 @@ func TestMemoryStore_SetOutput_Nil(t *testing.T) { } func TestMemoryStore_GetStatus(t *testing.T) { - status := &types.Status{ + status := types.Status{ State: types.StateRunning, Description: "Running host preflights", LastUpdated: time.Now(), } - hostPreflight := &types.HostPreflights{ + hostPreflight := types.HostPreflights{ Status: status, } - store := NewMemoryStore(hostPreflight) + store := NewMemoryStore(WithHostPreflight(hostPreflight)) result, err := store.GetStatus() require.NoError(t, err) - assert.NotNil(t, result) assert.Equal(t, status, result) } func TestMemoryStore_SetStatus(t *testing.T) { - hostPreflight := &types.HostPreflights{ - Status: &types.Status{ + hostPreflight := types.HostPreflights{ + Status: types.Status{ State: types.StateFailed, Description: "Failed", }, } - store := NewMemoryStore(hostPreflight) - expectedStatus := &types.Status{ + store := NewMemoryStore(WithHostPreflight(hostPreflight)) + expectedStatus := types.Status{ State: types.StateSucceeded, Description: "Host preflights passed", LastUpdated: time.Now(), @@ -162,64 +160,10 @@ func TestMemoryStore_SetStatus(t *testing.T) { assert.Equal(t, expectedStatus, actualStatus) } -func TestMemoryStore_IsRunning(t *testing.T) { - tests := []struct { - name string - status *types.Status - expectedBool bool - }{ - { - name: "is running when state is running", - status: &types.Status{ - State: types.StateRunning, - Description: "Running host preflights", - }, - expectedBool: true, - }, - { - name: "is not running when state is succeeded", - status: &types.Status{ - State: types.StateSucceeded, - Description: "Host preflights passed", - }, - expectedBool: false, - }, - { - name: "is not running when state is failed", - status: &types.Status{ - State: types.StateFailed, - Description: "Host preflights failed", - }, - expectedBool: false, - }, - { - name: "is not running when state is pending", - status: &types.Status{ - State: types.StatePending, - Description: "Pending host preflights", - }, - expectedBool: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - hostPreflight := &types.HostPreflights{ - Status: tt.status, - } - store := NewMemoryStore(hostPreflight) - - result := store.IsRunning() - - assert.Equal(t, tt.expectedBool, result) - }) - } -} - // Useful to test concurrent access with -race flag func TestMemoryStore_ConcurrentAccess(t *testing.T) { - hostPreflight := types.NewHostPreflights() - store := NewMemoryStore(hostPreflight) + hostPreflight := types.HostPreflights{} + store := NewMemoryStore(WithHostPreflight(hostPreflight)) var wg sync.WaitGroup // Test concurrent reads and writes @@ -273,13 +217,13 @@ func TestMemoryStore_ConcurrentAccess(t *testing.T) { } // Concurrent status operations - wg.Add(numGoroutines * 3) + wg.Add(numGoroutines * 2) for i := 0; i < numGoroutines; i++ { // Concurrent writes go func(id int) { defer wg.Done() for j := 0; j < numOperations; j++ { - status := &types.Status{ + status := types.Status{ State: types.StateRunning, Description: "Running", LastUpdated: time.Now(), @@ -297,14 +241,6 @@ func TestMemoryStore_ConcurrentAccess(t *testing.T) { assert.NoError(t, err) } }(i) - - // Concurrent IsRunning calls - go func(id int) { - defer wg.Done() - for j := 0; j < numOperations; j++ { - store.IsRunning() - } - }(i) } wg.Wait() diff --git a/api/internal/store/store.go b/api/internal/store/store.go new file mode 100644 index 0000000000..a3906bfb55 --- /dev/null +++ b/api/internal/store/store.go @@ -0,0 +1,120 @@ +package store + +import ( + "github.com/replicatedhq/embedded-cluster/api/internal/store/infra" + kubernetesinstallation "github.com/replicatedhq/embedded-cluster/api/internal/store/kubernetes/installation" + linuxinstallation "github.com/replicatedhq/embedded-cluster/api/internal/store/linux/installation" + linuxpreflight "github.com/replicatedhq/embedded-cluster/api/internal/store/linux/preflight" +) + +var _ Store = &memoryStore{} + +// Store is the global interface that combines all substores +type Store interface { + // LinuxPreflightStore provides access to host preflight operations + LinuxPreflightStore() linuxpreflight.Store + + // LinuxInstallationStore provides access to installation operations + LinuxInstallationStore() linuxinstallation.Store + + // LinuxInfraStore provides access to infrastructure operations + LinuxInfraStore() infra.Store + + // KubernetesInstallationStore provides access to kubernetes installation operations + KubernetesInstallationStore() kubernetesinstallation.Store + + // KubernetesInfraStore provides access to kubernetes infrastructure operations + KubernetesInfraStore() infra.Store +} + +// StoreOption is a function that configures a store +type StoreOption func(*memoryStore) + +// WithLinuxPreflightStore sets the preflight store +func WithLinuxPreflightStore(store linuxpreflight.Store) StoreOption { + return func(s *memoryStore) { + s.linuxPreflightStore = store + } +} + +// WithLinuxInstallationStore sets the installation store +func WithLinuxInstallationStore(store linuxinstallation.Store) StoreOption { + return func(s *memoryStore) { + s.linuxInstallationStore = store + } +} + +// WithLinuxInfraStore sets the infra store +func WithLinuxInfraStore(store infra.Store) StoreOption { + return func(s *memoryStore) { + s.linuxInfraStore = store + } +} + +// WithKubernetesInstallationStore sets the kubernetes installation store +func WithKubernetesInstallationStore(store kubernetesinstallation.Store) StoreOption { + return func(s *memoryStore) { + s.kubernetesInstallationStore = store + } +} + +// memoryStore is an in-memory implementation of the global Store interface +type memoryStore struct { + linuxPreflightStore linuxpreflight.Store + linuxInstallationStore linuxinstallation.Store + linuxInfraStore infra.Store + + kubernetesInstallationStore kubernetesinstallation.Store + kubernetesInfraStore infra.Store +} + +// NewMemoryStore creates a new memory store with the given options +func NewMemoryStore(opts ...StoreOption) Store { + s := &memoryStore{} + + for _, opt := range opts { + opt(s) + } + + if s.linuxPreflightStore == nil { + s.linuxPreflightStore = linuxpreflight.NewMemoryStore() + } + + if s.linuxInstallationStore == nil { + s.linuxInstallationStore = linuxinstallation.NewMemoryStore() + } + + if s.linuxInfraStore == nil { + s.linuxInfraStore = infra.NewMemoryStore() + } + + if s.kubernetesInstallationStore == nil { + s.kubernetesInstallationStore = kubernetesinstallation.NewMemoryStore() + } + + if s.kubernetesInfraStore == nil { + s.kubernetesInfraStore = infra.NewMemoryStore() + } + + return s +} + +func (s *memoryStore) LinuxPreflightStore() linuxpreflight.Store { + return s.linuxPreflightStore +} + +func (s *memoryStore) LinuxInstallationStore() linuxinstallation.Store { + return s.linuxInstallationStore +} + +func (s *memoryStore) LinuxInfraStore() infra.Store { + return s.linuxInfraStore +} + +func (s *memoryStore) KubernetesInstallationStore() kubernetesinstallation.Store { + return s.kubernetesInstallationStore +} + +func (s *memoryStore) KubernetesInfraStore() infra.Store { + return s.kubernetesInfraStore +} diff --git a/api/internal/store/store_mock.go b/api/internal/store/store_mock.go new file mode 100644 index 0000000000..de088ded8a --- /dev/null +++ b/api/internal/store/store_mock.go @@ -0,0 +1,44 @@ +package store + +import ( + "github.com/replicatedhq/embedded-cluster/api/internal/store/infra" + kubernetesinstallation "github.com/replicatedhq/embedded-cluster/api/internal/store/kubernetes/installation" + linuxinstallation "github.com/replicatedhq/embedded-cluster/api/internal/store/linux/installation" + linuxpreflight "github.com/replicatedhq/embedded-cluster/api/internal/store/linux/preflight" +) + +var _ Store = (*MockStore)(nil) + +// MockStore is a mock implementation of the Store interface +type MockStore struct { + LinuxPreflightMockStore linuxpreflight.MockStore + LinuxInstallationMockStore linuxinstallation.MockStore + LinuxInfraMockStore infra.MockStore + KubernetesInstallationMockStore kubernetesinstallation.MockStore + KubernetesInfraMockStore infra.MockStore +} + +// LinuxPreflightStore returns the mock linux preflight store +func (m *MockStore) LinuxPreflightStore() linuxpreflight.Store { + return &m.LinuxPreflightMockStore +} + +// LinuxInstallationStore returns the mock linux installation store +func (m *MockStore) LinuxInstallationStore() linuxinstallation.Store { + return &m.LinuxInstallationMockStore +} + +// LinuxInfraStore returns the mock linux infra store +func (m *MockStore) LinuxInfraStore() infra.Store { + return &m.LinuxInfraMockStore +} + +// KubernetesInstallationStore returns the mock kubernetes installation store +func (m *MockStore) KubernetesInstallationStore() kubernetesinstallation.Store { + return &m.KubernetesInstallationMockStore +} + +// KubernetesInfraStore returns the mock kubernetes infra store +func (m *MockStore) KubernetesInfraStore() infra.Store { + return &m.KubernetesInfraMockStore +} diff --git a/api/pkg/utils/domains.go b/api/internal/utils/domains.go similarity index 100% rename from api/pkg/utils/domains.go rename to api/internal/utils/domains.go diff --git a/api/pkg/utils/netutils.go b/api/internal/utils/netutils.go similarity index 82% rename from api/pkg/utils/netutils.go rename to api/internal/utils/netutils.go index 6445fa4c16..49deaf2840 100644 --- a/api/pkg/utils/netutils.go +++ b/api/internal/utils/netutils.go @@ -1,6 +1,8 @@ package utils import ( + "net" + newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" "github.com/replicatedhq/embedded-cluster/pkg/netutils" ) @@ -8,6 +10,7 @@ import ( type NetUtils interface { ListValidNetworkInterfaces() ([]string, error) DetermineBestNetworkInterface() (string, error) + FirstValidIPNet(networkInterface string) (*net.IPNet, error) FirstValidAddress(networkInterface string) (string, error) } @@ -37,6 +40,10 @@ func (n *netUtils) DetermineBestNetworkInterface() (string, error) { return newconfig.DetermineBestNetworkInterface() } +func (n *netUtils) FirstValidIPNet(networkInterface string) (*net.IPNet, error) { + return netutils.FirstValidIPNet(networkInterface) +} + func (n *netUtils) FirstValidAddress(networkInterface string) (string, error) { return netutils.FirstValidAddress(networkInterface) } diff --git a/api/pkg/utils/netutils_mock.go b/api/internal/utils/netutils_mock.go similarity index 79% rename from api/pkg/utils/netutils_mock.go rename to api/internal/utils/netutils_mock.go index 224ff06957..cd57a44b5c 100644 --- a/api/pkg/utils/netutils_mock.go +++ b/api/internal/utils/netutils_mock.go @@ -1,6 +1,8 @@ package utils import ( + "net" + "github.com/stretchr/testify/mock" ) @@ -26,6 +28,12 @@ func (m *MockNetUtils) DetermineBestNetworkInterface() (string, error) { return args.String(0), args.Error(1) } +// FirstValidIPNet mocks the FirstValidIPNet method +func (m *MockNetUtils) FirstValidIPNet(networkInterface string) (*net.IPNet, error) { + args := m.Called(networkInterface) + return args.Get(0).(*net.IPNet), args.Error(1) +} + // FirstValidAddress mocks the FirstValidAddress method func (m *MockNetUtils) FirstValidAddress(networkInterface string) (string, error) { args := m.Called(networkInterface) diff --git a/api/pkg/logger/logger.go b/api/pkg/logger/logger.go index c108fb9c8f..b93990aabb 100644 --- a/api/pkg/logger/logger.go +++ b/api/pkg/logger/logger.go @@ -12,7 +12,7 @@ import ( ) func NewLogger() (*logrus.Logger, error) { - fname := fmt.Sprintf("%s-%s.api.log", runtimeconfig.BinaryName(), time.Now().Format("20060102150405.000")) + fname := fmt.Sprintf("%s-%s.api.log", runtimeconfig.AppSlug(), time.Now().Format("20060102150405.000")) logpath := runtimeconfig.PathToLog(fname) logfile, err := os.OpenFile(logpath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0400) if err != nil { diff --git a/api/routes.go b/api/routes.go new file mode 100644 index 0000000000..7d9f098099 --- /dev/null +++ b/api/routes.go @@ -0,0 +1,65 @@ +package api + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/replicatedhq/embedded-cluster/api/docs" + httpSwagger "github.com/swaggo/http-swagger/v2" +) + +// RegisterRoutes registers the routes for the API. A router is passed in to allow for the routes +// to be registered on a subrouter. +func (a *API) RegisterRoutes(router *mux.Router) { + router.HandleFunc("/health", a.handlers.health.GetHealth).Methods("GET") + + // Hack to fix issue + // https://github.com/swaggo/swag/issues/1588#issuecomment-2797801240 + router.HandleFunc("/swagger/doc.json", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte(docs.SwaggerInfo.ReadDoc())) + }).Methods("GET") + router.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler) + + router.HandleFunc("/auth/login", a.handlers.auth.PostLogin).Methods("POST") + + authenticatedRouter := router.PathPrefix("/").Subrouter() + authenticatedRouter.Use(a.handlers.auth.Middleware) + + a.registerLinuxRoutes(authenticatedRouter) + a.registerKubernetesRoutes(authenticatedRouter) + a.registerConsoleRoutes(authenticatedRouter) +} + +func (a *API) registerLinuxRoutes(router *mux.Router) { + linuxRouter := router.PathPrefix("/linux").Subrouter() + + installRouter := linuxRouter.PathPrefix("/install").Subrouter() + installRouter.HandleFunc("/installation/config", a.handlers.linux.GetInstallationConfig).Methods("GET") + installRouter.HandleFunc("/installation/configure", a.handlers.linux.PostConfigureInstallation).Methods("POST") + installRouter.HandleFunc("/installation/status", a.handlers.linux.GetInstallationStatus).Methods("GET") + + installRouter.HandleFunc("/host-preflights/run", a.handlers.linux.PostRunHostPreflights).Methods("POST") + installRouter.HandleFunc("/host-preflights/status", a.handlers.linux.GetHostPreflightsStatus).Methods("GET") + + installRouter.HandleFunc("/infra/setup", a.handlers.linux.PostSetupInfra).Methods("POST") + installRouter.HandleFunc("/infra/status", a.handlers.linux.GetInfraStatus).Methods("GET") +} + +func (a *API) registerKubernetesRoutes(router *mux.Router) { + kubernetesRouter := router.PathPrefix("/kubernetes").Subrouter() + + installRouter := kubernetesRouter.PathPrefix("/install").Subrouter() + installRouter.HandleFunc("/installation/config", a.handlers.kubernetes.GetInstallationConfig).Methods("GET") + installRouter.HandleFunc("/installation/configure", a.handlers.kubernetes.PostConfigureInstallation).Methods("POST") + installRouter.HandleFunc("/installation/status", a.handlers.kubernetes.GetInstallationStatus).Methods("GET") + + installRouter.HandleFunc("/infra/setup", a.handlers.kubernetes.PostSetupInfra).Methods("POST") + installRouter.HandleFunc("/infra/status", a.handlers.kubernetes.GetInfraStatus).Methods("GET") +} + +func (a *API) registerConsoleRoutes(router *mux.Router) { + consoleRouter := router.PathPrefix("/console").Subrouter() + consoleRouter.HandleFunc("/available-network-interfaces", a.handlers.console.GetListAvailableNetworkInterfaces).Methods("GET") +} diff --git a/api/types/api.go b/api/types/api.go new file mode 100644 index 0000000000..836a62e4b5 --- /dev/null +++ b/api/types/api.go @@ -0,0 +1,34 @@ +package types + +import ( + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/kubernetesinstallation" + "github.com/replicatedhq/embedded-cluster/pkg/release" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + "k8s.io/cli-runtime/pkg/genericclioptions" +) + +// APIConfig holds the configuration for the API server +type APIConfig struct { + Password string + TLSConfig TLSConfig + License []byte + AirgapBundle string + ConfigValues string + ReleaseData *release.ReleaseData + EndUserConfig *ecv1beta1.Config + ClusterID string + + LinuxConfig + KubernetesConfig +} + +type LinuxConfig struct { + RuntimeConfig runtimeconfig.RuntimeConfig + AllowIgnoreHostPreflights bool +} + +type KubernetesConfig struct { + RESTClientGetterFactory func(namespace string) genericclioptions.RESTClientGetter + Installation kubernetesinstallation.Installation +} diff --git a/api/types/errors.go b/api/types/errors.go index fef75b9739..031cd8f214 100644 --- a/api/types/errors.go +++ b/api/types/errors.go @@ -6,11 +6,6 @@ import ( "errors" "fmt" "net/http" - "regexp" - "strings" - - "golang.org/x/text/cases" - "golang.org/x/text/language" ) type APIError struct { @@ -68,6 +63,14 @@ func NewConflictError(err error) *APIError { } } +func NewForbiddenError(err error) *APIError { + return &APIError{ + StatusCode: http.StatusForbidden, + Message: err.Error(), + err: err, + } +} + func NewUnauthorizedError(err error) *APIError { return &APIError{ StatusCode: http.StatusUnauthorized, @@ -105,64 +108,11 @@ func AppendFieldError(apiErr *APIError, field string, err error) *APIError { if apiErr == nil { apiErr = NewBadRequestError(errors.New("field errors")) } - return AppendError(apiErr, newFieldError(field, err)) -} - -func camelCaseToWords(s string) string { - // Handle special cases - specialCases := map[string]string{ - "cidr": "CIDR", - "Cidr": "CIDR", - "CIDR": "CIDR", - } - - // Check if the entire string is a special case - if replacement, ok := specialCases[strings.ToLower(s)]; ok { - return replacement - } - - // Split on capital letters - re := regexp.MustCompile(`([a-z])([A-Z])`) - words := re.ReplaceAllString(s, "$1 $2") - - // Split the words and handle special cases - wordList := strings.Split(strings.ToLower(words), " ") - for i, word := range wordList { - if replacement, ok := specialCases[word]; ok { - wordList[i] = replacement - } else { - // Capitalize other words - c := cases.Title(language.English) - wordList[i] = c.String(word) - } - } - - return strings.Join(wordList, " ") -} - -func newFieldError(field string, err error) *APIError { - msg := err.Error() - - // Try different patterns to replace the field name - patterns := []string{ - field, // exact match - strings.ToLower(field), // lowercase - strings.ToUpper(field), // uppercase - "cidr", // special case for CIDR - } - - for _, pattern := range patterns { - if strings.Contains(msg, pattern) { - msg = strings.Replace(msg, pattern, camelCaseToWords(field), 1) - break - } - } - - return &APIError{ - Message: msg, + return AppendError(apiErr, &APIError{ + Message: err.Error(), Field: field, err: err, - } + }) } // JSON writes the APIError as JSON to the provided http.ResponseWriter diff --git a/api/types/infra.go b/api/types/infra.go index 2e68d3d998..a4367c67cd 100644 --- a/api/types/infra.go +++ b/api/types/infra.go @@ -1,17 +1,17 @@ package types +// LinuxInfraSetupRequest represents a request to set up infrastructure +type LinuxInfraSetupRequest struct { + IgnoreHostPreflights bool `json:"ignoreHostPreflights"` +} + type Infra struct { Components []InfraComponent `json:"components"` - Status *Status `json:"status"` -} -type InfraComponent struct { - Name string `json:"name"` - Status *Status `json:"status"` + Logs string `json:"logs"` + Status Status `json:"status"` } -func NewInfra() *Infra { - return &Infra{ - Components: []InfraComponent{}, - Status: NewStatus(), - } +type InfraComponent struct { + Name string `json:"name"` + Status Status `json:"status"` } diff --git a/api/types/install.go b/api/types/install.go deleted file mode 100644 index 5cb37d9e0c..0000000000 --- a/api/types/install.go +++ /dev/null @@ -1,26 +0,0 @@ -package types - -// Install represents the install workflow state -type Install struct { - Steps InstallSteps `json:"steps"` - Status *Status `json:"status"` -} - -// InstallSteps represents the steps of the install workflow -type InstallSteps struct { - Installation *Installation `json:"installation"` - HostPreflight *HostPreflights `json:"hostPreflight"` - Infra *Infra `json:"infra"` -} - -// NewInstall initializes a new install workflow state -func NewInstall() *Install { - return &Install{ - Steps: InstallSteps{ - Installation: NewInstallation(), - HostPreflight: NewHostPreflights(), - Infra: NewInfra(), - }, - Status: NewStatus(), - } -} diff --git a/api/types/installation.go b/api/types/installation.go index 3b2dca16f7..c77c2d3a97 100644 --- a/api/types/installation.go +++ b/api/types/installation.go @@ -1,12 +1,12 @@ package types -type Installation struct { - Config *InstallationConfig `json:"config"` - Status *Status `json:"status"` +type LinuxInstallation struct { + Config LinuxInstallationConfig `json:"config"` + Status Status `json:"status"` } -// InstallationConfig represents the configuration for an installation -type InstallationConfig struct { +// LinuxInstallationConfig represents the configuration for an installation +type LinuxInstallationConfig struct { AdminConsolePort int `json:"adminConsolePort"` DataDirectory string `json:"dataDirectory"` LocalArtifactMirrorPort int `json:"localArtifactMirrorPort"` @@ -19,10 +19,14 @@ type InstallationConfig struct { GlobalCIDR string `json:"globalCidr"` } -// NewInstallation initializes a new installation state -func NewInstallation() *Installation { - return &Installation{ - Config: &InstallationConfig{}, - Status: NewStatus(), - } +type KubernetesInstallation struct { + Config KubernetesInstallationConfig `json:"config"` + Status Status `json:"status"` +} + +type KubernetesInstallationConfig struct { + AdminConsolePort int `json:"adminConsolePort"` + HTTPProxy string `json:"httpProxy"` + HTTPSProxy string `json:"httpsProxy"` + NoProxy string `json:"noProxy"` } diff --git a/api/types/preflight.go b/api/types/preflight.go index f3ad83cc69..158c7fa86d 100644 --- a/api/types/preflight.go +++ b/api/types/preflight.go @@ -1,10 +1,15 @@ package types +type PostInstallRunHostPreflightsRequest struct { + IsUI bool `json:"isUi"` +} + // HostPreflights represents the host preflight checks state type HostPreflights struct { - Titles []string `json:"titles"` - Output *HostPreflightsOutput `json:"output"` - Status *Status `json:"status"` + Titles []string `json:"titles"` + Output *HostPreflightsOutput `json:"output"` + Status Status `json:"status"` + AllowIgnoreHostPreflights bool `json:"allowIgnoreHostPreflights"` } type HostPreflightsOutput struct { @@ -19,12 +24,6 @@ type HostPreflightsRecord struct { Message string `json:"message"` } -func NewHostPreflights() *HostPreflights { - return &HostPreflights{ - Status: NewStatus(), - } -} - // HasFail returns true if any of the preflight checks failed. func (o HostPreflightsOutput) HasFail() bool { return len(o.Fail) > 0 diff --git a/api/types/responses.go b/api/types/responses.go index 44db4cdf17..1bbc55b429 100644 --- a/api/types/responses.go +++ b/api/types/responses.go @@ -2,7 +2,13 @@ package types // InstallHostPreflightsStatusResponse represents the response when polling install host preflights status type InstallHostPreflightsStatusResponse struct { - Titles []string `json:"titles"` - Output *HostPreflightsOutput `json:"output,omitempty"` - Status *Status `json:"status,omitempty"` + Titles []string `json:"titles"` + Output *HostPreflightsOutput `json:"output,omitempty"` + Status Status `json:"status,omitempty"` + AllowIgnoreHostPreflights bool `json:"allowIgnoreHostPreflights"` +} + +// GetListAvailableNetworkInterfacesResponse represents the response when listing available network interfaces +type GetListAvailableNetworkInterfacesResponse struct { + NetworkInterfaces []string `json:"networkInterfaces"` } diff --git a/api/types/status.go b/api/types/status.go index 19a769e424..bf9d8af5b3 100644 --- a/api/types/status.go +++ b/api/types/status.go @@ -21,19 +21,9 @@ const ( StateFailed State = "Failed" ) -func NewStatus() *Status { - return &Status{ - State: StatePending, - } -} - -func ValidateStatus(status *Status) error { +func ValidateStatus(status Status) error { var ve *APIError - if status == nil { - return NewBadRequestError(errors.New("a status is required")) - } - switch status.State { case StatePending, StateRunning, StateSucceeded, StateFailed: // valid states diff --git a/api/types/status_test.go b/api/types/status_test.go index 8032d18de1..b6f3476a0a 100644 --- a/api/types/status_test.go +++ b/api/types/status_test.go @@ -10,12 +10,12 @@ import ( func TestValidateStatus(t *testing.T) { tests := []struct { name string - status *Status + status Status expectedErr bool }{ { name: "valid status - pending", - status: &Status{ + status: Status{ State: StatePending, Description: "Installation pending", LastUpdated: time.Now(), @@ -24,7 +24,7 @@ func TestValidateStatus(t *testing.T) { }, { name: "valid status - running", - status: &Status{ + status: Status{ State: StateRunning, Description: "Installation in progress", LastUpdated: time.Now(), @@ -33,7 +33,7 @@ func TestValidateStatus(t *testing.T) { }, { name: "valid status - succeeded", - status: &Status{ + status: Status{ State: StateSucceeded, Description: "Installation completed successfully", LastUpdated: time.Now(), @@ -42,7 +42,7 @@ func TestValidateStatus(t *testing.T) { }, { name: "valid status - failed", - status: &Status{ + status: Status{ State: StateFailed, Description: "Installation failed", LastUpdated: time.Now(), @@ -50,13 +50,13 @@ func TestValidateStatus(t *testing.T) { expectedErr: false, }, { - name: "nil status", - status: nil, + name: "empty status", + status: Status{}, expectedErr: true, }, { name: "invalid state", - status: &Status{ + status: Status{ State: "Invalid", Description: "Invalid state", LastUpdated: time.Now(), @@ -65,7 +65,7 @@ func TestValidateStatus(t *testing.T) { }, { name: "missing description", - status: &Status{ + status: Status{ State: StateRunning, Description: "", LastUpdated: time.Now(), diff --git a/cmd/installer/cli/adminconsole.go b/cmd/installer/cli/adminconsole.go index fb7e9764e5..f651190084 100644 --- a/cmd/installer/cli/adminconsole.go +++ b/cmd/installer/cli/adminconsole.go @@ -7,16 +7,16 @@ import ( "github.com/spf13/cobra" ) -func AdminConsoleCmd(ctx context.Context, name string) *cobra.Command { +func AdminConsoleCmd(ctx context.Context, appTitle string) *cobra.Command { cmd := &cobra.Command{ Use: "admin-console", - Short: fmt.Sprintf("Manage the %s Admin Console", name), + Short: fmt.Sprintf("Manage the %s Admin Console", appTitle), RunE: func(cmd *cobra.Command, args []string) error { return nil }, } - cmd.AddCommand(AdminConsoleResetPasswordCmd(ctx, name)) + cmd.AddCommand(AdminConsoleResetPasswordCmd(ctx, appTitle)) return cmd } diff --git a/cmd/installer/cli/adminconsole_resetpassword.go b/cmd/installer/cli/adminconsole_resetpassword.go index 137131116c..8a4dd0b3c1 100644 --- a/cmd/installer/cli/adminconsole_resetpassword.go +++ b/cmd/installer/cli/adminconsole_resetpassword.go @@ -7,6 +7,7 @@ import ( "os" "github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli" + "github.com/replicatedhq/embedded-cluster/pkg/dryrun" "github.com/replicatedhq/embedded-cluster/pkg/prompts" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" rcutil "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig/util" @@ -14,15 +15,16 @@ import ( "github.com/spf13/cobra" ) -func AdminConsoleResetPasswordCmd(ctx context.Context, name string) *cobra.Command { +func AdminConsoleResetPasswordCmd(ctx context.Context, appTitle string) *cobra.Command { var rc runtimeconfig.RuntimeConfig cmd := &cobra.Command{ Use: "reset-password [password]", Args: cobra.MaximumNArgs(1), - Short: fmt.Sprintf("Reset the %s Admin Console password. If no password is provided, you will be prompted to enter a new one.", name), + Short: fmt.Sprintf("Reset the %s Admin Console password. If no password is provided, you will be prompted to enter a new one.", appTitle), PreRunE: func(cmd *cobra.Command, args []string) error { - if os.Getuid() != 0 { + // Skip root check if dryrun mode is enabled + if !dryrun.Enabled() && os.Getuid() != 0 { return fmt.Errorf("reset-password command must be run as root") } diff --git a/cmd/installer/cli/api.go b/cmd/installer/cli/api.go index 5401dac0cb..a1eaf0c9de 100644 --- a/cmd/installer/cli/api.go +++ b/cmd/installer/cli/api.go @@ -14,50 +14,44 @@ import ( "github.com/gorilla/mux" "github.com/replicatedhq/embedded-cluster/api" - apiclient "github.com/replicatedhq/embedded-cluster/api/client" apilogger "github.com/replicatedhq/embedded-cluster/api/pkg/logger" apitypes "github.com/replicatedhq/embedded-cluster/api/types" - ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/cloudutils" "github.com/replicatedhq/embedded-cluster/pkg-new/tlsutils" "github.com/replicatedhq/embedded-cluster/pkg/metrics" - "github.com/replicatedhq/embedded-cluster/pkg/release" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/web" "github.com/sirupsen/logrus" ) -// apiConfig holds the configuration for the API server -type apiConfig struct { - RuntimeConfig runtimeconfig.RuntimeConfig +// apiOptions holds the configuration options for the API server +type apiOptions struct { + apitypes.APIConfig + + ManagerPort int + InstallTarget string + Logger logrus.FieldLogger MetricsReporter metrics.ReporterInterface - Password string - TLSConfig apitypes.TLSConfig - ManagerPort int - LicenseFile string - AirgapBundle string - ConfigValues string - ReleaseData *release.ReleaseData - EndUserConfig *ecv1beta1.Config WebAssetsFS fs.FS } -func startAPI(ctx context.Context, cert tls.Certificate, config apiConfig) error { - listener, err := net.Listen("tcp", fmt.Sprintf(":%d", config.ManagerPort)) +func startAPI(ctx context.Context, cert tls.Certificate, opts apiOptions, cancel context.CancelFunc) error { + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", opts.ManagerPort)) if err != nil { return fmt.Errorf("unable to create listener: %w", err) } go func() { - if err := serveAPI(ctx, listener, cert, config); err != nil { + // If the api exits, we want to exit the entire process + defer cancel() + if err := serveAPI(ctx, listener, cert, opts); err != nil { if !errors.Is(err, http.ErrServerClosed) { - logrus.Errorf("api error: %v", err) + logrus.Errorf("API server exited with error: %v", err) } } }() - addr := fmt.Sprintf("localhost:%d", config.ManagerPort) + addr := fmt.Sprintf("localhost:%d", opts.ManagerPort) if err := waitForAPI(ctx, addr); err != nil { return fmt.Errorf("unable to wait for api: %w", err) } @@ -65,41 +59,35 @@ func startAPI(ctx context.Context, cert tls.Certificate, config apiConfig) error return nil } -func serveAPI(ctx context.Context, listener net.Listener, cert tls.Certificate, config apiConfig) error { +func serveAPI(ctx context.Context, listener net.Listener, cert tls.Certificate, opts apiOptions) error { router := mux.NewRouter() - if config.ReleaseData == nil { + if opts.ReleaseData == nil { return fmt.Errorf("release not found") } - if config.ReleaseData.Application == nil { + if opts.ReleaseData.Application == nil { return fmt.Errorf("application not found") } - logger, err := loggerFromConfig(config) + logger, err := loggerFromOptions(opts) if err != nil { return fmt.Errorf("new api logger: %w", err) } api, err := api.New( - config.Password, + opts.APIConfig, api.WithLogger(logger), - api.WithRuntimeConfig(config.RuntimeConfig), - api.WithMetricsReporter(config.MetricsReporter), - api.WithReleaseData(config.ReleaseData), - api.WithTLSConfig(config.TLSConfig), - api.WithLicenseFile(config.LicenseFile), - api.WithAirgapBundle(config.AirgapBundle), - api.WithConfigValues(config.ConfigValues), - api.WithEndUserConfig(config.EndUserConfig), + api.WithMetricsReporter(opts.MetricsReporter), ) if err != nil { return fmt.Errorf("new api: %w", err) } webServer, err := web.New(web.InitialState{ - Title: config.ReleaseData.Application.Spec.Title, - Icon: config.ReleaseData.Application.Spec.Icon, - }, web.WithLogger(logger), web.WithAssetsFS(config.WebAssetsFS)) + Title: opts.ReleaseData.Application.Spec.Title, + Icon: opts.ReleaseData.Application.Spec.Icon, + InstallTarget: opts.InstallTarget, + }, web.WithLogger(logger), web.WithAssetsFS(opts.WebAssetsFS)) if err != nil { return fmt.Errorf("new web server: %w", err) } @@ -117,15 +105,15 @@ func serveAPI(ctx context.Context, listener net.Listener, cert tls.Certificate, go func() { <-ctx.Done() logrus.Debugf("Shutting down API") - server.Shutdown(context.Background()) + _ = server.Shutdown(context.Background()) }() return server.ServeTLS(listener, "", "") } -func loggerFromConfig(config apiConfig) (logrus.FieldLogger, error) { - if config.Logger != nil { - return config.Logger, nil +func loggerFromOptions(opts apiOptions) (logrus.FieldLogger, error) { + if opts.Logger != nil { + return opts.Logger, nil } logger, err := apilogger.NewLogger() if err != nil { @@ -171,45 +159,6 @@ func waitForAPI(ctx context.Context, addr string) error { } } -func markUIInstallComplete(password string, managerPort int, installErr error) error { - httpClient := &http.Client{ - Transport: &http.Transport{ - Proxy: nil, // This is a local client so no proxy is needed - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - }, - } - apiClient := apiclient.New( - fmt.Sprintf("https://localhost:%d", managerPort), - apiclient.WithHTTPClient(httpClient), - ) - if err := apiClient.Authenticate(password); err != nil { - return fmt.Errorf("unable to authenticate: %w", err) - } - - var state apitypes.State - var description string - if installErr != nil { - state = apitypes.StateFailed - description = fmt.Sprintf("Installation failed: %v", installErr) - } else { - state = apitypes.StateSucceeded - description = "Installation succeeded" - } - - _, err := apiClient.SetInstallStatus(&apitypes.Status{ - State: state, - Description: description, - LastUpdated: time.Now(), - }) - if err != nil { - return fmt.Errorf("unable to set install status: %w", err) - } - - return nil -} - func getManagerURL(hostname string, port int) string { if hostname != "" { return fmt.Sprintf("https://%s:%v", hostname, port) diff --git a/cmd/installer/cli/api_test.go b/cmd/installer/cli/api_test.go index 4d17bf90ba..1f4d22e0b2 100644 --- a/cmd/installer/cli/api_test.go +++ b/cmd/installer/cli/api_test.go @@ -12,6 +12,7 @@ import ( "time" apilogger "github.com/replicatedhq/embedded-cluster/api/pkg/logger" + apitypes "github.com/replicatedhq/embedded-cluster/api/types" "github.com/replicatedhq/embedded-cluster/pkg-new/tlsutils" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" @@ -54,16 +55,19 @@ func Test_serveAPI(t *testing.T) { portInt, err := strconv.Atoi(port) require.NoError(t, err) - config := apiConfig{ - Logger: apilogger.NewDiscardLogger(), - Password: "password", - ManagerPort: portInt, - WebAssetsFS: webAssetsFS, - ReleaseData: &release.ReleaseData{ - Application: &kotsv1beta1.Application{ - Spec: kotsv1beta1.ApplicationSpec{}, + config := apiOptions{ + APIConfig: apitypes.APIConfig{ + Password: "password", + ReleaseData: &release.ReleaseData{ + Application: &kotsv1beta1.Application{ + Spec: kotsv1beta1.ApplicationSpec{}, + }, }, + ClusterID: "123", }, + ManagerPort: portInt, + Logger: apilogger.NewDiscardLogger(), + WebAssetsFS: webAssetsFS, } go func() { diff --git a/cmd/installer/cli/cidr.go b/cmd/installer/cli/cidr.go index c477b9f883..95b4d2f4d0 100644 --- a/cmd/installer/cli/cidr.go +++ b/cmd/installer/cli/cidr.go @@ -7,20 +7,17 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) -func addCIDRFlags(cmd *cobra.Command) error { - cmd.Flags().String("pod-cidr", k0sv1beta1.DefaultNetwork().PodCIDR, "IP address range for Pods") - if err := cmd.Flags().MarkHidden("pod-cidr"); err != nil { - return err - } - cmd.Flags().String("service-cidr", k0sv1beta1.DefaultNetwork().ServiceCIDR, "IP address range for Services") - if err := cmd.Flags().MarkHidden("service-cidr"); err != nil { - return err - } - cmd.Flags().String("cidr", ecv1beta1.DefaultNetworkCIDR, "CIDR block of available private IP addresses (/16 or larger)") +func mustAddCIDRFlags(flagSet *pflag.FlagSet) { + flagSet.String("cidr", ecv1beta1.DefaultNetworkCIDR, "CIDR block of available private IP addresses (/16 or larger)") - return nil + flagSet.String("pod-cidr", k0sv1beta1.DefaultNetwork().PodCIDR, "IP address range for Pods") + mustMarkFlagHidden(flagSet, "pod-cidr") + + flagSet.String("service-cidr", k0sv1beta1.DefaultNetwork().ServiceCIDR, "IP address range for Services") + mustMarkFlagHidden(flagSet, "service-cidr") } func validateCIDRFlags(cmd *cobra.Command) error { diff --git a/cmd/installer/cli/cidr_test.go b/cmd/installer/cli/cidr_test.go index aba77aa76c..2cc36e67fa 100644 --- a/cmd/installer/cli/cidr_test.go +++ b/cmd/installer/cli/cidr_test.go @@ -83,7 +83,7 @@ func Test_getCIDRConfig(t *testing.T) { req := require.New(t) cmd := &cobra.Command{} - addCIDRFlags(cmd) + mustAddCIDRFlags(cmd.Flags()) test.setFlags(cmd.Flags()) diff --git a/cmd/installer/cli/enable_ha.go b/cmd/installer/cli/enable_ha.go index 36c8bc5c6e..6308fdcbd4 100644 --- a/cmd/installer/cli/enable_ha.go +++ b/cmd/installer/cli/enable_ha.go @@ -5,9 +5,12 @@ import ( "fmt" "os" + "github.com/replicatedhq/embedded-cluster/pkg-new/domains" "github.com/replicatedhq/embedded-cluster/pkg/addons" + "github.com/replicatedhq/embedded-cluster/pkg/dryrun" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" + "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" rcutil "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig/util" "github.com/replicatedhq/embedded-cluster/pkg/spinner" @@ -17,21 +20,21 @@ import ( ) // EnableHACmd is the command for enabling HA mode. -func EnableHACmd(ctx context.Context, name string) *cobra.Command { +func EnableHACmd(ctx context.Context, appTitle string) *cobra.Command { var rc runtimeconfig.RuntimeConfig cmd := &cobra.Command{ Use: "enable-ha", - Short: fmt.Sprintf("Enable high availability for the %s cluster", name), + Short: fmt.Sprintf("Enable high availability for the %s cluster", appTitle), PreRunE: func(cmd *cobra.Command, args []string) error { - if os.Getuid() != 0 { + // Skip root check if dryrun mode is enabled + if !dryrun.Enabled() && os.Getuid() != 0 { return fmt.Errorf("enable-ha command must be run as root") } rc = rcutil.InitBestRuntimeConfig(cmd.Context()) - os.Setenv("KUBECONFIG", rc.PathToKubeConfig()) - os.Setenv("TMPDIR", rc.EmbeddedClusterTmpSubDir()) + _ = rc.SetEnv() return nil }, @@ -92,7 +95,7 @@ func runEnableHA(ctx context.Context, rc runtimeconfig.RuntimeConfig) error { addons.WithKubernetesClientSet(kclient), addons.WithMetadataClient(mcli), addons.WithHelmClient(hcli), - addons.WithRuntimeConfig(rc), + addons.WithDomains(domains.GetDomains(in.Spec.Config, release.GetChannelRelease())), ) canEnableHA, reason, err := addOns.CanEnableHA(ctx) @@ -107,5 +110,20 @@ func runEnableHA(ctx context.Context, rc runtimeconfig.RuntimeConfig) error { loading := spinner.Start() defer loading.Close() - return addOns.EnableHA(ctx, in.Spec.Network.ServiceCIDR, in.Spec, loading) + opts := addons.EnableHAOptions{ + ClusterID: in.Spec.ClusterID, + AdminConsolePort: rc.AdminConsolePort(), + IsAirgap: in.Spec.AirGap, + IsMultiNodeEnabled: in.Spec.LicenseInfo != nil && in.Spec.LicenseInfo.IsMultiNodeEnabled, + EmbeddedConfigSpec: in.Spec.Config, + EndUserConfigSpec: nil, // TODO: add support for end user config spec + ProxySpec: rc.ProxySpec(), + HostCABundlePath: rc.HostCABundlePath(), + DataDir: rc.EmbeddedClusterHomeDirectory(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), + SeaweedFSDataDir: rc.EmbeddedClusterSeaweedFSSubDir(), + ServiceCIDR: rc.ServiceCIDR(), + } + + return addOns.EnableHA(ctx, opts, loading) } diff --git a/cmd/installer/cli/flags.go b/cmd/installer/cli/flags.go new file mode 100644 index 0000000000..a8965772da --- /dev/null +++ b/cmd/installer/cli/flags.go @@ -0,0 +1,155 @@ +package cli + +import ( + "os" + "text/template" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const ( + flagAnnotationTarget = "replicated.com/target" + flagAnnotationTargetValueLinux = "linux" + flagAnnotationTargetValueKubernetes = "kubernetes" +) + +const ( + defaultUsageTemplateV3 = `Usage:{{if .Runnable}} +{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} +{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} + +Aliases: +{{.NameAndAliases}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} + +Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} +{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} + +{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} +{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} + +Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} +{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}{{if (usesTargetFlagMenu .LocalFlags)}} + +Common Flags: + +{{(commonFlags .LocalFlags).FlagUsages | trimTrailingWhitespaces}} + +Linux‑Specific Flags: + (Valid only with --target=linux) + +{{(linuxFlags .LocalFlags).FlagUsages | trimTrailingWhitespaces}} + +Kubernetes‑Specific Flags: + (Valid only with --target=kubernetes) + +{{(kubernetesFlags .LocalFlags).FlagUsages | trimTrailingWhitespaces}}{{else}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasExample}} + +Examples: +{{.Example}}{{end}}{{if .HasHelpSubCommands}} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} +{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +` +) + +func init() { + cobra.AddTemplateFuncs(template.FuncMap{ + // usesTargetFlagMenu returns true if the target flag is present and the ENABLE_V3 environment variable is set. + "usesTargetFlagMenu": func(flagSet *pflag.FlagSet) bool { + if isV3Enabled() { + return flagSet.Lookup("target") != nil + } + return false + }, + // commonFlags returns a flag set with all flags that do not have a target annotation. + "commonFlags": func(flagSet *pflag.FlagSet) *pflag.FlagSet { + return filterFlagSetNoTarget(flagSet) + }, + // linuxFlags returns a flag set with all flags that have the target annotation set to linux. + "linuxFlags": func(flagSet *pflag.FlagSet) *pflag.FlagSet { + return filterFlagSetByTarget(flagSet, flagAnnotationTargetValueLinux) + }, + // kubernetesFlags returns a flag set with all flags that have the target annotation set to kubernetes. + "kubernetesFlags": func(flagSet *pflag.FlagSet) *pflag.FlagSet { + return filterFlagSetByTarget(flagSet, flagAnnotationTargetValueKubernetes) + }, + }) +} + +func isV3Enabled() bool { + return os.Getenv("ENABLE_V3") == "1" +} + +func mustSetFlagTargetLinux(flags *pflag.FlagSet, name string) { + mustSetFlagTarget(flags, name, flagAnnotationTargetValueLinux) +} + +func mustSetFlagTargetKubernetes(flags *pflag.FlagSet, name string) { + mustSetFlagTarget(flags, name, flagAnnotationTargetValueKubernetes) +} + +func mustSetFlagTarget(flags *pflag.FlagSet, name string, target string) { + err := flags.SetAnnotation(name, flagAnnotationTarget, []string{target}) + if err != nil { + panic(err) + } +} + +func mustMarkFlagRequired(flags *pflag.FlagSet, name string) { + err := cobra.MarkFlagRequired(flags, name) + if err != nil { + panic(err) + } +} + +func mustMarkFlagHidden(flags *pflag.FlagSet, name string) { + err := flags.MarkHidden(name) + if err != nil { + panic(err) + } +} + +func mustMarkFlagDeprecated(flags *pflag.FlagSet, name string, deprecationMessage string) { + err := flags.MarkDeprecated(name, deprecationMessage) + if err != nil { + panic(err) + } +} + +func filterFlagSetByTarget(flags *pflag.FlagSet, target string) *pflag.FlagSet { + if flags == nil { + return nil + } + next := pflag.NewFlagSet(flags.Name(), pflag.ContinueOnError) + flags.VisitAll(func(flag *pflag.Flag) { + for _, t := range flag.Annotations[flagAnnotationTarget] { + if t == target { + next.AddFlag(flag) + break + } + } + }) + return next +} + +func filterFlagSetNoTarget(flags *pflag.FlagSet) *pflag.FlagSet { + if flags == nil { + return nil + } + next := pflag.NewFlagSet(flags.Name(), pflag.ContinueOnError) + flags.VisitAll(func(flag *pflag.Flag) { + if len(flag.Annotations[flagAnnotationTarget]) == 0 { + next.AddFlag(flag) + } + }) + return next +} diff --git a/cmd/installer/cli/install.go b/cmd/installer/cli/install.go index 440150843d..a9d2cfbff3 100644 --- a/cmd/installer/cli/install.go +++ b/cmd/installer/cli/install.go @@ -7,17 +7,22 @@ import ( "fmt" "io/fs" "os" + "path/filepath" + "slices" "strings" "syscall" "time" "github.com/AlecAivazis/survey/v2/terminal" + "github.com/google/uuid" k0sv1beta1 "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" apitypes "github.com/replicatedhq/embedded-cluster/api/types" "github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/cloudutils" newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" + "github.com/replicatedhq/embedded-cluster/pkg-new/domains" "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" "github.com/replicatedhq/embedded-cluster/pkg-new/k0s" ecmetadata "github.com/replicatedhq/embedded-cluster/pkg-new/metadata" @@ -31,8 +36,8 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/extensions" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/helpers" + "github.com/replicatedhq/embedded-cluster/pkg/kubernetesinstallation" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" - "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/netutils" "github.com/replicatedhq/embedded-cluster/pkg/prompts" "github.com/replicatedhq/embedded-cluster/pkg/release" @@ -44,77 +49,99 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" + helmcli "helm.sh/helm/v3/pkg/cli" + "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" ) type InstallCmdFlags struct { - adminConsolePassword string - adminConsolePort int - airgapBundle string - isAirgap bool + adminConsolePassword string + adminConsolePort int + airgapBundle string + isAirgap bool + licenseFile string + assumeYes bool + overrides string + configValues string + + // linux flags dataDir string - licenseFile string localArtifactMirrorPort int - assumeYes bool - overrides string skipHostPreflights bool ignoreHostPreflights bool - configValues string networkInterface string + // kubernetes flags + kubernetesEnvSettings *helmcli.EnvSettings + // guided UI flags enableManagerExperience bool + target string managerPort int tlsCertFile string tlsKeyFile string hostname string - // TODO: move to substruct + installConfig +} + +type installConfig struct { + clusterID string license *kotsv1beta1.License - proxy *ecv1beta1.ProxySpec - cidrCfg *newconfig.CIDRConfig + licenseBytes []byte tlsCert tls.Certificate tlsCertBytes []byte tlsKeyBytes []byte + + kubernetesRESTClientGetterFactory func(namespace string) genericclioptions.RESTClientGetter } // webAssetsFS is the filesystem to be used by the web component. Defaults to nil allowing the web server to use the default assets embedded in the binary. Useful for testing. var webAssetsFS fs.FS = nil // InstallCmd returns a cobra command for installing the embedded cluster. -func InstallCmd(ctx context.Context, name string) *cobra.Command { +func InstallCmd(ctx context.Context, appSlug, appTitle string) *cobra.Command { var flags InstallCmdFlags ctx, cancel := context.WithCancel(ctx) + rc := runtimeconfig.New(nil) + ki := kubernetesinstallation.New(nil) + + short := fmt.Sprintf("Install %s", appTitle) + if isV3Enabled() { + short = fmt.Sprintf("Install %s onto Linux or Kubernetes", appTitle) + } cmd := &cobra.Command{ - Use: "install", - Short: fmt.Sprintf("Install %s", name), + Use: "install", + Short: short, + Example: installCmdExample(appSlug), PostRun: func(cmd *cobra.Command, args []string) { rc.Cleanup() cancel() // Cancel context when command completes }, RunE: func(cmd *cobra.Command, args []string) error { - if err := verifyAndPrompt(ctx, name, flags, prompts.New()); err != nil { + if err := preRunInstall(cmd, &flags, rc, ki); err != nil { return err } - if err := preRunInstall(cmd, &flags, rc); err != nil { + if err := verifyAndPrompt(ctx, cmd, appSlug, &flags, prompts.New()); err != nil { return err } - if flags.enableManagerExperience { - return runManagerExperienceInstall(ctx, flags, rc) - } - - clusterID := metrics.ClusterID() installReporter := newInstallReporter( - replicatedAppURL(), clusterID, cmd.CalledAs(), flagsToStringSlice(cmd.Flags()), - flags.license.Spec.LicenseID, flags.license.Spec.AppSlug, + replicatedAppURL(), cmd.CalledAs(), flagsToStringSlice(cmd.Flags()), + flags.license.Spec.LicenseID, flags.clusterID, flags.license.Spec.AppSlug, ) installReporter.ReportInstallationStarted(ctx) + if flags.enableManagerExperience { + return runManagerExperienceInstall(ctx, flags, rc, ki, installReporter, appTitle) + } + + _ = rc.SetEnv() + // Setup signal handler with the metrics reporter cleanup function signalHandler(ctx, cancel, func(ctx context.Context, sig os.Signal) { installReporter.ReportSignalAborted(ctx, sig) @@ -135,110 +162,249 @@ func InstallCmd(ctx context.Context, name string) *cobra.Command { }, } - if err := addInstallFlags(cmd, &flags); err != nil { - panic(err) - } + cmd.SetUsageTemplate(defaultUsageTemplateV3) + + mustAddInstallFlags(cmd, &flags) + if err := addInstallAdminConsoleFlags(cmd, &flags); err != nil { panic(err) } - if err := addManagerExperienceFlags(cmd, &flags); err != nil { + if err := addManagementConsoleFlags(cmd, &flags); err != nil { panic(err) } - cmd.AddCommand(InstallRunPreflightsCmd(ctx, name)) + cmd.AddCommand(InstallRunPreflightsCmd(ctx, appSlug)) return cmd } -func addInstallFlags(cmd *cobra.Command, flags *InstallCmdFlags) error { - cmd.Flags().StringVar(&flags.airgapBundle, "airgap-bundle", "", "Path to the air gap bundle. If set, the installation will complete without internet access.") - cmd.Flags().StringVar(&flags.dataDir, "data-dir", ecv1beta1.DefaultDataDir, "Path to the data directory") - cmd.Flags().IntVar(&flags.localArtifactMirrorPort, "local-artifact-mirror-port", ecv1beta1.DefaultLocalArtifactMirrorPort, "Port on which the Local Artifact Mirror will be served") - cmd.Flags().StringVar(&flags.networkInterface, "network-interface", "", "The network interface to use for the cluster") - cmd.Flags().BoolVarP(&flags.assumeYes, "yes", "y", false, "Assume yes to all prompts.") - cmd.Flags().SetNormalizeFunc(normalizeNoPromptToYes) +const ( + installCmdExampleText = ` + # Install on a Linux host + %s install \ + --target linux \ + --data-dir /opt/embedded-cluster \ + --license ./license.yaml \ + --yes + + # Install in a Kubernetes cluster + %s install \ + --target kubernetes \ + --kubeconfig ./kubeconfig \ + --airgap-bundle ./replicated.airgap \ + --license ./license.yaml +` +) - cmd.Flags().StringVar(&flags.overrides, "overrides", "", "File with an EmbeddedClusterConfig object to override the default configuration") - if err := cmd.Flags().MarkHidden("overrides"); err != nil { - return err +func installCmdExample(appSlug string) string { + if !isV3Enabled() { + return "" } - cmd.Flags().StringSlice("private-ca", []string{}, "Path to a trusted private CA certificate file") - if err := cmd.Flags().MarkHidden("private-ca"); err != nil { - return err - } - if err := cmd.Flags().MarkDeprecated("private-ca", "This flag is no longer used and will be removed in a future version. The CA bundle will be automatically detected from the host."); err != nil { - return err + return fmt.Sprintf(installCmdExampleText, appSlug, appSlug) +} + +func mustAddInstallFlags(cmd *cobra.Command, flags *InstallCmdFlags) { + enableV3 := isV3Enabled() + + normalizeFuncs := []func(f *pflag.FlagSet, name string) pflag.NormalizedName{} + + commonFlagSet := newCommonInstallFlags(flags, enableV3) + cmd.Flags().AddFlagSet(commonFlagSet) + if fn := commonFlagSet.GetNormalizeFunc(); fn != nil { + normalizeFuncs = append(normalizeFuncs, fn) } - if err := addProxyFlags(cmd); err != nil { - return err + linuxFlagSet := newLinuxInstallFlags(flags, enableV3) + cmd.Flags().AddFlagSet(linuxFlagSet) + if fn := linuxFlagSet.GetNormalizeFunc(); fn != nil { + normalizeFuncs = append(normalizeFuncs, fn) } - if err := addCIDRFlags(cmd); err != nil { - return err + + kubernetesFlagSet := newKubernetesInstallFlags(flags, enableV3) + cmd.Flags().AddFlagSet(kubernetesFlagSet) + if fn := kubernetesFlagSet.GetNormalizeFunc(); fn != nil { + normalizeFuncs = append(normalizeFuncs, fn) } - cmd.Flags().BoolVar(&flags.skipHostPreflights, "skip-host-preflights", false, "Skip host preflight checks. This is not recommended and has been deprecated.") - if err := cmd.Flags().MarkHidden("skip-host-preflights"); err != nil { - return err + cmd.Flags().SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName { + result := pflag.NormalizedName(strings.ToLower(name)) + for _, fn := range normalizeFuncs { + if fn != nil { + result = fn(f, string(result)) + } + } + return result + }) +} + +func newCommonInstallFlags(flags *InstallCmdFlags, enableV3 bool) *pflag.FlagSet { + flagSet := pflag.NewFlagSet("common", pflag.ContinueOnError) + + flagSet.StringVar(&flags.target, "target", "", "The target platform to install to. Valid options are 'linux' or 'kubernetes'.") + if enableV3 { + mustMarkFlagRequired(flagSet, "target") + } else { + mustMarkFlagHidden(flagSet, "target") } - if err := cmd.Flags().MarkDeprecated("skip-host-preflights", "This flag is deprecated and will be removed in a future version. Use --ignore-host-preflights instead."); err != nil { - return err + + flagSet.StringVar(&flags.airgapBundle, "airgap-bundle", "", "Path to the air gap bundle. If set, the installation will complete without internet access.") + + flagSet.StringVar(&flags.overrides, "overrides", "", "File with an EmbeddedClusterConfig object to override the default configuration") + mustMarkFlagHidden(flagSet, "overrides") + + mustAddProxyFlags(flagSet) + + flagSet.BoolVarP(&flags.assumeYes, "yes", "y", false, "Assume yes to all prompts.") + flagSet.SetNormalizeFunc(normalizeNoPromptToYes) + + return flagSet +} + +func newLinuxInstallFlags(flags *InstallCmdFlags, enableV3 bool) *pflag.FlagSet { + flagSet := pflag.NewFlagSet("linux", pflag.ContinueOnError) + + // Use the app slug as default data directory only when ENABLE_V3 is set + defaultDataDir := ecv1beta1.DefaultDataDir + if enableV3 { + defaultDataDir = filepath.Join("/var/lib", runtimeconfig.AppSlug()) } - cmd.Flags().BoolVar(&flags.ignoreHostPreflights, "ignore-host-preflights", false, "Allow bypassing host preflight failures") - return nil + flagSet.StringVar(&flags.dataDir, "data-dir", defaultDataDir, "Path to the data directory") + flagSet.IntVar(&flags.localArtifactMirrorPort, "local-artifact-mirror-port", ecv1beta1.DefaultLocalArtifactMirrorPort, "Port on which the Local Artifact Mirror will be served") + flagSet.StringVar(&flags.networkInterface, "network-interface", "", "The network interface to use for the cluster") + + flagSet.StringSlice("private-ca", []string{}, "Path to a trusted private CA certificate file") + mustMarkFlagHidden(flagSet, "private-ca") + mustMarkFlagDeprecated(flagSet, "private-ca", "This flag is no longer used and will be removed in a future version. The CA bundle will be automatically detected from the host.") + + flagSet.BoolVar(&flags.skipHostPreflights, "skip-host-preflights", false, "Skip host preflight checks. This is not recommended and has been deprecated.") + mustMarkFlagHidden(flagSet, "skip-host-preflights") + mustMarkFlagDeprecated(flagSet, "skip-host-preflights", "This flag is deprecated and will be removed in a future version. Use --ignore-host-preflights instead.") + + flagSet.BoolVar(&flags.ignoreHostPreflights, "ignore-host-preflights", false, "Allow bypassing host preflight failures") + + mustAddCIDRFlags(flagSet) + + flagSet.VisitAll(func(flag *pflag.Flag) { + mustSetFlagTargetLinux(flagSet, flag.Name) + }) + + return flagSet +} + +func newKubernetesInstallFlags(flags *InstallCmdFlags, enableV3 bool) *pflag.FlagSet { + flagSet := pflag.NewFlagSet("kubernetes", pflag.ContinueOnError) + + addKubernetesCLIFlags(flagSet, flags) + + flagSet.VisitAll(func(flag *pflag.Flag) { + if !enableV3 { + mustMarkFlagHidden(flagSet, flag.Name) + } + mustSetFlagTargetKubernetes(flagSet, flag.Name) + }) + + return flagSet +} + +func addKubernetesCLIFlags(flagSet *pflag.FlagSet, flags *InstallCmdFlags) { + // From helm + // https://github.com/helm/helm/blob/v3.18.3/pkg/cli/environment.go#L145-L163 + + s := helmcli.New() + + flagSet.StringVar(&s.KubeConfig, "kubeconfig", "", "Path to the kubeconfig file") + flagSet.StringVar(&s.KubeContext, "kube-context", s.KubeContext, "Name of the kubeconfig context to use") + flagSet.StringVar(&s.KubeToken, "kube-token", s.KubeToken, "Bearer token used for authentication") + flagSet.StringVar(&s.KubeAsUser, "kube-as-user", s.KubeAsUser, "Username to impersonate for the operation") + flagSet.StringArrayVar(&s.KubeAsGroups, "kube-as-group", s.KubeAsGroups, "Group to impersonate for the operation, this flag can be repeated to specify multiple groups.") + flagSet.StringVar(&s.KubeAPIServer, "kube-apiserver", s.KubeAPIServer, "The address and the port for the Kubernetes API server") + flagSet.StringVar(&s.KubeCaFile, "kube-ca-file", s.KubeCaFile, "The certificate authority file for the Kubernetes API server connection") + flagSet.StringVar(&s.KubeTLSServerName, "kube-tls-server-name", s.KubeTLSServerName, "Server name to use for Kubernetes API server certificate validation. If it is not provided, the hostname used to contact the server is used") + // flagSet.BoolVar(&s.Debug, "helm-debug", s.Debug, "enable verbose output") + flagSet.BoolVar(&s.KubeInsecureSkipTLSVerify, "kube-insecure-skip-tls-verify", s.KubeInsecureSkipTLSVerify, "If true, the Kubernetes API server's certificate will not be checked for validity. This will make your HTTPS connections insecure") + // flagSet.StringVar(&s.RegistryConfig, "helm-registry-config", s.RegistryConfig, "Path to the Helm registry config file") + // flagSet.StringVar(&s.RepositoryConfig, "helm-repository-config", s.RepositoryConfig, "Path to the file containing Helm repository names and URLs") + // flagSet.StringVar(&s.RepositoryCache, "helm-repository-cache", s.RepositoryCache, "Path to the directory containing cached Helm repository indexes") + flagSet.IntVar(&s.BurstLimit, "burst-limit", s.BurstLimit, "Kubernetes API client-side default throttling limit") + flagSet.Float32Var(&s.QPS, "qps", s.QPS, "Queries per second used when communicating with the Kubernetes API, not including bursting") + + flags.kubernetesEnvSettings = s } func addInstallAdminConsoleFlags(cmd *cobra.Command, flags *InstallCmdFlags) error { cmd.Flags().StringVar(&flags.adminConsolePassword, "admin-console-password", "", "Password for the Admin Console") cmd.Flags().IntVar(&flags.adminConsolePort, "admin-console-port", ecv1beta1.DefaultAdminConsolePort, "Port on which the Admin Console will be served") cmd.Flags().StringVarP(&flags.licenseFile, "license", "l", "", "Path to the license file") - if err := cmd.MarkFlagRequired("license"); err != nil { - panic(err) - } + mustMarkFlagRequired(cmd.Flags(), "license") cmd.Flags().StringVar(&flags.configValues, "config-values", "", "Path to the config values to use when installing") return nil } -func addManagerExperienceFlags(cmd *cobra.Command, flags *InstallCmdFlags) error { - cmd.Flags().BoolVar(&flags.enableManagerExperience, "manager-experience", false, "Run the browser-based installation experience.") +func addManagementConsoleFlags(cmd *cobra.Command, flags *InstallCmdFlags) error { cmd.Flags().IntVar(&flags.managerPort, "manager-port", ecv1beta1.DefaultManagerPort, "Port on which the Manager will be served") cmd.Flags().StringVar(&flags.tlsCertFile, "tls-cert", "", "Path to the TLS certificate file") cmd.Flags().StringVar(&flags.tlsKeyFile, "tls-key", "", "Path to the TLS key file") cmd.Flags().StringVar(&flags.hostname, "hostname", "", "Hostname to use for TLS configuration") - if err := cmd.Flags().MarkHidden("manager-experience"); err != nil { - return err + // If the ENABLE_V3 environment variable is set, default to the new manager experience and do + // not hide the new flags. + if !isV3Enabled() { + if err := cmd.Flags().MarkHidden("manager-port"); err != nil { + return err + } + if err := cmd.Flags().MarkHidden("tls-cert"); err != nil { + return err + } + if err := cmd.Flags().MarkHidden("tls-key"); err != nil { + return err + } + if err := cmd.Flags().MarkHidden("hostname"); err != nil { + return err + } } - if err := cmd.Flags().MarkHidden("manager-port"); err != nil { - return err + + return nil +} + +func preRunInstall(cmd *cobra.Command, flags *InstallCmdFlags, rc runtimeconfig.RuntimeConfig, ki kubernetesinstallation.Installation) error { + if !isV3Enabled() { + flags.target = "linux" } - if err := cmd.Flags().MarkHidden("tls-cert"); err != nil { - return err + + if !slices.Contains([]string{"linux", "kubernetes"}, flags.target) { + return fmt.Errorf(`invalid --target (must be one of: "linux", "kubernetes")`) } - if err := cmd.Flags().MarkHidden("tls-key"); err != nil { + + flags.clusterID = uuid.New().String() + + if err := preRunInstallCommon(cmd, flags, rc, ki); err != nil { return err } - if err := cmd.Flags().MarkHidden("hostname"); err != nil { - return err + + switch flags.target { + case "linux": + return preRunInstallLinux(cmd, flags, rc) + case "kubernetes": + return preRunInstallKubernetes(cmd, flags, ki) } return nil } -func preRunInstall(cmd *cobra.Command, flags *InstallCmdFlags, rc runtimeconfig.RuntimeConfig) error { - if os.Getuid() != 0 { - return fmt.Errorf("install command must be run as root") - } - - // set the umask to 022 so that we can create files/directories with 755 permissions - // this does not return an error - it returns the previous umask - _ = syscall.Umask(0o022) +func preRunInstallCommon(cmd *cobra.Command, flags *InstallCmdFlags, rc runtimeconfig.RuntimeConfig, ki kubernetesinstallation.Installation) error { + flags.enableManagerExperience = isV3Enabled() // license file can be empty for restore if flags.licenseFile != "" { + b, err := os.ReadFile(flags.licenseFile) + if err != nil { + return fmt.Errorf("unable to read license file: %w", err) + } + flags.licenseBytes = b + // validate the the license is indeed a license file l, err := helpers.ParseLicense(flags.licenseFile) if err != nil { @@ -260,49 +426,47 @@ func preRunInstall(cmd *cobra.Command, flags *InstallCmdFlags, rc runtimeconfig. flags.isAirgap = flags.airgapBundle != "" - hostCABundlePath, err := findHostCABundle() + if flags.managerPort != 0 && flags.adminConsolePort != 0 { + if flags.managerPort == flags.adminConsolePort { + return fmt.Errorf("manager port cannot be the same as admin console port") + } + } + + proxy, err := proxyConfigFromCmd(cmd, flags.assumeYes) if err != nil { - return fmt.Errorf("unable to find host CA bundle: %w", err) + return err } - logrus.Debugf("using host CA bundle: %s", hostCABundlePath) - // update the runtime config - rc.SetHostCABundlePath(hostCABundlePath) + rc.SetAdminConsolePort(flags.adminConsolePort) + ki.SetAdminConsolePort(flags.adminConsolePort) + rc.SetManagerPort(flags.managerPort) + ki.SetManagerPort(flags.managerPort) - // restore command doesn't have a password flag - if cmd.Flags().Lookup("admin-console-password") != nil { - if err := ensureAdminConsolePassword(flags); err != nil { - return err - } - } + rc.SetProxySpec(proxy) + ki.SetProxySpec(proxy) - // everything below this point is not relevant for the manager experience - if flags.enableManagerExperience { - return nil - } + return nil +} - proxy, err := parseProxyFlags(cmd) - if err != nil { - return err +func preRunInstallLinux(cmd *cobra.Command, flags *InstallCmdFlags, rc runtimeconfig.RuntimeConfig) error { + if !cmd.Flags().Changed("skip-host-preflights") && (os.Getenv("SKIP_HOST_PREFLIGHTS") == "1" || os.Getenv("SKIP_HOST_PREFLIGHTS") == "true") { + flags.skipHostPreflights = true } - flags.proxy = proxy - if err := verifyProxyConfig(flags.proxy, prompts.New(), flags.assumeYes); err != nil { - return err + if os.Getuid() != 0 { + return fmt.Errorf("install command must be run as root") } - logrus.Debug("User confirmed prompt to proceed installing with `http_proxy` set and `https_proxy` unset") - if err := validateCIDRFlags(cmd); err != nil { - return err - } + // set the umask to 022 so that we can create files/directories with 755 permissions + // this does not return an error - it returns the previous umask + _ = syscall.Umask(0o022) - // parse the various cidr flags to make sure we have exactly what we want - cidrCfg, err := getCIDRConfig(cmd) + hostCABundlePath, err := findHostCABundle() if err != nil { - return fmt.Errorf("unable to determine pod and service CIDRs: %w", err) + return fmt.Errorf("unable to find host CA bundle: %w", err) } - flags.cidrCfg = cidrCfg + logrus.Debugf("using host CA bundle: %s", hostCABundlePath) // if a network interface flag was not provided, attempt to discover it if flags.networkInterface == "" { @@ -318,18 +482,99 @@ func preRunInstall(cmd *cobra.Command, flags *InstallCmdFlags, rc runtimeconfig. } } + eucfg, err := helpers.ParseEndUserConfig(flags.overrides) + if err != nil { + return fmt.Errorf("process overrides file: %w", err) + } + + cidrCfg, err := cidrConfigFromCmd(cmd) + if err != nil { + return err + } + + k0sCfg, err := k0s.NewK0sConfig(flags.networkInterface, flags.isAirgap, cidrCfg.PodCIDR, cidrCfg.ServiceCIDR, eucfg, nil) + if err != nil { + return fmt.Errorf("unable to create k0s config: %w", err) + } + networkSpec := helpers.NetworkSpecFromK0sConfig(k0sCfg) + networkSpec.NetworkInterface = flags.networkInterface + if cidrCfg.GlobalCIDR != nil { + networkSpec.GlobalCIDR = *cidrCfg.GlobalCIDR + } + // TODO: validate that a single port isn't used for multiple services rc.SetDataDir(flags.dataDir) rc.SetLocalArtifactMirrorPort(flags.localArtifactMirrorPort) - rc.SetAdminConsolePort(flags.adminConsolePort) + rc.SetHostCABundlePath(hostCABundlePath) + rc.SetNetworkSpec(networkSpec) + + return nil +} + +func preRunInstallKubernetes(_ *cobra.Command, flags *InstallCmdFlags, _ kubernetesinstallation.Installation) error { + // TODO: we only support amd64 clusters for target=kubernetes installs + helpers.SetClusterArch("amd64") + + // If set, validate that the kubeconfig file exists and can be read + if flags.kubernetesEnvSettings.KubeConfig != "" { + if _, err := os.Stat(flags.kubernetesEnvSettings.KubeConfig); os.IsNotExist(err) { + return fmt.Errorf("kubeconfig file does not exist: %s", flags.kubernetesEnvSettings.KubeConfig) + } else if err != nil { + return fmt.Errorf("unable to stat kubeconfig file: %w", err) + } + } - os.Setenv("KUBECONFIG", rc.PathToKubeConfig()) // this is needed for restore as well since it shares this function - os.Setenv("TMPDIR", rc.EmbeddedClusterTmpSubDir()) + restConfig, err := flags.kubernetesEnvSettings.RESTClientGetter().ToRESTConfig() + if err != nil { + return fmt.Errorf("failed to discover kubeconfig: %w", err) + } + + // If this is the default host, there was probably no kubeconfig discovered. + // HACK: This is fragile but it is the best thing I could come up with + if flags.kubernetesEnvSettings.KubeConfig == "" && restConfig.Host == "http://localhost:8080" { + return fmt.Errorf("a kubeconfig is required when using kubernetes") + } + + flags.installConfig.kubernetesRESTClientGetterFactory = func(namespace string) genericclioptions.RESTClientGetter { + // TODO: this is not thread safe + flags.kubernetesEnvSettings.SetNamespace(namespace) + return flags.kubernetesEnvSettings.RESTClientGetter() + } return nil } -func runManagerExperienceInstall(ctx context.Context, flags InstallCmdFlags, rc runtimeconfig.RuntimeConfig) (finalErr error) { +func proxyConfigFromCmd(cmd *cobra.Command, assumeYes bool) (*ecv1beta1.ProxySpec, error) { + proxy, err := parseProxyFlags(cmd) + if err != nil { + return nil, err + } + + if err := verifyProxyConfig(proxy, prompts.New(), assumeYes); err != nil { + return nil, err + } + + return proxy, nil +} + +func cidrConfigFromCmd(cmd *cobra.Command) (*newconfig.CIDRConfig, error) { + if err := validateCIDRFlags(cmd); err != nil { + return nil, err + } + + // parse the various cidr flags to make sure we have exactly what we want + cidrCfg, err := getCIDRConfig(cmd) + if err != nil { + return nil, fmt.Errorf("unable to determine pod and service CIDRs: %w", err) + } + + return cidrCfg, nil +} + +func runManagerExperienceInstall( + ctx context.Context, flags InstallCmdFlags, rc runtimeconfig.RuntimeConfig, ki kubernetesinstallation.Installation, + installReporter *InstallReporter, appTitle string, +) (finalErr error) { // this is necessary because the api listens on all interfaces, // and we only know the interface to use when the user selects it in the ui ipAddresses, err := netutils.ListAllValidIPAddresses() @@ -385,30 +630,46 @@ func runManagerExperienceInstall(ctx context.Context, flags InstallCmdFlags, rc return fmt.Errorf("process overrides file: %w", err) } - apiConfig := apiConfig{ - // TODO (@salah): implement reporting in api - // MetricsReporter: installReporter, - RuntimeConfig: rc, - Password: flags.adminConsolePassword, - TLSConfig: apitypes.TLSConfig{ - CertBytes: flags.tlsCertBytes, - KeyBytes: flags.tlsKeyBytes, - Hostname: flags.hostname, + apiConfig := apiOptions{ + APIConfig: apitypes.APIConfig{ + Password: flags.adminConsolePassword, + TLSConfig: apitypes.TLSConfig{ + CertBytes: flags.tlsCertBytes, + KeyBytes: flags.tlsKeyBytes, + Hostname: flags.hostname, + }, + License: flags.licenseBytes, + AirgapBundle: flags.airgapBundle, + ConfigValues: flags.configValues, + ReleaseData: release.GetReleaseData(), + EndUserConfig: eucfg, + ClusterID: flags.clusterID, + + LinuxConfig: apitypes.LinuxConfig{ + RuntimeConfig: rc, + AllowIgnoreHostPreflights: flags.ignoreHostPreflights, + }, + KubernetesConfig: apitypes.KubernetesConfig{ + RESTClientGetterFactory: flags.installConfig.kubernetesRESTClientGetterFactory, + Installation: ki, + }, }, - ManagerPort: flags.managerPort, - LicenseFile: flags.licenseFile, - AirgapBundle: flags.airgapBundle, - ConfigValues: flags.configValues, - ReleaseData: release.GetReleaseData(), - EndUserConfig: eucfg, + + ManagerPort: flags.managerPort, + InstallTarget: flags.target, + MetricsReporter: installReporter.reporter, } - if err := startAPI(ctx, flags.tlsCert, apiConfig); err != nil { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + if err := startAPI(ctx, flags.tlsCert, apiConfig, cancel); err != nil { return fmt.Errorf("unable to start api: %w", err) } - // TODO: add app name to this message (e.g., App Name manager) - logrus.Infof("\nVisit the manager to continue: %s\n", getManagerURL(flags.hostname, flags.managerPort)) + logrus.Infof("\nVisit the %s manager to continue: %s\n", + appTitle, + getManagerURL(flags.hostname, flags.managerPort)) <-ctx.Done() return nil @@ -432,8 +693,7 @@ func runInstall(ctx context.Context, flags InstallCmdFlags, rc runtimeconfig.Run return fmt.Errorf("unable to run install preflights: %w", err) } - k0sCfg, err := installAndStartCluster(ctx, flags, rc, nil) - if err != nil { + if _, err := installAndStartCluster(ctx, flags, rc, nil); err != nil { return fmt.Errorf("unable to install cluster: %w", err) } @@ -450,7 +710,7 @@ func runInstall(ctx context.Context, flags InstallCmdFlags, rc runtimeconfig.Run errCh := kubeutils.WaitForKubernetes(ctx, kcli) defer logKubernetesErrors(errCh) - in, err := recordInstallation(ctx, kcli, flags, rc, k0sCfg, flags.license) + in, err := recordInstallation(ctx, kcli, flags, rc) if err != nil { return fmt.Errorf("unable to record installation: %w", err) } @@ -462,11 +722,11 @@ func runInstall(ctx context.Context, flags InstallCmdFlags, rc runtimeconfig.Run // TODO (@salah): update installation status to reflect what's happening logrus.Debugf("adding insecure registry") - registryIP, err := registry.GetRegistryClusterIP(flags.cidrCfg.ServiceCIDR) + registryIP, err := registry.GetRegistryClusterIP(rc.ServiceCIDR()) if err != nil { return fmt.Errorf("unable to get registry cluster IP: %w", err) } - if err := airgap.AddInsecureRegistry(fmt.Sprintf("%s:5000", registryIP)); err != nil { + if err := hostutils.AddInsecureRegistry(fmt.Sprintf("%s:5000", registryIP)); err != nil { return fmt.Errorf("unable to add insecure registry: %w", err) } @@ -486,7 +746,7 @@ func runInstall(ctx context.Context, flags InstallCmdFlags, rc runtimeconfig.Run defer hcli.Close() logrus.Debugf("installing addons") - if err := installAddons(ctx, kcli, mcli, hcli, rc, flags); err != nil { + if err := installAddons(ctx, kcli, mcli, hcli, flags, rc); err != nil { return err } @@ -499,7 +759,7 @@ func runInstall(ctx context.Context, flags InstallCmdFlags, rc runtimeconfig.Run return fmt.Errorf("unable to update installation: %w", err) } - if err = support.CreateHostSupportBundle(); err != nil { + if err = support.CreateHostSupportBundle(ctx, kcli); err != nil { logrus.Warnf("Unable to create host support bundle: %v", err) } @@ -508,9 +768,61 @@ func runInstall(ctx context.Context, flags InstallCmdFlags, rc runtimeconfig.Run return nil } -func verifyAndPrompt(ctx context.Context, name string, flags InstallCmdFlags, prompt prompts.Prompt) error { +func getAddonInstallOpts(flags InstallCmdFlags, rc runtimeconfig.RuntimeConfig, loading **spinner.MessageWriter) (*addons.InstallOptions, error) { + var embCfgSpec *ecv1beta1.ConfigSpec + if embCfg := release.GetEmbeddedClusterConfig(); embCfg != nil { + embCfgSpec = &embCfg.Spec + } + + euCfg, err := helpers.ParseEndUserConfig(flags.overrides) + if err != nil { + return nil, fmt.Errorf("unable to process overrides file: %w", err) + } + var euCfgSpec *ecv1beta1.ConfigSpec + if euCfg != nil { + euCfgSpec = &euCfg.Spec + } + + opts := &addons.InstallOptions{ + ClusterID: flags.clusterID, + AdminConsolePwd: flags.adminConsolePassword, + AdminConsolePort: rc.AdminConsolePort(), + License: flags.license, + IsAirgap: flags.airgapBundle != "", + TLSCertBytes: flags.tlsCertBytes, + TLSKeyBytes: flags.tlsKeyBytes, + Hostname: flags.hostname, + DisasterRecoveryEnabled: flags.license.Spec.IsDisasterRecoverySupported, + IsMultiNodeEnabled: flags.license.Spec.IsEmbeddedClusterMultiNodeEnabled, + EmbeddedConfigSpec: embCfgSpec, + EndUserConfigSpec: euCfgSpec, + ProxySpec: rc.ProxySpec(), + HostCABundlePath: rc.HostCABundlePath(), + DataDir: rc.EmbeddedClusterHomeDirectory(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), + OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), + ServiceCIDR: rc.ServiceCIDR(), + KotsInstaller: func() error { + opts := kotscli.InstallOptions{ + RuntimeConfig: rc, + AppSlug: flags.license.Spec.AppSlug, + License: flags.licenseBytes, + Namespace: constants.KotsadmNamespace, + ClusterID: flags.clusterID, + AirgapBundle: flags.airgapBundle, + ConfigValuesFile: flags.configValues, + ReplicatedAppEndpoint: replicatedAppURL(), + Stdout: *loading, + } + return kotscli.Install(opts) + }, + } + return opts, nil +} + +func verifyAndPrompt(ctx context.Context, cmd *cobra.Command, appSlug string, flags *InstallCmdFlags, prompt prompts.Prompt) error { logrus.Debugf("checking if k0s is already installed") - err := verifyNoInstallation(name, "reinstall") + err := verifyNoInstallation(appSlug, "reinstall") if err != nil { return err } @@ -547,6 +859,13 @@ func verifyAndPrompt(ctx context.Context, name string, flags InstallCmdFlags, pr return err } + // restore command doesn't have a password flag + if cmd.Flags().Lookup("admin-console-password") != nil { + if err := ensureAdminConsolePassword(flags); err != nil { + return err + } + } + return nil } @@ -682,7 +1001,7 @@ func verifyChannelRelease(cmdName string, isAirgap bool, assumeYes bool) error { return nil } -func verifyNoInstallation(name string, cmdName string) error { +func verifyNoInstallation(appSlug string, cmdName string) error { installed, err := k0s.IsInstalled() if err != nil { return err @@ -691,7 +1010,7 @@ func verifyNoInstallation(name string, cmdName string) error { logrus.Errorf("\nAn installation is detected on this machine.") logrus.Infof("To %s, you must first remove the existing installation.", cmdName) logrus.Infof("You can do this by running the following command:") - logrus.Infof("\n sudo ./%s reset\n", name) + logrus.Infof("\n sudo ./%s reset\n", appSlug) return NewErrorNothingElseToAdd(errors.New("previous installation detected")) } return nil @@ -702,11 +1021,14 @@ func initializeInstall(ctx context.Context, flags InstallCmdFlags, rc runtimecon spinner := spinner.Start() spinner.Infof("Initializing") + licenseBytes, err := os.ReadFile(flags.licenseFile) + if err != nil { + return fmt.Errorf("unable to read license file: %w", err) + } + if err := hostutils.ConfigureHost(ctx, rc, hostutils.InitForInstallOptions{ - LicenseFile: flags.licenseFile, + License: licenseBytes, AirgapBundle: flags.airgapBundle, - PodCIDR: flags.cidrCfg.PodCIDR, - ServiceCIDR: flags.cidrCfg.ServiceCIDR, }); err != nil { spinner.ErrorClosef("Initialization failed") return fmt.Errorf("configure host: %w", err) @@ -726,20 +1048,20 @@ func installAndStartCluster(ctx context.Context, flags InstallCmdFlags, rc runti return nil, fmt.Errorf("process overrides file: %w", err) } - cfg, err := k0s.WriteK0sConfig(ctx, flags.networkInterface, flags.airgapBundle, flags.cidrCfg.PodCIDR, flags.cidrCfg.ServiceCIDR, eucfg, mutate) + cfg, err := k0s.WriteK0sConfig(ctx, flags.networkInterface, flags.airgapBundle, rc.PodCIDR(), rc.ServiceCIDR(), eucfg, mutate) if err != nil { loading.ErrorClosef("Failed to install node") return nil, fmt.Errorf("create config file: %w", err) } logrus.Debugf("creating systemd unit files") - if err := hostutils.CreateSystemdUnitFiles(ctx, logrus.StandardLogger(), rc, false, flags.proxy); err != nil { + if err := hostutils.CreateSystemdUnitFiles(ctx, logrus.StandardLogger(), rc, false); err != nil { loading.ErrorClosef("Failed to install node") return nil, fmt.Errorf("create systemd unit files: %w", err) } logrus.Debugf("installing k0s") - if err := k0s.Install(rc, flags.networkInterface); err != nil { + if err := k0s.Install(rc); err != nil { loading.ErrorClosef("Failed to install node") return nil, fmt.Errorf("install cluster: %w", err) } @@ -761,21 +1083,7 @@ func installAndStartCluster(ctx context.Context, flags InstallCmdFlags, rc runti return cfg, nil } -func installAddons(ctx context.Context, kcli client.Client, mcli metadata.Interface, hcli helm.Client, rc runtimeconfig.RuntimeConfig, flags InstallCmdFlags) error { - var embCfgSpec *ecv1beta1.ConfigSpec - if embCfg := release.GetEmbeddedClusterConfig(); embCfg != nil { - embCfgSpec = &embCfg.Spec - } - - euCfg, err := helpers.ParseEndUserConfig(flags.overrides) - if err != nil { - return fmt.Errorf("process overrides file: %w", err) - } - var euCfgSpec *ecv1beta1.ConfigSpec - if euCfg != nil { - euCfgSpec = &euCfg.Spec - } - +func installAddons(ctx context.Context, kcli client.Client, mcli metadata.Interface, hcli helm.Client, flags InstallCmdFlags, rc runtimeconfig.RuntimeConfig) error { progressChan := make(chan addontypes.AddOnProgress) defer close(progressChan) @@ -799,43 +1107,31 @@ func installAddons(ctx context.Context, kcli client.Client, mcli metadata.Interf addons.WithKubernetesClient(kcli), addons.WithMetadataClient(mcli), addons.WithHelmClient(hcli), - addons.WithRuntimeConfig(rc), + addons.WithDomains(getDomains()), addons.WithProgressChannel(progressChan), ) - if err := addOns.Install(ctx, addons.InstallOptions{ - AdminConsolePwd: flags.adminConsolePassword, - License: flags.license, - IsAirgap: flags.airgapBundle != "", - Proxy: flags.proxy, - TLSCertBytes: flags.tlsCertBytes, - TLSKeyBytes: flags.tlsKeyBytes, - Hostname: flags.hostname, - ServiceCIDR: flags.cidrCfg.ServiceCIDR, - DisasterRecoveryEnabled: flags.license.Spec.IsDisasterRecoverySupported, - IsMultiNodeEnabled: flags.license.Spec.IsEmbeddedClusterMultiNodeEnabled, - EmbeddedConfigSpec: embCfgSpec, - EndUserConfigSpec: euCfgSpec, - KotsInstaller: func() error { - opts := kotscli.InstallOptions{ - RuntimeConfig: rc, - AppSlug: flags.license.Spec.AppSlug, - LicenseFile: flags.licenseFile, - Namespace: runtimeconfig.KotsadmNamespace, - AirgapBundle: flags.airgapBundle, - ConfigValuesFile: flags.configValues, - ReplicatedAppEndpoint: replicatedAppURL(), - Stdout: loading, - } - return kotscli.Install(opts) - }, - }); err != nil { + opts, err := getAddonInstallOpts(flags, rc, &loading) + if err != nil { + return fmt.Errorf("get addon install opts: %w", err) + } + + if err := addOns.Install(ctx, *opts); err != nil { return fmt.Errorf("install addons: %w", err) } return nil } +func getDomains() ecv1beta1.Domains { + var embCfgSpec *ecv1beta1.ConfigSpec + if embCfg := release.GetEmbeddedClusterConfig(); embCfg != nil { + embCfgSpec = &embCfg.Spec + } + + return domains.GetDomains(embCfgSpec, release.GetChannelRelease()) +} + func installExtensions(ctx context.Context, hcli helm.Client) error { progressChan := make(chan extensions.ExtensionsProgress) defer close(progressChan) @@ -966,6 +1262,7 @@ func verifyProxyConfig(proxy *ecv1beta1.ProxySpec, prompt prompts.Prompt, assume if !confirmed { return NewErrorNothingElseToAdd(errors.New("user aborted: HTTP proxy configured without HTTPS proxy")) } + logrus.Debug("User confirmed prompt to proceed installing with `http_proxy` set and `https_proxy` unset") } return nil } @@ -986,20 +1283,12 @@ func validateAdminConsolePassword(password, passwordCheck string) bool { } func replicatedAppURL() string { - var embCfgSpec *ecv1beta1.ConfigSpec - if embCfg := release.GetEmbeddedClusterConfig(); embCfg != nil { - embCfgSpec = &embCfg.Spec - } - domains := runtimeconfig.GetDomains(embCfgSpec) + domains := getDomains() return netutils.MaybeAddHTTPS(domains.ReplicatedAppDomain) } func proxyRegistryURL() string { - var embCfgSpec *ecv1beta1.ConfigSpec - if embCfg := release.GetEmbeddedClusterConfig(); embCfg != nil { - embCfgSpec = &embCfg.Spec - } - domains := runtimeconfig.GetDomains(embCfgSpec) + domains := getDomains() return netutils.MaybeAddHTTPS(domains.ProxyRegistryDomain) } @@ -1021,7 +1310,6 @@ func waitForNode(ctx context.Context) error { func recordInstallation( ctx context.Context, kcli client.Client, flags InstallCmdFlags, rc runtimeconfig.RuntimeConfig, - k0sCfg *k0sv1beta1.ClusterConfig, license *kotsv1beta1.License, ) (*ecv1beta1.Installation, error) { // get the embedded cluster config cfg := release.GetEmbeddedClusterConfig() @@ -1038,10 +1326,9 @@ func recordInstallation( // record the installation installation, err := kubeutils.RecordInstallation(ctx, kcli, kubeutils.RecordInstallationOptions{ + ClusterID: flags.clusterID, IsAirgap: flags.isAirgap, - Proxy: flags.proxy, - K0sConfig: k0sCfg, - License: license, + License: flags.license, ConfigSpec: cfgspec, MetricsBaseURL: replicatedAppURL(), RuntimeConfig: rc.Get(), @@ -1097,12 +1384,12 @@ func getAdminConsoleURL(hostname string, networkInterface string, port int) stri } ipaddr := cloudutils.TryDiscoverPublicIP() if ipaddr == "" { - var err error - ipaddr, err = netutils.FirstValidAddress(networkInterface) - if err != nil { - if addr := os.Getenv("EC_PUBLIC_ADDRESS"); addr != "" { - ipaddr = addr - } else { + if addr := os.Getenv("EC_PUBLIC_ADDRESS"); addr != "" { + ipaddr = addr + } else { + var err error + ipaddr, err = netutils.FirstValidAddress(networkInterface) + if err != nil { logrus.Errorf("Unable to determine node IP address: %v", err) ipaddr = "NODE-IP-ADDRESS" } diff --git a/cmd/installer/cli/install_runpreflights.go b/cmd/installer/cli/install_runpreflights.go index f78b0e9bfa..5377c9248c 100644 --- a/cmd/installer/cli/install_runpreflights.go +++ b/cmd/installer/cli/install_runpreflights.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "os" "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" "github.com/replicatedhq/embedded-cluster/pkg-new/preflights" + "github.com/replicatedhq/embedded-cluster/pkg/kubernetesinstallation" "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/netutils" "github.com/replicatedhq/embedded-cluster/pkg/prompts" @@ -20,26 +22,30 @@ import ( // they contain failures. We use this to differentiate the way we provide user feedback. var ErrPreflightsHaveFail = metrics.NewErrorNoFail(fmt.Errorf("host preflight failures detected")) -func InstallRunPreflightsCmd(ctx context.Context, name string) *cobra.Command { +func InstallRunPreflightsCmd(ctx context.Context, appSlug string) *cobra.Command { var flags InstallCmdFlags + rc := runtimeconfig.New(nil) + ki := kubernetesinstallation.New(nil) cmd := &cobra.Command{ Use: "run-preflights", Short: "Run install host preflights", Hidden: true, - PreRunE: func(cmd *cobra.Command, args []string) error { - if err := preRunInstall(cmd, &flags, rc); err != nil { - return err - } - - return nil - }, PostRun: func(cmd *cobra.Command, args []string) { rc.Cleanup() }, RunE: func(cmd *cobra.Command, args []string) error { - if err := runInstallRunPreflights(cmd.Context(), name, flags, rc); err != nil { + if err := preRunInstall(cmd, &flags, rc, ki); err != nil { + return err + } + if err := verifyAndPrompt(ctx, cmd, appSlug, &flags, prompts.New()); err != nil { + return err + } + + _ = rc.SetEnv() + + if err := runInstallRunPreflights(cmd.Context(), flags, rc); err != nil { return err } @@ -47,9 +53,8 @@ func InstallRunPreflightsCmd(ctx context.Context, name string) *cobra.Command { }, } - if err := addInstallFlags(cmd, &flags); err != nil { - panic(err) - } + mustAddInstallFlags(cmd, &flags) + if err := addInstallAdminConsoleFlags(cmd, &flags); err != nil { panic(err) } @@ -57,17 +62,16 @@ func InstallRunPreflightsCmd(ctx context.Context, name string) *cobra.Command { return cmd } -func runInstallRunPreflights(ctx context.Context, name string, flags InstallCmdFlags, rc runtimeconfig.RuntimeConfig) error { - if err := verifyAndPrompt(ctx, name, flags, prompts.New()); err != nil { - return err +func runInstallRunPreflights(ctx context.Context, flags InstallCmdFlags, rc runtimeconfig.RuntimeConfig) error { + licenseBytes, err := os.ReadFile(flags.licenseFile) + if err != nil { + return fmt.Errorf("unable to read license file: %w", err) } logrus.Debugf("configuring host") if err := hostutils.ConfigureHost(ctx, rc, hostutils.InitForInstallOptions{ - LicenseFile: flags.licenseFile, + License: licenseBytes, AirgapBundle: flags.airgapBundle, - PodCIDR: flags.cidrCfg.PodCIDR, - ServiceCIDR: flags.cidrCfg.ServiceCIDR, }); err != nil { return fmt.Errorf("configure host: %w", err) } @@ -89,12 +93,12 @@ func runInstallPreflights(ctx context.Context, flags InstallCmdFlags, rc runtime replicatedAppURL := replicatedAppURL() proxyRegistryURL := proxyRegistryURL() - nodeIP, err := netutils.FirstValidAddress(flags.networkInterface) + nodeIP, err := netutils.FirstValidAddress(rc.NetworkInterface()) if err != nil { return fmt.Errorf("unable to find first valid address: %w", err) } - hpf, err := preflights.Prepare(ctx, preflights.PrepareOptions{ + opts := preflights.PrepareOptions{ HostPreflightSpec: release.GetHostPreflights(), ReplicatedAppURL: replicatedAppURL, ProxyRegistryURL: proxyRegistryURL, @@ -103,18 +107,22 @@ func runInstallPreflights(ctx context.Context, flags InstallCmdFlags, rc runtime DataDir: rc.EmbeddedClusterHomeDirectory(), K0sDataDir: rc.EmbeddedClusterK0sSubDir(), OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), - Proxy: flags.proxy, - PodCIDR: flags.cidrCfg.PodCIDR, - ServiceCIDR: flags.cidrCfg.ServiceCIDR, - GlobalCIDR: flags.cidrCfg.GlobalCIDR, + Proxy: rc.ProxySpec(), + PodCIDR: rc.PodCIDR(), + ServiceCIDR: rc.ServiceCIDR(), NodeIP: nodeIP, IsAirgap: flags.isAirgap, - }) + } + if globalCIDR := rc.GlobalCIDR(); globalCIDR != "" { + opts.GlobalCIDR = &globalCIDR + } + + hpf, err := preflights.Prepare(ctx, opts) if err != nil { return err } - if err := runHostPreflights(ctx, hpf, flags.proxy, rc, flags.skipHostPreflights, flags.ignoreHostPreflights, flags.assumeYes, metricsReporter); err != nil { + if err := runHostPreflights(ctx, hpf, rc, flags.skipHostPreflights, flags.ignoreHostPreflights, flags.assumeYes, metricsReporter); err != nil { return err } diff --git a/cmd/installer/cli/install_test.go b/cmd/installer/cli/install_test.go index b63d79813f..3c4ac15710 100644 --- a/cmd/installer/cli/install_test.go +++ b/cmd/installer/cli/install_test.go @@ -14,7 +14,9 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/prompts" "github.com/replicatedhq/embedded-cluster/pkg/prompts/plain" "github.com/replicatedhq/embedded-cluster/pkg/release" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -607,3 +609,97 @@ func Test_verifyProxyConfig(t *testing.T) { }) } } + +func Test_preRunInstall_SkipHostPreflightsEnvVar(t *testing.T) { + tests := []struct { + name string + envVarValue string + flagValue *bool // nil means not set, true/false means explicitly set + expectedSkipPreflights bool + }{ + { + name: "env var set to 1, no flag", + envVarValue: "1", + flagValue: nil, + expectedSkipPreflights: true, + }, + { + name: "env var set to true, no flag", + envVarValue: "true", + flagValue: nil, + expectedSkipPreflights: true, + }, + { + name: "env var set, flag explicitly false (flag takes precedence)", + envVarValue: "1", + flagValue: boolPtr(false), + expectedSkipPreflights: false, + }, + { + name: "env var set, flag explicitly true", + envVarValue: "1", + flagValue: boolPtr(true), + expectedSkipPreflights: true, + }, + { + name: "env var not set, no flag", + envVarValue: "", + flagValue: nil, + expectedSkipPreflights: false, + }, + { + name: "env var not set, flag explicitly false", + envVarValue: "", + flagValue: boolPtr(false), + expectedSkipPreflights: false, + }, + { + name: "env var not set, flag explicitly true", + envVarValue: "", + flagValue: boolPtr(true), + expectedSkipPreflights: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up environment variable + if tt.envVarValue != "" { + t.Setenv("SKIP_HOST_PREFLIGHTS", tt.envVarValue) + } + + // Create a mock cobra command to simulate flag behavior + cmd := &cobra.Command{} + flags := &InstallCmdFlags{} + + // Add the flag to the command (similar to addInstallFlags) + cmd.Flags().BoolVar(&flags.skipHostPreflights, "skip-host-preflights", false, "Skip host preflight checks") + + // Set the flag if explicitly provided in test + if tt.flagValue != nil { + err := cmd.Flags().Set("skip-host-preflights", fmt.Sprintf("%t", *tt.flagValue)) + require.NoError(t, err) + } + + // Create a minimal runtime config for the test + rc := runtimeconfig.New(nil) + + // Call preRunInstall (this would normally require root, but we're just testing the flag logic) + // We expect this to fail due to non-root execution, but we can check the flag value before it fails + err := preRunInstallLinux(cmd, flags, rc) + + // The function will fail due to non-root check, but we can verify the flag was set correctly + // by checking the flag value before the root check fails + assert.Equal(t, tt.expectedSkipPreflights, flags.skipHostPreflights) + + // We expect an error due to non-root execution + assert.Error(t, err) + assert.Contains(t, err.Error(), "install command must be run as root") + }) + } +} + +// Helper function to create bool pointer +func boolPtr(b bool) *bool { + return &b +} diff --git a/cmd/installer/cli/join.go b/cmd/installer/cli/join.go index cd78d2b8fb..a8bafb5449 100644 --- a/cmd/installer/cli/join.go +++ b/cmd/installer/cli/join.go @@ -14,12 +14,14 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/kinds/types/join" newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" + "github.com/replicatedhq/embedded-cluster/pkg-new/domains" "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" "github.com/replicatedhq/embedded-cluster/pkg-new/k0s" "github.com/replicatedhq/embedded-cluster/pkg-new/preflights" "github.com/replicatedhq/embedded-cluster/pkg/addons" "github.com/replicatedhq/embedded-cluster/pkg/airgap" "github.com/replicatedhq/embedded-cluster/pkg/config" + "github.com/replicatedhq/embedded-cluster/pkg/dryrun" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/pkg/kotsadm" @@ -48,7 +50,7 @@ type JoinCmdFlags struct { } // JoinCmd returns a cobra command for joining a node to the cluster. -func JoinCmd(ctx context.Context, name string) *cobra.Command { +func JoinCmd(ctx context.Context, appSlug, appTitle string) *cobra.Command { var flags JoinCmdFlags ctx, cancel := context.WithCancel(ctx) @@ -56,7 +58,7 @@ func JoinCmd(ctx context.Context, name string) *cobra.Command { cmd := &cobra.Command{ Use: "join ", - Short: fmt.Sprintf("Join a node to the %s cluster", name), + Short: fmt.Sprintf("Join a node to the %s cluster", appTitle), Args: cobra.ExactArgs(2), PreRunE: func(cmd *cobra.Command, args []string) error { if err := preRunJoin(&flags); err != nil { @@ -76,7 +78,7 @@ func JoinCmd(ctx context.Context, name string) *cobra.Command { return fmt.Errorf("unable to get join token: %w", err) } joinReporter := newJoinReporter( - jcmd.InstallationSpec.MetricsBaseURL, jcmd.ClusterID, cmd.CalledAs(), flagsToStringSlice(cmd.Flags()), + jcmd.InstallationSpec.MetricsBaseURL, jcmd.ClusterID.String(), cmd.CalledAs(), flagsToStringSlice(cmd.Flags()), ) joinReporter.ReportJoinStarted(ctx) @@ -85,7 +87,7 @@ func JoinCmd(ctx context.Context, name string) *cobra.Command { joinReporter.ReportSignalAborted(ctx, sig) }) - if err := runJoin(cmd.Context(), name, flags, rc, jcmd, args[0], joinReporter); err != nil { + if err := runJoin(cmd.Context(), appSlug, flags, rc, jcmd, args[0], joinReporter); err != nil { // Check if this is an interrupt error from the terminal if errors.Is(err, terminal.InterruptErr) { joinReporter.ReportSignalAborted(ctx, syscall.SIGINT) @@ -104,14 +106,15 @@ func JoinCmd(ctx context.Context, name string) *cobra.Command { panic(err) } - cmd.AddCommand(JoinRunPreflightsCmd(ctx, name)) - cmd.AddCommand(JoinPrintCommandCmd(ctx, name)) + cmd.AddCommand(JoinRunPreflightsCmd(ctx, appSlug, appTitle)) + cmd.AddCommand(JoinPrintCommandCmd(ctx, appTitle)) return cmd } func preRunJoin(flags *JoinCmdFlags) error { - if os.Getuid() != 0 { + // Skip root check if dryrun mode is enabled + if !dryrun.Enabled() && os.Getuid() != 0 { return fmt.Errorf("join command must be run as root") } @@ -150,18 +153,18 @@ func addJoinFlags(cmd *cobra.Command, flags *JoinCmdFlags) error { return nil } -func runJoin(ctx context.Context, name string, flags JoinCmdFlags, rc runtimeconfig.RuntimeConfig, jcmd *join.JoinCommandResponse, kotsAPIAddress string, joinReporter *JoinReporter) error { +func runJoin(ctx context.Context, appSlug string, flags JoinCmdFlags, rc runtimeconfig.RuntimeConfig, jcmd *join.JoinCommandResponse, kotsAPIAddress string, joinReporter *JoinReporter) error { // both controller and worker nodes will have 'worker' in the join command isWorker := !strings.Contains(jcmd.K0sJoinCommand, "controller") if !isWorker { logrus.Warn("\nDo not join another node until this node has joined successfully.") } - if err := runJoinVerifyAndPrompt(name, flags, rc, jcmd); err != nil { + if err := runJoinVerifyAndPrompt(appSlug, flags, rc, jcmd); err != nil { return err } - cidrCfg, err := initializeJoin(ctx, name, rc, jcmd, kotsAPIAddress) + cidrCfg, err := initializeJoin(ctx, appSlug, rc, jcmd, kotsAPIAddress) if err != nil { return fmt.Errorf("unable to initialize join: %w", err) } @@ -177,7 +180,7 @@ func runJoin(ctx context.Context, name string, flags JoinCmdFlags, rc runtimecon logrus.Debugf("installing and joining cluster") loading := spinner.Start() loading.Infof("Installing node") - if err := installAndJoinCluster(ctx, rc, jcmd, name, flags, isWorker); err != nil { + if err := installAndJoinCluster(ctx, rc, jcmd, appSlug, flags, isWorker); err != nil { loading.ErrorClosef("Failed to install node") return err } @@ -215,7 +218,7 @@ func runJoin(ctx context.Context, name string, flags JoinCmdFlags, rc runtimecon return nil } - if err := maybeEnableHA(ctx, kcli, mcli, flags, rc, cidrCfg.ServiceCIDR, jcmd); err != nil { + if err := maybeEnableHA(ctx, kcli, mcli, flags, rc, jcmd); err != nil { return fmt.Errorf("unable to enable high availability: %w", err) } @@ -223,9 +226,9 @@ func runJoin(ctx context.Context, name string, flags JoinCmdFlags, rc runtimecon return nil } -func runJoinVerifyAndPrompt(name string, flags JoinCmdFlags, rc runtimeconfig.RuntimeConfig, jcmd *join.JoinCommandResponse) error { +func runJoinVerifyAndPrompt(appSlug string, flags JoinCmdFlags, rc runtimeconfig.RuntimeConfig, jcmd *join.JoinCommandResponse) error { logrus.Debugf("checking if k0s is already installed") - err := verifyNoInstallation(name, "join a node") + err := verifyNoInstallation(appSlug, "join a node") if err != nil { return err } @@ -262,24 +265,26 @@ func runJoinVerifyAndPrompt(name string, flags JoinCmdFlags, rc runtimeconfig.Ru return fmt.Errorf("embedded cluster version mismatch - this binary is version %q, but the cluster is running version %q", versions.Version, jcmd.EmbeddedClusterVersion) } - newconfig.SetProxyEnv(jcmd.InstallationSpec.Proxy) + if proxySpec := rc.ProxySpec(); proxySpec != nil { + newconfig.SetProxyEnv(proxySpec) - proxyOK, localIP, err := newconfig.CheckProxyConfigForLocalIP(jcmd.InstallationSpec.Proxy, flags.networkInterface, nil) - if err != nil { - return fmt.Errorf("failed to check proxy config for local IP: %w", err) - } + proxyOK, localIP, err := newconfig.CheckProxyConfigForLocalIP(proxySpec, flags.networkInterface, nil) + if err != nil { + return fmt.Errorf("failed to check proxy config for local IP: %w", err) + } - if !proxyOK { - logrus.Errorf("\nThis node's IP address %s is not included in the no-proxy list (%s).", localIP, jcmd.InstallationSpec.Proxy.NoProxy) - logrus.Infof(`The no-proxy list cannot easily be modified after installing.`) - logrus.Infof(`Recreate the first node and pass all node IP addresses to --no-proxy.`) - return NewErrorNothingElseToAdd(errors.New("node ip address not included in no-proxy list")) + if !proxyOK { + logrus.Errorf("\nThis node's IP address %s is not included in the no-proxy list (%s).", localIP, proxySpec.NoProxy) + logrus.Infof(`The no-proxy list cannot easily be modified after installing.`) + logrus.Infof(`Recreate the first node and pass all node IP addresses to --no-proxy.`) + return NewErrorNothingElseToAdd(errors.New("node ip address not included in no-proxy list")) + } } return nil } -func initializeJoin(ctx context.Context, name string, rc runtimeconfig.RuntimeConfig, jcmd *join.JoinCommandResponse, kotsAPIAddress string) (cidrCfg *newconfig.CIDRConfig, err error) { +func initializeJoin(ctx context.Context, appSlug string, rc runtimeconfig.RuntimeConfig, jcmd *join.JoinCommandResponse, kotsAPIAddress string) (cidrCfg *newconfig.CIDRConfig, err error) { logrus.Info("") spinner := spinner.Start() spinner.Infof("Initializing") @@ -301,7 +306,7 @@ func initializeJoin(ctx context.Context, name string, rc runtimeconfig.RuntimeCo logrus.Debugf("unable to chmod embedded-cluster home dir: %s", err) } - logrus.Debugf("materializing %s binaries", name) + logrus.Debugf("materializing %s binaries", appSlug) if err := materializeFilesForJoin(ctx, rc, jcmd, kotsAPIAddress); err != nil { return nil, fmt.Errorf("failed to materialize files: %w", err) } @@ -321,7 +326,7 @@ func initializeJoin(ctx context.Context, name string, rc runtimeconfig.RuntimeCo return nil, fmt.Errorf("unable to configure network manager: %w", err) } - cidrCfg, err = getJoinCIDRConfig(jcmd) + cidrCfg, err = getJoinCIDRConfig(rc) if err != nil { return nil, fmt.Errorf("unable to get join CIDR config: %w", err) } @@ -339,7 +344,8 @@ func materializeFilesForJoin(ctx context.Context, rc runtimeconfig.RuntimeConfig if err := materializer.Materialize(); err != nil { return fmt.Errorf("materialize binaries: %w", err) } - if err := support.MaterializeSupportBundleSpec(rc); err != nil { + + if err := support.MaterializeSupportBundleSpec(rc, jcmd.InstallationSpec.AirGap); err != nil { return fmt.Errorf("materialize support bundle spec: %w", err) } @@ -352,19 +358,22 @@ func materializeFilesForJoin(ctx context.Context, rc runtimeconfig.RuntimeConfig return nil } -func getJoinCIDRConfig(jcmd *join.JoinCommandResponse) (*newconfig.CIDRConfig, error) { - podCIDR, serviceCIDR, err := netutils.SplitNetworkCIDR(ecv1beta1.DefaultNetworkCIDR) +func getJoinCIDRConfig(rc runtimeconfig.RuntimeConfig) (*newconfig.CIDRConfig, error) { + globalCIDR := ecv1beta1.DefaultNetworkCIDR + if rc.GlobalCIDR() != "" { + globalCIDR = rc.GlobalCIDR() + } + + podCIDR, serviceCIDR, err := netutils.SplitNetworkCIDR(globalCIDR) if err != nil { - return nil, fmt.Errorf("unable to split default network CIDR: %w", err) + return nil, fmt.Errorf("unable to split global network CIDR: %w", err) } - if jcmd.InstallationSpec.Network != nil { - if jcmd.InstallationSpec.Network.PodCIDR != "" { - podCIDR = jcmd.InstallationSpec.Network.PodCIDR - } - if jcmd.InstallationSpec.Network.ServiceCIDR != "" { - serviceCIDR = jcmd.InstallationSpec.Network.ServiceCIDR - } + if rc.PodCIDR() != "" { + podCIDR = rc.PodCIDR() + } + if rc.ServiceCIDR() != "" { + serviceCIDR = rc.ServiceCIDR() } return &newconfig.CIDRConfig{ @@ -373,30 +382,30 @@ func getJoinCIDRConfig(jcmd *join.JoinCommandResponse) (*newconfig.CIDRConfig, e }, nil } -func installAndJoinCluster(ctx context.Context, rc runtimeconfig.RuntimeConfig, jcmd *join.JoinCommandResponse, name string, flags JoinCmdFlags, isWorker bool) error { +func installAndJoinCluster(ctx context.Context, rc runtimeconfig.RuntimeConfig, jcmd *join.JoinCommandResponse, appSlug string, flags JoinCmdFlags, isWorker bool) error { logrus.Debugf("saving token to disk") if err := saveTokenToDisk(jcmd.K0sToken); err != nil { return fmt.Errorf("unable to save token to disk: %w", err) } - logrus.Debugf("installing %s binaries", name) + logrus.Debugf("installing %s binaries", appSlug) if err := installK0sBinary(rc); err != nil { return fmt.Errorf("unable to install k0s binary: %w", err) } if jcmd.AirgapRegistryAddress != "" { - if err := airgap.AddInsecureRegistry(jcmd.AirgapRegistryAddress); err != nil { + if err := hostutils.AddInsecureRegistry(jcmd.AirgapRegistryAddress); err != nil { return fmt.Errorf("unable to add insecure registry: %w", err) } } logrus.Debugf("creating systemd unit files") - if err := hostutils.CreateSystemdUnitFiles(ctx, logrus.StandardLogger(), rc, isWorker, jcmd.InstallationSpec.Proxy); err != nil { + if err := hostutils.CreateSystemdUnitFiles(ctx, logrus.StandardLogger(), rc, isWorker); err != nil { return fmt.Errorf("unable to create systemd unit files: %w", err) } logrus.Debugf("overriding network configuration") - if err := applyNetworkConfiguration(flags.networkInterface, jcmd); err != nil { + if err := applyNetworkConfiguration(rc, jcmd); err != nil { return fmt.Errorf("unable to apply network configuration: %w", err) } @@ -415,7 +424,7 @@ func installAndJoinCluster(ctx context.Context, rc runtimeconfig.RuntimeConfig, return fmt.Errorf("unable to join node to cluster: %w", err) } - if err := startAndWaitForK0s(name); err != nil { + if err := startAndWaitForK0s(appSlug); err != nil { return err } @@ -444,43 +453,50 @@ func installK0sBinary(rc runtimeconfig.RuntimeConfig) error { return nil } -func applyNetworkConfiguration(networkInterface string, jcmd *join.JoinCommandResponse) error { - if jcmd.InstallationSpec.Network != nil { - domains := runtimeconfig.GetDomains(jcmd.InstallationSpec.Config) - clusterSpec := config.RenderK0sConfig(domains.ProxyRegistryDomain) +func applyNetworkConfiguration(rc runtimeconfig.RuntimeConfig, jcmd *join.JoinCommandResponse) error { + domains := domains.GetDomains(jcmd.InstallationSpec.Config, release.GetChannelRelease()) + clusterSpec := config.RenderK0sConfig(domains.ProxyRegistryDomain) - address, err := netutils.FirstValidAddress(networkInterface) - if err != nil { - return fmt.Errorf("unable to find first valid address: %w", err) - } - clusterSpec.Spec.API.Address = address - clusterSpec.Spec.Storage.Etcd.PeerAddress = address - // NOTE: we should be copying everything from the in cluster config spec and overriding - // the node specific config from clusterSpec.GetClusterWideConfig() - clusterSpec.Spec.Network.PodCIDR = jcmd.InstallationSpec.Network.PodCIDR - clusterSpec.Spec.Network.ServiceCIDR = jcmd.InstallationSpec.Network.ServiceCIDR - if jcmd.InstallationSpec.Network.NodePortRange != "" { - if clusterSpec.Spec.API.ExtraArgs == nil { - clusterSpec.Spec.API.ExtraArgs = map[string]string{} - } - clusterSpec.Spec.API.ExtraArgs["service-node-port-range"] = jcmd.InstallationSpec.Network.NodePortRange - } - clusterSpecYaml, err := k8syaml.Marshal(clusterSpec) + address, err := netutils.FirstValidAddress(rc.NetworkInterface()) + if err != nil { + return fmt.Errorf("unable to find first valid address: %w", err) + } - if err != nil { - return fmt.Errorf("unable to marshal cluster spec: %w", err) - } - err = os.WriteFile(runtimeconfig.K0sConfigPath, clusterSpecYaml, 0644) - if err != nil { - return fmt.Errorf("unable to write cluster spec to /etc/k0s/k0s.yaml: %w", err) + cidrCfg, err := getJoinCIDRConfig(rc) + if err != nil { + return fmt.Errorf("unable to get join CIDR config: %w", err) + } + + clusterSpec.Spec.API.Address = address + clusterSpec.Spec.Storage.Etcd.PeerAddress = address + // NOTE: we should be copying everything from the in cluster config spec and overriding + // the node specific config from clusterSpec.GetClusterWideConfig() + clusterSpec.Spec.Network.PodCIDR = cidrCfg.PodCIDR + clusterSpec.Spec.Network.ServiceCIDR = cidrCfg.ServiceCIDR + + if rc.NodePortRange() != "" { + if clusterSpec.Spec.API.ExtraArgs == nil { + clusterSpec.Spec.API.ExtraArgs = map[string]string{} } + clusterSpec.Spec.API.ExtraArgs["service-node-port-range"] = rc.NodePortRange() + } + + clusterSpecYaml, err := k8syaml.Marshal(clusterSpec) + if err != nil { + return fmt.Errorf("unable to marshal cluster spec: %w", err) } + + err = os.WriteFile(runtimeconfig.K0sConfigPath, clusterSpecYaml, 0644) + if err != nil { + return fmt.Errorf("unable to write cluster spec to /etc/k0s/k0s.yaml: %w", err) + } + return nil } // startAndWaitForK0s starts the k0s service and waits for the node to be ready. -func startAndWaitForK0s(name string) error { - logrus.Debugf("starting %s service", name) +func startAndWaitForK0s(appSlug string) error { + logrus.Debugf("starting %s service", appSlug) if _, err := helpers.RunCommand(runtimeconfig.K0sBinaryPath, "start"); err != nil { return fmt.Errorf("unable to start service: %w", err) } @@ -573,7 +589,7 @@ func waitForNodeToJoin(ctx context.Context, kcli client.Client, hostname string, return nil } -func maybeEnableHA(ctx context.Context, kcli client.Client, mcli metadata.Interface, flags JoinCmdFlags, rc runtimeconfig.RuntimeConfig, serviceCIDR string, jcmd *join.JoinCommandResponse) error { +func maybeEnableHA(ctx context.Context, kcli client.Client, mcli metadata.Interface, flags JoinCmdFlags, rc runtimeconfig.RuntimeConfig, jcmd *join.JoinCommandResponse) error { if flags.noHA { logrus.Debug("--no-ha flag provided, skipping high availability") return nil @@ -604,7 +620,7 @@ func maybeEnableHA(ctx context.Context, kcli client.Client, mcli metadata.Interf addons.WithKubernetesClientSet(kclient), addons.WithMetadataClient(mcli), addons.WithHelmClient(hcli), - addons.WithRuntimeConfig(rc), + addons.WithDomains(getDomains()), ) canEnableHA, _, err := addOns.CanEnableHA(ctx) @@ -640,5 +656,20 @@ func maybeEnableHA(ctx context.Context, kcli client.Client, mcli metadata.Interf loading := spinner.Start() defer loading.Close() - return addOns.EnableHA(ctx, serviceCIDR, jcmd.InstallationSpec, loading) + opts := addons.EnableHAOptions{ + ClusterID: jcmd.InstallationSpec.ClusterID, + AdminConsolePort: rc.AdminConsolePort(), + IsAirgap: jcmd.InstallationSpec.AirGap, + IsMultiNodeEnabled: jcmd.InstallationSpec.LicenseInfo != nil && jcmd.InstallationSpec.LicenseInfo.IsMultiNodeEnabled, + EmbeddedConfigSpec: jcmd.InstallationSpec.Config, + EndUserConfigSpec: nil, // TODO: add support for end user config spec + ProxySpec: rc.ProxySpec(), + HostCABundlePath: rc.HostCABundlePath(), + DataDir: rc.EmbeddedClusterHomeDirectory(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), + SeaweedFSDataDir: rc.EmbeddedClusterSeaweedFSSubDir(), + ServiceCIDR: rc.ServiceCIDR(), + } + + return addOns.EnableHA(ctx, opts, loading) } diff --git a/cmd/installer/cli/join_printcommand.go b/cmd/installer/cli/join_printcommand.go index 4ca997209c..30b59a9b49 100644 --- a/cmd/installer/cli/join_printcommand.go +++ b/cmd/installer/cli/join_printcommand.go @@ -3,18 +3,41 @@ package cli import ( "context" "fmt" + "os" "github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli" + "github.com/replicatedhq/embedded-cluster/pkg/dryrun" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + rcutil "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig/util" "github.com/spf13/cobra" ) -func JoinPrintCommandCmd(ctx context.Context, name string) *cobra.Command { +func JoinPrintCommandCmd(ctx context.Context, appTitle string) *cobra.Command { + var rc runtimeconfig.RuntimeConfig + cmd := &cobra.Command{ Use: "print-command", - Short: fmt.Sprintf("Print controller join command for %s", name), + Short: fmt.Sprintf("Print controller join command for %s", appTitle), + PreRunE: func(cmd *cobra.Command, args []string) error { + // Skip root check if dryrun mode is enabled + if !dryrun.Enabled() && os.Getuid() != 0 { + return fmt.Errorf("print-command command must be run as root") + } + + var err error + rc, err = rcutil.GetRuntimeConfigFromCluster(ctx) + if err != nil { + return fmt.Errorf("failed to init runtime config from cluster: %w", err) + } + + os.Setenv("TMPDIR", rc.EmbeddedClusterTmpSubDir()) + + return nil + }, + PostRun: func(cmd *cobra.Command, args []string) { + rc.Cleanup() + }, RunE: func(cmd *cobra.Command, args []string) error { - rc := runtimeconfig.New(nil) jcmd, err := kotscli.GetJoinCommand(cmd.Context(), rc) if err != nil { return fmt.Errorf("unable to get join command: %w", err) diff --git a/cmd/installer/cli/join_runpreflights.go b/cmd/installer/cli/join_runpreflights.go index 170b74b9e4..e8aa327758 100644 --- a/cmd/installer/cli/join_runpreflights.go +++ b/cmd/installer/cli/join_runpreflights.go @@ -7,6 +7,7 @@ import ( "github.com/replicatedhq/embedded-cluster/kinds/types/join" newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" + "github.com/replicatedhq/embedded-cluster/pkg-new/domains" "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" "github.com/replicatedhq/embedded-cluster/pkg-new/preflights" "github.com/replicatedhq/embedded-cluster/pkg/kotsadm" @@ -18,13 +19,13 @@ import ( "github.com/spf13/cobra" ) -func JoinRunPreflightsCmd(ctx context.Context, name string) *cobra.Command { +func JoinRunPreflightsCmd(ctx context.Context, appSlug, appTitle string) *cobra.Command { var flags JoinCmdFlags rc := runtimeconfig.New(nil) cmd := &cobra.Command{ Use: "run-preflights", - Short: fmt.Sprintf("Run join host preflights for %s", name), + Short: fmt.Sprintf("Run join host preflights for %s", appTitle), Args: cobra.ExactArgs(2), PreRunE: func(cmd *cobra.Command, args []string) error { if err := preRunJoin(&flags); err != nil { @@ -42,7 +43,7 @@ func JoinRunPreflightsCmd(ctx context.Context, name string) *cobra.Command { if err != nil { return fmt.Errorf("unable to get join token: %w", err) } - if err := runJoinRunPreflights(cmd.Context(), name, flags, rc, jcmd, args[0]); err != nil { + if err := runJoinRunPreflights(cmd.Context(), appSlug, flags, rc, jcmd, args[0]); err != nil { return err } @@ -57,12 +58,12 @@ func JoinRunPreflightsCmd(ctx context.Context, name string) *cobra.Command { return cmd } -func runJoinRunPreflights(ctx context.Context, name string, flags JoinCmdFlags, rc runtimeconfig.RuntimeConfig, jcmd *join.JoinCommandResponse, kotsAPIAddress string) error { - if err := runJoinVerifyAndPrompt(name, flags, rc, jcmd); err != nil { +func runJoinRunPreflights(ctx context.Context, appSlug string, flags JoinCmdFlags, rc runtimeconfig.RuntimeConfig, jcmd *join.JoinCommandResponse, kotsAPIAddress string) error { + if err := runJoinVerifyAndPrompt(appSlug, flags, rc, jcmd); err != nil { return err } - logrus.Debugf("materializing %s binaries", name) + logrus.Debugf("materializing %s binaries", appSlug) if err := materializeFilesForJoin(ctx, rc, jcmd, kotsAPIAddress); err != nil { return fmt.Errorf("failed to materialize files: %w", err) } @@ -77,7 +78,7 @@ func runJoinRunPreflights(ctx context.Context, name string, flags JoinCmdFlags, logrus.Debugf("unable to configure kernel modules: %v", err) } - cidrCfg, err := getJoinCIDRConfig(jcmd) + cidrCfg, err := getJoinCIDRConfig(rc) if err != nil { return fmt.Errorf("unable to get join CIDR config: %w", err) } @@ -101,7 +102,7 @@ func runJoinPreflights(ctx context.Context, jcmd *join.JoinCommandResponse, flag return fmt.Errorf("unable to find first valid address: %w", err) } - domains := runtimeconfig.GetDomains(jcmd.InstallationSpec.Config) + domains := domains.GetDomains(jcmd.InstallationSpec.Config, release.GetChannelRelease()) hpf, err := preflights.Prepare(ctx, preflights.PrepareOptions{ HostPreflightSpec: release.GetHostPreflights(), @@ -112,7 +113,7 @@ func runJoinPreflights(ctx context.Context, jcmd *join.JoinCommandResponse, flag DataDir: rc.EmbeddedClusterHomeDirectory(), K0sDataDir: rc.EmbeddedClusterK0sSubDir(), OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), - Proxy: jcmd.InstallationSpec.Proxy, + Proxy: rc.ProxySpec(), PodCIDR: cidrCfg.PodCIDR, ServiceCIDR: cidrCfg.ServiceCIDR, NodeIP: nodeIP, @@ -124,7 +125,7 @@ func runJoinPreflights(ctx context.Context, jcmd *join.JoinCommandResponse, flag return err } - if err := runHostPreflights(ctx, hpf, jcmd.InstallationSpec.Proxy, rc, flags.skipHostPreflights, flags.ignoreHostPreflights, flags.assumeYes, metricsReporter); err != nil { + if err := runHostPreflights(ctx, hpf, rc, flags.skipHostPreflights, flags.ignoreHostPreflights, flags.assumeYes, metricsReporter); err != nil { return err } diff --git a/cmd/installer/cli/logging.go b/cmd/installer/cli/logging.go index 5d5642b463..19294b3ccb 100644 --- a/cmd/installer/cli/logging.go +++ b/cmd/installer/cli/logging.go @@ -108,7 +108,7 @@ func SetupLogging() { return } logrus.SetLevel(logrus.DebugLevel) - fname := fmt.Sprintf("%s-%s.log", runtimeconfig.BinaryName(), time.Now().Format("20060102150405.000")) + fname := fmt.Sprintf("%s-%s.log", runtimeconfig.AppSlug(), time.Now().Format("20060102150405.000")) logpath := runtimeconfig.PathToLog(fname) logfile, err := os.OpenFile(logpath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0400) if err != nil { diff --git a/cmd/installer/cli/materialize.go b/cmd/installer/cli/materialize.go index 1a2495149d..81e2171721 100644 --- a/cmd/installer/cli/materialize.go +++ b/cmd/installer/cli/materialize.go @@ -7,11 +7,12 @@ import ( "github.com/replicatedhq/embedded-cluster/cmd/installer/goods" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/dryrun" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/spf13/cobra" ) -func MaterializeCmd(ctx context.Context, name string) *cobra.Command { +func MaterializeCmd(ctx context.Context) *cobra.Command { var dataDir string rc := runtimeconfig.New(nil) @@ -20,7 +21,8 @@ func MaterializeCmd(ctx context.Context, name string) *cobra.Command { Short: "Materialize embedded assets into the data directory", Hidden: true, PreRunE: func(cmd *cobra.Command, args []string) error { - if os.Getuid() != 0 { + // Skip root check if dryrun mode is enabled + if !dryrun.Enabled() && os.Getuid() != 0 { return fmt.Errorf("materialize command must be run as root") } diff --git a/cmd/installer/cli/metrics.go b/cmd/installer/cli/metrics.go index a59d510bb8..f091ac65b1 100644 --- a/cmd/installer/cli/metrics.go +++ b/cmd/installer/cli/metrics.go @@ -16,7 +16,7 @@ type InstallReporter struct { appSlug string } -func newInstallReporter(baseURL string, clusterID uuid.UUID, cmd string, args []string, licenseID string, appSlug string) *InstallReporter { +func newInstallReporter(baseURL string, cmd string, args []string, licenseID string, clusterID string, appSlug string) *InstallReporter { executionID := uuid.New().String() reporter := metrics.NewReporter(executionID, baseURL, clusterID, cmd, args) return &InstallReporter{ @@ -54,7 +54,7 @@ type JoinReporter struct { reporter metrics.ReporterInterface } -func newJoinReporter(baseURL string, clusterID uuid.UUID, cmd string, flags []string) *JoinReporter { +func newJoinReporter(baseURL string, clusterID string, cmd string, flags []string) *JoinReporter { executionID := uuid.New().String() reporter := metrics.NewReporter(executionID, baseURL, clusterID, cmd, flags) return &JoinReporter{ diff --git a/cmd/installer/cli/node.go b/cmd/installer/cli/node.go index 213dbddfff..dd65c464a4 100644 --- a/cmd/installer/cli/node.go +++ b/cmd/installer/cli/node.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" ) -func NodeCmd(ctx context.Context, name string) *cobra.Command { +func NodeCmd(ctx context.Context, appSlug, appTitle string) *cobra.Command { cmd := &cobra.Command{ Use: "node", Short: "Manage cluster nodes", @@ -17,11 +17,11 @@ func NodeCmd(ctx context.Context, name string) *cobra.Command { } // here for legacy reasons - joinCmd := JoinCmd(ctx, name) + joinCmd := JoinCmd(ctx, appSlug, appTitle) joinCmd.Hidden = true cmd.AddCommand(joinCmd) - resetCmd := ResetCmd(ctx, name) + resetCmd := ResetCmd(ctx, appTitle) resetCmd.Hidden = true cmd.AddCommand(resetCmd) diff --git a/cmd/installer/cli/preflights.go b/cmd/installer/cli/preflights.go index a7fa69f24a..7de9e57f99 100644 --- a/cmd/installer/cli/preflights.go +++ b/cmd/installer/cli/preflights.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/preflights" "github.com/replicatedhq/embedded-cluster/pkg/dryrun" "github.com/replicatedhq/embedded-cluster/pkg/metrics" @@ -18,7 +17,6 @@ import ( func runHostPreflights( ctx context.Context, hpf *troubleshootv1beta2.HostPreflightSpec, - proxy *ecv1beta1.ProxySpec, rc runtimeconfig.RuntimeConfig, skipHostPreflights bool, ignoreHostPreflights bool, @@ -43,7 +41,7 @@ func runHostPreflights( spinner.Infof("Running host preflights") - output, stderr, err := preflights.Run(ctx, hpf, proxy, rc) + output, stderr, err := preflights.Run(ctx, hpf, rc) if err != nil { spinner.ErrorClosef("Failed to run host preflights") return fmt.Errorf("host preflights failed to run: %w", err) diff --git a/cmd/installer/cli/proxy.go b/cmd/installer/cli/proxy.go index f069741d07..5573e9afce 100644 --- a/cmd/installer/cli/proxy.go +++ b/cmd/installer/cli/proxy.go @@ -8,6 +8,7 @@ import ( newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" "github.com/replicatedhq/embedded-cluster/pkg/netutils" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) // NetworkLookup defines the interface for network lookups @@ -23,12 +24,10 @@ func (d *defaultNetworkLookup) FirstValidIPNet(networkInterface string) (*net.IP var defaultNetworkLookupImpl NetworkLookup = &defaultNetworkLookup{} -func addProxyFlags(cmd *cobra.Command) error { - cmd.Flags().String("http-proxy", "", "HTTP proxy to use for the installation (overrides http_proxy/HTTP_PROXY environment variables)") - cmd.Flags().String("https-proxy", "", "HTTPS proxy to use for the installation (overrides https_proxy/HTTPS_PROXY environment variables)") - cmd.Flags().String("no-proxy", "", "Comma-separated list of hosts for which not to use a proxy (overrides no_proxy/NO_PROXY environment variables)") - - return nil +func mustAddProxyFlags(flagSet *pflag.FlagSet) { + flagSet.String("http-proxy", "", "HTTP proxy to use for the installation (overrides http_proxy/HTTP_PROXY environment variables)") + flagSet.String("https-proxy", "", "HTTPS proxy to use for the installation (overrides https_proxy/HTTPS_PROXY environment variables)") + flagSet.String("no-proxy", "", "Comma-separated list of hosts for which not to use a proxy (overrides no_proxy/NO_PROXY environment variables)") } func parseProxyFlags(cmd *cobra.Command) (*ecv1beta1.ProxySpec, error) { diff --git a/cmd/installer/cli/proxy_test.go b/cmd/installer/cli/proxy_test.go index 2ff8701418..d6e320b88a 100644 --- a/cmd/installer/cli/proxy_test.go +++ b/cmd/installer/cli/proxy_test.go @@ -151,8 +151,8 @@ func Test_getProxySpecFromFlags(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := &cobra.Command{} - addCIDRFlags(cmd) - addProxyFlags(cmd) + mustAddCIDRFlags(cmd.Flags()) + mustAddProxyFlags(cmd.Flags()) cmd.Flags().String("network-interface", "", "The network interface to use for the cluster") flagSet := cmd.Flags() diff --git a/cmd/installer/cli/reset.go b/cmd/installer/cli/reset.go index eefa71a38e..345ff6fec9 100644 --- a/cmd/installer/cli/reset.go +++ b/cmd/installer/cli/reset.go @@ -43,7 +43,7 @@ type hostInfo struct { RoleName string } -func ResetCmd(ctx context.Context, name string) *cobra.Command { +func ResetCmd(ctx context.Context, appTitle string) *cobra.Command { var ( force bool assumeYes bool @@ -53,7 +53,7 @@ func ResetCmd(ctx context.Context, name string) *cobra.Command { cmd := &cobra.Command{ Use: "reset", - Short: fmt.Sprintf("Remove %s from the current node", name), + Short: fmt.Sprintf("Remove %s from the current node", appTitle), PreRunE: func(cmd *cobra.Command, args []string) error { if os.Getuid() != 0 { return fmt.Errorf("reset command must be run as root") @@ -61,8 +61,7 @@ func ResetCmd(ctx context.Context, name string) *cobra.Command { rc = rcutil.InitBestRuntimeConfig(cmd.Context()) - os.Setenv("KUBECONFIG", rc.PathToKubeConfig()) - os.Setenv("TMPDIR", rc.EmbeddedClusterTmpSubDir()) + _ = rc.SetEnv() return nil }, @@ -98,7 +97,7 @@ func ResetCmd(ctx context.Context, name string) *cobra.Command { return err } if !safeToRemove { - return fmt.Errorf("%s\nRun reset command with --force to ignore this.", reason) + return fmt.Errorf("%s\nRun reset command with --force to ignore this", reason) } } @@ -222,7 +221,7 @@ func ResetCmd(ctx context.Context, name string) *cobra.Command { cmd.Flags().BoolVarP(&assumeYes, "yes", "y", false, "Assume yes to all prompts.") cmd.Flags().SetNormalizeFunc(normalizeNoPromptToYes) - cmd.AddCommand(ResetFirewalldCmd(ctx, name)) + cmd.AddCommand(ResetFirewalldCmd(ctx, appTitle)) return cmd } diff --git a/cmd/installer/cli/reset_firewalld.go b/cmd/installer/cli/reset_firewalld.go index e7e5babc33..e74d4a6905 100644 --- a/cmd/installer/cli/reset_firewalld.go +++ b/cmd/installer/cli/reset_firewalld.go @@ -6,28 +6,29 @@ import ( "os" "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" + "github.com/replicatedhq/embedded-cluster/pkg/dryrun" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" rcutil "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig/util" "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) -func ResetFirewalldCmd(ctx context.Context, name string) *cobra.Command { +func ResetFirewalldCmd(ctx context.Context, appTitle string) *cobra.Command { var rc runtimeconfig.RuntimeConfig cmd := &cobra.Command{ Use: "firewalld", - Short: "Remove %s firewalld configuration from the current node", + Short: fmt.Sprintf("Remove %s firewalld configuration from the current node", appTitle), Hidden: true, PreRunE: func(cmd *cobra.Command, args []string) error { - if os.Getuid() != 0 { + // Skip root check if dryrun mode is enabled + if !dryrun.Enabled() && os.Getuid() != 0 { return fmt.Errorf("reset firewalld command must be run as root") } rc = rcutil.InitBestRuntimeConfig(cmd.Context()) - os.Setenv("KUBECONFIG", rc.PathToKubeConfig()) - os.Setenv("TMPDIR", rc.EmbeddedClusterTmpSubDir()) + _ = rc.SetEnv() return nil }, diff --git a/cmd/installer/cli/restore.go b/cmd/installer/cli/restore.go index 035011ec83..2144c9bf80 100644 --- a/cmd/installer/cli/restore.go +++ b/cmd/installer/cli/restore.go @@ -21,15 +21,15 @@ import ( apitypes "github.com/replicatedhq/embedded-cluster/api/types" "github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" "github.com/replicatedhq/embedded-cluster/pkg-new/preflights" "github.com/replicatedhq/embedded-cluster/pkg/addons" addontypes "github.com/replicatedhq/embedded-cluster/pkg/addons/types" - "github.com/replicatedhq/embedded-cluster/pkg/airgap" - "github.com/replicatedhq/embedded-cluster/pkg/constants" "github.com/replicatedhq/embedded-cluster/pkg/disasterrecovery" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/helpers" + "github.com/replicatedhq/embedded-cluster/pkg/kubernetesinstallation" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" "github.com/replicatedhq/embedded-cluster/pkg/netutils" "github.com/replicatedhq/embedded-cluster/pkg/prompts" @@ -86,29 +86,29 @@ const ( resourceModifiersCMName = "restore-resource-modifiers" ) -func RestoreCmd(ctx context.Context, name string) *cobra.Command { +func RestoreCmd(ctx context.Context, appSlug, appTitle string) *cobra.Command { var flags InstallCmdFlags var s3Store s3BackupStore var skipStoreValidation bool rc := runtimeconfig.New(nil) + ki := kubernetesinstallation.New(nil) cmd := &cobra.Command{ Use: "restore", - Short: fmt.Sprintf("Restore %s from a backup", name), - PreRunE: func(cmd *cobra.Command, args []string) error { - if err := preRunInstall(cmd, &flags, rc); err != nil { - return err - } - - return nil - }, + Short: fmt.Sprintf("Restore %s from a backup", appTitle), PostRun: func(cmd *cobra.Command, args []string) { rc.Cleanup() }, RunE: func(cmd *cobra.Command, args []string) error { - if err := runRestore(cmd.Context(), name, flags, rc, s3Store, skipStoreValidation); err != nil { + if err := preRunInstall(cmd, &flags, rc, ki); err != nil { + return err + } + + _ = rc.SetEnv() + + if err := runRestore(cmd.Context(), appSlug, appTitle, flags, rc, s3Store, skipStoreValidation); err != nil { return err } @@ -119,14 +119,12 @@ func RestoreCmd(ctx context.Context, name string) *cobra.Command { addS3Flags(cmd, &s3Store) cmd.Flags().BoolVar(&skipStoreValidation, "skip-store-validation", false, "Skip validation of the backup storage location") - if err := addInstallFlags(cmd, &flags); err != nil { - panic(err) - } + mustAddInstallFlags(cmd, &flags) return cmd } -func runRestore(ctx context.Context, name string, flags InstallCmdFlags, rc runtimeconfig.RuntimeConfig, s3Store s3BackupStore, skipStoreValidation bool) error { +func runRestore(ctx context.Context, appSlug, appTitle string, flags InstallCmdFlags, rc runtimeconfig.RuntimeConfig, s3Store s3BackupStore, skipStoreValidation bool) error { err := verifyChannelRelease("restore", flags.isAirgap, flags.assumeYes) if err != nil { return err @@ -182,14 +180,17 @@ func runRestore(ctx context.Context, name string, flags InstallCmdFlags, rc runt ) } else { rc.Set(rcSpec) + + if err := rc.WriteToDisk(); err != nil { + return fmt.Errorf("unable to write runtime config to disk: %w", err) + } } - os.Setenv("KUBECONFIG", rc.PathToKubeConfig()) - os.Setenv("TMPDIR", rc.EmbeddedClusterTmpSubDir()) + _ = rc.SetEnv() switch state { case ecRestoreStateNew: - err = runRestoreStepNew(ctx, name, flags, rc, &s3Store, skipStoreValidation) + err = runRestoreStepNew(ctx, appSlug, appTitle, flags, rc, &s3Store, skipStoreValidation) if err != nil { return err } @@ -344,15 +345,15 @@ func runRestore(ctx context.Context, name string, flags InstallCmdFlags, rc runt return nil } -func runRestoreStepNew(ctx context.Context, name string, flags InstallCmdFlags, rc runtimeconfig.RuntimeConfig, s3Store *s3BackupStore, skipStoreValidation bool) error { +func runRestoreStepNew(ctx context.Context, appSlug, appTitle string, flags InstallCmdFlags, rc runtimeconfig.RuntimeConfig, s3Store *s3BackupStore, skipStoreValidation bool) error { logrus.Debugf("checking if k0s is already installed") - err := verifyNoInstallation(name, "restore") + err := verifyNoInstallation(appSlug, "restore") if err != nil { return err } if !s3BackupStoreHasData(s3Store) { - logrus.Infof("You'll be guided through the process of restoring %s from a backup.\n", name) + logrus.Infof("You'll be guided through the process of restoring %s from a backup.\n", appTitle) logrus.Info("Enter information to configure access to your backup storage location.\n") if err := promptForS3BackupStore(s3Store); err != nil { @@ -371,8 +372,6 @@ func runRestoreStepNew(ctx context.Context, name string, flags InstallCmdFlags, logrus.Debugf("configuring host") if err := hostutils.ConfigureHost(ctx, rc, hostutils.InitForInstallOptions{ AirgapBundle: flags.airgapBundle, - PodCIDR: flags.cidrCfg.PodCIDR, - ServiceCIDR: flags.cidrCfg.ServiceCIDR, }); err != nil { return fmt.Errorf("configure host: %w", err) } @@ -434,7 +433,7 @@ func runRestoreStepNew(ctx context.Context, name string, flags InstallCmdFlags, Path: s3Store.prefix, AccessKeyID: s3Store.accessKeyID, SecretAccessKey: s3Store.secretAccessKey, - Namespace: runtimeconfig.KotsadmNamespace, + Namespace: constants.KotsadmNamespace, }); err != nil { return err } @@ -472,17 +471,18 @@ func installAddonsForRestore(ctx context.Context, kcli client.Client, mcli metad addons.WithKubernetesClient(kcli), addons.WithMetadataClient(mcli), addons.WithHelmClient(hcli), - addons.WithRuntimeConfig(rc), + addons.WithDomains(getDomains()), addons.WithProgressChannel(progressChan), ) - if err := addOns.Install(ctx, addons.InstallOptions{ - IsAirgap: flags.airgapBundle != "", - Proxy: flags.proxy, - ServiceCIDR: flags.cidrCfg.ServiceCIDR, - IsRestore: true, + if err := addOns.Restore(ctx, addons.RestoreOptions{ EmbeddedConfigSpec: embCfgSpec, EndUserConfigSpec: nil, // TODO: support for end user config overrides + ProxySpec: rc.ProxySpec(), + HostCABundlePath: rc.HostCABundlePath(), + DataDir: rc.EmbeddedClusterHomeDirectory(), + OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), }); err != nil { return fmt.Errorf("install addons: %w", err) } @@ -603,6 +603,15 @@ func runRestoreEnableAdminConsoleHA(ctx context.Context, flags InstallCmdFlags, airgapChartsPath = rc.EmbeddedClusterChartsSubDir() } + euCfg, err := helpers.ParseEndUserConfig(flags.overrides) + if err != nil { + return fmt.Errorf("parse end user config: %w", err) + } + var euCfgSpec *ecv1beta1.ConfigSpec + if euCfg != nil { + euCfgSpec = &euCfg.Spec + } + hcli, err := helm.NewClient(helm.HelmOptions{ KubeConfig: rc.PathToKubeConfig(), K0sVersion: versions.K0sVersion, @@ -618,10 +627,25 @@ func runRestoreEnableAdminConsoleHA(ctx context.Context, flags InstallCmdFlags, addons.WithKubernetesClient(kcli), addons.WithMetadataClient(mcli), addons.WithHelmClient(hcli), - addons.WithRuntimeConfig(rc), + addons.WithDomains(getDomains()), ) - err = addOns.EnableAdminConsoleHA(ctx, flags.isAirgap, flags.cidrCfg.ServiceCIDR, flags.proxy, in.Spec.Config, in.Spec.LicenseInfo) + opts := addons.EnableHAOptions{ + ClusterID: in.Spec.ClusterID, + AdminConsolePort: rc.AdminConsolePort(), + IsAirgap: in.Spec.AirGap, + IsMultiNodeEnabled: in.Spec.LicenseInfo != nil && in.Spec.LicenseInfo.IsMultiNodeEnabled, + EmbeddedConfigSpec: in.Spec.Config, + EndUserConfigSpec: euCfgSpec, + ProxySpec: rc.ProxySpec(), + HostCABundlePath: rc.HostCABundlePath(), + DataDir: rc.EmbeddedClusterHomeDirectory(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), + SeaweedFSDataDir: rc.EmbeddedClusterSeaweedFSSubDir(), + ServiceCIDR: rc.ServiceCIDR(), + } + + err = addOns.EnableAdminConsoleHA(ctx, opts) if err != nil { return err } @@ -664,7 +688,7 @@ func runRestoreRegistry(ctx context.Context, flags InstallCmdFlags, backupToRest return fmt.Errorf("unable to read registry address from backup") } - if err := airgap.AddInsecureRegistry(registryAddress); err != nil { + if err := hostutils.AddInsecureRegistry(registryAddress); err != nil { return fmt.Errorf("failed to add insecure registry: %w", err) } @@ -772,7 +796,7 @@ func getECRestoreState(ctx context.Context) ecRestoreState { cm := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Namespace: runtimeconfig.EmbeddedClusterNamespace, + Namespace: constants.EmbeddedClusterNamespace, Name: constants.EcRestoreStateCMName, }, } @@ -804,7 +828,7 @@ func setECRestoreState(ctx context.Context, state ecRestoreState, backupName str ns := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ - Name: runtimeconfig.EmbeddedClusterNamespace, + Name: constants.EmbeddedClusterNamespace, }, } @@ -814,7 +838,7 @@ func setECRestoreState(ctx context.Context, state ecRestoreState, backupName str cm := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Namespace: runtimeconfig.EmbeddedClusterNamespace, + Namespace: constants.EmbeddedClusterNamespace, Name: constants.EcRestoreStateCMName, }, Data: map[string]string{ @@ -847,7 +871,7 @@ func resetECRestoreState(ctx context.Context) error { cm := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Namespace: runtimeconfig.EmbeddedClusterNamespace, + Namespace: constants.EmbeddedClusterNamespace, Name: constants.EcRestoreStateCMName, }, } @@ -872,7 +896,7 @@ func getBackupFromRestoreState(ctx context.Context, isAirgap bool, rc runtimecon cm := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Namespace: runtimeconfig.EmbeddedClusterNamespace, + Namespace: constants.EmbeddedClusterNamespace, Name: constants.EcRestoreStateCMName, }, } @@ -886,7 +910,7 @@ func getBackupFromRestoreState(ctx context.Context, isAirgap bool, rc runtimecon return nil, nil } - backup, err := disasterrecovery.GetReplicatedBackup(ctx, kcli, runtimeconfig.VeleroNamespace, backupName) + backup, err := disasterrecovery.GetReplicatedBackup(ctx, kcli, constants.VeleroNamespace, backupName) if err != nil { return nil, err } @@ -1225,7 +1249,7 @@ func waitForVeleroRestoreCompleted(ctx context.Context, restoreName string) (*ve for { restore := velerov1.Restore{} - err = kcli.Get(ctx, types.NamespacedName{Name: restoreName, Namespace: runtimeconfig.VeleroNamespace}, &restore) + err = kcli.Get(ctx, types.NamespacedName{Name: restoreName, Namespace: constants.VeleroNamespace}, &restore) if err != nil { return nil, fmt.Errorf("unable to get restore: %w", err) } @@ -1323,7 +1347,7 @@ func ensureRestoreResourceModifiers(ctx context.Context, backup *velerov1.Backup cm := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Namespace: runtimeconfig.VeleroNamespace, + Namespace: constants.VeleroNamespace, Name: resourceModifiersCMName, }, Data: map[string]string{ @@ -1379,7 +1403,7 @@ func waitForDRComponent(ctx context.Context, drComponent disasterRecoveryCompone return fmt.Errorf("unable to create kube client: %w", err) } - if err := restoreWaitForAdminConsoleReady(ctx, kcli, runtimeconfig.KotsadmNamespace, loading); err != nil { + if err := restoreWaitForAdminConsoleReady(ctx, kcli, constants.KotsadmNamespace, loading); err != nil { return fmt.Errorf("unable to wait for admin console: %w", err) } } else if drComponent == disasterRecoveryComponentSeaweedFS { @@ -1389,7 +1413,7 @@ func waitForDRComponent(ctx context.Context, drComponent disasterRecoveryCompone return fmt.Errorf("unable to create kube client: %w", err) } - if err := restoreWaitForSeaweedfsReady(ctx, kcli, runtimeconfig.SeaweedFSNamespace, nil); err != nil { + if err := restoreWaitForSeaweedfsReady(ctx, kcli, constants.SeaweedFSNamespace, nil); err != nil { return fmt.Errorf("unable to wait for seaweedfs to be ready: %w", err) } } else if drComponent == disasterRecoveryComponentRegistry { @@ -1399,7 +1423,7 @@ func waitForDRComponent(ctx context.Context, drComponent disasterRecoveryCompone return fmt.Errorf("unable to create kube client: %w", err) } - if err := kubeutils.WaitForDeployment(ctx, kcli, runtimeconfig.RegistryNamespace, "registry", nil); err != nil { + if err := kubeutils.WaitForDeployment(ctx, kcli, constants.RegistryNamespace, "registry", nil); err != nil { return fmt.Errorf("unable to wait for registry to be ready: %w", err) } } else if drComponent == disasterRecoveryComponentECO { @@ -1410,7 +1434,7 @@ func waitForDRComponent(ctx context.Context, drComponent disasterRecoveryCompone } if isV2 { - if err := kubeutils.WaitForDeployment(ctx, kcli, runtimeconfig.EmbeddedClusterNamespace, "embedded-cluster-operator", nil); err != nil { + if err := kubeutils.WaitForDeployment(ctx, kcli, constants.EmbeddedClusterNamespace, "embedded-cluster-operator", nil); err != nil { return fmt.Errorf("unable to wait for embedded cluster operator to be ready: %w", err) } } else { @@ -1490,14 +1514,14 @@ func restoreAppFromBackup(ctx context.Context, backup *velerov1.Backup, restore // check if a restore object already exists rest := velerov1.Restore{} - err = kcli.Get(ctx, types.NamespacedName{Name: restoreName, Namespace: runtimeconfig.VeleroNamespace}, &rest) + err = kcli.Get(ctx, types.NamespacedName{Name: restoreName, Namespace: constants.VeleroNamespace}, &rest) if err != nil && !k8serrors.IsNotFound(err) { return fmt.Errorf("unable to get restore: %w", err) } // create a new restore object if it doesn't exist if k8serrors.IsNotFound(err) { - restore.Namespace = runtimeconfig.VeleroNamespace + restore.Namespace = constants.VeleroNamespace restore.Name = restoreName if restore.Annotations == nil { restore.Annotations = map[string]string{} @@ -1532,7 +1556,7 @@ func restoreFromBackup(ctx context.Context, backup *velerov1.Backup, drComponent // check if a restore object already exists rest := velerov1.Restore{} - err = kcli.Get(ctx, types.NamespacedName{Name: restoreName, Namespace: runtimeconfig.VeleroNamespace}, &rest) + err = kcli.Get(ctx, types.NamespacedName{Name: restoreName, Namespace: constants.VeleroNamespace}, &rest) if err != nil && !k8serrors.IsNotFound(err) { return fmt.Errorf("unable to get restore: %w", err) } @@ -1555,7 +1579,7 @@ func restoreFromBackup(ctx context.Context, backup *velerov1.Backup, drComponent restore := &velerov1.Restore{ ObjectMeta: metav1.ObjectMeta{ - Namespace: runtimeconfig.VeleroNamespace, + Namespace: constants.VeleroNamespace, Name: restoreName, Annotations: map[string]string{ disasterrecovery.BackupIsECAnnotation: "true", diff --git a/cmd/installer/cli/root.go b/cmd/installer/cli/root.go index 41d2a73216..d11b3d1b3c 100644 --- a/cmd/installer/cli/root.go +++ b/cmd/installer/cli/root.go @@ -9,6 +9,8 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/dryrun" "github.com/replicatedhq/embedded-cluster/pkg/metrics" + "github.com/replicatedhq/embedded-cluster/pkg/release" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -30,8 +32,8 @@ func NewErrorNothingElseToAdd(err error) ErrorNothingElseToAdd { } } -func InitAndExecute(ctx context.Context, name string) { - cmd := RootCmd(ctx, name) +func InitAndExecute(ctx context.Context) { + cmd := RootCmd(ctx) err := cmd.Execute() if err != nil { if !errors.As(err, &ErrorNothingElseToAdd{}) { @@ -48,10 +50,12 @@ func InitAndExecute(ctx context.Context, name string) { } } -func RootCmd(ctx context.Context, name string) *cobra.Command { +func RootCmd(ctx context.Context) *cobra.Command { + appSlug := runtimeconfig.AppSlug() + cmd := &cobra.Command{ - Use: name, - Short: name, + Use: appSlug, + Short: appSlug, SilenceUsage: true, SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { @@ -96,18 +100,23 @@ func RootCmd(ctx context.Context, name string) *cobra.Command { }, } - cmd.AddCommand(InstallCmd(ctx, name)) - cmd.AddCommand(JoinCmd(ctx, name)) - cmd.AddCommand(ShellCmd(ctx, name)) - cmd.AddCommand(NodeCmd(ctx, name)) - cmd.AddCommand(EnableHACmd(ctx, name)) - cmd.AddCommand(VersionCmd(ctx, name)) - cmd.AddCommand(ResetCmd(ctx, name)) - cmd.AddCommand(MaterializeCmd(ctx, name)) - cmd.AddCommand(UpdateCmd(ctx, name)) - cmd.AddCommand(RestoreCmd(ctx, name)) - cmd.AddCommand(AdminConsoleCmd(ctx, name)) - cmd.AddCommand(SupportBundleCmd(ctx, name)) + appTitle := release.GetAppTitle() + if appTitle == "" { + appTitle = appSlug + } + + cmd.AddCommand(InstallCmd(ctx, appSlug, appTitle)) + cmd.AddCommand(JoinCmd(ctx, appSlug, appTitle)) + cmd.AddCommand(ShellCmd(ctx, appTitle)) + cmd.AddCommand(NodeCmd(ctx, appSlug, appTitle)) + cmd.AddCommand(EnableHACmd(ctx, appTitle)) + cmd.AddCommand(VersionCmd(ctx, appTitle)) + cmd.AddCommand(ResetCmd(ctx, appTitle)) + cmd.AddCommand(MaterializeCmd(ctx)) + cmd.AddCommand(UpdateCmd(ctx, appSlug, appTitle)) + cmd.AddCommand(RestoreCmd(ctx, appSlug, appTitle)) + cmd.AddCommand(AdminConsoleCmd(ctx, appTitle)) + cmd.AddCommand(SupportBundleCmd(ctx)) return cmd } diff --git a/cmd/installer/cli/shell.go b/cmd/installer/cli/shell.go index 25e2c23a26..964e374952 100644 --- a/cmd/installer/cli/shell.go +++ b/cmd/installer/cli/shell.go @@ -11,6 +11,7 @@ import ( "syscall" "github.com/creack/pty" + "github.com/replicatedhq/embedded-cluster/pkg/dryrun" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" rcutil "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig/util" "github.com/sirupsen/logrus" @@ -27,14 +28,15 @@ const welcome = ` ~~~~~~~~~~~ ` -func ShellCmd(ctx context.Context, name string) *cobra.Command { +func ShellCmd(ctx context.Context, appTitle string) *cobra.Command { var rc runtimeconfig.RuntimeConfig cmd := &cobra.Command{ Use: "shell", - Short: fmt.Sprintf("Start a shell with access to the %s cluster", name), + Short: fmt.Sprintf("Start a shell with access to the %s cluster", appTitle), PreRunE: func(cmd *cobra.Command, args []string) error { - if os.Getuid() != 0 { + // Skip root check if dryrun mode is enabled + if !dryrun.Enabled() && os.Getuid() != 0 { return fmt.Errorf("shell command must be run as root") } @@ -53,7 +55,7 @@ func ShellCmd(ctx context.Context, name string) *cobra.Command { shpath = "/bin/bash" } - fmt.Printf(welcome, runtimeconfig.BinaryName()) + fmt.Printf(welcome, runtimeconfig.AppSlug()) shell := exec.Command(shpath) shell.Env = os.Environ() diff --git a/cmd/installer/cli/supportbundle.go b/cmd/installer/cli/supportbundle.go index 7d0adff5b4..4ca1b3c7d0 100644 --- a/cmd/installer/cli/supportbundle.go +++ b/cmd/installer/cli/supportbundle.go @@ -17,7 +17,7 @@ import ( "github.com/spf13/cobra" ) -func SupportBundleCmd(ctx context.Context, name string) *cobra.Command { +func SupportBundleCmd(ctx context.Context) *cobra.Command { var rc runtimeconfig.RuntimeConfig cmd := &cobra.Command{ diff --git a/cmd/installer/cli/update.go b/cmd/installer/cli/update.go index cfc80d9196..a5a8a97d4a 100644 --- a/cmd/installer/cli/update.go +++ b/cmd/installer/cli/update.go @@ -6,22 +6,25 @@ import ( "os" "github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli" - "github.com/replicatedhq/embedded-cluster/pkg/release" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" + "github.com/replicatedhq/embedded-cluster/pkg/dryrun" + "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" rcutil "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig/util" "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) -func UpdateCmd(ctx context.Context, name string) *cobra.Command { +func UpdateCmd(ctx context.Context, appSlug, appTitle string) *cobra.Command { var airgapBundle string var rc runtimeconfig.RuntimeConfig cmd := &cobra.Command{ Use: "update", - Short: fmt.Sprintf("Update %s with a new air gap bundle", name), + Short: fmt.Sprintf("Update %s with a new air gap bundle", appTitle), PreRunE: func(cmd *cobra.Command, args []string) error { - if os.Getuid() != 0 { + // Skip root check if dryrun mode is enabled + if !dryrun.Enabled() && os.Getuid() != 0 { return fmt.Errorf("update command must be run as root") } @@ -47,16 +50,22 @@ func UpdateCmd(ctx context.Context, name string) *cobra.Command { } } - rel := release.GetChannelRelease() - if rel == nil { - return fmt.Errorf("no channel release found") + kcli, err := kubeutils.KubeClient() + if err != nil { + return fmt.Errorf("failed to create kubernetes client: %w", err) + } + + in, err := kubeutils.GetLatestInstallation(ctx, kcli) + if err != nil { + return fmt.Errorf("failed to get latest installation: %w", err) } if err := kotscli.AirgapUpdate(kotscli.AirgapUpdateOptions{ RuntimeConfig: rc, - AppSlug: rel.AppSlug, - Namespace: runtimeconfig.KotsadmNamespace, + AppSlug: appSlug, + Namespace: constants.KotsadmNamespace, AirgapBundle: airgapBundle, + ClusterID: in.Spec.ClusterID, }); err != nil { return err } @@ -66,7 +75,7 @@ func UpdateCmd(ctx context.Context, name string) *cobra.Command { } cmd.Flags().StringVar(&airgapBundle, "airgap-bundle", "", "Path to the air gap bundle. If set, the installation will complete without internet access.") - cmd.MarkFlagRequired("airgap-bundle") + mustMarkFlagRequired(cmd.Flags(), "airgap-bundle") return cmd } diff --git a/cmd/installer/cli/version.go b/cmd/installer/cli/version.go index 7f784d4966..ae68844c8e 100644 --- a/cmd/installer/cli/version.go +++ b/cmd/installer/cli/version.go @@ -15,16 +15,16 @@ import ( "github.com/spf13/cobra" ) -func VersionCmd(ctx context.Context, name string) *cobra.Command { +func VersionCmd(ctx context.Context, appTitle string) *cobra.Command { cmd := &cobra.Command{ Use: "version", - Short: fmt.Sprintf("Show the %s component versions", name), + Short: fmt.Sprintf("Show the %s component versions", appTitle), RunE: func(cmd *cobra.Command, args []string) error { writer := table.NewWriter() writer.AppendHeader(table.Row{"component", "version"}) channelRelease := release.GetChannelRelease() if channelRelease != nil { - writer.AppendRow(table.Row{runtimeconfig.BinaryName(), channelRelease.VersionLabel}) + writer.AppendRow(table.Row{runtimeconfig.AppSlug(), channelRelease.VersionLabel}) } writer.AppendRow(table.Row{"Installer", versions.Version}) writer.AppendRow(table.Row{"Kubernetes", versions.K0sVersion}) @@ -56,9 +56,9 @@ func VersionCmd(ctx context.Context, name string) *cobra.Command { }, } - cmd.AddCommand(VersionMetadataCmd(ctx, name)) - cmd.AddCommand(VersionEmbeddedDataCmd(ctx, name)) - cmd.AddCommand(VersionListImagesCmd(ctx, name)) + cmd.AddCommand(VersionMetadataCmd(ctx)) + cmd.AddCommand(VersionEmbeddedDataCmd(ctx)) + cmd.AddCommand(VersionListImagesCmd(ctx)) return cmd } diff --git a/cmd/installer/cli/version_embeddeddata.go b/cmd/installer/cli/version_embeddeddata.go index 8f29417e47..3ad68a6a8d 100644 --- a/cmd/installer/cli/version_embeddeddata.go +++ b/cmd/installer/cli/version_embeddeddata.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" ) -func VersionEmbeddedDataCmd(ctx context.Context, name string) *cobra.Command { +func VersionEmbeddedDataCmd(ctx context.Context) *cobra.Command { cmd := &cobra.Command{ Use: "embedded-data", Short: "Read the application data embedded in the cluster", diff --git a/cmd/installer/cli/version_listimages.go b/cmd/installer/cli/version_listimages.go index a4bd99ee1d..517227bb59 100644 --- a/cmd/installer/cli/version_listimages.go +++ b/cmd/installer/cli/version_listimages.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" ) -func VersionListImagesCmd(ctx context.Context, name string) *cobra.Command { +func VersionListImagesCmd(ctx context.Context) *cobra.Command { var ( omitReleaseMetadata bool ) diff --git a/cmd/installer/cli/version_metadata.go b/cmd/installer/cli/version_metadata.go index 7a37c89524..4957ab1d4c 100644 --- a/cmd/installer/cli/version_metadata.go +++ b/cmd/installer/cli/version_metadata.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -func VersionMetadataCmd(ctx context.Context, name string) *cobra.Command { +func VersionMetadataCmd(ctx context.Context) *cobra.Command { var ( omitReleaseMetadata bool ) diff --git a/cmd/installer/goods/materializer.go b/cmd/installer/goods/materializer.go index d860ecfaf8..77fe645d2e 100644 --- a/cmd/installer/goods/materializer.go +++ b/cmd/installer/goods/materializer.go @@ -207,7 +207,7 @@ func (m *Materializer) Ourselves() error { return fmt.Errorf("unable to get our own executable path: %w", err) } - dstpath := m.rc.PathToEmbeddedClusterBinary(runtimeconfig.BinaryName()) + dstpath := m.rc.PathToEmbeddedClusterBinary(runtimeconfig.AppSlug()) if srcpath == dstpath { return nil } diff --git a/cmd/installer/goods/support/host-support-bundle.tmpl.yaml b/cmd/installer/goods/support/host-support-bundle.tmpl.yaml index fb93075f7f..6b8a750cc9 100644 --- a/cmd/installer/goods/support/host-support-bundle.tmpl.yaml +++ b/cmd/installer/goods/support/host-support-bundle.tmpl.yaml @@ -173,6 +173,97 @@ spec: collectorName: "ip-route-table" command: "ip" args: ["route"] + - run: + collectorName: "ip-neighbor-show" + command: "ip" + args: ["-s", "-d", "neigh", "show"] + # HTTP connectivity checks (only run for online installations) + - http: + collectorName: http-replicated-app + get: + url: '{{ .ReplicatedAppURL }}/healthz' + timeout: 5s + proxy: '{{ .HTTPSProxy }}' + exclude: '{{ or .IsAirgap (eq .ReplicatedAppURL "") }}' + - http: + collectorName: http-proxy-replicated-com + get: + url: '{{ .ProxyRegistryURL }}/v2/' + timeout: 5s + proxy: '{{ .HTTPSProxy }}' + exclude: '{{ or .IsAirgap (eq .ProxyRegistryURL "") }}' + # Curl-based connectivity checks (for comparison with HTTP collectors) + - run: + collectorName: curl-replicated-app + command: sh + args: + - -c + - | + if [ -n "{{ .HTTPSProxy }}" ]; then + curl --connect-timeout 5 --max-time 10 -v --proxy "{{ .HTTPSProxy }}" "{{ .ReplicatedAppURL }}/healthz" 2>&1 + else + curl --connect-timeout 5 --max-time 10 -v "{{ .ReplicatedAppURL }}" 2>&1 + fi + exclude: '{{ or .IsAirgap (eq .ReplicatedAppURL "") }}' + - run: + collectorName: curl-proxy-replicated-com + command: sh + args: + - -c + - | + if [ -n "{{ .HTTPSProxy }}" ]; then + curl --connect-timeout 5 --max-time 10 -v --proxy "{{ .HTTPSProxy }}" "{{ .ProxyRegistryURL }}/v2/" 2>&1 + else + curl --connect-timeout 5 --max-time 10 -v "{{ .ProxyRegistryURL }}/v2/" 2>&1 + fi + exclude: '{{ or .IsAirgap (eq .ProxyRegistryURL "") }}' + - run: + collectorName: "ip-address-stats" + command: "ip" + args: ["-s", "-s", "address"] + - run: + collectorName: "lspci" + command: "lspci" + args: ["-vvv", "-D"] + - run: + collectorName: "ethool-info" + command: "sh" + args: + - -c + - > + interfaces=$(ls /sys/class/net); + for iface in $interfaces; do + echo "=============================================="; + echo "Interface: $iface"; + echo "=============================================="; + + echo + echo "--- Basic Info ---" + ethtool "$iface" + + echo + echo "--- Features (Offloads) ---" + ethtool -k "$iface" + + echo + echo "--- Pause Parameters ---" + ethtool -a "$iface" + + echo + echo "--- Ring Parameters ---" + ethtool -g "$iface" + + echo + echo "--- Coalesce Settings ---" + ethtool -c "$iface" + + echo + echo "--- Driver Info ---" + ethtool -i "$iface" + + echo + echo + done - run: collectorName: "sysctl" command: "sysctl" diff --git a/cmd/installer/kotscli/kotscli.go b/cmd/installer/kotscli/kotscli.go index bbe8f1781c..674771769c 100644 --- a/cmd/installer/kotscli/kotscli.go +++ b/cmd/installer/kotscli/kotscli.go @@ -11,7 +11,6 @@ import ( "github.com/replicatedhq/embedded-cluster/cmd/installer/goods" "github.com/replicatedhq/embedded-cluster/pkg/helpers" - "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/pkg/spinner" @@ -25,8 +24,9 @@ var ( type InstallOptions struct { RuntimeConfig runtimeconfig.RuntimeConfig AppSlug string - LicenseFile string + License []byte Namespace string + ClusterID string AirgapBundle string ConfigValuesFile string ReplicatedAppEndpoint string @@ -53,12 +53,22 @@ func Install(opts InstallOptions) error { upstreamURI = fmt.Sprintf("%s/%s", upstreamURI, channelSlug) } + licenseFile, err := os.CreateTemp("", "license") + if err != nil { + return fmt.Errorf("unable to create temp file: %w", err) + } + defer os.Remove(licenseFile.Name()) + + if _, err := licenseFile.Write(opts.License); err != nil { + return fmt.Errorf("unable to write license to temp file: %w", err) + } + maskfn := MaskKotsOutputForOnline() installArgs := []string{ "install", upstreamURI, "--license-file", - opts.LicenseFile, + licenseFile.Name(), "--namespace", opts.Namespace, "--app-version-label", @@ -73,17 +83,15 @@ func Install(opts InstallOptions) error { installArgs = append(installArgs, "--config-values", opts.ConfigValuesFile) } - if opts.Stdout != nil { - if msg, ok := opts.Stdout.(*spinner.MessageWriter); ok { - msg.SetMask(maskfn) - defer msg.SetMask(nil) - } + if msg, ok := opts.Stdout.(*spinner.MessageWriter); ok && msg != nil { + msg.SetMask(maskfn) + defer msg.SetMask(nil) } runCommandOptions := helpers.RunCommandOptions{ LogOnSuccess: true, Env: map[string]string{ - "EMBEDDED_CLUSTER_ID": metrics.ClusterID().String(), + "EMBEDDED_CLUSTER_ID": opts.ClusterID, }, } if opts.Stdout != nil { @@ -126,6 +134,7 @@ type AirgapUpdateOptions struct { AppSlug string Namespace string AirgapBundle string + ClusterID string } func AirgapUpdate(opts AirgapUpdateOptions) error { @@ -152,7 +161,7 @@ func AirgapUpdate(opts AirgapUpdateOptions) error { runCommandOptions := helpers.RunCommandOptions{ Stdout: loading, Env: map[string]string{ - "EMBEDDED_CLUSTER_ID": metrics.ClusterID().String(), + "EMBEDDED_CLUSTER_ID": opts.ClusterID, }, } if err := helpers.RunCommandWithOptions(runCommandOptions, kotsBinPath, airgapUpdateArgs...); err != nil { diff --git a/cmd/installer/main.go b/cmd/installer/main.go index 375d71b453..e51bdae373 100644 --- a/cmd/installer/main.go +++ b/cmd/installer/main.go @@ -3,7 +3,6 @@ package main import ( "context" "os" - "path" "syscall" "github.com/mattn/go-isatty" @@ -18,12 +17,10 @@ func main() { prompts.SetTerminal(isatty.IsTerminal(os.Stdout.Fd())) - name := path.Base(os.Args[0]) - // set the umask to 022 so that we can create files/directories with 755 permissions // this does not return an error - it returns the previous umask // we do this before calling cli.InitAndExecute so that it is set before the process forks _ = syscall.Umask(0o022) - cli.InitAndExecute(ctx, name) + cli.InitAndExecute(ctx) } diff --git a/e2e/cluster/cmx/cluster.go b/e2e/cluster/cmx/cluster.go index edc2bf1836..cfd25b3409 100644 --- a/e2e/cluster/cmx/cluster.go +++ b/e2e/cluster/cmx/cluster.go @@ -504,9 +504,6 @@ func copyFileFromNode(node Node, src, dst string) error { func sshArgs() []string { return []string{ "-o", "StrictHostKeyChecking=no", - "-o", "ServerAliveInterval=30", - "-o", "ServerAliveCountMax=10", - "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", } } diff --git a/e2e/install_test.go b/e2e/install_test.go index 8ba5319f8d..7ea47bfb41 100644 --- a/e2e/install_test.go +++ b/e2e/install_test.go @@ -420,17 +420,12 @@ func TestSingleNodeUpgradePreviousStable(t *testing.T) { initialVersion := fmt.Sprintf("appver-%s-previous-stable", os.Getenv("SHORT_SHA")) - withEnv := map[string]string{ - "EMBEDDED_CLUSTER_BIN": "embedded-cluster", - } - downloadECReleaseWithOptions(t, tc, 0, downloadECReleaseOptions{ version: initialVersion, }) installSingleNodeWithOptions(t, tc, installOptions{ version: initialVersion, - withEnv: withEnv, }) if stdout, stderr, err := tc.SetupPlaywrightAndRunTest("deploy-app"); err != nil { @@ -440,7 +435,6 @@ func TestSingleNodeUpgradePreviousStable(t *testing.T) { checkInstallationStateWithOptions(t, tc, installationStateOptions{ version: initialVersion, k8sVersion: k8sVersionPreviousStable(), - withEnv: withEnv, }) appUpgradeVersion := fmt.Sprintf("appver-%s-noop", os.Getenv("SHORT_SHA")) @@ -459,7 +453,6 @@ func TestSingleNodeUpgradePreviousStable(t *testing.T) { checkInstallationStateWithOptions(t, tc, installationStateOptions{ version: appUpgradeVersion, - withEnv: withEnv, }) appUpgradeVersion = fmt.Sprintf("appver-%s-upgrade", os.Getenv("SHORT_SHA")) @@ -470,9 +463,7 @@ func TestSingleNodeUpgradePreviousStable(t *testing.T) { t.Fatalf("fail to run playwright test deploy-upgrade: %v: %s: %s", err, stdout, stderr) } - checkPostUpgradeStateWithOptions(t, tc, postUpgradeStateOptions{ - withEnv: withEnv, - }) + checkPostUpgradeState(t, tc) t.Logf("%s: test complete", time.Now().Format(time.RFC3339)) } @@ -1159,7 +1150,6 @@ func TestMultiNodeAirgapUpgradePreviousStable(t *testing.T) { // Use an alternate data directory withEnv := map[string]string{ "EMBEDDED_CLUSTER_BASE_DIR": "/var/lib/ec", - "EMBEDDED_CLUSTER_BIN": "embedded-cluster", } tc := cmx.NewCluster(&cmx.ClusterInput{ diff --git a/e2e/playwright/package-lock.json b/e2e/playwright/package-lock.json index 97121082bf..1965279ff3 100644 --- a/e2e/playwright/package-lock.json +++ b/e2e/playwright/package-lock.json @@ -11,17 +11,17 @@ "devDependencies": { "@playwright/test": "^1.48.0", "@types/node": "^24.0.1", - "ts-retry": "^4.2.5" + "ts-retry": "^6.0.0" } }, "node_modules/@playwright/test": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz", - "integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==", + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.1.tgz", + "integrity": "sha512-Z4c23LHV0muZ8hfv4jw6HngPJkbbtZxTkxPNIg7cJcTc9C28N/p2q7g3JZS2SiKBBHJ3uM1dgDye66bB7LEk5w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.53.0" + "playwright": "1.53.1" }, "bin": { "playwright": "cli.js" @@ -31,11 +31,10 @@ } }, "node_modules/@types/node": { - "version": "24.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.1.tgz", - "integrity": "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==", + "version": "24.0.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.7.tgz", + "integrity": "sha512-YIEUUr4yf8q8oQoXPpSlnvKNVKDQlPMWrmOcgzoduo7kvA2UF0/BwJ/eMKFTiTtkNL17I0M6Xe2tvwFU7be6iw==", "dev": true, - "license": "MIT", "dependencies": { "undici-types": "~7.8.0" } @@ -56,13 +55,13 @@ } }, "node_modules/playwright": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz", - "integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==", + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.1.tgz", + "integrity": "sha512-LJ13YLr/ocweuwxyGf1XNFWIU4M2zUSo149Qbp+A4cpwDjsxRPj7k6H25LBrEHiEwxvRbD8HdwvQmRMSvquhYw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.53.0" + "playwright-core": "1.53.1" }, "bin": { "playwright": "cli.js" @@ -75,9 +74,9 @@ } }, "node_modules/playwright-core": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz", - "integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==", + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.1.tgz", + "integrity": "sha512-Z46Oq7tLAyT0lGoFx4DOuB1IA9D1TPj0QkYxpPVUnGDqHHvDpCftu1J2hM2PiWsNMoZh8+LQaarAWcDfPBc6zg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -88,10 +87,11 @@ } }, "node_modules/ts-retry": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/ts-retry/-/ts-retry-4.2.5.tgz", - "integrity": "sha512-dFBa4pxMBkt/bjzdBio8EwYfbAdycEAwe0KZgzlUKKwU9Wr1WErK7Hg9QLqJuDDYJXTW4KYZyXAyqYKOdO/ehA==", - "dev": true + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ts-retry/-/ts-retry-6.0.0.tgz", + "integrity": "sha512-WsVRE/P+VNYbiQC3E6TeIXBRCQj7vzjN4MlXd84AC88K7WwuWShN7A3Q/QSV/yd1hjO8qn2Cevdqny2HMwKUaA==", + "dev": true, + "license": "MIT" }, "node_modules/undici-types": { "version": "7.8.0", diff --git a/e2e/playwright/package.json b/e2e/playwright/package.json index 9cd1dd4794..9475e6363c 100644 --- a/e2e/playwright/package.json +++ b/e2e/playwright/package.json @@ -10,6 +10,6 @@ "devDependencies": { "@playwright/test": "^1.48.0", "@types/node": "^24.0.1", - "ts-retry": "^4.2.5" + "ts-retry": "^6.0.0" } } diff --git a/e2e/scripts/common.sh b/e2e/scripts/common.sh index 07e9de3777..030b1d3b42 100755 --- a/e2e/scripts/common.sh +++ b/e2e/scripts/common.sh @@ -298,10 +298,10 @@ ensure_version_metadata_present() { } # ensure_binary_copy verifies that the installer is copying itself to the default location of -# banaries in the node. +# binaries on the node. ensure_binary_copy() { if ! ls "${EMBEDDED_CLUSTER_BASE_DIR}/bin/${EMBEDDED_CLUSTER_BIN}" ; then - echo "embedded-cluster binary not found on default location" + echo "embedded-cluster binary not found at default location" ls -la "${EMBEDDED_CLUSTER_BASE_DIR}/bin" return 1 fi diff --git a/e2e/scripts/install-and-configure-squid.sh b/e2e/scripts/install-and-configure-squid.sh index 5e50868146..1e1ac86009 100755 --- a/e2e/scripts/install-and-configure-squid.sh +++ b/e2e/scripts/install-and-configure-squid.sh @@ -92,6 +92,7 @@ create_squid_ssl() { main() { + apt-get update -y apt install -y squid-openssl /usr/lib/squid/security_file_certgen -c -s /opt/ssl.db -M 4MB mkdir -p /etc/squid/ssl_cert diff --git a/go.mod b/go.mod index 41030bb9db..1f392109bd 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,11 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/apparentlymart/go-cidr v1.1.0 github.com/aws/aws-sdk-go v1.55.7 - github.com/aws/aws-sdk-go-v2 v1.36.4 - github.com/aws/aws-sdk-go-v2/config v1.29.16 - github.com/aws/aws-sdk-go-v2/credentials v1.17.69 - github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.79 - github.com/aws/aws-sdk-go-v2/service/s3 v1.80.2 + github.com/aws/aws-sdk-go-v2 v1.36.5 + github.com/aws/aws-sdk-go-v2/config v1.29.17 + github.com/aws/aws-sdk-go-v2/credentials v1.17.70 + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.82 + github.com/aws/aws-sdk-go-v2/service/s3 v1.82.0 github.com/bombsimon/logrusr/v4 v4.1.0 github.com/canonical/lxd v0.0.0-20241030172432-dee0d04b56ee github.com/containers/image/v5 v5.34.3 @@ -27,7 +27,7 @@ require ( github.com/gosimple/slug v1.15.0 github.com/jedib0t/go-pretty/v6 v6.6.7 github.com/k0sproject/k0s v1.31.9-0.20250428141639-26a9908cf691 - github.com/ohler55/ojg v1.26.6 + github.com/ohler55/ojg v1.26.7 github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.37.0 github.com/replicatedhq/embedded-cluster/kinds v0.0.0 @@ -40,24 +40,25 @@ require ( github.com/stretchr/testify v1.10.0 github.com/swaggo/http-swagger/v2 v2.0.2 github.com/swaggo/swag/v2 v2.0.0-rc4 + github.com/tiendc/go-deepcopy v1.6.1 github.com/urfave/cli/v2 v2.27.7 github.com/vmware-tanzu/velero v1.16.1 go.uber.org/multierr v1.11.0 - golang.org/x/crypto v0.38.0 + golang.org/x/crypto v0.39.0 golang.org/x/term v0.32.0 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 gotest.tools v2.2.0+incompatible - helm.sh/helm/v3 v3.17.3 - k8s.io/api v0.32.3 - k8s.io/apimachinery v0.32.3 - k8s.io/cli-runtime v0.32.3 - k8s.io/client-go v0.32.3 - k8s.io/kubectl v0.32.3 + helm.sh/helm/v3 v3.18.3 + k8s.io/api v0.33.2 + k8s.io/apimachinery v0.33.2 + k8s.io/cli-runtime v0.33.2 + k8s.io/client-go v0.33.2 + k8s.io/kubectl v0.33.2 k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 - oras.land/oras-go/v2 v2.5.0 + oras.land/oras-go/v2 v2.6.0 sigs.k8s.io/controller-runtime v0.20.4 - sigs.k8s.io/yaml v1.4.0 + sigs.k8s.io/yaml v1.5.0 ) replace ( @@ -66,7 +67,7 @@ replace ( ) require ( - cel.dev/expr v0.18.0 // indirect + cel.dev/expr v0.19.1 // indirect cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.14.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect @@ -77,7 +78,7 @@ require ( dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/BurntSushi/toml v1.5.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 // indirect @@ -92,20 +93,20 @@ require ( github.com/VividCortex/ewma v1.2.0 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/ahmetalpbalkan/go-cursor v0.0.0-20131010032410-8136607ea412 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.31 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.35 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.35 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.35 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.16 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.16 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.25.4 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.33.21 // indirect - github.com/aws/smithy-go v1.22.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect + github.com/aws/smithy-go v1.22.4 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -131,7 +132,7 @@ require ( github.com/containers/storage v1.57.2 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20231217050601-ba74d44ecf5f // indirect - github.com/cyphar/filepath-securejoin v0.3.6 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/distribution/distribution/v3 v3.0.0 // indirect github.com/docker/cli v27.5.1+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect @@ -196,7 +197,7 @@ require ( github.com/jmoiron/sqlx v1.4.0 // indirect github.com/k0sproject/dig v0.4.0 // indirect github.com/k0sproject/version v0.6.0 // indirect - github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect @@ -220,7 +221,7 @@ require ( github.com/moby/sys/capability v0.4.0 // indirect github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/moby/sys/user v0.3.0 // indirect - github.com/moby/term v0.5.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/oklog/ulid v1.3.1 // indirect @@ -235,12 +236,12 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/proglottis/gpgme v0.1.4 // indirect - github.com/prometheus/client_golang v1.20.5 // indirect + github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/rubenv/sql-migrate v1.7.1 // indirect + github.com/rubenv/sql-migrate v1.8.0 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect github.com/segmentio/ksuid v1.0.4 // indirect @@ -279,14 +280,14 @@ require ( github.com/zitadel/logging v0.6.1 // indirect github.com/zitadel/oidc/v3 v3.31.0 // indirect github.com/zitadel/schema v1.3.0 // indirect - go.etcd.io/etcd/api/v3 v3.5.18 // indirect - go.etcd.io/etcd/client/pkg/v3 v3.5.18 // indirect - go.etcd.io/etcd/client/v3 v3.5.18 // indirect + go.etcd.io/etcd/api/v3 v3.5.21 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.5.21 // indirect + go.etcd.io/etcd/client/v3 v3.5.21 // indirect go.mongodb.org/mongo-driver v1.17.3 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect @@ -294,9 +295,11 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.3 // indirect golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/sync v0.14.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/sync v0.15.0 // indirect golang.org/x/tools v0.33.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/api v0.218.0 // indirect @@ -305,18 +308,19 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect google.golang.org/grpc v1.69.4 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect - k8s.io/apiserver v0.32.3 // indirect - k8s.io/component-base v0.32.3 // indirect + k8s.io/apiserver v0.33.2 // indirect + k8s.io/component-base v0.33.2 // indirect k8s.io/kubelet v0.32.3 // indirect - k8s.io/metrics v0.32.3 // indirect + k8s.io/metrics v0.33.2 // indirect oras.land/oras-go v1.2.6 // indirect periph.io/x/host/v3 v3.8.5 // indirect - sigs.k8s.io/kustomize/api v0.18.0 // indirect - sigs.k8s.io/kustomize/kyaml v0.18.1 // indirect + sigs.k8s.io/kustomize/api v0.19.0 // indirect + sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect ) require ( - github.com/Masterminds/semver/v3 v3.3.1 + github.com/Masterminds/semver/v3 v3.4.0 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -329,10 +333,10 @@ require ( github.com/go-openapi/swag v0.23.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/gnostic-models v0.6.9 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect - github.com/gorilla/websocket v1.5.3 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gosimple/unidecode v1.0.1 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -363,13 +367,13 @@ require ( golang.org/x/net v0.40.0 // indirect golang.org/x/oauth2 v0.28.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.25.0 + golang.org/x/text v0.26.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/protobuf v1.36.5 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/apiextensions-apiserver v0.32.3 + k8s.io/apiextensions-apiserver v0.33.2 k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect + k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect ) diff --git a/go.sum b/go.sum index 5031b3bb90..729eab3cb2 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.18.0 h1:CJ6drgk+Hf96lkLikr4rFf19WrU0BOWEihyZnI2TAzo= -cel.dev/expr v0.18.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= +cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -637,8 +637,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0 h1:7rKG7Um github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0/go.mod h1:Wjo+24QJVhhl/L7jy6w9yzFF2yDOf3cKECAa8ecf9vE= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 h1:eXnN9kaS8TiDwXjoie3hMRLuwdUBUMW9KRgOqB3mCaw= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0/go.mod h1:XIpam8wumeZ5rVMuhdDQLMfIPDf1WO3IzrCRO3e3e3o= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3 h1:H5xDQaE3XowWfhZRUpnfC+rGZMEVoSiji+b+/HFAPU4= github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -662,8 +662,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= -github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= @@ -703,44 +703,44 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:W github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.36.4 h1:GySzjhVvx0ERP6eyfAbAuAXLtAda5TEy19E5q5W8I9E= -github.com/aws/aws-sdk-go-v2 v1.36.4/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14= -github.com/aws/aws-sdk-go-v2/config v1.29.16 h1:XkruGnXX1nEZ+Nyo9v84TzsX+nj86icbFAeust6uo8A= -github.com/aws/aws-sdk-go-v2/config v1.29.16/go.mod h1:uCW7PNjGwZ5cOGZ5jr8vCWrYkGIhPoTNV23Q/tpHKzg= -github.com/aws/aws-sdk-go-v2/credentials v1.17.69 h1:8B8ZQboRc3uaIKjshve/XlvJ570R7BKNy3gftSbS178= -github.com/aws/aws-sdk-go-v2/credentials v1.17.69/go.mod h1:gPME6I8grR1jCqBFEGthULiolzf/Sexq/Wy42ibKK9c= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.31 h1:oQWSGexYasNpYp4epLGZxxjsDo8BMBh6iNWkTXQvkwk= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.31/go.mod h1:nc332eGUU+djP3vrMI6blS0woaCfHTe3KiSQUVTMRq0= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.79 h1:mGo6WGWry+s5GEf2GLfw3zkHad109FQmtvBV3VYQ8mA= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.79/go.mod h1:siwnpWxHYFSSge7Euw9lGMgQBgvRyym352mCuGNHsMQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.35 h1:o1v1VFfPcDVlK3ll1L5xHsaQAFdNtZ5GXnNR7SwueC4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.35/go.mod h1:rZUQNYMNG+8uZxz9FOerQJ+FceCiodXvixpeRtdESrU= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.35 h1:R5b82ubO2NntENm3SAm0ADME+H630HomNJdgv+yZ3xw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.35/go.mod h1:FuA+nmgMRfkzVKYDNEqQadvEMxtxl9+RLT9ribCwEMs= +github.com/aws/aws-sdk-go-v2 v1.36.5 h1:0OF9RiEMEdDdZEMqF9MRjevyxAQcf6gY+E7vwBILFj0= +github.com/aws/aws-sdk-go-v2 v1.36.5/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY= +github.com/aws/aws-sdk-go-v2/config v1.29.17 h1:jSuiQ5jEe4SAMH6lLRMY9OVC+TqJLP5655pBGjmnjr0= +github.com/aws/aws-sdk-go-v2/config v1.29.17/go.mod h1:9P4wwACpbeXs9Pm9w1QTh6BwWwJjwYvJ1iCt5QbCXh8= +github.com/aws/aws-sdk-go-v2/credentials v1.17.70 h1:ONnH5CM16RTXRkS8Z1qg7/s2eDOhHhaXVd72mmyv4/0= +github.com/aws/aws-sdk-go-v2/credentials v1.17.70/go.mod h1:M+lWhhmomVGgtuPOhO85u4pEa3SmssPTdcYpP/5J/xc= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 h1:KAXP9JSHO1vKGCr5f4O6WmlVKLFFXgWYAGoJosorxzU= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32/go.mod h1:h4Sg6FQdexC1yYG9RDnOvLbW1a/P986++/Y/a+GyEM8= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.82 h1:EO13QJTCD1Ig2IrQnoHTRrn981H9mB7afXsZ89WptI4= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.82/go.mod h1:AGh1NCg0SH+uyJamiJA5tTQcql4MMRDXGRdMmCxCXzY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 h1:SsytQyTMHMDPspp+spo7XwXTP44aJZZAC7fBV2C5+5s= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36/go.mod h1:Q1lnJArKRXkenyog6+Y+zr7WDpk4e6XlR6gs20bbeNo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 h1:i2vNHQiXUvKhs3quBR6aqlgJaiaexz/aNvdCktW/kAM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36/go.mod h1:UdyGa7Q91id/sdyHPwth+043HhmP6yP9MBHgbZM0xo8= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.35 h1:th/m+Q18CkajTw1iqx2cKkLCij/uz8NMwJFPK91p2ug= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.35/go.mod h1:dkJuf0a1Bc8HAA0Zm2MoTGm/WDC18Td9vSbrQ1+VqE8= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.3 h1:VHPZakq2L7w+RLzV54LmQavbvheFaR2u1NomJRSEfcU= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.3/go.mod h1:DX1e/lkbsAt0MkY3NgLYuH4jQvRfw8MYxTe9feR7aXM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.16 h1:/ldKrPPXTC421bTNWrUIpq3CxwHwRI/kpc+jPUTJocM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.16/go.mod h1:5vkf/Ws0/wgIMJDQbjI4p2op86hNW6Hie5QtebrDgT8= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.16 h1:2HuI7vWKhFWsBhIr2Zq8KfFZT6xqaId2XXnXZjkbEuc= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.16/go.mod h1:BrwWnsfbFtFeRjdx0iM1ymvlqDX1Oz68JsQaibX/wG8= -github.com/aws/aws-sdk-go-v2/service/s3 v1.80.2 h1:T6Wu+8E2LeTUqzqQ/Bh1EoFNj1u4jUyveMgmTlu9fDU= -github.com/aws/aws-sdk-go-v2/service/s3 v1.80.2/go.mod h1:chSY8zfqmS0OnhZoO/hpPx/BHfAIL80m77HwhRLYScY= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.4 h1:EU58LP8ozQDVroOEyAfcq0cGc5R/FTZjVoYJ6tvby3w= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.4/go.mod h1:CrtOgCcysxMvrCoHnvNAD7PHWclmoFG78Q2xLK0KKcs= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.2 h1:XB4z0hbQtpmBnb1FQYvKaCM7UsS6Y/u8jVBwIUGeCTk= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.2/go.mod h1:hwRpqkRxnQ58J9blRDrB4IanlXCpcKmsC83EhG77upg= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.21 h1:nyLjs8sYJShFYj6aiyjCBI3EcLn1udWrQTjEF+SOXB0= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.21/go.mod h1:EhdxtZ+g84MSGrSrHzZiUm9PYiZkrADNja15wtRJSJo= -github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= -github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36/go.mod h1:gDhdAV6wL3PmPqBhiPbnlS447GoWs8HTTOYef9/9Inw= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 h1:CXV68E2dNqhuynZJPB80bhPQwAKqBWVer887figW6Jc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 h1:t0E6FzREdtCsiLIoLCWsYliNsRBgyGD/MCK571qk4MI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17/go.mod h1:ygpklyoaypuyDvOM5ujWGrYWpAK3h7ugnmKCU/76Ys4= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U= +github.com/aws/aws-sdk-go-v2/service/s3 v1.82.0 h1:JubM8CGDDFaAOmBrd8CRYNr49ZNgEAiLwGwgNMdS0nw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.82.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 h1:AIRJ3lfb2w/1/8wOOSqYb9fUKGwQbtysJ2H1MofRUPg= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.5/go.mod h1:b7SiVprpU+iGazDUqvRSLf5XmCdn+JtT1on7uNL6Ipc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 h1:BpOxT3yhLwSJ77qIY3DoHAQjZsc4HEGfMCE4NGy3uFg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3/go.mod h1:vq/GQR1gOFLquZMSrxUK/cpvKCNVYibNyJ1m7JrU88E= +github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 h1:NFOJ/NXEGV4Rq//71Hs1jC/NvPs1ezajK+yQmkwnPV0= +github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w= +github.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw= +github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -843,8 +843,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyberphone/json-canonicalization v0.0.0-20231217050601-ba74d44ecf5f h1:eHnXnuK47UlSTOQexbzxAZfekVz6i+LKRdj1CU5DPaM= github.com/cyberphone/json-canonicalization v0.0.0-20231217050601-ba74d44ecf5f/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= -github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM= -github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -1053,8 +1053,8 @@ github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= -github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -1147,8 +1147,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= @@ -1161,8 +1161,8 @@ github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -1243,8 +1243,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= github.com/klauspost/compress v1.15.11/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= @@ -1345,8 +1345,8 @@ github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -1372,8 +1372,8 @@ github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= -github.com/ohler55/ojg v1.26.6 h1:0cOJJcTUOfx4HpqYE2/rNn0IOJShTjH/gY8T+EKv/OQ= -github.com/ohler55/ojg v1.26.6/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= +github.com/ohler55/ojg v1.26.7 h1:yZLS2xlZF/qk5LHM4LFhxxTDyMgZl+46Z6p7wQm8KAU= +github.com/ohler55/ojg v1.26.7/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -1439,8 +1439,8 @@ github.com/proglottis/gpgme v0.1.4/go.mod h1:5LoXMgpE4bttgwwdv9bLs/vwqv3qV7F4glE github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= -github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= -github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -1479,8 +1479,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= -github.com/rubenv/sql-migrate v1.7.1 h1:f/o0WgfO/GqNuVg+6801K/KW3WdDSupzSjDYODmiUq4= -github.com/rubenv/sql-migrate v1.7.1/go.mod h1:Ob2Psprc0/3ggbM6wCzyYVFFuc6FyZrb2AS+ezLDFb4= +github.com/rubenv/sql-migrate v1.8.0 h1:dXnYiJk9k3wetp7GfQbKJcPHjVJL6YK19tKj8t2Ns0o= +github.com/rubenv/sql-migrate v1.8.0/go.mod h1:F2bGFBwCU+pnmbtNYDeKvSuvL6lBVtXDXUUv5t+u1qw= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= @@ -1563,6 +1563,8 @@ github.com/sylabs/sif/v2 v2.20.2 h1:HGEPzauCHhIosw5o6xmT3jczuKEuaFzSfdjAsH33vYw= github.com/sylabs/sif/v2 v2.20.2/go.mod h1:WyYryGRaR4Wp21SAymm5pK0p45qzZCSRiZMFvUZiuhc= github.com/tchap/go-patricia/v2 v2.3.2 h1:xTHFutuitO2zqKAQ5rCROYgUb7Or/+IC3fts9/Yc7nM= github.com/tchap/go-patricia/v2 v2.3.2/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/tiendc/go-deepcopy v1.6.1 h1:uVRTItFeNHkMcLueHS7OCsxgxT9P8MzGB/taUa2Y4Tk= +github.com/tiendc/go-deepcopy v1.6.1/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= @@ -1615,12 +1617,12 @@ github.com/zitadel/oidc/v3 v3.31.0 h1:XQcTVHTYpSkNxjGccEb6pRfrGJdUhkTgXOIzSqRXdo github.com/zitadel/oidc/v3 v3.31.0/go.mod h1:DyE/XClysRK/ozFaZSqlYamKVnTh4l6Ln25ihSNI03w= github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= -go.etcd.io/etcd/api/v3 v3.5.18 h1:Q4oDAKnmwqTo5lafvB+afbgCDF7E35E4EYV2g+FNGhs= -go.etcd.io/etcd/api/v3 v3.5.18/go.mod h1:uY03Ob2H50077J7Qq0DeehjM/A9S8PhVfbQ1mSaMopU= -go.etcd.io/etcd/client/pkg/v3 v3.5.18 h1:mZPOYw4h8rTk7TeJ5+3udUkfVGBqc+GCjOJYd68QgNM= -go.etcd.io/etcd/client/pkg/v3 v3.5.18/go.mod h1:BxVf2o5wXG9ZJV+/Cu7QNUiJYk4A29sAhoI5tIRsCu4= -go.etcd.io/etcd/client/v3 v3.5.18 h1:nvvYmNHGumkDjZhTHgVU36A9pykGa2K4lAJ0yY7hcXA= -go.etcd.io/etcd/client/v3 v3.5.18/go.mod h1:kmemwOsPU9broExyhYsBxX4spCTDX3yLgPMWtpBXG6E= +go.etcd.io/etcd/api/v3 v3.5.21 h1:A6O2/JDb3tvHhiIz3xf9nJ7REHvtEFJJ3veW3FbCnS8= +go.etcd.io/etcd/api/v3 v3.5.21/go.mod h1:c3aH5wcvXv/9dqIw2Y810LDXJfhSYdHQ0vxmP3CCHVY= +go.etcd.io/etcd/client/pkg/v3 v3.5.21 h1:lPBu71Y7osQmzlflM9OfeIV2JlmpBjqBNlLtcoBqUTc= +go.etcd.io/etcd/client/pkg/v3 v3.5.21/go.mod h1:BgqT/IXPjK9NkeSDjbzwsHySX3yIle2+ndz28nVsjUs= +go.etcd.io/etcd/client/v3 v3.5.21 h1:T6b1Ow6fNjOLOtM0xSoKNQt1ASPCLWrF9XMHcH9pEyY= +go.etcd.io/etcd/client/v3 v3.5.21/go.mod h1:mFYy67IOqmbRf/kRUvsHixzo3iG+1OF2W2+jVIQRAnU= go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= @@ -1640,8 +1642,8 @@ go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//sn go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo= go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 h1:PS8wXpbyaDJQ2VDHHncMe9Vct0Zn1fEjpsjrLxGJoSc= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= @@ -1654,10 +1656,10 @@ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7Z go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0/go.mod h1:3rHrKNtLIoS0oZwkY2vxi+oJcwFRWdtUyRII+so45p8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 h1:9kV11HXBHZAvuPUZxmMWrH8hZn/6UnHX4K0mu36vNsU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0/go.mod h1:JyA0FHXe22E1NeNiHmVp7kFHglnexDQ7uRWDiiJ1hKQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI= go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU= @@ -1683,8 +1685,8 @@ go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= -go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= -go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -1693,6 +1695,10 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -1709,8 +1715,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1773,8 +1779,8 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1895,8 +1901,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -2041,8 +2047,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -2437,8 +2443,8 @@ gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= -helm.sh/helm/v3 v3.17.3 h1:3n5rW3D0ArjFl0p4/oWO8IbY/HKaNNwJtOQFdH2AZHg= -helm.sh/helm/v3 v3.17.3/go.mod h1:+uJKMH/UiMzZQOALR3XUf3BLIoczI2RKKD6bMhPh4G8= +helm.sh/helm/v3 v3.18.3 h1:+cvyGKgs7Jt7BN3Klmb4SsG4IkVpA7GAZVGvMz6VO4I= +helm.sh/helm/v3 v3.18.3/go.mod h1:wUc4n3txYBocM7S9RjTeZBN9T/b5MjffpcSsWEjSIpw= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -2447,30 +2453,30 @@ honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= -k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= -k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= -k8s.io/apiextensions-apiserver v0.32.3 h1:4D8vy+9GWerlErCwVIbcQjsWunF9SUGNu7O7hiQTyPY= -k8s.io/apiextensions-apiserver v0.32.3/go.mod h1:8YwcvVRMVzw0r1Stc7XfGAzB/SIVLunqApySV5V7Dss= -k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= -k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= -k8s.io/apiserver v0.32.3 h1:kOw2KBuHOA+wetX1MkmrxgBr648ksz653j26ESuWNY8= -k8s.io/apiserver v0.32.3/go.mod h1:q1x9B8E/WzShF49wh3ADOh6muSfpmFL0I2t+TG0Zdgc= -k8s.io/cli-runtime v0.32.3 h1:khLF2ivU2T6Q77H97atx3REY9tXiA3OLOjWJxUrdvss= -k8s.io/cli-runtime v0.32.3/go.mod h1:vZT6dZq7mZAca53rwUfdFSZjdtLyfF61mkf/8q+Xjak= -k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= -k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= -k8s.io/component-base v0.32.3 h1:98WJvvMs3QZ2LYHBzvltFSeJjEx7t5+8s71P7M74u8k= -k8s.io/component-base v0.32.3/go.mod h1:LWi9cR+yPAv7cu2X9rZanTiFKB2kHA+JjmhkKjCZRpI= +k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY= +k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs= +k8s.io/apiextensions-apiserver v0.33.2 h1:6gnkIbngnaUflR3XwE1mCefN3YS8yTD631JXQhsU6M8= +k8s.io/apiextensions-apiserver v0.33.2/go.mod h1:IvVanieYsEHJImTKXGP6XCOjTwv2LUMos0YWc9O+QP8= +k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= +k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/apiserver v0.33.2 h1:KGTRbxn2wJagJowo29kKBp4TchpO1DRO3g+dB/KOJN4= +k8s.io/apiserver v0.33.2/go.mod h1:9qday04wEAMLPWWo9AwqCZSiIn3OYSZacDyu/AcoM/M= +k8s.io/cli-runtime v0.33.2 h1:koNYQKSDdq5AExa/RDudXMhhtFasEg48KLS2KSAU74Y= +k8s.io/cli-runtime v0.33.2/go.mod h1:gnhsAWpovqf1Zj5YRRBBU7PFsRc6NkEkwYNQE+mXL88= +k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= +k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo= +k8s.io/component-base v0.33.2 h1:sCCsn9s/dG3ZrQTX/Us0/Sx2R0G5kwa0wbZFYoVp/+0= +k8s.io/component-base v0.33.2/go.mod h1:/41uw9wKzuelhN+u+/C59ixxf4tYQKW7p32ddkYNe2k= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= -k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= -k8s.io/kubectl v0.32.3 h1:VMi584rbboso+yjfv0d8uBHwwxbC438LKq+dXd5tOAI= -k8s.io/kubectl v0.32.3/go.mod h1:6Euv2aso5GKzo/UVMacV6C7miuyevpfI91SvBvV9Zdg= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= +k8s.io/kubectl v0.33.2 h1:7XKZ6DYCklu5MZQzJe+CkCjoGZwD1wWl7t/FxzhMz7Y= +k8s.io/kubectl v0.33.2/go.mod h1:8rC67FB8tVTYraovAGNi/idWIK90z2CHFNMmGJZJ3KI= k8s.io/kubelet v0.32.3 h1:B9HzW4yB67flx8tN2FYuDwZvxnmK3v5EjxxFvOYjmc8= k8s.io/kubelet v0.32.3/go.mod h1:yyAQSCKC+tjSlaFw4HQG7Jein+vo+GeKBGdXdQGvL1U= -k8s.io/metrics v0.32.3 h1:2vsBvw0v8rIIlczZ/lZ8Kcqk9tR6Fks9h+dtFNbc2a4= -k8s.io/metrics v0.32.3/go.mod h1:9R1Wk5cb+qJpCQon9h52mgkVCcFeYxcY+YkumfwHVCU= +k8s.io/metrics v0.33.2 h1:gNCBmtnUMDMCRg9Ly5ehxP3OdKISMsOnh1vzk01iCgE= +k8s.io/metrics v0.33.2/go.mod h1:yxoAosKGRsZisv3BGekC5W6T1J8XSV+PoUEevACRv7c= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= @@ -2509,8 +2515,8 @@ modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= oras.land/oras-go v1.2.6 h1:z8cmxQXBU8yZ4mkytWqXfo6tZcamPwjsuxYU81xJ8Lk= oras.land/oras-go v1.2.6/go.mod h1:OVPc1PegSEe/K8YiLfosrlqlqTN9PUyFvOw5Y9gwrT8= -oras.land/oras-go/v2 v2.5.0 h1:o8Me9kLY74Vp5uw07QXPiitjsw7qNXi8Twd+19Zf02c= -oras.land/oras-go/v2 v2.5.0/go.mod h1:z4eisnLP530vwIOUOJeBIj0aGI0L1C3d53atvCBqZHg= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= periph.io/x/host/v3 v3.8.5 h1:g4g5xE1XZtDiGl1UAJaUur1aT7uNiFLMkyMEiZ7IHII= periph.io/x/host/v3 v3.8.5/go.mod h1:hPq8dISZIc+UNfWoRj+bPH3XEBQqJPdFdx218W92mdc= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= @@ -2521,11 +2527,15 @@ sigs.k8s.io/controller-runtime v0.20.4 h1:X3c+Odnxz+iPTRobG4tp092+CvBU9UK0t/bRf+ sigs.k8s.io/controller-runtime v0.20.4/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= -sigs.k8s.io/kustomize/api v0.18.0 h1:hTzp67k+3NEVInwz5BHyzc9rGxIauoXferXyjv5lWPo= -sigs.k8s.io/kustomize/api v0.18.0/go.mod h1:f8isXnX+8b+SGLHQ6yO4JG1rdkZlvhaCf/uZbLVMb0U= -sigs.k8s.io/kustomize/kyaml v0.18.1 h1:WvBo56Wzw3fjS+7vBjN6TeivvpbW9GmRaWZ9CIVmt4E= -sigs.k8s.io/kustomize/kyaml v0.18.1/go.mod h1:C3L2BFVU1jgcddNBE1TxuVLgS46TjObMwW5FT9FcjYo= -sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= -sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/kustomize/api v0.19.0 h1:F+2HB2mU1MSiR9Hp1NEgoU2q9ItNOaBJl0I4Dlus5SQ= +sigs.k8s.io/kustomize/api v0.19.0/go.mod h1:/BbwnivGVcBh1r+8m3tH1VNxJmHSk1PzP5fkP6lbL1o= +sigs.k8s.io/kustomize/kyaml v0.19.0 h1:RFge5qsO1uHhwJsu3ipV7RNolC7Uozc0jUBC/61XSlA= +sigs.k8s.io/kustomize/kyaml v0.19.0/go.mod h1:FeKD5jEOH+FbZPpqUghBP8mrLjJ3+zD3/rf9NNu1cwY= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= +sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= diff --git a/kinds/apis/v1beta1/installation_types.go b/kinds/apis/v1beta1/installation_types.go index dc28608d5b..7f842a12f9 100644 --- a/kinds/apis/v1beta1/installation_types.go +++ b/kinds/apis/v1beta1/installation_types.go @@ -87,9 +87,11 @@ type ProxySpec struct { // NetworkSpec holds the network configuration. type NetworkSpec struct { - PodCIDR string `json:"podCIDR,omitempty"` - ServiceCIDR string `json:"serviceCIDR,omitempty"` - NodePortRange string `json:"nodePortRange,omitempty"` + NetworkInterface string `json:"networkInterface,omitempty"` + GlobalCIDR string `json:"globalCIDR,omitempty"` + PodCIDR string `json:"podCIDR,omitempty"` + ServiceCIDR string `json:"serviceCIDR,omitempty"` + NodePortRange string `json:"nodePortRange,omitempty"` } // AdminConsoleSpec holds the admin console configuration. @@ -155,14 +157,12 @@ type InstallationSpec struct { HighAvailability bool `json:"highAvailability,omitempty"` // AirGap indicates if the installation is airgapped. AirGap bool `json:"airGap,omitempty"` - // Proxy holds the proxy configuration. - Proxy *ProxySpec `json:"proxy,omitempty"` - // Network holds the network configuration. - Network *NetworkSpec `json:"network,omitempty"` // EndUserK0sConfigOverrides holds the end user k0s config overrides // used at installation time. EndUserK0sConfigOverrides string `json:"endUserK0sConfigOverrides,omitempty"` + Deprecated_Proxy *ProxySpec `json:"proxy,omitempty"` + Deprecated_Network *NetworkSpec `json:"network,omitempty"` Deprecated_AdminConsole *AdminConsoleSpec `json:"adminConsole,omitempty"` Deprecated_LocalArtifactMirror *LocalArtifactMirrorSpec `json:"localArtifactMirror,omitempty"` } @@ -179,6 +179,37 @@ func (i *InstallationSpec) UnmarshalJSON(data []byte) error { i.SourceType = InstallationSourceTypeCRD } + if i.Deprecated_Proxy != nil { + if i.RuntimeConfig == nil { + i.RuntimeConfig = &RuntimeConfigSpec{} + } + if i.RuntimeConfig.Proxy == nil { + i.RuntimeConfig.Proxy = &ProxySpec{} + } + if i.Deprecated_Proxy.HTTPProxy != "" { + i.RuntimeConfig.Proxy.HTTPProxy = i.Deprecated_Proxy.HTTPProxy + } + if i.Deprecated_Proxy.HTTPSProxy != "" { + i.RuntimeConfig.Proxy.HTTPSProxy = i.Deprecated_Proxy.HTTPSProxy + } + if i.Deprecated_Proxy.NoProxy != "" { + i.RuntimeConfig.Proxy.NoProxy = i.Deprecated_Proxy.NoProxy + } + } + if i.Deprecated_Network != nil { + if i.RuntimeConfig == nil { + i.RuntimeConfig = &RuntimeConfigSpec{} + } + if i.Deprecated_Network.PodCIDR != "" { + i.RuntimeConfig.Network.PodCIDR = i.Deprecated_Network.PodCIDR + } + if i.Deprecated_Network.ServiceCIDR != "" { + i.RuntimeConfig.Network.ServiceCIDR = i.Deprecated_Network.ServiceCIDR + } + if i.Deprecated_Network.NodePortRange != "" { + i.RuntimeConfig.Network.NodePortRange = i.Deprecated_Network.NodePortRange + } + } if i.Deprecated_AdminConsole != nil && i.Deprecated_AdminConsole.Port > 0 { if i.RuntimeConfig == nil { i.RuntimeConfig = &RuntimeConfigSpec{} diff --git a/kinds/apis/v1beta1/installation_types_test.go b/kinds/apis/v1beta1/installation_types_test.go index ee3e273a0a..8c9c429852 100644 --- a/kinds/apis/v1beta1/installation_types_test.go +++ b/kinds/apis/v1beta1/installation_types_test.go @@ -116,6 +116,78 @@ spec: }, }, }, + { + name: "proxy configuration", + args: args{ + in: ` +apiVersion: embeddedcluster.replicated.com/v1beta1 +kind: Installation +metadata: + name: test +spec: + config: + version: 1.29.1+k0s.0 + proxy: + httpProxy: http://proxy.example.com:8080 + httpsProxy: https://proxy.example.com:8443 + noProxy: localhost,127.0.0.1 +`, + }, + want: InstallationSpec{ + Config: &ConfigSpec{ + Version: "1.29.1+k0s.0", + }, + SourceType: InstallationSourceTypeCRD, + RuntimeConfig: &RuntimeConfigSpec{ + Proxy: &ProxySpec{ + HTTPProxy: "http://proxy.example.com:8080", + HTTPSProxy: "https://proxy.example.com:8443", + NoProxy: "localhost,127.0.0.1", + }, + }, + Deprecated_Proxy: &ProxySpec{ + HTTPProxy: "http://proxy.example.com:8080", + HTTPSProxy: "https://proxy.example.com:8443", + NoProxy: "localhost,127.0.0.1", + }, + }, + }, + { + name: "network configuration", + args: args{ + in: ` +apiVersion: embeddedcluster.replicated.com/v1beta1 +kind: Installation +metadata: + name: test +spec: + config: + version: 1.29.1+k0s.0 + network: + podCIDR: 10.244.0.0/16 + serviceCIDR: 10.96.0.0/12 + nodePortRange: 30000-32767 +`, + }, + want: InstallationSpec{ + Config: &ConfigSpec{ + Version: "1.29.1+k0s.0", + }, + SourceType: InstallationSourceTypeCRD, + RuntimeConfig: &RuntimeConfigSpec{ + Network: NetworkSpec{ + PodCIDR: "10.244.0.0/16", + ServiceCIDR: "10.96.0.0/12", + NodePortRange: "30000-32767", + }, + }, + Deprecated_Network: &NetworkSpec{ + PodCIDR: "10.244.0.0/16", + ServiceCIDR: "10.96.0.0/12", + NodePortRange: "30000-32767", + }, + }, + }, } for _, tt := range tests { scheme := runtime.NewScheme() diff --git a/kinds/apis/v1beta1/kubernetes_installation_types.go b/kinds/apis/v1beta1/kubernetes_installation_types.go new file mode 100644 index 0000000000..5e4e3da0c6 --- /dev/null +++ b/kinds/apis/v1beta1/kubernetes_installation_types.go @@ -0,0 +1,88 @@ +/* +Copyright 2023. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type KubernetesInstallationState string + +// What follows is a list of all valid states for an KubernetesInstallation object. +const ( + KubernetesInstallationStateEnqueued KubernetesInstallationState = "Enqueued" + KubernetesInstallationStateInstalling KubernetesInstallationState = "Installing" + KubernetesInstallationStateInstalled KubernetesInstallationState = "Installed" + KubernetesInstallationStateAddonsInstalling KubernetesInstallationState = "AddonsInstalling" + KubernetesInstallationStateAddonsInstalled KubernetesInstallationState = "AddonsInstalled" + KubernetesInstallationStateObsolete KubernetesInstallationState = "Obsolete" + KubernetesInstallationStateFailed KubernetesInstallationState = "Failed" + KubernetesInstallationStateUnknown KubernetesInstallationState = "Unknown" +) + +// KubernetesInstallationSpec defines the desired state of KubernetesInstallation. +type KubernetesInstallationSpec struct { + // ClusterID holds the cluster id, generated during the installation. + ClusterID string `json:"clusterID,omitempty"` + // MetricsBaseURL holds the base URL for the metrics server. + MetricsBaseURL string `json:"metricsBaseURL,omitempty"` + // Config holds the configuration used at installation time. + Config *ConfigSpec `json:"config,omitempty"` + // BinaryName holds the name of the binary used to install the cluster. + // this will follow the pattern 'appslug-channelslug' + BinaryName string `json:"binaryName,omitempty"` + // LicenseInfo holds information about the license used to install the cluster. + LicenseInfo *LicenseInfo `json:"licenseInfo,omitempty"` + // Proxy holds the proxy configuration. + Proxy *ProxySpec `json:"proxy,omitempty"` + // AdminConsole holds the Admin Console configuration. + AdminConsole AdminConsoleSpec `json:"adminConsole,omitempty"` + // Manager holds the Manager configuration. + Manager ManagerSpec `json:"manager,omitempty"` + // HighAvailability indicates if the installation is high availability. + HighAvailability bool `json:"highAvailability,omitempty"` + // AirGap indicates if the installation is airgapped. + AirGap bool `json:"airGap,omitempty"` +} + +// KubernetesInstallationStatus defines the observed state of KubernetesInstallation +type KubernetesInstallationStatus struct { + // State holds the current state of the installation. + State KubernetesInstallationState `json:"state,omitempty"` + // Reason holds the reason for the current state. + Reason string `json:"reason,omitempty"` +} + +// KubernetesInstallation is the Schema for the kubernetes installations API +type KubernetesInstallation struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec KubernetesInstallationSpec `json:"spec,omitempty"` + Status KubernetesInstallationStatus `json:"status,omitempty"` +} + +func GetDefaultKubernetesInstallationSpec() KubernetesInstallationSpec { + c := KubernetesInstallationSpec{} + kubernetesInstallationSpecSetDefaults(&c) + return c +} + +func kubernetesInstallationSpecSetDefaults(c *KubernetesInstallationSpec) { + adminConsoleSpecSetDefaults(&c.AdminConsole) + managerSpecSetDefaults(&c.Manager) +} diff --git a/kinds/apis/v1beta1/runtimeconfig_types.go b/kinds/apis/v1beta1/runtimeconfig_types.go index faf82dce5b..1e67872bd6 100644 --- a/kinds/apis/v1beta1/runtimeconfig_types.go +++ b/kinds/apis/v1beta1/runtimeconfig_types.go @@ -9,6 +9,7 @@ const ( DefaultAdminConsolePort = 30000 DefaultLocalArtifactMirrorPort = 50000 DefaultNetworkCIDR = "10.244.0.0/16" + DefaultNetworkNodePortRange = "80-32767" DefaultManagerPort = 30080 ) @@ -26,6 +27,11 @@ type RuntimeConfigSpec struct { // HostCABundlePath holds the path to the CA bundle for the host. HostCABundlePath string `json:"hostCABundlePath,omitempty"` + // Proxy holds the proxy configuration. + Proxy *ProxySpec `json:"proxy,omitempty"` + // Network holds the network configuration. + Network NetworkSpec `json:"network,omitempty"` + // AdminConsole holds the Admin Console configuration. AdminConsole AdminConsoleSpec `json:"adminConsole,omitempty"` // LocalArtifactMirrorPort holds the Local Artifact Mirror configuration. @@ -54,11 +60,21 @@ func runtimeConfigSetDefaults(c *RuntimeConfigSpec) { if c.DataDir == "" { c.DataDir = DefaultDataDir } + networkSpecSetDefaults(&c.Network) adminConsoleSpecSetDefaults(&c.AdminConsole) localArtifactMirrorSpecSetDefaults(&c.LocalArtifactMirror) managerSpecSetDefaults(&c.Manager) } +func networkSpecSetDefaults(s *NetworkSpec) { + if s.GlobalCIDR == "" { + s.GlobalCIDR = DefaultNetworkCIDR + } + if s.NodePortRange == "" { + s.NodePortRange = DefaultNetworkNodePortRange + } +} + func adminConsoleSpecSetDefaults(s *AdminConsoleSpec) { if s.Port == 0 { s.Port = DefaultAdminConsolePort diff --git a/kinds/apis/v1beta1/zz_generated.deepcopy.go b/kinds/apis/v1beta1/zz_generated.deepcopy.go index 25b497a375..881191eefb 100644 --- a/kinds/apis/v1beta1/zz_generated.deepcopy.go +++ b/kinds/apis/v1beta1/zz_generated.deepcopy.go @@ -371,15 +371,15 @@ func (in *InstallationSpec) DeepCopyInto(out *InstallationSpec) { if in.RuntimeConfig != nil { in, out := &in.RuntimeConfig, &out.RuntimeConfig *out = new(RuntimeConfigSpec) - **out = **in + (*in).DeepCopyInto(*out) } - if in.Proxy != nil { - in, out := &in.Proxy, &out.Proxy + if in.Deprecated_Proxy != nil { + in, out := &in.Deprecated_Proxy, &out.Deprecated_Proxy *out = new(ProxySpec) **out = **in } - if in.Network != nil { - in, out := &in.Network, &out.Network + if in.Deprecated_Network != nil { + in, out := &in.Deprecated_Network, &out.Deprecated_Network *out = new(NetworkSpec) **out = **in } @@ -437,6 +437,72 @@ func (in *InstallationStatus) DeepCopy() *InstallationStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubernetesInstallation) DeepCopyInto(out *KubernetesInstallation) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesInstallation. +func (in *KubernetesInstallation) DeepCopy() *KubernetesInstallation { + if in == nil { + return nil + } + out := new(KubernetesInstallation) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubernetesInstallationSpec) DeepCopyInto(out *KubernetesInstallationSpec) { + *out = *in + if in.Config != nil { + in, out := &in.Config, &out.Config + *out = new(ConfigSpec) + (*in).DeepCopyInto(*out) + } + if in.LicenseInfo != nil { + in, out := &in.LicenseInfo, &out.LicenseInfo + *out = new(LicenseInfo) + **out = **in + } + if in.Proxy != nil { + in, out := &in.Proxy, &out.Proxy + *out = new(ProxySpec) + **out = **in + } + out.AdminConsole = in.AdminConsole + out.Manager = in.Manager +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesInstallationSpec. +func (in *KubernetesInstallationSpec) DeepCopy() *KubernetesInstallationSpec { + if in == nil { + return nil + } + out := new(KubernetesInstallationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubernetesInstallationStatus) DeepCopyInto(out *KubernetesInstallationStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesInstallationStatus. +func (in *KubernetesInstallationStatus) DeepCopy() *KubernetesInstallationStatus { + if in == nil { + return nil + } + out := new(KubernetesInstallationStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LicenseInfo) DeepCopyInto(out *LicenseInfo) { *out = *in @@ -630,6 +696,12 @@ func (in *Roles) DeepCopy() *Roles { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RuntimeConfigSpec) DeepCopyInto(out *RuntimeConfigSpec) { *out = *in + if in.Proxy != nil { + in, out := &in.Proxy, &out.Proxy + *out = new(ProxySpec) + **out = **in + } + out.Network = in.Network out.AdminConsole = in.AdminConsole out.LocalArtifactMirror = in.LocalArtifactMirror out.Manager = in.Manager diff --git a/kinds/go.mod b/kinds/go.mod index f00d89650f..414d0992a5 100644 --- a/kinds/go.mod +++ b/kinds/go.mod @@ -9,8 +9,8 @@ require ( github.com/stretchr/testify v1.10.0 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 - k8s.io/api v0.32.3 - k8s.io/apimachinery v0.32.3 + k8s.io/api v0.33.2 + k8s.io/apimachinery v0.33.2 sigs.k8s.io/controller-runtime v0.20.4 sigs.k8s.io/yaml v1.4.0 ) @@ -23,10 +23,8 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/gofuzz v1.2.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect @@ -37,7 +35,6 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/vishvananda/netlink v1.3.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect @@ -45,16 +42,17 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect - golang.org/x/net v0.37.0 // indirect + golang.org/x/net v0.38.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/text v0.23.0 // indirect golang.org/x/tools v0.31.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect helm.sh/helm/v3 v3.17.3 // indirect - k8s.io/apiextensions-apiserver v0.32.3 // indirect - k8s.io/client-go v0.32.3 // indirect + k8s.io/apiextensions-apiserver v0.33.2 // indirect + k8s.io/client-go v0.33.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect ) diff --git a/kinds/go.sum b/kinds/go.sum index 6b1b56bd99..fbbd81abaf 100644 --- a/kinds/go.sum +++ b/kinds/go.sum @@ -16,8 +16,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -26,8 +26,6 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -104,8 +102,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -145,14 +143,14 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= helm.sh/helm/v3 v3.17.3 h1:3n5rW3D0ArjFl0p4/oWO8IbY/HKaNNwJtOQFdH2AZHg= helm.sh/helm/v3 v3.17.3/go.mod h1:+uJKMH/UiMzZQOALR3XUf3BLIoczI2RKKD6bMhPh4G8= -k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= -k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= -k8s.io/apiextensions-apiserver v0.32.3 h1:4D8vy+9GWerlErCwVIbcQjsWunF9SUGNu7O7hiQTyPY= -k8s.io/apiextensions-apiserver v0.32.3/go.mod h1:8YwcvVRMVzw0r1Stc7XfGAzB/SIVLunqApySV5V7Dss= -k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= -k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= -k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= -k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= +k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY= +k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs= +k8s.io/apiextensions-apiserver v0.33.2 h1:6gnkIbngnaUflR3XwE1mCefN3YS8yTD631JXQhsU6M8= +k8s.io/apiextensions-apiserver v0.33.2/go.mod h1:IvVanieYsEHJImTKXGP6XCOjTwv2LUMos0YWc9O+QP8= +k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= +k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= +k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= @@ -161,7 +159,10 @@ sigs.k8s.io/controller-runtime v0.20.4 h1:X3c+Odnxz+iPTRobG4tp092+CvBU9UK0t/bRf+ sigs.k8s.io/controller-runtime v0.20.4/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= -sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= -sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/operator/charts/embedded-cluster-operator/charts/crds/templates/resources.yaml b/operator/charts/embedded-cluster-operator/charts/crds/templates/resources.yaml index b8aec7b648..e8b53ae965 100644 --- a/operator/charts/embedded-cluster-operator/charts/crds/templates/resources.yaml +++ b/operator/charts/embedded-cluster-operator/charts/crds/templates/resources.yaml @@ -580,8 +580,12 @@ spec: description: MetricsBaseURL holds the base URL for the metrics server. type: string network: - description: Network holds the network configuration. + description: NetworkSpec holds the network configuration. properties: + globalCIDR: + type: string + networkInterface: + type: string nodePortRange: type: string podCIDR: @@ -590,7 +594,7 @@ spec: type: string type: object proxy: - description: Proxy holds the proxy configuration. + description: ProxySpec holds the proxy configuration. properties: httpProxy: type: string @@ -638,11 +642,37 @@ spec: description: Port holds the port on which the manager will be served. type: integer type: object + network: + description: Network holds the network configuration. + properties: + globalCIDR: + type: string + networkInterface: + type: string + nodePortRange: + type: string + podCIDR: + type: string + serviceCIDR: + type: string + type: object openEBSDataDirOverride: description: |- OpenEBSDataDirOverride holds the override for the data directory for the OpenEBS storage provisioner. By default the data will be stored in a subdirectory of DataDir. type: string + proxy: + description: Proxy holds the proxy configuration. + properties: + httpProxy: + type: string + httpsProxy: + type: string + noProxy: + type: string + providedNoProxy: + type: string + type: object type: object sourceType: description: SourceType indicates where this Installation object is stored (CRD, ConfigMap, etc...). diff --git a/operator/charts/embedded-cluster-operator/values.yaml b/operator/charts/embedded-cluster-operator/values.yaml index aceb2ec4fe..4e066a3194 100644 --- a/operator/charts/embedded-cluster-operator/values.yaml +++ b/operator/charts/embedded-cluster-operator/values.yaml @@ -13,6 +13,7 @@ image: pullPolicy: IfNotPresent utilsImage: busybox:latest +goldpingerImage: bloomberg/goldpinger:latest extraEnv: [] # - name: HTTP_PROXY @@ -56,6 +57,8 @@ affinity: operator: In values: - linux + - key: node-role.kubernetes.io/control-plane + operator: Exists metrics: enabled: false diff --git a/operator/config/crd/bases/embeddedcluster.replicated.com_installations.yaml b/operator/config/crd/bases/embeddedcluster.replicated.com_installations.yaml index 711eb679e6..b52e1086ca 100644 --- a/operator/config/crd/bases/embeddedcluster.replicated.com_installations.yaml +++ b/operator/config/crd/bases/embeddedcluster.replicated.com_installations.yaml @@ -351,8 +351,12 @@ spec: description: MetricsBaseURL holds the base URL for the metrics server. type: string network: - description: Network holds the network configuration. + description: NetworkSpec holds the network configuration. properties: + globalCIDR: + type: string + networkInterface: + type: string nodePortRange: type: string podCIDR: @@ -361,7 +365,7 @@ spec: type: string type: object proxy: - description: Proxy holds the proxy configuration. + description: ProxySpec holds the proxy configuration. properties: httpProxy: type: string @@ -415,11 +419,37 @@ spec: be served. type: integer type: object + network: + description: Network holds the network configuration. + properties: + globalCIDR: + type: string + networkInterface: + type: string + nodePortRange: + type: string + podCIDR: + type: string + serviceCIDR: + type: string + type: object openEBSDataDirOverride: description: |- OpenEBSDataDirOverride holds the override for the data directory for the OpenEBS storage provisioner. By default the data will be stored in a subdirectory of DataDir. type: string + proxy: + description: Proxy holds the proxy configuration. + properties: + httpProxy: + type: string + httpsProxy: + type: string + noProxy: + type: string + providedNoProxy: + type: string + type: object type: object sourceType: description: SourceType indicates where this Installation object is diff --git a/operator/config/crd/bases/embeddedcluster.replicated.com_kubernetesinstallations.yaml b/operator/config/crd/bases/embeddedcluster.replicated.com_kubernetesinstallations.yaml new file mode 100644 index 0000000000..35a9a7db3c --- /dev/null +++ b/operator/config/crd/bases/embeddedcluster.replicated.com_kubernetesinstallations.yaml @@ -0,0 +1,323 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: kubernetesinstallations.embeddedcluster.replicated.com +spec: + group: embeddedcluster.replicated.com + names: + kind: KubernetesInstallation + listKind: KubernetesInstallationList + plural: kubernetesinstallations + singular: kubernetesinstallation + scope: Namespaced + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: KubernetesInstallation is the Schema for the kubernetes installations + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: KubernetesInstallationSpec defines the desired state of KubernetesInstallation. + properties: + adminConsole: + description: AdminConsole holds the Admin Console configuration. + properties: + port: + description: Port holds the port on which the admin console will + be served. + type: integer + type: object + airGap: + description: AirGap indicates if the installation is airgapped. + type: boolean + binaryName: + description: |- + BinaryName holds the name of the binary used to install the cluster. + this will follow the pattern 'appslug-channelslug' + type: string + clusterID: + description: ClusterID holds the cluster id, generated during the + installation. + type: string + config: + description: Config holds the configuration used at installation time. + properties: + binaryOverrideUrl: + type: string + domains: + properties: + proxyRegistryDomain: + type: string + replicatedAppDomain: + type: string + replicatedRegistryDomain: + type: string + type: object + extensions: + properties: + helm: + description: Helm contains helm extension settings + properties: + charts: + items: + description: Chart single helm addon + properties: + chartname: + type: string + forceUpgrade: + description: 'ForceUpgrade when set to false, disables + the use of the "--force" flag when upgrading the + the chart (default: true).' + type: boolean + name: + type: string + namespace: + type: string + order: + type: integer + timeout: + description: |- + Timeout specifies the timeout for how long to wait for the chart installation to finish. + A duration string is a sequence of decimal numbers, each with optional fraction and a unit suffix, such as "300ms" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". + type: string + x-kubernetes-int-or-string: true + values: + type: string + version: + type: string + type: object + type: array + concurrencyLevel: + type: integer + repositories: + items: + description: Repository describes single repository + entry. Fields map to the CLI flags for the "helm add" + command + properties: + caFile: + description: CA bundle file to use when verifying + HTTPS-enabled servers. + type: string + certFile: + description: The TLS certificate file to use for + HTTPS client authentication. + type: string + insecure: + description: Whether to skip TLS certificate checks + when connecting to the repository. + type: boolean + keyfile: + description: The TLS key file to use for HTTPS client + authentication. + type: string + name: + description: The repository name. + minLength: 1 + type: string + password: + description: Password for Basic HTTP authentication. + type: string + url: + description: The repository URL. + minLength: 1 + type: string + username: + description: Username for Basic HTTP authentication. + type: string + required: + - name + - url + type: object + type: array + type: object + type: object + metadataOverrideUrl: + type: string + roles: + description: Roles is the various roles in the cluster. + properties: + controller: + description: NodeRole is the role of a node in the cluster. + properties: + description: + type: string + labels: + additionalProperties: + type: string + type: object + name: + type: string + nodeCount: + description: NodeCount holds a series of rules for a given + node role. + properties: + range: + description: |- + NodeRange contains a min and max or only one of them (conflicts + with Values). + properties: + max: + description: Max is the maximum number of nodes. + type: integer + min: + description: Min is the minimum number of nodes. + type: integer + type: object + values: + description: Values holds a list of allowed node counts. + items: + type: integer + type: array + type: object + type: object + custom: + items: + description: NodeRole is the role of a node in the cluster. + properties: + description: + type: string + labels: + additionalProperties: + type: string + type: object + name: + type: string + nodeCount: + description: NodeCount holds a series of rules for a + given node role. + properties: + range: + description: |- + NodeRange contains a min and max or only one of them (conflicts + with Values). + properties: + max: + description: Max is the maximum number of nodes. + type: integer + min: + description: Min is the minimum number of nodes. + type: integer + type: object + values: + description: Values holds a list of allowed node + counts. + items: + type: integer + type: array + type: object + type: object + type: array + type: object + unsupportedOverrides: + description: |- + UnsupportedOverrides holds the config overrides used to configure + the cluster. + properties: + builtInExtensions: + description: |- + BuiltInExtensions holds overrides for the default add-ons we ship + with Embedded Cluster. + items: + description: BuiltInExtension holds the override for a built-in + extension (add-on). + properties: + name: + description: The name of the helm chart to override + values of, for instance `openebs`. + type: string + values: + description: |- + YAML-formatted helm values that will override those provided to the + chart by Embedded Cluster. Properties are overridden individually - + setting a new value for `images.tag` here will not prevent Embedded + Cluster from setting `images.pullPolicy = IfNotPresent`, for example. + type: string + required: + - name + - values + type: object + type: array + k0s: + description: |- + K0s holds the overrides used to configure k0s. These overrides + are merged on top of the default k0s configuration. As the data + layout inside this configuration is very dynamic we have chosen + to use a string here. + type: string + type: object + version: + type: string + type: object + highAvailability: + description: HighAvailability indicates if the installation is high + availability. + type: boolean + licenseInfo: + description: LicenseInfo holds information about the license used + to install the cluster. + properties: + isDisasterRecoverySupported: + type: boolean + isMultiNodeEnabled: + type: boolean + type: object + manager: + description: Manager holds the Manager configuration. + properties: + port: + description: Port holds the port on which the manager will be + served. + type: integer + type: object + metricsBaseURL: + description: MetricsBaseURL holds the base URL for the metrics server. + type: string + proxy: + description: Proxy holds the proxy configuration. + properties: + httpProxy: + type: string + httpsProxy: + type: string + noProxy: + type: string + providedNoProxy: + type: string + type: object + type: object + status: + description: KubernetesInstallationStatus defines the observed state of + KubernetesInstallation + properties: + reason: + description: Reason holds the reason for the current state. + type: string + state: + description: State holds the current state of the installation. + type: string + type: object + type: object + served: true + storage: true diff --git a/operator/pkg/artifacts/upgrade.go b/operator/pkg/artifacts/upgrade.go index 32411c5a1c..9e4648abd5 100644 --- a/operator/pkg/artifacts/upgrade.go +++ b/operator/pkg/artifacts/upgrade.go @@ -6,13 +6,13 @@ import ( "encoding/base64" "encoding/json" "fmt" - "runtime" "strings" autopilotv1beta2 "github.com/k0sproject/k0s/pkg/apis/autopilot/v1beta2" clusterv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/operator/pkg/release" "github.com/replicatedhq/embedded-cluster/operator/pkg/util" + "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "go.uber.org/multierr" @@ -300,12 +300,12 @@ func getArtifactJobForNode( ) // Add proxy environment variables if proxy is configured - if in.Spec.Proxy != nil { + if proxy := rc.ProxySpec(); proxy != nil { job.Spec.Template.Spec.Containers[0].Env = append( job.Spec.Template.Spec.Containers[0].Env, - corev1.EnvVar{Name: "HTTP_PROXY", Value: in.Spec.Proxy.HTTPProxy}, - corev1.EnvVar{Name: "HTTPS_PROXY", Value: in.Spec.Proxy.HTTPSProxy}, - corev1.EnvVar{Name: "NO_PROXY", Value: in.Spec.Proxy.NoProxy}, + corev1.EnvVar{Name: "HTTP_PROXY", Value: proxy.HTTPProxy}, + corev1.EnvVar{Name: "HTTPS_PROXY", Value: proxy.HTTPSProxy}, + corev1.EnvVar{Name: "NO_PROXY", Value: proxy.NoProxy}, ) } @@ -408,7 +408,7 @@ func CreateAutopilotAirgapPlanCommand(ctx context.Context, cli client.Client, rc AirgapUpdate: &autopilotv1beta2.PlanCommandAirgapUpdate{ Version: meta.Versions["Kubernetes"], Platforms: map[string]autopilotv1beta2.PlanResourceURL{ - fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH): { + fmt.Sprintf("%s-%s", helpers.ClusterOS(), helpers.ClusterArch()): { URL: imageURL, }, }, diff --git a/operator/pkg/artifacts/upgrade_test.go b/operator/pkg/artifacts/upgrade_test.go index 77e2fbc0ad..0cfc9f15de 100644 --- a/operator/pkg/artifacts/upgrade_test.go +++ b/operator/pkg/artifacts/upgrade_test.go @@ -268,7 +268,7 @@ func TestEnsureArtifactsJobForNodes(t *testing.T) { }() } - rc := runtimeconfig.New(nil) + rc := runtimeconfig.New(tt.args.in.Spec.RuntimeConfig) if err := EnsureArtifactsJobForNodes( ctx, cli, rc, tt.args.in, @@ -500,7 +500,7 @@ func TestGetArtifactJobForNode_HostCABundle(t *testing.T) { }, } - rc := runtimeconfig.New(nil) + rc := runtimeconfig.New(installation.Spec.RuntimeConfig) // Call the function under test job, err := getArtifactJobForNode( @@ -595,7 +595,7 @@ func TestGetArtifactJobForNode_HostCABundle(t *testing.T) { }, } - rc := runtimeconfig.New(nil) + rc := runtimeconfig.New(installation.Spec.RuntimeConfig) // Call the function under test job, err := getArtifactJobForNode( diff --git a/operator/pkg/cli/upgrade_job.go b/operator/pkg/cli/upgrade_job.go index 656c5d7120..f4a9630363 100644 --- a/operator/pkg/cli/upgrade_job.go +++ b/operator/pkg/cli/upgrade_job.go @@ -7,14 +7,12 @@ import ( "os" "runtime/debug" - "github.com/google/uuid" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/operator/pkg/cli/migratev2" "github.com/replicatedhq/embedded-cluster/operator/pkg/upgrade" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" - "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/pkg/versions" "github.com/spf13/cobra" @@ -45,13 +43,6 @@ func UpgradeJobCmd() *cobra.Command { // set the runtime config from the installation spec rc.Set(in.Spec.RuntimeConfig) - // initialize the cluster ID - clusterUUID, err := uuid.Parse(in.Spec.ClusterID) - if err != nil { - return fmt.Errorf("failed to parse cluster ID: %w", err) - } - metrics.SetClusterID(clusterUUID) - return nil }, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/operator/pkg/upgrade/autopilot.go b/operator/pkg/upgrade/autopilot.go index 4d46238c5d..64ea1c71d4 100644 --- a/operator/pkg/upgrade/autopilot.go +++ b/operator/pkg/upgrade/autopilot.go @@ -3,7 +3,6 @@ package upgrade import ( "context" "fmt" - "runtime" "strings" "github.com/google/uuid" @@ -11,6 +10,7 @@ import ( "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" ectypes "github.com/replicatedhq/embedded-cluster/kinds/types" "github.com/replicatedhq/embedded-cluster/operator/pkg/artifacts" + "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -89,7 +89,7 @@ func startAutopilotUpgrade(ctx context.Context, cli client.Client, rc runtimecon Version: meta.Versions["Kubernetes"], Targets: targets, Platforms: apv1b2.PlanPlatformResourceURLMap{ - fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH): {URL: k0surl, Sha256: meta.K0sSHA}, + fmt.Sprintf("%s-%s", helpers.ClusterOS(), helpers.ClusterArch()): {URL: k0surl, Sha256: meta.K0sSHA}, }, }, }, diff --git a/operator/pkg/upgrade/job.go b/operator/pkg/upgrade/job.go index df054e3686..c13fb5a5b2 100644 --- a/operator/pkg/upgrade/job.go +++ b/operator/pkg/upgrade/job.go @@ -14,6 +14,8 @@ import ( "github.com/replicatedhq/embedded-cluster/operator/pkg/autopilot" "github.com/replicatedhq/embedded-cluster/operator/pkg/metadata" "github.com/replicatedhq/embedded-cluster/operator/pkg/release" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" + "github.com/replicatedhq/embedded-cluster/pkg-new/domains" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" batchv1 "k8s.io/api/batch/v1" @@ -30,7 +32,7 @@ import ( const ( upgradeJobName = "embedded-cluster-upgrade-%s" - upgradeJobNamespace = runtimeconfig.KotsadmNamespace + upgradeJobNamespace = constants.KotsadmNamespace upgradeJobConfigMap = "upgrade-job-configmap-%s" ) @@ -121,18 +123,18 @@ func CreateUpgradeJob( }, } - if in.Spec.Proxy != nil { + if proxy := rc.ProxySpec(); proxy != nil { env = append(env, corev1.EnvVar{ Name: "HTTP_PROXY", - Value: in.Spec.Proxy.HTTPProxy, + Value: proxy.HTTPProxy, }) env = append(env, corev1.EnvVar{ Name: "HTTPS_PROXY", - Value: in.Spec.Proxy.HTTPSProxy, + Value: proxy.HTTPSProxy, }) env = append(env, corev1.EnvVar{ Name: "NO_PROXY", - Value: in.Spec.Proxy.NoProxy, + Value: proxy.NoProxy, }) } @@ -180,7 +182,7 @@ func CreateUpgradeJob( }, }, RestartPolicy: corev1.RestartPolicyNever, - ServiceAccountName: runtimeconfig.KotsadmServiceAccount, + ServiceAccountName: constants.KotsadmServiceAccount, Volumes: []corev1.Volume{ { Name: "config", @@ -292,7 +294,10 @@ func operatorImageName(ctx context.Context, cli client.Client, in *ecv1beta1.Ins } for _, image := range meta.Images { if strings.Contains(image, "embedded-cluster-operator-image") { - domains := runtimeconfig.GetDomains(in.Spec.Config) + // TODO: This will not work in a non-production environment. + // The domains in the release are used to supply alternative defaults for staging and the dev environment. + // The GetDomains function will always fall back to production defaults. + domains := domains.GetDomains(in.Spec.Config, nil) image = strings.Replace(image, "proxy.replicated.com", domains.ProxyRegistryDomain, 1) return image, nil } diff --git a/operator/pkg/upgrade/upgrade.go b/operator/pkg/upgrade/upgrade.go index c431a82422..384752f5da 100644 --- a/operator/pkg/upgrade/upgrade.go +++ b/operator/pkg/upgrade/upgrade.go @@ -13,6 +13,7 @@ import ( ectypes "github.com/replicatedhq/embedded-cluster/kinds/types" "github.com/replicatedhq/embedded-cluster/operator/pkg/autopilot" "github.com/replicatedhq/embedded-cluster/operator/pkg/release" + "github.com/replicatedhq/embedded-cluster/pkg-new/domains" "github.com/replicatedhq/embedded-cluster/pkg/addons" "github.com/replicatedhq/embedded-cluster/pkg/config" "github.com/replicatedhq/embedded-cluster/pkg/extensions" @@ -67,7 +68,7 @@ func Upgrade(ctx context.Context, cli client.Client, hcli helm.Client, rc runtim return fmt.Errorf("upgrade extensions: %w", err) } - err = support.CreateHostSupportBundle() + err = support.CreateHostSupportBundle(ctx, cli) if err != nil { slog.Error("Failed to upgrade host support bundle", "error", err) } @@ -180,7 +181,10 @@ func updateClusterConfig(ctx context.Context, cli client.Client, in *ecv1beta1.I return fmt.Errorf("get cluster config: %w", err) } - domains := runtimeconfig.GetDomains(in.Spec.Config) + // TODO: This will not work in a non-production environment. + // The domains in the release are used to supply alternative defaults for staging and the dev environment. + // The GetDomains function will always fall back to production defaults. + domains := domains.GetDomains(in.Spec.Config, nil) didUpdate := false @@ -267,14 +271,38 @@ func upgradeAddons(ctx context.Context, cli client.Client, hcli helm.Client, rc return fmt.Errorf("create metadata client: %w", err) } + // TODO: This will not work in a non-production environment. + // The domains in the release are used to supply alternative defaults for staging and the dev environment. + // The GetDomains function will always fall back to production defaults. + domains := domains.GetDomains(in.Spec.Config, nil) + addOns := addons.New( addons.WithLogFunc(slog.Info), addons.WithKubernetesClient(cli), addons.WithMetadataClient(mcli), addons.WithHelmClient(hcli), - addons.WithRuntimeConfig(rc), + addons.WithDomains(domains), ) - if err := addOns.Upgrade(ctx, in, meta); err != nil { + + opts := addons.UpgradeOptions{ + ClusterID: in.Spec.ClusterID, + AdminConsolePort: rc.AdminConsolePort(), + IsAirgap: in.Spec.AirGap, + IsHA: in.Spec.HighAvailability, + DisasterRecoveryEnabled: in.Spec.LicenseInfo != nil && in.Spec.LicenseInfo.IsDisasterRecoverySupported, + IsMultiNodeEnabled: in.Spec.LicenseInfo != nil && in.Spec.LicenseInfo.IsMultiNodeEnabled, + EmbeddedConfigSpec: in.Spec.Config, + EndUserConfigSpec: nil, // TODO: add support for end user config spec + ProxySpec: rc.ProxySpec(), + HostCABundlePath: rc.HostCABundlePath(), + DataDir: rc.EmbeddedClusterHomeDirectory(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), + OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), + SeaweedFSDataDir: rc.EmbeddedClusterSeaweedFSSubDir(), + ServiceCIDR: rc.ServiceCIDR(), + } + + if err := addOns.Upgrade(ctx, in, meta, opts); err != nil { return fmt.Errorf("upgrade addons: %w", err) } diff --git a/operator/schemas/kubernetesinstallation-embeddedcluster-v1beta1.json b/operator/schemas/kubernetesinstallation-embeddedcluster-v1beta1.json new file mode 100644 index 0000000000..aa105b43ca --- /dev/null +++ b/operator/schemas/kubernetesinstallation-embeddedcluster-v1beta1.json @@ -0,0 +1,364 @@ +{ + "description": "KubernetesInstallation is the Schema for the kubernetes installations API", + "type": "object", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "type": "object" + }, + "spec": { + "description": "KubernetesInstallationSpec defines the desired state of KubernetesInstallation.", + "type": "object", + "properties": { + "adminConsole": { + "description": "AdminConsole holds the Admin Console configuration.", + "type": "object", + "properties": { + "port": { + "description": "Port holds the port on which the admin console will be served.", + "type": "integer" + } + } + }, + "airGap": { + "description": "AirGap indicates if the installation is airgapped.", + "type": "boolean" + }, + "binaryName": { + "description": "BinaryName holds the name of the binary used to install the cluster.\nthis will follow the pattern 'appslug-channelslug'", + "type": "string" + }, + "clusterID": { + "description": "ClusterID holds the cluster id, generated during the installation.", + "type": "string" + }, + "config": { + "description": "Config holds the configuration used at installation time.", + "type": "object", + "properties": { + "binaryOverrideUrl": { + "type": "string" + }, + "domains": { + "type": "object", + "properties": { + "proxyRegistryDomain": { + "type": "string" + }, + "replicatedAppDomain": { + "type": "string" + }, + "replicatedRegistryDomain": { + "type": "string" + } + } + }, + "extensions": { + "type": "object", + "properties": { + "helm": { + "description": "Helm contains helm extension settings", + "type": "object", + "properties": { + "charts": { + "type": "array", + "items": { + "description": "Chart single helm addon", + "type": "object", + "properties": { + "chartname": { + "type": "string" + }, + "forceUpgrade": { + "description": "ForceUpgrade when set to false, disables the use of the \"--force\" flag when upgrading the the chart (default: true).", + "type": "boolean" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "order": { + "type": "integer" + }, + "timeout": { + "description": "Timeout specifies the timeout for how long to wait for the chart installation to finish.\nA duration string is a sequence of decimal numbers, each with optional fraction and a unit suffix, such as \"300ms\" or \"2h45m\". Valid time units are \"ns\", \"us\" (or \"µs\"), \"ms\", \"s\", \"m\", \"h\".", + "type": "string", + "x-kubernetes-int-or-string": true + }, + "values": { + "type": "string" + }, + "version": { + "type": "string" + } + } + } + }, + "concurrencyLevel": { + "type": "integer" + }, + "repositories": { + "type": "array", + "items": { + "description": "Repository describes single repository entry. Fields map to the CLI flags for the \"helm add\" command", + "type": "object", + "required": [ + "name", + "url" + ], + "properties": { + "caFile": { + "description": "CA bundle file to use when verifying HTTPS-enabled servers.", + "type": "string" + }, + "certFile": { + "description": "The TLS certificate file to use for HTTPS client authentication.", + "type": "string" + }, + "insecure": { + "description": "Whether to skip TLS certificate checks when connecting to the repository.", + "type": "boolean" + }, + "keyfile": { + "description": "The TLS key file to use for HTTPS client authentication.", + "type": "string" + }, + "name": { + "description": "The repository name.", + "type": "string", + "minLength": 1 + }, + "password": { + "description": "Password for Basic HTTP authentication.", + "type": "string" + }, + "url": { + "description": "The repository URL.", + "type": "string", + "minLength": 1 + }, + "username": { + "description": "Username for Basic HTTP authentication.", + "type": "string" + } + } + } + } + } + } + } + }, + "metadataOverrideUrl": { + "type": "string" + }, + "roles": { + "description": "Roles is the various roles in the cluster.", + "type": "object", + "properties": { + "controller": { + "description": "NodeRole is the role of a node in the cluster.", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "nodeCount": { + "description": "NodeCount holds a series of rules for a given node role.", + "type": "object", + "properties": { + "range": { + "description": "NodeRange contains a min and max or only one of them (conflicts\nwith Values).", + "type": "object", + "properties": { + "max": { + "description": "Max is the maximum number of nodes.", + "type": "integer" + }, + "min": { + "description": "Min is the minimum number of nodes.", + "type": "integer" + } + } + }, + "values": { + "description": "Values holds a list of allowed node counts.", + "type": "array", + "items": { + "type": "integer" + } + } + } + } + } + }, + "custom": { + "type": "array", + "items": { + "description": "NodeRole is the role of a node in the cluster.", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "nodeCount": { + "description": "NodeCount holds a series of rules for a given node role.", + "type": "object", + "properties": { + "range": { + "description": "NodeRange contains a min and max or only one of them (conflicts\nwith Values).", + "type": "object", + "properties": { + "max": { + "description": "Max is the maximum number of nodes.", + "type": "integer" + }, + "min": { + "description": "Min is the minimum number of nodes.", + "type": "integer" + } + } + }, + "values": { + "description": "Values holds a list of allowed node counts.", + "type": "array", + "items": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "unsupportedOverrides": { + "description": "UnsupportedOverrides holds the config overrides used to configure\nthe cluster.", + "type": "object", + "properties": { + "builtInExtensions": { + "description": "BuiltInExtensions holds overrides for the default add-ons we ship\nwith Embedded Cluster.", + "type": "array", + "items": { + "description": "BuiltInExtension holds the override for a built-in extension (add-on).", + "type": "object", + "required": [ + "name", + "values" + ], + "properties": { + "name": { + "description": "The name of the helm chart to override values of, for instance `openebs`.", + "type": "string" + }, + "values": { + "description": "YAML-formatted helm values that will override those provided to the\nchart by Embedded Cluster. Properties are overridden individually -\nsetting a new value for `images.tag` here will not prevent Embedded\nCluster from setting `images.pullPolicy = IfNotPresent`, for example.", + "type": "string" + } + } + } + }, + "k0s": { + "description": "K0s holds the overrides used to configure k0s. These overrides\nare merged on top of the default k0s configuration. As the data\nlayout inside this configuration is very dynamic we have chosen\nto use a string here.", + "type": "string" + } + } + }, + "version": { + "type": "string" + } + } + }, + "highAvailability": { + "description": "HighAvailability indicates if the installation is high availability.", + "type": "boolean" + }, + "licenseInfo": { + "description": "LicenseInfo holds information about the license used to install the cluster.", + "type": "object", + "properties": { + "isDisasterRecoverySupported": { + "type": "boolean" + }, + "isMultiNodeEnabled": { + "type": "boolean" + } + } + }, + "manager": { + "description": "Manager holds the Manager configuration.", + "type": "object", + "properties": { + "port": { + "description": "Port holds the port on which the manager will be served.", + "type": "integer" + } + } + }, + "metricsBaseURL": { + "description": "MetricsBaseURL holds the base URL for the metrics server.", + "type": "string" + }, + "proxy": { + "description": "Proxy holds the proxy configuration.", + "type": "object", + "properties": { + "httpProxy": { + "type": "string" + }, + "httpsProxy": { + "type": "string" + }, + "noProxy": { + "type": "string" + }, + "providedNoProxy": { + "type": "string" + } + } + } + } + }, + "status": { + "description": "KubernetesInstallationStatus defines the observed state of KubernetesInstallation", + "type": "object", + "properties": { + "reason": { + "description": "Reason holds the reason for the current state.", + "type": "string" + }, + "state": { + "description": "State holds the current state of the installation.", + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/pkg-new/constants/constants.go b/pkg-new/constants/constants.go new file mode 100644 index 0000000000..3ee7a50170 --- /dev/null +++ b/pkg-new/constants/constants.go @@ -0,0 +1,14 @@ +package constants + +const ( + KotsadmNamespace = "kotsadm" + KotsadmServiceAccount = "kotsadm" + SeaweedFSNamespace = "seaweedfs" + RegistryNamespace = "registry" + VeleroNamespace = "velero" + EmbeddedClusterNamespace = "embedded-cluster" +) + +const ( + EcRestoreStateCMName = "embedded-cluster-restore-state" +) diff --git a/pkg/airgap/containerd.go b/pkg-new/hostutils/containerd.go similarity index 91% rename from pkg/airgap/containerd.go rename to pkg-new/hostutils/containerd.go index a730a100f6..42b522405f 100644 --- a/pkg/airgap/containerd.go +++ b/pkg-new/hostutils/containerd.go @@ -1,4 +1,4 @@ -package airgap +package hostutils import ( "fmt" @@ -17,7 +17,7 @@ const registryConfigTemplate = ` // AddInsecureRegistry adds a registry to the list of registries that // are allowed to be accessed over HTTP. -func AddInsecureRegistry(registry string) error { +func (h *HostUtils) AddInsecureRegistry(registry string) error { parentDir := runtimeconfig.K0sContainerdConfigPath contents := fmt.Sprintf(registryConfigTemplate, registry) diff --git a/pkg-new/hostutils/files.go b/pkg-new/hostutils/files.go index 3aaa4a8a9e..32378eb3b1 100644 --- a/pkg-new/hostutils/files.go +++ b/pkg-new/hostutils/files.go @@ -15,7 +15,9 @@ func (h *HostUtils) MaterializeFiles(rc runtimeconfig.RuntimeConfig, airgapBundl if err := materializer.Materialize(); err != nil { return fmt.Errorf("materialize binaries: %w", err) } - if err := support.MaterializeSupportBundleSpec(rc); err != nil { + + isAirgap := airgapBundle != "" + if err := support.MaterializeSupportBundleSpec(rc, isAirgap); err != nil { return fmt.Errorf("materialize support bundle spec: %w", err) } diff --git a/pkg-new/hostutils/initialize.go b/pkg-new/hostutils/initialize.go index 1d806110ab..024735b08a 100644 --- a/pkg-new/hostutils/initialize.go +++ b/pkg-new/hostutils/initialize.go @@ -6,15 +6,12 @@ import ( "os" "path/filepath" - "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" ) type InitForInstallOptions struct { - LicenseFile string + License []byte AirgapBundle string - PodCIDR string - ServiceCIDR string } func (h *HostUtils) ConfigureHost(ctx context.Context, rc runtimeconfig.RuntimeConfig, opts InitForInstallOptions) error { @@ -35,11 +32,10 @@ func (h *HostUtils) ConfigureHost(ctx context.Context, rc runtimeconfig.RuntimeC return fmt.Errorf("materialize files: %w", err) } - if opts.LicenseFile != "" { - h.logger.Debugf("copy license file to %s", rc.EmbeddedClusterHomeDirectory()) - if err := helpers.CopyFile(opts.LicenseFile, filepath.Join(rc.EmbeddedClusterHomeDirectory(), "license.yaml"), 0400); err != nil { - // We have decided not to report this error - h.logger.Warnf("unable to copy license file to %s: %v", rc.EmbeddedClusterHomeDirectory(), err) + if opts.License != nil { + h.logger.Debugf("write license file to %s", rc.EmbeddedClusterHomeDirectory()) + if err := os.WriteFile(filepath.Join(rc.EmbeddedClusterHomeDirectory(), "license.yaml"), opts.License, 0400); err != nil { + h.logger.Warnf("unable to write license file to %s: %v", rc.EmbeddedClusterHomeDirectory(), err) } } @@ -59,7 +55,7 @@ func (h *HostUtils) ConfigureHost(ctx context.Context, rc runtimeconfig.RuntimeC } h.logger.Debugf("configuring firewalld") - if err := h.ConfigureFirewalld(ctx, opts.PodCIDR, opts.ServiceCIDR); err != nil { + if err := h.ConfigureFirewalld(ctx, rc.PodCIDR(), rc.ServiceCIDR()); err != nil { h.logger.Debugf("unable to configure firewalld: %v", err) } diff --git a/pkg-new/hostutils/interface.go b/pkg-new/hostutils/interface.go index 4d9a8b1c56..9feb63a9fc 100644 --- a/pkg-new/hostutils/interface.go +++ b/pkg-new/hostutils/interface.go @@ -3,7 +3,6 @@ package hostutils import ( "context" - ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/sirupsen/logrus" ) @@ -26,8 +25,9 @@ type HostUtilsInterface interface { ConfigureFirewalld(ctx context.Context, podNetwork, serviceNetwork string) error ResetFirewalld(ctx context.Context) error MaterializeFiles(rc runtimeconfig.RuntimeConfig, airgapBundle string) error - CreateSystemdUnitFiles(ctx context.Context, logger logrus.FieldLogger, rc runtimeconfig.RuntimeConfig, isWorker bool, proxy *ecv1beta1.ProxySpec) error + CreateSystemdUnitFiles(ctx context.Context, logger logrus.FieldLogger, rc runtimeconfig.RuntimeConfig, isWorker bool) error WriteLocalArtifactMirrorDropInFile(rc runtimeconfig.RuntimeConfig) error + AddInsecureRegistry(registry string) error } // Convenience functions @@ -61,10 +61,14 @@ func MaterializeFiles(rc runtimeconfig.RuntimeConfig, airgapBundle string) error return h.MaterializeFiles(rc, airgapBundle) } -func CreateSystemdUnitFiles(ctx context.Context, logger logrus.FieldLogger, rc runtimeconfig.RuntimeConfig, isWorker bool, proxy *ecv1beta1.ProxySpec) error { - return h.CreateSystemdUnitFiles(ctx, logger, rc, isWorker, proxy) +func CreateSystemdUnitFiles(ctx context.Context, logger logrus.FieldLogger, rc runtimeconfig.RuntimeConfig, isWorker bool) error { + return h.CreateSystemdUnitFiles(ctx, logger, rc, isWorker) } func WriteLocalArtifactMirrorDropInFile(rc runtimeconfig.RuntimeConfig) error { return h.WriteLocalArtifactMirrorDropInFile(rc) } + +func AddInsecureRegistry(registry string) error { + return h.AddInsecureRegistry(registry) +} diff --git a/pkg-new/hostutils/mock.go b/pkg-new/hostutils/mock.go index bd88652858..9154441ece 100644 --- a/pkg-new/hostutils/mock.go +++ b/pkg-new/hostutils/mock.go @@ -3,7 +3,6 @@ package hostutils import ( "context" - ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/sirupsen/logrus" "github.com/stretchr/testify/mock" @@ -18,7 +17,7 @@ type MockHostUtils struct { // ConfigureHost mocks the ConfigureHost method func (m *MockHostUtils) ConfigureHost(ctx context.Context, rc runtimeconfig.RuntimeConfig, opts InitForInstallOptions) error { - args := m.Called(ctx, opts) + args := m.Called(ctx, rc, opts) return args.Error(0) } @@ -59,8 +58,8 @@ func (m *MockHostUtils) MaterializeFiles(rc runtimeconfig.RuntimeConfig, airgapB } // CreateSystemdUnitFiles mocks the CreateSystemdUnitFiles method -func (m *MockHostUtils) CreateSystemdUnitFiles(ctx context.Context, logger logrus.FieldLogger, rc runtimeconfig.RuntimeConfig, isWorker bool, proxy *ecv1beta1.ProxySpec) error { - args := m.Called(ctx, logger, rc, isWorker, proxy) +func (m *MockHostUtils) CreateSystemdUnitFiles(ctx context.Context, logger logrus.FieldLogger, rc runtimeconfig.RuntimeConfig, isWorker bool) error { + args := m.Called(ctx, logger, rc, isWorker) return args.Error(0) } @@ -69,3 +68,9 @@ func (m *MockHostUtils) WriteLocalArtifactMirrorDropInFile(rc runtimeconfig.Runt args := m.Called(rc) return args.Error(0) } + +// AddInsecureRegistry mocks the AddInsecureRegistry method +func (m *MockHostUtils) AddInsecureRegistry(registry string) error { + args := m.Called(registry) + return args.Error(0) +} diff --git a/pkg-new/hostutils/system.go b/pkg-new/hostutils/system.go index 373df96076..ff36623bb1 100644 --- a/pkg-new/hostutils/system.go +++ b/pkg-new/hostutils/system.go @@ -14,7 +14,6 @@ import ( "time" "github.com/replicatedhq/embedded-cluster/cmd/installer/goods" - ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/pkg/helpers/systemd" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" @@ -207,7 +206,7 @@ func modprobe(module string) error { // CreateSystemdUnitFiles links the k0s systemd unit file. this also creates a new // systemd unit file for the local artifact mirror service. -func (h *HostUtils) CreateSystemdUnitFiles(ctx context.Context, logger logrus.FieldLogger, rc runtimeconfig.RuntimeConfig, isWorker bool, proxy *ecv1beta1.ProxySpec) error { +func (h *HostUtils) CreateSystemdUnitFiles(ctx context.Context, logger logrus.FieldLogger, rc runtimeconfig.RuntimeConfig, isWorker bool) error { dst := systemdUnitFileName() if _, err := os.Lstat(dst); err == nil { if err := os.Remove(dst); err != nil { @@ -218,7 +217,7 @@ func (h *HostUtils) CreateSystemdUnitFiles(ctx context.Context, logger logrus.Fi if isWorker { src = "/etc/systemd/system/k0sworker.service" } - if proxy != nil { + if proxy := rc.ProxySpec(); proxy != nil { if err := ensureProxyConfig(fmt.Sprintf("%s.d", src), proxy.HTTPProxy, proxy.HTTPSProxy, proxy.NoProxy); err != nil { return fmt.Errorf("unable to create proxy config: %w", err) } @@ -238,7 +237,7 @@ func (h *HostUtils) CreateSystemdUnitFiles(ctx context.Context, logger logrus.Fi } func systemdUnitFileName() string { - return fmt.Sprintf("/etc/systemd/system/%s.service", runtimeconfig.BinaryName()) + return fmt.Sprintf("/etc/systemd/system/%s.service", runtimeconfig.AppSlug()) } // ensureProxyConfig creates a new http-proxy.conf configuration file. The file is saved in the diff --git a/pkg-new/k0s/interface.go b/pkg-new/k0s/interface.go index 19e65f888d..021647817d 100644 --- a/pkg-new/k0s/interface.go +++ b/pkg-new/k0s/interface.go @@ -4,6 +4,8 @@ import ( "context" k0sv1beta1 "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" ) var ( @@ -33,7 +35,10 @@ type K0sVars struct { type K0sInterface interface { GetStatus(ctx context.Context) (*K0sStatus, error) + Install(rc runtimeconfig.RuntimeConfig) error IsInstalled() (bool, error) + WriteK0sConfig(ctx context.Context, networkInterface string, airgapBundle string, podCIDR string, serviceCIDR string, eucfg *ecv1beta1.Config, mutate func(*k0sv1beta1.ClusterConfig) error) (*k0sv1beta1.ClusterConfig, error) + PatchK0sConfig(path string, patch string) error WaitForK0s() error } @@ -41,10 +46,22 @@ func GetStatus(ctx context.Context) (*K0sStatus, error) { return _k0s.GetStatus(ctx) } +func Install(rc runtimeconfig.RuntimeConfig) error { + return _k0s.Install(rc) +} + func IsInstalled() (bool, error) { return _k0s.IsInstalled() } +func WriteK0sConfig(ctx context.Context, networkInterface string, airgapBundle string, podCIDR string, serviceCIDR string, eucfg *ecv1beta1.Config, mutate func(*k0sv1beta1.ClusterConfig) error) (*k0sv1beta1.ClusterConfig, error) { + return _k0s.WriteK0sConfig(ctx, networkInterface, airgapBundle, podCIDR, serviceCIDR, eucfg, mutate) +} + +func PatchK0sConfig(path string, patch string) error { + return _k0s.PatchK0sConfig(path, patch) +} + func WaitForK0s() error { return _k0s.WaitForK0s() } diff --git a/pkg-new/k0s/k0s.go b/pkg-new/k0s/k0s.go index 09180a971d..3b003acd2b 100644 --- a/pkg-new/k0s/k0s.go +++ b/pkg-new/k0s/k0s.go @@ -11,6 +11,7 @@ import ( k0sv1beta1 "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg-new/domains" "github.com/replicatedhq/embedded-cluster/pkg/airgap" "github.com/replicatedhq/embedded-cluster/pkg/config" "github.com/replicatedhq/embedded-cluster/pkg/helpers" @@ -30,6 +31,10 @@ var _ K0sInterface = (*K0s)(nil) type K0s struct { } +func New() *K0s { + return &K0s{} +} + // GetStatus calls the k0s status command and returns information about system init, PID, k0s role, // kubeconfig and similar. func (k *K0s) GetStatus(ctx context.Context) (*K0sStatus, error) { @@ -52,14 +57,14 @@ func (k *K0s) GetStatus(ctx context.Context) (*K0sStatus, error) { // Install runs the k0s install command and waits for it to finish. If no configuration // is found one is generated. -func Install(rc runtimeconfig.RuntimeConfig, networkInterface string) error { +func (k *K0s) Install(rc runtimeconfig.RuntimeConfig) error { ourbin := rc.PathToEmbeddedClusterBinary("k0s") hstbin := runtimeconfig.K0sBinaryPath if err := helpers.MoveFile(ourbin, hstbin); err != nil { return fmt.Errorf("unable to move k0s binary: %w", err) } - nodeIP, err := netutils.FirstValidAddress(networkInterface) + nodeIP, err := netutils.FirstValidAddress(rc.NetworkInterface()) if err != nil { return fmt.Errorf("unable to find first valid address: %w", err) } @@ -89,24 +94,14 @@ func (k *K0s) IsInstalled() (bool, error) { return false, fmt.Errorf("unable to check if already installed: %w", err) } -// WriteK0sConfig creates a new k0s.yaml configuration file. The file is saved in the -// global location (as returned by runtimeconfig.K0sConfigPath). If a file already sits -// there, this function returns an error. -func WriteK0sConfig(ctx context.Context, networkInterface string, airgapBundle string, podCIDR string, serviceCIDR string, eucfg *ecv1beta1.Config, mutate func(*k0sv1beta1.ClusterConfig) error) (*k0sv1beta1.ClusterConfig, error) { - cfgpath := runtimeconfig.K0sConfigPath - if _, err := os.Stat(cfgpath); err == nil { - return nil, fmt.Errorf("configuration file already exists") - } - if err := os.MkdirAll(filepath.Dir(cfgpath), 0755); err != nil { - return nil, fmt.Errorf("unable to create directory: %w", err) - } - +// NewK0sConfig creates a new k0sv1beta1.ClusterConfig object from the input parameters. +func NewK0sConfig(networkInterface string, isAirgap bool, podCIDR string, serviceCIDR string, eucfg *ecv1beta1.Config, mutate func(*k0sv1beta1.ClusterConfig) error) (*k0sv1beta1.ClusterConfig, error) { var embCfgSpec *ecv1beta1.ConfigSpec if embCfg := release.GetEmbeddedClusterConfig(); embCfg != nil { embCfgSpec = &embCfg.Spec } - domains := runtimeconfig.GetDomains(embCfgSpec) + domains := domains.GetDomains(embCfgSpec, release.GetChannelRelease()) cfg := config.RenderK0sConfig(domains.ProxyRegistryDomain) address, err := netutils.FirstValidAddress(networkInterface) @@ -130,11 +125,31 @@ func WriteK0sConfig(ctx context.Context, networkInterface string, airgapBundle s return nil, fmt.Errorf("unable to apply unsupported overrides: %w", err) } - if airgapBundle != "" { + if isAirgap { // update the k0s config to install with airgap airgap.SetAirgapConfig(cfg) } + return cfg, nil +} + +// WriteK0sConfig creates a new k0s.yaml configuration file. The file is saved in the +// global location (as returned by runtimeconfig.K0sConfigPath). If a file already sits +// there, this function returns an error. +func (k *K0s) WriteK0sConfig(ctx context.Context, networkInterface string, airgapBundle string, podCIDR string, serviceCIDR string, eucfg *ecv1beta1.Config, mutate func(*k0sv1beta1.ClusterConfig) error) (*k0sv1beta1.ClusterConfig, error) { + cfg, err := NewK0sConfig(networkInterface, airgapBundle != "", podCIDR, serviceCIDR, eucfg, mutate) + if err != nil { + return nil, fmt.Errorf("unable to create k0s config: %w", err) + } + + cfgpath := runtimeconfig.K0sConfigPath + if _, err := os.Stat(cfgpath); err == nil { + return nil, fmt.Errorf("configuration file already exists") + } + if err := os.MkdirAll(filepath.Dir(cfgpath), 0755); err != nil { + return nil, fmt.Errorf("unable to create directory: %w", err) + } + // This is necessary to install the previous version of k0s in e2e tests // TODO: remove this once the previous version is > 1.29 unstructured, err := helpers.K0sClusterConfigTo129Compat(cfg) @@ -148,6 +163,7 @@ func WriteK0sConfig(ctx context.Context, networkInterface string, airgapBundle s if err := os.WriteFile(cfgpath, data, 0600); err != nil { return nil, fmt.Errorf("unable to write config file: %w", err) } + return cfg, nil } @@ -179,7 +195,7 @@ func applyUnsupportedOverrides(cfg *k0sv1beta1.ClusterConfig, eucfg *ecv1beta1.C } // PatchK0sConfig patches the created k0s config with the unsupported overrides passed in. -func PatchK0sConfig(path string, patch string) error { +func (k *K0s) PatchK0sConfig(path string, patch string) error { if len(patch) == 0 { return nil } @@ -248,7 +264,7 @@ func (k *K0s) WaitForK0s() error { break } if !success { - return fmt.Errorf("timeout waiting for %s", runtimeconfig.BinaryName()) + return fmt.Errorf("timeout waiting for %s", runtimeconfig.AppSlug()) } for i := 1; ; i++ { diff --git a/pkg-new/k0s/mock.go b/pkg-new/k0s/mock.go index 210971aea3..9f6627ecb5 100644 --- a/pkg-new/k0s/mock.go +++ b/pkg-new/k0s/mock.go @@ -3,6 +3,9 @@ package k0s import ( "context" + k0sv1beta1 "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/stretchr/testify/mock" ) @@ -22,12 +25,30 @@ func (m *MockK0s) GetStatus(ctx context.Context) (*K0sStatus, error) { return args.Get(0).(*K0sStatus), args.Error(1) } +// Install mocks the Install method +func (m *MockK0s) Install(rc runtimeconfig.RuntimeConfig) error { + args := m.Called(rc) + return args.Error(0) +} + // IsInstalled mocks the IsInstalled method func (m *MockK0s) IsInstalled() (bool, error) { args := m.Called() return args.Bool(0), args.Error(1) } +// WriteK0sConfig mocks the WriteK0sConfig method +func (m *MockK0s) WriteK0sConfig(ctx context.Context, networkInterface string, airgapBundle string, podCIDR string, serviceCIDR string, eucfg *ecv1beta1.Config, mutate func(*k0sv1beta1.ClusterConfig) error) (*k0sv1beta1.ClusterConfig, error) { + args := m.Called(ctx, networkInterface, airgapBundle, podCIDR, serviceCIDR, eucfg, mutate) + return args.Get(0).(*k0sv1beta1.ClusterConfig), args.Error(1) +} + +// PatchK0sConfig mocks the PatchK0sConfig method +func (m *MockK0s) PatchK0sConfig(path string, patch string) error { + args := m.Called(path, patch) + return args.Error(0) +} + // WaitForK0s mocks the WaitForK0s method func (m *MockK0s) WaitForK0s() error { args := m.Called() diff --git a/pkg-new/metadata/metadata.go b/pkg-new/metadata/metadata.go index a1822727af..b055be0fbf 100644 --- a/pkg-new/metadata/metadata.go +++ b/pkg-new/metadata/metadata.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "runtime" "sort" "strings" @@ -86,7 +85,7 @@ func GatherVersionMetadata(channelRelease *release.ChannelRelease) (*types.Relea versionsMap["Troubleshoot"] = versions.TroubleshootVersion if channelRelease != nil { - versionsMap[runtimeconfig.BinaryName()] = channelRelease.VersionLabel + versionsMap[runtimeconfig.AppSlug()] = channelRelease.VersionLabel } sha, err := goods.K0sBinarySHA256() @@ -95,9 +94,9 @@ func GatherVersionMetadata(channelRelease *release.ChannelRelease) (*types.Relea } artifacts := map[string]string{ - "k0s": fmt.Sprintf("k0s-binaries/%s-%s", versions.K0sVersion, runtime.GOARCH), - "kots": fmt.Sprintf("kots-binaries/%s-%s.tar.gz", adminconsole.KotsVersion, runtime.GOARCH), - "operator": fmt.Sprintf("operator-binaries/%s-%s.tar.gz", embeddedclusteroperator.Metadata.Version, runtime.GOARCH), + "k0s": fmt.Sprintf("k0s-binaries/%s-%s", versions.K0sVersion, helpers.ClusterArch()), + "kots": fmt.Sprintf("kots-binaries/%s-%s.tar.gz", adminconsole.KotsVersion, helpers.ClusterArch()), + "operator": fmt.Sprintf("operator-binaries/%s-%s.tar.gz", embeddedclusteroperator.Metadata.Version, helpers.ClusterArch()), "local-artifact-mirror-image": versions.LocalArtifactMirrorImage, } if versions.K0sBinaryURLOverride != "" { diff --git a/pkg-new/preflights/host-preflight.yaml b/pkg-new/preflights/host-preflight.yaml index 7553c443bb..a3750c70b4 100644 --- a/pkg-new/preflights/host-preflight.yaml +++ b/pkg-new/preflights/host-preflight.yaml @@ -172,6 +172,20 @@ spec: dir="{{ .DataDir }}" while [ "$dir" != "/" ]; do find "$dir" -maxdepth 0 ! -perm -111; dir=$(dirname "$dir"); done find "/" -maxdepth 0 ! -perm -111 + - run: + collectorName: 'xfs_info-data-dir' + command: 'sh' + args: + - '-c' + - > + # Get filesystem type + fstype=$(findmnt -n -o FSTYPE --target "{{ .DataDir }}") + if [ "$fstype" = "xfs" ]; then + echo "Filesystem is XFS. Running xfs_info..." + xfs_info "{{ .DataDir }}" + else + echo "Filesystem is not XFS (detected: $fstype). Skipping xfs_info." + fi analyzers: - cpu: checkName: CPU @@ -205,10 +219,20 @@ spec: outcomes: - fail: when: 'total < 40Gi' - message: The filesystem at {{ .DataDir }} has less than 40 Gi of total space. Ensure sufficient space is available, or use --data-dir to specify an alternative data directory. + message: >- + {{ if .IsUI -}} + The filesystem at {{ .DataDir }} has less than 40 Gi of total space. Ensure sufficient space is available, or go back to the Setup page and choose a different data directory. + {{- else -}} + The filesystem at {{ .DataDir }} has less than 40 Gi of total space. Ensure sufficient space is available, or use --data-dir to specify an alternative data directory. + {{- end }} - fail: when: 'used/total > 80%' - message: The filesystem at {{ .DataDir }} is more than 80% full. Ensure sufficient space is available, or use --data-dir to specify an alternative data directory. + message: >- + {{ if .IsUI -}} + The filesystem at {{ .DataDir }} is more than 80% full. Ensure sufficient space is available, or go back to the Setup page and choose a different data directory. + {{- else -}} + The filesystem at {{ .DataDir }} is more than 80% full. Ensure sufficient space is available, or use --data-dir to specify an alternative data directory. + {{- end }} - pass: message: The filesystem at {{ .DataDir }} has sufficient space - textAnalyze: @@ -411,22 +435,30 @@ spec: outcomes: - fail: when: error - message: > + message: >- + {{ if .IsUI -}} Error connecting to {{ .ReplicatedAppURL }}. - Ensure your firewall is properly configured, and use --http-proxy, - --https-proxy, and --no-proxy if there is a proxy server. - The static IP addresses for {{ .ReplicatedAppURL }} are - 162.159.133.41 and 162.159.134.41. + Ensure your firewall is properly configured. If your environment uses a proxy server, go back to the Setup page and specify the proxy settings. + The static IP addresses for {{ .ReplicatedAppURL }} are 162.159.133.41 and 162.159.134.41. + {{- else -}} + Error connecting to {{ .ReplicatedAppURL }}. + Ensure your firewall is properly configured. If your environment uses a proxy server, use --http-proxy, --https-proxy, and --no-proxy. + The static IP addresses for {{ .ReplicatedAppURL }} are 162.159.133.41 and 162.159.134.41. + {{- end }} - pass: when: 'statusCode == 200' message: 'Connected to {{ .ReplicatedAppURL }}' - fail: - message: > - Error connecting to {{ .ReplicatedAppURL }}. - Ensure your firewall is properly configured, and use --http-proxy, - --https-proxy, and --no-proxy if there is a proxy server. - The static IP addresses for {{ .ReplicatedAppURL }} are - 162.159.133.41 and 162.159.134.41. + message: >- + {{ if .IsUI -}} + Unexpected response from {{ .ReplicatedAppURL }}. + Ensure your firewall is properly configured. If your environment uses a proxy server, go back to the Setup page and specify the proxy settings. + The static IP addresses for {{ .ReplicatedAppURL }} are 162.159.133.41 and 162.159.134.41. + {{- else -}} + Unexpected response from {{ .ReplicatedAppURL }}. + Ensure your firewall is properly configured. If your environment uses a proxy server, use --http-proxy, --https-proxy, and --no-proxy. + The static IP addresses for {{ .ReplicatedAppURL }} are 162.159.133.41 and 162.159.134.41. + {{- end }} - http: checkName: Proxy Registry Access collectorName: http-proxy-replicated-com @@ -434,22 +466,30 @@ spec: outcomes: - fail: when: error - message: > + message: >- + {{ if .IsUI -}} Error connecting to {{ .ProxyRegistryURL }}. - Ensure your firewall is properly configured, and use --http-proxy, - --https-proxy, and --no-proxy if there is a proxy server. - The static IP addresses for {{ .ProxyRegistryURL }} are - 162.159.137.43 and 162.159.138.43. + Ensure your firewall is properly configured. If your environment uses a proxy server, go back to the Setup page and specify the proxy settings. + The static IP addresses for {{ .ProxyRegistryURL }} are 162.159.137.43 and 162.159.138.43. + {{- else -}} + Error connecting to {{ .ProxyRegistryURL }}. + Ensure your firewall is properly configured, If your environment uses a proxy server, use --http-proxy, --https-proxy, and --no-proxy. + The static IP addresses for {{ .ProxyRegistryURL }} are 162.159.137.43 and 162.159.138.43. + {{- end }} - pass: when: 'statusCode == 401' message: 'Connected to {{ .ProxyRegistryURL }}' - fail: - message: > + message: >- + {{ if .IsUI -}} + Unexpected response from {{ .ProxyRegistryURL }}. + Ensure your firewall is properly configured. If your environment uses a proxy server, go back to the Setup page and specify the proxy settings. + The static IP addresses for {{ .ProxyRegistryURL }} are 162.159.137.43 and 162.159.138.43. + {{- else -}} Unexpected response from {{ .ProxyRegistryURL }}. - Ensure your firewall is properly configured, and use --http-proxy, - --https-proxy, and --no-proxy if there is a proxy server. - The static IP addresses for {{ .ProxyRegistryURL }} are - 162.159.137.43 and 162.159.138.43. + Ensure your firewall is properly configured, If your environment uses a proxy server, use --http-proxy, --https-proxy, and --no-proxy. + The static IP addresses for {{ .ProxyRegistryURL }} are 162.159.137.43 and 162.159.138.43. + {{- end }} - textAnalyze: checkName: Resolver Configuration fileName: host-collectors/run-host/resolv.conf.txt @@ -514,7 +554,7 @@ spec: message: Port 2380/TCP is required, but another process is already using it. Relocate the conflicting process to continue. - fail: when: "connection-timeout" - message: Port 2380/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 2380/TCP. + message: "Port 2380/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 2380/TCP." - fail: when: "error" message: Port 2380/TCP is required, but an unexpected error occurred when trying to connect to it. Ensure port 2380/TCP is available. @@ -532,10 +572,15 @@ spec: message: Port {{ .LocalArtifactMirrorPort }}/TCP is required, but the connection to it was refused. Ensure port {{ .LocalArtifactMirrorPort }}/TCP is available. - fail: when: "address-in-use" - message: Port {{ .LocalArtifactMirrorPort }}/TCP is required, but another process is already using it. Relocate the conflicting process or use --local-artifact-mirror-port to select a different port. + message: >- + {{ if .IsUI -}} + Port {{ .LocalArtifactMirrorPort }}/TCP is required, but another process is already using it. Relocate the conflicting process or go back to the Setup page and choose a different port. + {{- else -}} + Port {{ .LocalArtifactMirrorPort }}/TCP is required, but another process is already using it. Relocate the conflicting process or use --local-artifact-mirror-port to select a different port. + {{- end }} - fail: when: "connection-timeout" - message: Port {{ .LocalArtifactMirrorPort }}/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port {{ .LocalArtifactMirrorPort }}/TCP. + message: "Port {{ .LocalArtifactMirrorPort }}/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port {{ .LocalArtifactMirrorPort }}/TCP." - fail: when: "error" message: Port {{ .LocalArtifactMirrorPort }}/TCP is required, but an unexpected error occurred when trying to connect to it. Ensure port {{ .LocalArtifactMirrorPort }}/TCP is available. @@ -556,7 +601,7 @@ spec: message: Port 9091/TCP is required, but another process is already using it. Relocate the conflicting process to continue. - fail: when: "connection-timeout" - message: Port 9091/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 9091/TCP. + message: "Port 9091/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 9091/TCP." - fail: when: "error" message: Port 9091/TCP is required, but an unexpected error occurred when trying to connect to it. Ensure port 9091/TCP is available. @@ -577,7 +622,7 @@ spec: message: Port 6443/TCP is required, but another process is already using it. Relocate the conflicting process to continue. - fail: when: "connection-timeout" - message: Port 6443/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 6443/TCP. + message: "Port 6443/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 6443/TCP." - fail: when: "error" message: Port 6443/TCP is required, but an unexpected error occurred when trying to connect to it. Ensure port 6443/TCP is available. @@ -598,7 +643,7 @@ spec: message: Port 7443/TCP is required, but another process is already using it. Relocate the conflicting process to continue. - fail: when: "connection-timeout" - message: Port 7443/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 7443/TCP. + message: "Port 7443/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 7443/TCP." - fail: when: "error" message: Port 7443/TCP is required, but an unexpected error occurred when trying to connect to it. Ensure port 7443/TCP is available. @@ -616,10 +661,15 @@ spec: message: Port {{ .AdminConsolePort }}/TCP is required, but the connection to it was refused. Ensure port {{ .AdminConsolePort }}/TCP is available. - fail: when: "address-in-use" - message: Port {{ .AdminConsolePort }}/TCP is required, but another process is already using it. Relocate the conflicting process or use --admin-console-port to select a different port. + message: >- + {{ if .IsUI -}} + Port {{ .AdminConsolePort }}/TCP is required, but another process is already using it. Relocate the conflicting process or go back to the Setup page and choose a different port. + {{- else -}} + Port {{ .AdminConsolePort }}/TCP is required, but another process is already using it. Relocate the conflicting process or use --admin-console-port to select a different port. + {{- end }} - fail: when: "connection-timeout" - message: Port {{ .AdminConsolePort }}/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port {{ .AdminConsolePort }}/TCP. + message: "Port {{ .AdminConsolePort }}/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port {{ .AdminConsolePort }}/TCP." - fail: when: "error" message: Port {{ .AdminConsolePort }}/TCP is required, but an unexpected error occurred when trying to connect to it. Ensure port {{ .AdminConsolePort }}/TCP is available. @@ -640,7 +690,7 @@ spec: message: Port 10250/TCP is required, but another process is already using it. Relocate the conflicting process to continue. - fail: when: "connection-timeout" - message: Port 10250/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 10250/TCP. + message: "Port 10250/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 10250/TCP." - fail: when: "error" message: Port 10250/TCP is required, but an unexpected error occurred when trying to connect to it. Ensure port 10250/TCP is available. @@ -661,7 +711,7 @@ spec: message: Port 9443/TCP is required, but another process is already using it. Relocate the conflicting process to continue. - fail: when: "connection-timeout" - message: Port 9443/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 9443/TCP. + message: "Port 9443/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 9443/TCP." - fail: when: "error" message: Port 9443/TCP is required, but an unexpected error occurred when trying to connect to it. Ensure port 9443/TCP is available. @@ -682,7 +732,7 @@ spec: message: Port 9099/TCP is required, but another process is already using it. Relocate the conflicting process to continue. - fail: when: "connection-timeout" - message: Port 9099/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 9099/TCP. + message: "Port 9099/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 9099/TCP." - fail: when: "error" message: Port 9099/TCP is required, but an unexpected error occurred when trying to connect to it. Ensure port 9099/TCP is available. @@ -703,7 +753,7 @@ spec: message: Port 10256/TCP is required, but another process is already using it. Relocate the conflicting process to continue. - fail: when: "connection-timeout" - message: Port 10256/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 10256/TCP. + message: "Port 10256/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 10256/TCP." - fail: when: "error" message: Port 10256/TCP is required, but an unexpected error occurred when trying to connect to it. Ensure port 10256/TCP is available. @@ -724,7 +774,7 @@ spec: message: Port 10249/TCP is required, but another process is already using it. Relocate the conflicting process to continue. - fail: when: "connection-timeout" - message: Port 10249/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 10249/TCP. + message: "Port 10249/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 10249/TCP." - fail: when: "error" message: Port 10249/TCP is required, but an unexpected error occurred when trying to connect to it. Ensure port 10249/TCP is available. @@ -745,7 +795,7 @@ spec: message: Port 10259/TCP is required, but another process is already using it. Relocate the conflicting process to continue. - fail: when: "connection-timeout" - message: Port 10259/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 10259/TCP. + message: "Port 10259/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 10259/TCP." - fail: when: "error" message: Port 10259/TCP is required, but an unexpected error occurred when trying to connect to it. Ensure port 10259/TCP is available. @@ -766,7 +816,7 @@ spec: message: Port 10257/TCP is required, but another process is already using it. Relocate the conflicting process to continue. - fail: when: "connection-timeout" - message: Port 10257/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 10257/TCP. + message: "Port 10257/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 10257/TCP." - fail: when: "error" message: Port 10257/TCP is required, but an unexpected error occurred when trying to connect to it. Ensure port 10257/TCP is available. @@ -787,7 +837,7 @@ spec: message: Port 10248/TCP is required, but another process is already using it. Relocate the conflicting process to continue. - fail: when: "connection-timeout" - message: Port 10248/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 10248/TCP. + message: "Port 10248/TCP is required, but the connection timed out. Ensure that your firewall doesn't block port 10248/TCP." - fail: when: "error" message: Port 10248/TCP is required, but an unexpected error occurred when trying to connect to it. Ensure port 10248/TCP is available. @@ -824,7 +874,12 @@ spec: outcomes: - fail: when: 'true' - message: "{{ .DataDir }} cannot be symlinked. Remove the symlink, or use --data-dir to specify an alternative data directory." + message: >- + {{ if .IsUI -}} + {{ .DataDir }} cannot be symlinked. Remove the symlink, or go back to the Setup page and choose a different data directory. + {{- else -}} + {{ .DataDir }} cannot be symlinked. Remove the symlink, or use --data-dir to specify an alternative data directory. + {{- end }} - pass: when: 'false' message: "{{ .DataDir }} is not a symlink." @@ -848,7 +903,8 @@ spec: outcomes: - fail: when: "no-subnet-available" - message: "{{ .PodCIDR.CIDR }} is not available. Use --pod-cidr to specify an available CIDR block." + message: >- + The network range {{ .PodCIDR.CIDR }} is not available. Use --pod-cidr to specify an available CIDR block. - pass: when: "a-subnet-is-available" message: Specified Pod CIDR is available. @@ -859,7 +915,8 @@ spec: outcomes: - fail: when: "no-subnet-available" - message: "{{ .ServiceCIDR.CIDR }} is not available. Use --service-cidr to specify an available CIDR block." + message: >- + The network range {{ .ServiceCIDR.CIDR }} is not available. Use --service-cidr to specify an available CIDR block. - pass: when: "a-subnet-is-available" message: Specified Service CIDR is available. @@ -870,7 +927,12 @@ spec: outcomes: - fail: when: "no-subnet-available" - message: "{{ .GlobalCIDR.CIDR }} is not available. Use --cidr to specify a CIDR block of available private IP addresses (/16 or larger)." + message: >- + {{ if .IsUI -}} + The network range {{ .GlobalCIDR.CIDR }} is not available. Go back to the Setup page and choose a different reserved network range of available private IP addresses (/16 or larger). + {{- else -}} + The network range {{ .GlobalCIDR.CIDR }} is not available. Use --cidr to specify a CIDR block of available private IP addresses (/16 or larger). + {{- end }} - pass: when: "a-subnet-is-available" message: Specified CIDR is available. @@ -882,14 +944,14 @@ spec: outcomes: - fail: when: "true" - message: | + message: >- {{ if .IsJoin -}} The node IP {{ .NodeIP }} cannot be within the Pod CIDR range {{ .PodCIDR.CIDR }}. Use --network-interface to specify a different network interface. {{- else -}} The node IP {{ .NodeIP }} cannot be within the Pod CIDR range {{ .PodCIDR.CIDR }}. Use --pod-cidr to specify a different Pod CIDR, or use --network-interface to specify a different network interface. {{- end }} - pass: - when: "false" + when: "false" message: The node IP {{ .NodeIP }} is not within the Pod CIDR range {{ .PodCIDR.CIDR }}. - subnetContainsIP: checkName: Node IP in Service CIDR Check @@ -899,7 +961,7 @@ spec: outcomes: - fail: when: "true" - message: | + message: >- {{ if .IsJoin -}} The node IP {{ .NodeIP }} cannot be within the Service CIDR range {{ .ServiceCIDR.CIDR }}. Use --network-interface to specify a different network interface. {{- else -}} @@ -916,11 +978,19 @@ spec: outcomes: - fail: when: "true" - message: | + message: >- {{ if .IsJoin -}} - The node IP {{ .NodeIP }} cannot be within the CIDR range {{ .GlobalCIDR.CIDR }}. Use --network-interface to specify a different network interface. + {{ if .IsUI -}} + The node IP {{ .NodeIP }} cannot be within the reserved network range ({{ .GlobalCIDR.CIDR }}). Go back to the Setup page and choose a different network interface. {{- else -}} - The node IP {{ .NodeIP }} cannot be within the CIDR range {{ .GlobalCIDR.CIDR }}. Use --cidr to specify a different CIDR block of available private IP addresses (/16 or larger), or use --network-interface to specify a different network interface. + The node IP {{ .NodeIP }} cannot be within the CIDR block ({{ .GlobalCIDR.CIDR }}). Use --network-interface to specify a different network interface. + {{- end }} + {{- else -}} + {{ if .IsUI -}} + The node IP {{ .NodeIP }} cannot be within the reserved network range ({{ .GlobalCIDR.CIDR }}). Go back to the Setup page and choose a different reserved network range of available private IP addresses (/16 or larger) or specify a different network interface. + {{- else -}} + The node IP {{ .NodeIP }} cannot be within the CIDR block ({{ .GlobalCIDR.CIDR }}). Use --cidr to specify a different CIDR block of available private IP addresses (/16 or larger), or use --network-interface to specify a different network interface. + {{- end }} {{- end }} - pass: when: "false" @@ -1138,3 +1208,14 @@ spec: - fail: message: >- The following directories lack execute permissions: {{ `{{ .Dirs | trim | splitList "\n" | join ", " }}` }}. + - textAnalyze: + checkName: Check filesystem on data directory path + fileName: host-collectors/run-host/xfs_info-data-dir.txt + regex: 'ftype=0' + outcomes: + - fail: + when: "true" + message: "The XFS filesystem at {{ .DataDir }} is configured with ftype=0, which is not supported. Reformat the filesystem with ftype=1, or choose a different data directory on a supported filesystem." + - pass: + when: "false" + message: "The filesystem at {{ .DataDir }} is either not XFS or is XFS with ftype=1." diff --git a/pkg-new/preflights/interface.go b/pkg-new/preflights/interface.go index 87e6ba978b..373891d32c 100644 --- a/pkg-new/preflights/interface.go +++ b/pkg-new/preflights/interface.go @@ -5,7 +5,6 @@ import ( "io" apitypes "github.com/replicatedhq/embedded-cluster/api/types" - ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" ) @@ -22,7 +21,7 @@ func Set(_p PreflightsRunnerInterface) { type PreflightsRunnerInterface interface { Prepare(ctx context.Context, opts PrepareOptions) (*troubleshootv1beta2.HostPreflightSpec, error) - Run(ctx context.Context, spec *troubleshootv1beta2.HostPreflightSpec, proxy *ecv1beta1.ProxySpec, rc runtimeconfig.RuntimeConfig) (*apitypes.HostPreflightsOutput, string, error) + Run(ctx context.Context, spec *troubleshootv1beta2.HostPreflightSpec, rc runtimeconfig.RuntimeConfig) (*apitypes.HostPreflightsOutput, string, error) CopyBundleTo(dst string) error SaveToDisk(output *apitypes.HostPreflightsOutput, path string) error OutputFromReader(reader io.Reader) (*apitypes.HostPreflightsOutput, error) @@ -37,8 +36,8 @@ func Prepare(ctx context.Context, opts PrepareOptions) (*troubleshootv1beta2.Hos return p.Prepare(ctx, opts) } -func Run(ctx context.Context, spec *troubleshootv1beta2.HostPreflightSpec, proxy *ecv1beta1.ProxySpec, rc runtimeconfig.RuntimeConfig) (*apitypes.HostPreflightsOutput, string, error) { - return p.Run(ctx, spec, proxy, rc) +func Run(ctx context.Context, spec *troubleshootv1beta2.HostPreflightSpec, rc runtimeconfig.RuntimeConfig) (*apitypes.HostPreflightsOutput, string, error) { + return p.Run(ctx, spec, rc) } func CopyBundleTo(dst string) error { diff --git a/pkg-new/preflights/mock.go b/pkg-new/preflights/mock.go index 610db7c07e..a5da03824d 100644 --- a/pkg-new/preflights/mock.go +++ b/pkg-new/preflights/mock.go @@ -5,7 +5,6 @@ import ( "io" apitypes "github.com/replicatedhq/embedded-cluster/api/types" - ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" "github.com/stretchr/testify/mock" @@ -28,8 +27,8 @@ func (m *MockPreflightRunner) Prepare(ctx context.Context, opts PrepareOptions) } // Run mocks the Run method -func (m *MockPreflightRunner) Run(ctx context.Context, spec *troubleshootv1beta2.HostPreflightSpec, proxy *ecv1beta1.ProxySpec, rc runtimeconfig.RuntimeConfig) (*apitypes.HostPreflightsOutput, string, error) { - args := m.Called(ctx, spec, proxy, rc) +func (m *MockPreflightRunner) Run(ctx context.Context, spec *troubleshootv1beta2.HostPreflightSpec, rc runtimeconfig.RuntimeConfig) (*apitypes.HostPreflightsOutput, string, error) { + args := m.Called(ctx, spec, rc) if args.Get(0) == nil { return nil, args.String(1), args.Error(2) } diff --git a/pkg-new/preflights/prepare.go b/pkg-new/preflights/prepare.go index a4a875e86c..e4e0a73dd1 100644 --- a/pkg-new/preflights/prepare.go +++ b/pkg-new/preflights/prepare.go @@ -3,10 +3,10 @@ package preflights import ( "context" "fmt" - "runtime" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/preflights/types" + "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" ) @@ -33,6 +33,7 @@ type PrepareOptions struct { IsAirgap bool TCPConnectionsRequired []string IsJoin bool + IsUI bool } // Prepare prepares the host preflights spec by merging provided spec with cluster preflights @@ -51,12 +52,13 @@ func (p *PreflightsRunner) Prepare(ctx context.Context, opts PrepareOptions) (*v DataDir: opts.DataDir, K0sDataDir: opts.K0sDataDir, OpenEBSDataDir: opts.OpenEBSDataDir, - SystemArchitecture: runtime.GOARCH, + SystemArchitecture: helpers.ClusterArch(), FromCIDR: opts.PodCIDR, ToCIDR: opts.ServiceCIDR, TCPConnectionsRequired: opts.TCPConnectionsRequired, NodeIP: opts.NodeIP, IsJoin: opts.IsJoin, + IsUI: opts.IsUI, }.WithCIDRData(opts.PodCIDR, opts.ServiceCIDR, opts.GlobalCIDR) if err != nil { diff --git a/pkg-new/preflights/run.go b/pkg-new/preflights/run.go index 8f10e65f06..1d41e04d13 100644 --- a/pkg-new/preflights/run.go +++ b/pkg-new/preflights/run.go @@ -22,7 +22,7 @@ import ( // Run runs the provided host preflight spec locally. This function is meant to be // used when upgrading a local node. -func (p *PreflightsRunner) Run(ctx context.Context, spec *troubleshootv1beta2.HostPreflightSpec, proxy *ecv1beta1.ProxySpec, rc runtimeconfig.RuntimeConfig) (*apitypes.HostPreflightsOutput, string, error) { +func (p *PreflightsRunner) Run(ctx context.Context, spec *troubleshootv1beta2.HostPreflightSpec, rc runtimeconfig.RuntimeConfig) (*apitypes.HostPreflightsOutput, string, error) { // Deduplicate collectors and analyzers before running preflights spec.Collectors = dedup(spec.Collectors) spec.Analyzers = dedup(spec.Analyzers) @@ -37,7 +37,7 @@ func (p *PreflightsRunner) Run(ctx context.Context, spec *troubleshootv1beta2.Ho cmd := exec.Command(binpath, "--interactive=false", "--format=json", fpath) cmdEnv := cmd.Environ() - cmdEnv = proxyEnv(cmdEnv, proxy) + cmdEnv = proxyEnv(cmdEnv, rc.ProxySpec()) cmdEnv = pathEnv(cmdEnv, rc) cmd.Env = cmdEnv diff --git a/pkg-new/preflights/template_test.go b/pkg-new/preflights/template_test.go index ecfda3783d..16e7289cc2 100644 --- a/pkg-new/preflights/template_test.go +++ b/pkg-new/preflights/template_test.go @@ -3,6 +3,7 @@ package preflights import ( "context" + "encoding/json" "strings" "testing" @@ -86,7 +87,7 @@ func TestTemplateWithCIDRData(t *testing.T) { { Fail: &v1beta2.SingleOutcome{ When: "no-subnet-available", - Message: "10.0.0.0/24 is not available. Use --pod-cidr to specify an available CIDR block.", + Message: "The network range 10.0.0.0/24 is not available. Use --pod-cidr to specify an available CIDR block.", }, }, { @@ -161,7 +162,7 @@ func TestTemplateWithCIDRData(t *testing.T) { { Fail: &v1beta2.SingleOutcome{ When: "no-subnet-available", - Message: "10.0.0.0/24 is not available. Use --service-cidr to specify an available CIDR block.", + Message: "The network range 10.0.0.0/24 is not available. Use --service-cidr to specify an available CIDR block.", }, }, { @@ -236,7 +237,7 @@ func TestTemplateWithCIDRData(t *testing.T) { { Fail: &v1beta2.SingleOutcome{ When: "no-subnet-available", - Message: "10.0.0.0/24 is not available. Use --cidr to specify a CIDR block of available private IP addresses (/16 or larger).", + Message: "The network range 10.0.0.0/24 is not available. Use --cidr to specify a CIDR block of available private IP addresses (/16 or larger).", }, }, { @@ -333,7 +334,9 @@ func TestTemplateWithCIDRData(t *testing.T) { req.NotNil(actual) req.Equal(analyzer.Exclude, actual.Exclude) for _, out := range analyzer.Outcomes { - req.Contains(actual.Outcomes, out) + got, _ := json.MarshalIndent(actual.Outcomes, "", " ") + want, _ := json.MarshalIndent(out, "", " ") + req.Contains(actual.Outcomes, out, "outcome %s not found:\ngot: %s\nwant: %s", out, string(got), string(want)) } } }) diff --git a/pkg-new/preflights/types/template.go b/pkg-new/preflights/types/template.go index cbfde9e0f5..5bcff8fe75 100644 --- a/pkg-new/preflights/types/template.go +++ b/pkg-new/preflights/types/template.go @@ -34,6 +34,7 @@ type TemplateData struct { TCPConnectionsRequired []string NodeIP string IsJoin bool + IsUI bool } // WithCIDRData sets the respective CIDR properties in the TemplateData struct based on the provided CIDR strings diff --git a/pkg-new/tlsutils/tls.go b/pkg-new/tlsutils/tls.go index 6a83bffbfc..30daa2b377 100644 --- a/pkg-new/tlsutils/tls.go +++ b/pkg-new/tlsutils/tls.go @@ -5,7 +5,7 @@ import ( "fmt" "net" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" certutil "k8s.io/client-go/util/cert" ) @@ -57,7 +57,7 @@ func GetTLSConfig(cert tls.Certificate) *tls.Config { } func generateCertHostnames(hostname string) (string, []string) { - namespace := runtimeconfig.KotsadmNamespace + namespace := constants.KotsadmNamespace if hostname == "" { hostname = fmt.Sprintf("kotsadm.%s.svc.cluster.local", namespace) diff --git a/pkg/addons/adminconsole/adminconsole.go b/pkg/addons/adminconsole/adminconsole.go index 30a512ef24..79663a6035 100644 --- a/pkg/addons/adminconsole/adminconsole.go +++ b/pkg/addons/adminconsole/adminconsole.go @@ -5,13 +5,14 @@ import ( "strings" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" ) const ( _releaseName = "admin-console" - _namespace = runtimeconfig.KotsadmNamespace + + _namespace = constants.KotsadmNamespace ) var _ types.AddOn = (*AdminConsole)(nil) @@ -19,14 +20,23 @@ var _ types.AddOn = (*AdminConsole)(nil) type AdminConsole struct { IsAirgap bool IsHA bool - Proxy *ecv1beta1.ProxySpec - ServiceCIDR string - Password string - TLSCertBytes []byte - TLSKeyBytes []byte - Hostname string - KotsInstaller KotsInstaller IsMultiNodeEnabled bool + Proxy *ecv1beta1.ProxySpec + AdminConsolePort int + + // Linux specific options + ClusterID string + ServiceCIDR string + HostCABundlePath string + DataDir string + K0sDataDir string + + // These options are only used during installation + Password string + TLSCertBytes []byte + TLSKeyBytes []byte + Hostname string + KotsInstaller KotsInstaller // DryRun is a flag to enable dry-run mode for Admin Console. // If true, Admin Console will only render the helm template and additional manifests, but not install @@ -77,3 +87,7 @@ func (a *AdminConsole) ChartLocation(domains ecv1beta1.Domains) string { func (a *AdminConsole) DryRunManifests() [][]byte { return a.dryRunManifests } + +func (a *AdminConsole) isEmbeddedCluster() bool { + return a.ClusterID != "" +} diff --git a/pkg/addons/adminconsole/install.go b/pkg/addons/adminconsole/install.go index 236bb89751..486e0b80ce 100644 --- a/pkg/addons/adminconsole/install.go +++ b/pkg/addons/adminconsole/install.go @@ -13,7 +13,6 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "golang.org/x/crypto/bcrypt" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -38,16 +37,15 @@ func init() { func (a *AdminConsole) Install( ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, - rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, - overrides []string, + domains ecv1beta1.Domains, overrides []string, ) error { // some resources are not part of the helm chart and need to be created before the chart is installed // TODO: move this to the helm chart - if err := a.createPreRequisites(ctx, logf, kcli, mcli, rc); err != nil { + if err := a.createPreRequisites(ctx, logf, kcli, mcli); err != nil { return errors.Wrap(err, "create prerequisites") } - values, err := a.GenerateHelmValues(ctx, kcli, rc, domains, overrides) + values, err := a.GenerateHelmValues(ctx, kcli, domains, overrides) if err != nil { return errors.Wrap(err, "generate helm values") } @@ -85,7 +83,7 @@ func (a *AdminConsole) Install( return nil } -func (a *AdminConsole) createPreRequisites(ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface, rc runtimeconfig.RuntimeConfig) error { +func (a *AdminConsole) createPreRequisites(ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface) error { if err := a.createNamespace(ctx, kcli); err != nil { return errors.Wrap(err, "create namespace") } @@ -98,11 +96,11 @@ func (a *AdminConsole) createPreRequisites(ctx context.Context, logf types.LogFu return errors.Wrap(err, "create kots TLS secret") } - if err := a.ensureCAConfigmap(ctx, logf, kcli, mcli, rc); err != nil { + if err := a.ensureCAConfigmap(ctx, logf, kcli, mcli); err != nil { return errors.Wrap(err, "ensure CA configmap") } - if a.IsAirgap { + if a.isEmbeddedCluster() && a.IsAirgap { registryIP, err := registry.GetRegistryClusterIP(a.ServiceCIDR) if err != nil { return errors.Wrap(err, "get registry cluster IP") @@ -265,17 +263,17 @@ func (a *AdminConsole) createTLSSecret(ctx context.Context, kcli client.Client) return nil } -func (a *AdminConsole) ensureCAConfigmap(ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface, rc runtimeconfig.RuntimeConfig) error { - if rc.HostCABundlePath() == "" { +func (a *AdminConsole) ensureCAConfigmap(ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface) error { + if a.HostCABundlePath == "" { return nil } if a.DryRun { - checksum, err := calculateFileChecksum(rc.HostCABundlePath()) + checksum, err := calculateFileChecksum(a.HostCABundlePath) if err != nil { return fmt.Errorf("calculate checksum: %w", err) } - new, err := newCAConfigMap(rc.HostCABundlePath(), checksum) + new, err := newCAConfigMap(a.HostCABundlePath, checksum) if err != nil { return fmt.Errorf("create map: %w", err) } @@ -287,7 +285,7 @@ func (a *AdminConsole) ensureCAConfigmap(ctx context.Context, logf types.LogFunc return nil } - err := EnsureCAConfigmap(ctx, logf, kcli, mcli, rc.HostCABundlePath()) + err := EnsureCAConfigmap(ctx, logf, kcli, mcli, a.HostCABundlePath) if k8serrors.IsRequestEntityTooLargeError(err) || errors.Is(err, fs.ErrNotExist) { // This can result in issues installing in environments with a MITM HTTP proxy. diff --git a/pkg/addons/adminconsole/install_test.go b/pkg/addons/adminconsole/install_test.go index 50fffaf2b0..dcecc2d2e8 100644 --- a/pkg/addons/adminconsole/install_test.go +++ b/pkg/addons/adminconsole/install_test.go @@ -9,7 +9,6 @@ import ( "path/filepath" "testing" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -174,13 +173,12 @@ func TestAdminConsole_ensureCAConfigmap(t *testing.T) { kcli, mcli := tt.initClients(t) - rc := runtimeconfig.New(nil) - rc.SetDataDir(t.TempDir()) - rc.SetHostCABundlePath(tt.caPath) - // Run test - addon := &AdminConsole{} - err = addon.ensureCAConfigmap(t.Context(), t.Logf, kcli, mcli, rc) + addon := &AdminConsole{ + DataDir: t.TempDir(), + HostCABundlePath: tt.caPath, + } + err = addon.ensureCAConfigmap(t.Context(), t.Logf, kcli, mcli) // Check results if tt.expectedErr { diff --git a/pkg/addons/adminconsole/integration/hostcabundle_test.go b/pkg/addons/adminconsole/integration/hostcabundle_test.go index 82097ce1be..44abaaa609 100644 --- a/pkg/addons/adminconsole/integration/hostcabundle_test.go +++ b/pkg/addons/adminconsole/integration/hostcabundle_test.go @@ -10,7 +10,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/adminconsole" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" @@ -20,20 +19,18 @@ import ( ) func TestHostCABundle(t *testing.T) { - rc := runtimeconfig.New(nil) - rc.SetHostCABundlePath(filepath.Join(t.TempDir(), "ca-certificates.crt")) - addon := &adminconsole.AdminConsole{ - DryRun: true, + DryRun: true, + HostCABundlePath: filepath.Join(t.TempDir(), "ca-certificates.crt"), } - err := os.WriteFile(rc.HostCABundlePath(), []byte("test"), 0644) + err := os.WriteFile(addon.HostCABundlePath, []byte("test"), 0644) require.NoError(t, err, "Failed to write CA bundle file") hcli, err := helm.NewClient(helm.HelmOptions{}) require.NoError(t, err, "NewClient should not return an error") - err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, rc, ecv1beta1.Domains{}, nil) + err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) require.NoError(t, err, "adminconsole.Install should not return an error") manifests := addon.DryRunManifests() @@ -60,7 +57,7 @@ func TestHostCABundle(t *testing.T) { } } if assert.NotNil(t, volume, "Admin Console host-ca-bundle volume should not be nil") { - assert.Equal(t, rc.HostCABundlePath(), volume.VolumeSource.HostPath.Path) + assert.Equal(t, addon.HostCABundlePath, volume.VolumeSource.HostPath.Path) assert.Equal(t, ptr.To(corev1.HostPathFileOrCreate), volume.VolumeSource.HostPath.Type) } diff --git a/pkg/addons/adminconsole/integration/kubernetes_test.go b/pkg/addons/adminconsole/integration/kubernetes_test.go new file mode 100644 index 0000000000..b079ddf0cd --- /dev/null +++ b/pkg/addons/adminconsole/integration/kubernetes_test.go @@ -0,0 +1,78 @@ +package integration + +import ( + "context" + "strings" + "testing" + + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/addons/adminconsole" + "github.com/replicatedhq/embedded-cluster/pkg/helm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + "sigs.k8s.io/yaml" +) + +func TestKubernetes_Airgap(t *testing.T) { + addon := &adminconsole.AdminConsole{ + DryRun: true, + + IsAirgap: true, + IsHA: false, + IsMultiNodeEnabled: true, + Proxy: nil, + AdminConsolePort: 8080, + + Password: "password", + TLSCertBytes: []byte("cert"), + TLSKeyBytes: []byte("key"), + Hostname: "admin-console", + KotsInstaller: nil, + } + + hcli, err := helm.NewClient(helm.HelmOptions{}) + require.NoError(t, err, "NewClient should not return an error") + + err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) + require.NoError(t, err, "adminconsole.Install should not return an error") + + manifests := addon.DryRunManifests() + require.NotEmpty(t, manifests, "DryRunManifests should not be empty") + + var adminDeployment *appsv1.Deployment + for _, manifest := range manifests { + manifestStr := string(manifest) + // Look for the kotsadm deployment by its template source + if strings.Contains(manifestStr, "# Source: admin-console/templates/kotsadm-deployment.yaml") { + err := yaml.Unmarshal(manifest, &adminDeployment) + require.NoError(t, err, "Failed to unmarshal Admin Console deployment") + break + } + } + + require.NotNil(t, adminDeployment, "Admin Console deployment should not be nil") + + // Check for environment variables + for _, env := range adminDeployment.Spec.Template.Spec.Containers[0].Env { + switch env.Name { + case "EMBEDDED_CLUSTER_ID": + assert.Fail(t, "EMBEDDED_CLUSTER_ID environment variable should not be set") + case "EMBEDDED_CLUSTER_DATA_DIR": + assert.Fail(t, "EMBEDDED_CLUSTER_DATA_DIR environment variable should not be set") + case "EMBEDDED_CLUSTER_K0S_DIR": + assert.Fail(t, "EMBEDDED_CLUSTER_K0S_DIR environment variable should not be set") + case "SSL_CERT_CONFIGMAP": + assert.Fail(t, "SSL_CERT_CONFIGMAP environment variable should not be set") + case "ENABLE_IMPROVED_DR": + assert.Fail(t, "ENABLE_IMPROVED_DR environment variable should not be set") + } + } + + for _, manifest := range manifests { + manifestStr := string(manifest) + if strings.Contains(manifestStr, "registry-creds") { + assert.Fail(t, "registry-creds secret should not be created") + } + } +} diff --git a/pkg/addons/adminconsole/integration/linux_test.go b/pkg/addons/adminconsole/integration/linux_test.go new file mode 100644 index 0000000000..1b5f956b68 --- /dev/null +++ b/pkg/addons/adminconsole/integration/linux_test.go @@ -0,0 +1,112 @@ +package integration + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/addons/adminconsole" + "github.com/replicatedhq/embedded-cluster/pkg/helm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/yaml" +) + +func TestLinux_Airgap(t *testing.T) { + dataDir := t.TempDir() + + addon := &adminconsole.AdminConsole{ + DryRun: true, + + IsAirgap: true, + IsHA: false, + IsMultiNodeEnabled: true, + Proxy: nil, + AdminConsolePort: 8080, + + ClusterID: "123", + ServiceCIDR: "10.0.0.0/24", + HostCABundlePath: filepath.Join(t.TempDir(), "ca-certificates.crt"), + DataDir: dataDir, + K0sDataDir: filepath.Join(dataDir, "k0s"), + + Password: "password", + TLSCertBytes: []byte("cert"), + TLSKeyBytes: []byte("key"), + Hostname: "admin-console", + KotsInstaller: nil, + } + + err := os.WriteFile(addon.HostCABundlePath, []byte("test"), 0644) + require.NoError(t, err, "Failed to write CA bundle file") + + hcli, err := helm.NewClient(helm.HelmOptions{}) + require.NoError(t, err, "NewClient should not return an error") + + err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) + require.NoError(t, err, "adminconsole.Install should not return an error") + + manifests := addon.DryRunManifests() + require.NotEmpty(t, manifests, "DryRunManifests should not be empty") + + var adminDeployment *appsv1.Deployment + for _, manifest := range manifests { + manifestStr := string(manifest) + // Look for the kotsadm deployment by its template source + if strings.Contains(manifestStr, "# Source: admin-console/templates/kotsadm-deployment.yaml") { + err := yaml.Unmarshal(manifest, &adminDeployment) + require.NoError(t, err, "Failed to unmarshal Admin Console deployment") + break + } + } + + require.NotNil(t, adminDeployment, "Admin Console deployment should not be nil") + + // Check for environment variables + var embeddedClusterEnv, embeddedClusterDataDirEnv, embeddedClusterK0sDirEnv, sslCertConfigmapEnv, enableImprovedDREnv *corev1.EnvVar + for _, env := range adminDeployment.Spec.Template.Spec.Containers[0].Env { + switch env.Name { + case "EMBEDDED_CLUSTER_ID": + embeddedClusterEnv = &env + case "EMBEDDED_CLUSTER_DATA_DIR": + embeddedClusterDataDirEnv = &env + case "EMBEDDED_CLUSTER_K0S_DIR": + embeddedClusterK0sDirEnv = &env + case "SSL_CERT_CONFIGMAP": + sslCertConfigmapEnv = &env + case "ENABLE_IMPROVED_DR": + enableImprovedDREnv = &env + } + } + if assert.NotNil(t, embeddedClusterEnv, "Admin Console EMBEDDED_CLUSTER_ID environment variable should not be nil") { + assert.Equal(t, "123", embeddedClusterEnv.Value) + } + if assert.NotNil(t, embeddedClusterDataDirEnv, "Admin Console EMBEDDED_CLUSTER_DATA_DIR environment variable should not be nil") { + assert.Equal(t, dataDir, embeddedClusterDataDirEnv.Value) + } + if assert.NotNil(t, embeddedClusterK0sDirEnv, "Admin Console EMBEDDED_CLUSTER_K0S_DIR environment variable should not be nil") { + assert.Equal(t, filepath.Join(dataDir, "k0s"), embeddedClusterK0sDirEnv.Value) + } + if assert.NotNil(t, sslCertConfigmapEnv, "Admin Console SSL_CERT_CONFIGMAP environment variable should not be nil") { + assert.Equal(t, "kotsadm-private-cas", sslCertConfigmapEnv.Value) + } + if assert.NotNil(t, enableImprovedDREnv, "Admin Console ENABLE_IMPROVED_DR environment variable should not be nil") { + assert.Equal(t, "true", enableImprovedDREnv.Value) + } + + var registrySecret *corev1.Secret + for _, manifest := range manifests { + manifestStr := string(manifest) + if strings.Contains(manifestStr, "registry-creds") { + err := yaml.Unmarshal(manifest, ®istrySecret) + require.NoError(t, err, "Failed to unmarshal registry secret") + } + } + + require.NotNil(t, registrySecret, "registry-creds secret should not be nil") +} diff --git a/pkg/addons/adminconsole/metadata.go b/pkg/addons/adminconsole/metadata.go index 905a2a0177..23e6c7ed60 100644 --- a/pkg/addons/adminconsole/metadata.go +++ b/pkg/addons/adminconsole/metadata.go @@ -8,6 +8,7 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" + "gopkg.in/yaml.v3" "k8s.io/utils/ptr" ) @@ -18,6 +19,12 @@ var ( Metadata release.AddonMetadata ) +func init() { + if err := yaml.Unmarshal(rawmetadata, &Metadata); err != nil { + panic(errors.Wrap(err, "unable to unmarshal metadata")) + } +} + var ( // Overwritten by -ldflags in Makefile AdminConsoleChartRepoOverride = "" @@ -44,7 +51,12 @@ func GetAdditionalImages() []string { } func GenerateChartConfig() ([]ecv1beta1.Chart, []k0sv1beta1.Repository, error) { - values, err := helm.MarshalValues(helmValues) + hv, err := helmValues() + if err != nil { + return nil, nil, errors.Wrap(err, "get helm values") + } + + marshalled, err := helm.MarshalValues(hv) if err != nil { return nil, nil, errors.Wrap(err, "marshal helm values") } @@ -53,7 +65,7 @@ func GenerateChartConfig() ([]ecv1beta1.Chart, []k0sv1beta1.Repository, error) { Name: _releaseName, ChartName: (&AdminConsole{}).ChartLocation(ecv1beta1.Domains{}), Version: Metadata.Version, - Values: string(values), + Values: string(marshalled), TargetNS: _namespace, ForceUpgrade: ptr.To(false), Order: 5, diff --git a/pkg/addons/adminconsole/static/metadata.yaml b/pkg/addons/adminconsole/static/metadata.yaml index 54bc5debd9..931bb8b2ad 100644 --- a/pkg/addons/adminconsole/static/metadata.yaml +++ b/pkg/addons/adminconsole/static/metadata.yaml @@ -5,24 +5,24 @@ # $ make buildtools # $ output/bin/buildtools update addon # -version: 1.124.18-ec.1 +version: 1.124.18-ec.2 location: oci://proxy.replicated.com/anonymous/registry.replicated.com/library/admin-console images: kotsadm: repo: proxy.replicated.com/anonymous/kotsadm/kotsadm tag: - amd64: v1.124.18-ec.1-amd64@sha256:e1b1b1a1ad17b1c136d8391b13fc494ffcdbdd39ef8b45ced3598c2031d8f73d - arm64: v1.124.18-ec.1-arm64@sha256:e7d66343a252714e67b0fc204ec6b0e10c2715779585f6df826290c91f6ab07d + amd64: v1.124.18-ec.2-amd64@sha256:204626d8364b495460d46dc30d11c4ff29dc06e3b02ad2eba1cdd4c68aece32d + arm64: v1.124.18-ec.2-arm64@sha256:d2823e321b7ddc1888b9c370320554b181f04cdb67dadcdc40bb2cdd763c6d2f kotsadm-migrations: repo: proxy.replicated.com/anonymous/kotsadm/kotsadm-migrations tag: - amd64: v1.124.18-ec.1-amd64@sha256:b1fd0efcf9108ddca16236bb6776ced5442e00d5993d1ade71b676407e4a541f - arm64: v1.124.18-ec.1-arm64@sha256:ad05891e72a57f14dc2ea8896fc201944ee0b971f134b610917107ba7e1503c1 + amd64: v1.124.18-ec.2-amd64@sha256:bea489071fa6dc3554c0e15a282ec46111ec49d53b17a4d4fb7d52301c0afce0 + arm64: v1.124.18-ec.2-arm64@sha256:c3e26f8a2831334eaed9c9f991c7ef1504e4fa52ae6abf2b0e80c56d169a6fd4 kurl-proxy: repo: proxy.replicated.com/anonymous/kotsadm/kurl-proxy tag: - amd64: v1.124.18-ec.1-amd64@sha256:87835c17cbd34cf492a473b5e946fe327f91c054f9639f28a1b79a40e433b6b0 - arm64: v1.124.18-ec.1-arm64@sha256:a60ab80b08cfbe8e1c92efc03d1bb8e07d469c605b5d39562adecc3252e15be4 + amd64: v1.124.18-ec.2-amd64@sha256:ffb7bc7f591d1e2a06e7b00f85ed3fd4445296921de4295b223d7d018d54d92a + arm64: v1.124.18-ec.2-arm64@sha256:9661b62f325a537007b1ec0b42fef66c9e07fa15884d2d14509ef174e4dc5c2e rqlite: repo: proxy.replicated.com/anonymous/kotsadm/rqlite tag: diff --git a/pkg/addons/adminconsole/static/values.tpl.yaml b/pkg/addons/adminconsole/static/values.tpl.yaml index aaefb023f0..557e5bbc86 100644 --- a/pkg/addons/adminconsole/static/values.tpl.yaml +++ b/pkg/addons/adminconsole/static/values.tpl.yaml @@ -6,7 +6,6 @@ images: rqlite: '{{ ImageString (index .Images "rqlite") }}' {{- end }} isHA: false -isHelmManaged: false kurlProxy: enabled: true nodePort: 30000 diff --git a/pkg/addons/adminconsole/upgrade.go b/pkg/addons/adminconsole/upgrade.go index a1cbd450f5..ffd85b0a86 100644 --- a/pkg/addons/adminconsole/upgrade.go +++ b/pkg/addons/adminconsole/upgrade.go @@ -7,7 +7,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" batchv1 "k8s.io/api/batch/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/metadata" @@ -17,7 +16,7 @@ import ( func (a *AdminConsole) Upgrade( ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, - rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, overrides []string, + domains ecv1beta1.Domains, overrides []string, ) error { exists, err := hcli.ReleaseExists(ctx, a.Namespace(), a.ReleaseName()) if err != nil { @@ -28,7 +27,7 @@ func (a *AdminConsole) Upgrade( return errors.New("admin console release not found") } - values, err := a.GenerateHelmValues(ctx, kcli, rc, domains, overrides) + values, err := a.GenerateHelmValues(ctx, kcli, domains, overrides) if err != nil { return errors.Wrap(err, "generate helm values") } diff --git a/pkg/addons/adminconsole/values.go b/pkg/addons/adminconsole/values.go index f5ae65b958..0e01bc1ff1 100644 --- a/pkg/addons/adminconsole/values.go +++ b/pkg/addons/adminconsole/values.go @@ -8,49 +8,24 @@ import ( "github.com/pkg/errors" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/netutils" "github.com/replicatedhq/embedded-cluster/pkg/release" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/pkg/versions" - "gopkg.in/yaml.v3" "sigs.k8s.io/controller-runtime/pkg/client" ) var ( //go:embed static/values.tpl.yaml rawvalues []byte - // helmValues is the unmarshal version of rawvalues. - helmValues map[string]interface{} ) -func init() { - if err := yaml.Unmarshal(rawmetadata, &Metadata); err != nil { - panic(errors.Wrap(err, "unmarshal metadata")) - } - - hv, err := release.RenderHelmValues(rawvalues, Metadata) +func (a *AdminConsole) GenerateHelmValues(ctx context.Context, kcli client.Client, domains ecv1beta1.Domains, overrides []string) (map[string]interface{}, error) { + hv, err := helmValues() if err != nil { - panic(errors.Wrap(err, "unmarshal values")) + return nil, errors.Wrap(err, "get helm values") } - helmValues = hv - helmValues["embeddedClusterVersion"] = versions.Version - - if AdminConsoleImageOverride != "" { - helmValues["images"].(map[string]any)["kotsadm"] = AdminConsoleImageOverride - } - if AdminConsoleMigrationsImageOverride != "" { - helmValues["images"].(map[string]any)["migrations"] = AdminConsoleMigrationsImageOverride - } - if AdminConsoleKurlProxyImageOverride != "" { - helmValues["images"].(map[string]any)["kurlProxy"] = AdminConsoleKurlProxyImageOverride - } -} - -func (a *AdminConsole) GenerateHelmValues(ctx context.Context, kcli client.Client, rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, overrides []string) (map[string]interface{}, error) { - // create a copy of the helm values so we don't modify the original - marshalled, err := helm.MarshalValues(helmValues) + marshalled, err := helm.MarshalValues(hv) if err != nil { return nil, errors.Wrap(err, "marshal helm values") } @@ -65,17 +40,16 @@ func (a *AdminConsole) GenerateHelmValues(ctx context.Context, kcli client.Clien return nil, errors.Wrap(err, "unmarshal helm values") } - copiedValues["embeddedClusterID"] = metrics.ClusterID().String() - copiedValues["embeddedClusterDataDir"] = rc.EmbeddedClusterHomeDirectory() - copiedValues["embeddedClusterK0sDir"] = rc.EmbeddedClusterK0sSubDir() + if a.isEmbeddedCluster() { + // embeddedClusterID controls whether the admin console thinks it is running in an embedded cluster + copiedValues["embeddedClusterID"] = a.ClusterID + copiedValues["embeddedClusterDataDir"] = a.DataDir + copiedValues["embeddedClusterK0sDir"] = a.K0sDataDir + } + copiedValues["isHA"] = a.IsHA copiedValues["isMultiNodeEnabled"] = a.IsMultiNodeEnabled - - if a.IsAirgap { - copiedValues["isAirgap"] = "true" - } else { - copiedValues["isAirgap"] = "false" - } + copiedValues["isAirgap"] = a.IsAirgap if domains.ReplicatedAppDomain != "" { copiedValues["replicatedAppEndpoint"] = netutils.MaybeAddHTTPS(domains.ReplicatedAppDomain) @@ -87,15 +61,20 @@ func (a *AdminConsole) GenerateHelmValues(ctx context.Context, kcli client.Clien copiedValues["proxyRegistryDomain"] = domains.ProxyRegistryDomain } - extraEnv := []map[string]interface{}{ - { - "name": "ENABLE_IMPROVED_DR", - "value": "true", - }, - { - "name": "SSL_CERT_CONFIGMAP", - "value": "kotsadm-private-cas", - }, + extraEnv := []map[string]interface{}{} + + // currently, the admin console only supports improved disaster recovery in embedded clusters + if a.isEmbeddedCluster() { + extraEnv = append(extraEnv, + map[string]interface{}{ + "name": "ENABLE_IMPROVED_DR", + "value": "true", + }, + map[string]interface{}{ + "name": "SSL_CERT_CONFIGMAP", + "value": privateCASConfigMapName, + }, + ) } if a.Proxy != nil { @@ -118,11 +97,11 @@ func (a *AdminConsole) GenerateHelmValues(ctx context.Context, kcli client.Clien extraVolumes := []map[string]interface{}{} extraVolumeMounts := []map[string]interface{}{} - if rc.HostCABundlePath() != "" { + if a.HostCABundlePath != "" { extraVolumes = append(extraVolumes, map[string]interface{}{ "name": "host-ca-bundle", "hostPath": map[string]interface{}{ - "path": rc.HostCABundlePath(), + "path": a.HostCABundlePath, "type": "FileOrCreate", }, }) @@ -142,7 +121,7 @@ func (a *AdminConsole) GenerateHelmValues(ctx context.Context, kcli client.Clien copiedValues["extraVolumes"] = extraVolumes copiedValues["extraVolumeMounts"] = extraVolumeMounts - err = helm.SetValue(copiedValues, "kurlProxy.nodePort", rc.AdminConsolePort()) + err = helm.SetValue(copiedValues, "kurlProxy.nodePort", a.AdminConsolePort) if err != nil { return nil, errors.Wrap(err, "set kurlProxy.nodePort") } @@ -156,3 +135,24 @@ func (a *AdminConsole) GenerateHelmValues(ctx context.Context, kcli client.Clien return copiedValues, nil } + +func helmValues() (map[string]interface{}, error) { + hv, err := release.RenderHelmValues(rawvalues, Metadata) + if err != nil { + return nil, errors.Wrap(err, "render helm values") + } + + hv["embeddedClusterVersion"] = versions.Version + + if AdminConsoleImageOverride != "" { + hv["images"].(map[string]any)["kotsadm"] = AdminConsoleImageOverride + } + if AdminConsoleMigrationsImageOverride != "" { + hv["images"].(map[string]any)["migrations"] = AdminConsoleMigrationsImageOverride + } + if AdminConsoleKurlProxyImageOverride != "" { + hv["images"].(map[string]any)["kurlProxy"] = AdminConsoleKurlProxyImageOverride + } + + return hv, nil +} diff --git a/pkg/addons/adminconsole/values_test.go b/pkg/addons/adminconsole/values_test.go index 6475310dab..b5083c7916 100644 --- a/pkg/addons/adminconsole/values_test.go +++ b/pkg/addons/adminconsole/values_test.go @@ -2,23 +2,22 @@ package adminconsole import ( "context" + "path/filepath" "testing" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGenerateHelmValues_HostCABundlePath(t *testing.T) { t.Run("with host CA bundle path", func(t *testing.T) { - rc := runtimeconfig.New(nil) - rc.SetDataDir(t.TempDir()) - rc.SetHostCABundlePath("/etc/ssl/certs/ca-certificates.crt") - - adminConsole := &AdminConsole{} + adminConsole := &AdminConsole{ + DataDir: t.TempDir(), + HostCABundlePath: "/etc/ssl/certs/ca-certificates.crt", + } - values, err := adminConsole.GenerateHelmValues(context.Background(), nil, rc, ecv1beta1.Domains{}, nil) + values, err := adminConsole.GenerateHelmValues(context.Background(), nil, ecv1beta1.Domains{}, nil) require.NoError(t, err, "GenerateHelmValues should not return an error") // Verify structure types @@ -61,13 +60,12 @@ func TestGenerateHelmValues_HostCABundlePath(t *testing.T) { }) t.Run("without host CA bundle path", func(t *testing.T) { - rc := runtimeconfig.New(nil) - rc.SetDataDir(t.TempDir()) // HostCABundlePath intentionally not set + adminConsole := &AdminConsole{ + DataDir: t.TempDir(), + } - adminConsole := &AdminConsole{} - - values, err := adminConsole.GenerateHelmValues(context.Background(), nil, rc, ecv1beta1.Domains{}, nil) + values, err := adminConsole.GenerateHelmValues(context.Background(), nil, ecv1beta1.Domains{}, nil) require.NoError(t, err, "GenerateHelmValues should not return an error") // Verify structure types @@ -86,3 +84,62 @@ func TestGenerateHelmValues_HostCABundlePath(t *testing.T) { } }) } + +func TestGenerateHelmValues_Target(t *testing.T) { + t.Run("Linux (with cluster ID)", func(t *testing.T) { + dataDir := t.TempDir() + + adminConsole := &AdminConsole{ + IsAirgap: false, + IsHA: false, + IsMultiNodeEnabled: false, + Proxy: nil, + AdminConsolePort: 8080, + + ClusterID: "123", + ServiceCIDR: "10.0.0.0/24", + HostCABundlePath: "/etc/ssl/certs/ca-certificates.crt", + DataDir: dataDir, + K0sDataDir: filepath.Join(dataDir, "k0s"), + } + + values, err := adminConsole.GenerateHelmValues(context.Background(), nil, ecv1beta1.Domains{}, nil) + require.NoError(t, err, "GenerateHelmValues should not return an error") + + assert.Contains(t, values, "embeddedClusterID") + assert.Equal(t, "123", values["embeddedClusterID"]) + assert.Equal(t, dataDir, values["embeddedClusterDataDir"]) + assert.Equal(t, filepath.Join(dataDir, "k0s"), values["embeddedClusterK0sDir"]) + + assert.Contains(t, values["extraEnv"], map[string]interface{}{ + "name": "SSL_CERT_CONFIGMAP", + "value": "kotsadm-private-cas", + }) + assert.Contains(t, values["extraEnv"], map[string]interface{}{ + "name": "ENABLE_IMPROVED_DR", + "value": "true", + }) + }) + + t.Run("Kubernetes (without cluster ID)", func(t *testing.T) { + adminConsole := &AdminConsole{ + IsAirgap: false, + IsHA: false, + IsMultiNodeEnabled: false, + Proxy: nil, + AdminConsolePort: 8080, + } + + values, err := adminConsole.GenerateHelmValues(context.Background(), nil, ecv1beta1.Domains{}, nil) + require.NoError(t, err, "GenerateHelmValues should not return an error") + + assert.NotContains(t, values, "embeddedClusterID") + assert.NotContains(t, values, "embeddedClusterDataDir") + assert.NotContains(t, values, "embeddedClusterK0sDir") + + for _, env := range values["extraEnv"].([]map[string]interface{}) { + assert.NotEqual(t, "SSL_CERT_CONFIGMAP", env["name"], "SSL_CERT_CONFIGMAP environment variable should not be set") + assert.NotEqual(t, "ENABLE_IMPROVED_DR", env["name"], "ENABLE_IMPROVED_DR environment variable should not be set") + } + }) +} diff --git a/pkg/addons/embeddedclusteroperator/embeddedclusteroperator.go b/pkg/addons/embeddedclusteroperator/embeddedclusteroperator.go index e58c423607..4c360e0235 100644 --- a/pkg/addons/embeddedclusteroperator/embeddedclusteroperator.go +++ b/pkg/addons/embeddedclusteroperator/embeddedclusteroperator.go @@ -29,8 +29,10 @@ func init() { var _ types.AddOn = (*EmbeddedClusterOperator)(nil) type EmbeddedClusterOperator struct { - IsAirgap bool - Proxy *ecv1beta1.ProxySpec + ClusterID string + IsAirgap bool + Proxy *ecv1beta1.ProxySpec + HostCABundlePath string ChartLocationOverride string ChartVersionOverride string diff --git a/pkg/addons/embeddedclusteroperator/install.go b/pkg/addons/embeddedclusteroperator/install.go index b58db33b88..5e0e1f5909 100644 --- a/pkg/addons/embeddedclusteroperator/install.go +++ b/pkg/addons/embeddedclusteroperator/install.go @@ -7,7 +7,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -15,10 +14,9 @@ import ( func (e *EmbeddedClusterOperator) Install( ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, - rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, - overrides []string, + domains ecv1beta1.Domains, overrides []string, ) error { - values, err := e.GenerateHelmValues(ctx, kcli, rc, domains, overrides) + values, err := e.GenerateHelmValues(ctx, kcli, domains, overrides) if err != nil { return errors.Wrap(err, "generate helm values") } diff --git a/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go b/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go index a97037755f..05d8ba8cf8 100644 --- a/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go +++ b/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go @@ -9,7 +9,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/embeddedclusteroperator" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" @@ -22,18 +21,17 @@ func TestHostCABundle(t *testing.T) { chartLocation, err := filepath.Abs("../../../../operator/charts/embedded-cluster-operator") require.NoError(t, err, "Failed to get chart location") - rc := runtimeconfig.New(nil) - rc.SetHostCABundlePath("/etc/ssl/certs/ca-certificates.crt") - addon := &embeddedclusteroperator.EmbeddedClusterOperator{ DryRun: true, ChartLocationOverride: chartLocation, + ClusterID: "123", + HostCABundlePath: "/etc/ssl/certs/ca-certificates.crt", } hcli, err := helm.NewClient(helm.HelmOptions{}) require.NoError(t, err, "NewClient should not return an error") - err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, rc, ecv1beta1.Domains{}, nil) + err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) require.NoError(t, err, "embeddedclusteroperator.Install should not return an error") manifests := addon.DryRunManifests() diff --git a/pkg/addons/embeddedclusteroperator/metadata.go b/pkg/addons/embeddedclusteroperator/metadata.go index 4973d41b93..11da82d812 100644 --- a/pkg/addons/embeddedclusteroperator/metadata.go +++ b/pkg/addons/embeddedclusteroperator/metadata.go @@ -8,6 +8,7 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" + "gopkg.in/yaml.v3" "k8s.io/utils/ptr" ) @@ -18,6 +19,12 @@ var ( Metadata release.AddonMetadata ) +func init() { + if err := yaml.Unmarshal(rawmetadata, &Metadata); err != nil { + panic(errors.Wrap(err, "unable to unmarshal metadata")) + } +} + func Version() map[string]string { return map[string]string{ "EmbeddedClusterOperator": "v" + Metadata.Version, @@ -41,7 +48,12 @@ func GetAdditionalImages() []string { } func GenerateChartConfig() ([]ecv1beta1.Chart, []k0sv1beta1.Repository, error) { - values, err := helm.MarshalValues(helmValues) + hv, err := helmValues() + if err != nil { + return nil, nil, errors.Wrap(err, "get helm values") + } + + marshalled, err := helm.MarshalValues(hv) if err != nil { return nil, nil, errors.Wrap(err, "marshal helm values") } @@ -50,7 +62,7 @@ func GenerateChartConfig() ([]ecv1beta1.Chart, []k0sv1beta1.Repository, error) { Name: _releaseName, ChartName: (&EmbeddedClusterOperator{}).ChartLocation(ecv1beta1.Domains{}), Version: Metadata.Version, - Values: string(values), + Values: string(marshalled), TargetNS: _namespace, ForceUpgrade: ptr.To(false), Order: 3, diff --git a/pkg/addons/embeddedclusteroperator/upgrade.go b/pkg/addons/embeddedclusteroperator/upgrade.go index d56fb455b2..cbc2668076 100644 --- a/pkg/addons/embeddedclusteroperator/upgrade.go +++ b/pkg/addons/embeddedclusteroperator/upgrade.go @@ -7,7 +7,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/sirupsen/logrus" "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" @@ -16,7 +15,7 @@ import ( func (e *EmbeddedClusterOperator) Upgrade( ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, - rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, overrides []string, + domains ecv1beta1.Domains, overrides []string, ) error { exists, err := hcli.ReleaseExists(ctx, e.Namespace(), e.ReleaseName()) if err != nil { @@ -24,13 +23,13 @@ func (e *EmbeddedClusterOperator) Upgrade( } if !exists { logrus.Debugf("Release not found, installing release %s in namespace %s", e.ReleaseName(), e.Namespace()) - if err := e.Install(ctx, logf, kcli, mcli, hcli, rc, domains, overrides); err != nil { + if err := e.Install(ctx, logf, kcli, mcli, hcli, domains, overrides); err != nil { return errors.Wrap(err, "install") } return nil } - values, err := e.GenerateHelmValues(ctx, kcli, rc, domains, overrides) + values, err := e.GenerateHelmValues(ctx, kcli, domains, overrides) if err != nil { return errors.Wrap(err, "generate helm values") } diff --git a/pkg/addons/embeddedclusteroperator/values.go b/pkg/addons/embeddedclusteroperator/values.go index ed10fee779..0106d98c9f 100644 --- a/pkg/addons/embeddedclusteroperator/values.go +++ b/pkg/addons/embeddedclusteroperator/values.go @@ -8,39 +8,23 @@ import ( "github.com/pkg/errors" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/release" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/pkg/versions" - "gopkg.in/yaml.v3" "sigs.k8s.io/controller-runtime/pkg/client" ) var ( //go:embed static/values.tpl.yaml rawvalues []byte - // helmValues is the unmarshal version of rawvalues. - helmValues map[string]interface{} ) -func init() { - if err := yaml.Unmarshal(rawmetadata, &Metadata); err != nil { - panic(errors.Wrap(err, "unmarshal metadata")) - } - - hv, err := release.RenderHelmValues(rawvalues, Metadata) +func (e *EmbeddedClusterOperator) GenerateHelmValues(ctx context.Context, kcli client.Client, domains ecv1beta1.Domains, overrides []string) (map[string]interface{}, error) { + hv, err := helmValues() if err != nil { - panic(errors.Wrap(err, "unmarshal values")) + return nil, errors.Wrap(err, "get helm values") } - helmValues = hv - helmValues["embeddedClusterVersion"] = versions.Version - helmValues["embeddedClusterK0sVersion"] = versions.K0sVersion -} - -func (e *EmbeddedClusterOperator) GenerateHelmValues(ctx context.Context, kcli client.Client, rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, overrides []string) (map[string]interface{}, error) { - // create a copy of the helm values so we don't modify the original - marshalled, err := helm.MarshalValues(helmValues) + marshalled, err := helm.MarshalValues(hv) if err != nil { return nil, errors.Wrap(err, "marshal helm values") } @@ -65,7 +49,7 @@ func (e *EmbeddedClusterOperator) GenerateHelmValues(ctx context.Context, kcli c copiedValues["utilsImage"] = e.UtilsImageOverride } - copiedValues["embeddedClusterID"] = metrics.ClusterID().String() + copiedValues["embeddedClusterID"] = e.ClusterID if e.IsAirgap { copiedValues["isAirgap"] = "true" @@ -92,11 +76,11 @@ func (e *EmbeddedClusterOperator) GenerateHelmValues(ctx context.Context, kcli c }...) } - if rc.HostCABundlePath() != "" { + if e.HostCABundlePath != "" { extraVolumes = append(extraVolumes, map[string]any{ "name": "host-ca-bundle", "hostPath": map[string]any{ - "path": rc.HostCABundlePath(), + "path": e.HostCABundlePath, "type": "FileOrCreate", }, }) @@ -132,3 +116,15 @@ func (e *EmbeddedClusterOperator) GenerateHelmValues(ctx context.Context, kcli c return copiedValues, nil } + +func helmValues() (map[string]interface{}, error) { + hv, err := release.RenderHelmValues(rawvalues, Metadata) + if err != nil { + return nil, errors.Wrap(err, "render helm values") + } + + hv["embeddedClusterVersion"] = versions.Version + hv["embeddedClusterK0sVersion"] = versions.K0sVersion + + return hv, nil +} diff --git a/pkg/addons/embeddedclusteroperator/values_test.go b/pkg/addons/embeddedclusteroperator/values_test.go index 0b286f9afc..52aef0e3ea 100644 --- a/pkg/addons/embeddedclusteroperator/values_test.go +++ b/pkg/addons/embeddedclusteroperator/values_test.go @@ -5,19 +5,17 @@ import ( "testing" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGenerateHelmValues_HostCABundlePath(t *testing.T) { - rc := runtimeconfig.New(nil) - rc.SetDataDir(t.TempDir()) - rc.SetHostCABundlePath("/etc/ssl/certs/ca-certificates.crt") - - e := &EmbeddedClusterOperator{} + e := &EmbeddedClusterOperator{ + ClusterID: "123", + HostCABundlePath: "/etc/ssl/certs/ca-certificates.crt", + } - values, err := e.GenerateHelmValues(context.Background(), nil, rc, ecv1beta1.Domains{}, nil) + values, err := e.GenerateHelmValues(context.Background(), nil, ecv1beta1.Domains{}, nil) require.NoError(t, err, "GenerateHelmValues should not return an error") require.NotEmpty(t, values["extraVolumes"]) @@ -52,4 +50,6 @@ func TestGenerateHelmValues_HostCABundlePath(t *testing.T) { extraVolumeMount := values["extraVolumeMounts"].([]map[string]any)[0] assert.Equal(t, "host-ca-bundle", extraVolumeMount["name"]) assert.Equal(t, "/certs/ca-certificates.crt", extraVolumeMount["mountPath"]) + + assert.Equal(t, "123", values["embeddedClusterID"]) } diff --git a/pkg/addons/highavailability.go b/pkg/addons/highavailability.go index ca467a235c..8ffd5dddaf 100644 --- a/pkg/addons/highavailability.go +++ b/pkg/addons/highavailability.go @@ -7,13 +7,12 @@ import ( "github.com/pkg/errors" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" "github.com/replicatedhq/embedded-cluster/pkg/addons/adminconsole" "github.com/replicatedhq/embedded-cluster/pkg/addons/registry" registrymigrate "github.com/replicatedhq/embedded-cluster/pkg/addons/registry/migrate" "github.com/replicatedhq/embedded-cluster/pkg/addons/seaweedfs" - "github.com/replicatedhq/embedded-cluster/pkg/constants" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/pkg/spinner" "github.com/sirupsen/logrus" appsv1 "k8s.io/api/apps/v1" @@ -22,6 +21,21 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) +type EnableHAOptions struct { + ClusterID string + AdminConsolePort int + IsAirgap bool + IsMultiNodeEnabled bool + EmbeddedConfigSpec *ecv1beta1.ConfigSpec + EndUserConfigSpec *ecv1beta1.ConfigSpec + ProxySpec *ecv1beta1.ProxySpec + HostCABundlePath string + DataDir string + K0sDataDir string + SeaweedFSDataDir string + ServiceCIDR string +} + // CanEnableHA checks if high availability can be enabled in the cluster. func (a *AddOns) CanEnableHA(ctx context.Context) (bool, string, error) { in, err := kubeutils.GetLatestInstallation(ctx, a.kcli) @@ -49,8 +63,8 @@ func (a *AddOns) CanEnableHA(ctx context.Context) (bool, string, error) { } // EnableHA enables high availability. -func (a *AddOns) EnableHA(ctx context.Context, serviceCIDR string, inSpec ecv1beta1.InstallationSpec, spinner *spinner.MessageWriter) error { - if inSpec.AirGap { +func (a *AddOns) EnableHA(ctx context.Context, opts EnableHAOptions, spinner *spinner.MessageWriter) error { + if opts.IsAirgap { logrus.Debugf("Enabling high availability") spinner.Infof("Enabling high availability") @@ -59,7 +73,7 @@ func (a *AddOns) EnableHA(ctx context.Context, serviceCIDR string, inSpec ecv1be return errors.Wrap(err, "check if registry data has been migrated") } else if !hasMigrated { logrus.Debugf("Installing seaweedfs") - err = a.ensureSeaweedfs(ctx, serviceCIDR, inSpec.Config) + err = a.ensureSeaweedfs(ctx, opts) if err != nil { return errors.Wrap(err, "ensure seaweedfs") } @@ -75,7 +89,7 @@ func (a *AddOns) EnableHA(ctx context.Context, serviceCIDR string, inSpec ecv1be logrus.Debugf("Migrating data for high availability") spinner.Infof("Migrating data for high availability") - err = a.migrateRegistryData(ctx, inSpec.Config, spinner) + err = a.migrateRegistryData(ctx, opts.EmbeddedConfigSpec, spinner) if err != nil { return errors.Wrap(err, "migrate registry data") } @@ -83,7 +97,7 @@ func (a *AddOns) EnableHA(ctx context.Context, serviceCIDR string, inSpec ecv1be logrus.Debugf("Enabling high availability for the registry") spinner.Infof("Enabling high availability for the registry") - err = a.enableRegistryHA(ctx, serviceCIDR, inSpec.Config) + err = a.enableRegistryHA(ctx, opts) if err != nil { return errors.Wrap(err, "enable registry high availability") } @@ -93,7 +107,7 @@ func (a *AddOns) EnableHA(ctx context.Context, serviceCIDR string, inSpec ecv1be logrus.Debugf("Updating the Admin Console for high availability") spinner.Infof("Updating the Admin Console for high availability") - err := a.EnableAdminConsoleHA(ctx, inSpec.AirGap, serviceCIDR, inSpec.Proxy, inSpec.Config, inSpec.LicenseInfo) + err := a.EnableAdminConsoleHA(ctx, opts) if err != nil { return errors.Wrap(err, "enable admin console high availability") } @@ -122,7 +136,7 @@ func (a *AddOns) maybeScaleRegistryBackOnFailure() { deploy := &appsv1.Deployment{} // this should use the background context as we want it to run even if the context expired - err := a.kcli.Get(context.Background(), client.ObjectKey{Namespace: runtimeconfig.RegistryNamespace, Name: "registry"}, deploy) + err := a.kcli.Get(context.Background(), client.ObjectKey{Namespace: constants.RegistryNamespace, Name: "registry"}, deploy) if err != nil { logrus.Errorf("Failed to get registry deployment: %v", err) return @@ -150,7 +164,7 @@ func (a *AddOns) maybeScaleRegistryBackOnFailure() { // scaleRegistryDown scales the registry deployment to 0 replicas. func (a *AddOns) scaleRegistryDown(ctx context.Context) error { deploy := &appsv1.Deployment{} - err := a.kcli.Get(ctx, client.ObjectKey{Namespace: runtimeconfig.RegistryNamespace, Name: "registry"}, deploy) + err := a.kcli.Get(ctx, client.ObjectKey{Namespace: constants.RegistryNamespace, Name: "registry"}, deploy) if err != nil { return fmt.Errorf("get registry deployment: %w", err) } @@ -176,9 +190,8 @@ func (a *AddOns) migrateRegistryData(ctx context.Context, cfgspec *ecv1beta1.Con if err != nil { return errors.Wrap(err, "get operator image") } - domains := runtimeconfig.GetDomains(cfgspec) - if domains.ProxyRegistryDomain != "" { - operatorImage = strings.Replace(operatorImage, "proxy.replicated.com", domains.ProxyRegistryDomain, 1) + if a.domains.ProxyRegistryDomain != "" { + operatorImage = strings.Replace(operatorImage, "proxy.replicated.com", a.domains.ProxyRegistryDomain, 1) } // TODO: timeout @@ -195,15 +208,14 @@ func (a *AddOns) migrateRegistryData(ctx context.Context, cfgspec *ecv1beta1.Con } // ensureSeaweedfs ensures that seaweedfs is installed. -func (a *AddOns) ensureSeaweedfs(ctx context.Context, serviceCIDR string, cfgspec *ecv1beta1.ConfigSpec) error { - domains := runtimeconfig.GetDomains(cfgspec) - +func (a *AddOns) ensureSeaweedfs(ctx context.Context, opts EnableHAOptions) error { // TODO (@salah): add support for end user overrides sw := &seaweedfs.SeaweedFS{ - ServiceCIDR: serviceCIDR, + ServiceCIDR: opts.ServiceCIDR, + SeaweedFSDataDir: opts.SeaweedFSDataDir, } - if err := sw.Upgrade(ctx, a.logf, a.kcli, a.mcli, a.hcli, a.rc, domains, a.addOnOverrides(sw, cfgspec, nil)); err != nil { + if err := sw.Upgrade(ctx, a.logf, a.kcli, a.mcli, a.hcli, a.domains, a.addOnOverrides(sw, opts.EmbeddedConfigSpec, opts.EndUserConfigSpec)); err != nil { return errors.Wrap(err, "upgrade seaweedfs") } @@ -212,15 +224,13 @@ func (a *AddOns) ensureSeaweedfs(ctx context.Context, serviceCIDR string, cfgspe // enableRegistryHA enables high availability for the registry and scales the registry deployment // to the desired number of replicas. -func (a *AddOns) enableRegistryHA(ctx context.Context, serviceCIDR string, cfgspec *ecv1beta1.ConfigSpec) error { - domains := runtimeconfig.GetDomains(cfgspec) - +func (a *AddOns) enableRegistryHA(ctx context.Context, opts EnableHAOptions) error { // TODO (@salah): add support for end user overrides r := ®istry.Registry{ - ServiceCIDR: serviceCIDR, + ServiceCIDR: opts.ServiceCIDR, IsHA: true, } - if err := r.Upgrade(ctx, a.logf, a.kcli, a.mcli, a.hcli, a.rc, domains, a.addOnOverrides(r, cfgspec, nil)); err != nil { + if err := r.Upgrade(ctx, a.logf, a.kcli, a.mcli, a.hcli, a.domains, a.addOnOverrides(r, opts.EmbeddedConfigSpec, opts.EndUserConfigSpec)); err != nil { return errors.Wrap(err, "upgrade registry") } @@ -228,22 +238,25 @@ func (a *AddOns) enableRegistryHA(ctx context.Context, serviceCIDR string, cfgsp } // EnableAdminConsoleHA enables high availability for the admin console. -func (a *AddOns) EnableAdminConsoleHA(ctx context.Context, isAirgap bool, serviceCIDR string, proxy *ecv1beta1.ProxySpec, cfgspec *ecv1beta1.ConfigSpec, licenseInfo *ecv1beta1.LicenseInfo) error { - domains := runtimeconfig.GetDomains(cfgspec) - +func (a *AddOns) EnableAdminConsoleHA(ctx context.Context, opts EnableHAOptions) error { // TODO (@salah): add support for end user overrides ac := &adminconsole.AdminConsole{ - IsAirgap: isAirgap, + ClusterID: opts.ClusterID, + IsAirgap: opts.IsAirgap, IsHA: true, - Proxy: proxy, - ServiceCIDR: serviceCIDR, - IsMultiNodeEnabled: licenseInfo != nil && licenseInfo.IsMultiNodeEnabled, + Proxy: opts.ProxySpec, + ServiceCIDR: opts.ServiceCIDR, + IsMultiNodeEnabled: opts.IsMultiNodeEnabled, + HostCABundlePath: opts.HostCABundlePath, + DataDir: opts.DataDir, + K0sDataDir: opts.K0sDataDir, + AdminConsolePort: opts.AdminConsolePort, } - if err := ac.Upgrade(ctx, a.logf, a.kcli, a.mcli, a.hcli, a.rc, domains, a.addOnOverrides(ac, cfgspec, nil)); err != nil { + if err := ac.Upgrade(ctx, a.logf, a.kcli, a.mcli, a.hcli, a.domains, a.addOnOverrides(ac, opts.EmbeddedConfigSpec, opts.EndUserConfigSpec)); err != nil { return errors.Wrap(err, "upgrade admin console") } - if err := kubeutils.WaitForStatefulset(ctx, a.kcli, runtimeconfig.KotsadmNamespace, "kotsadm-rqlite", nil); err != nil { + if err := kubeutils.WaitForStatefulset(ctx, a.kcli, constants.KotsadmNamespace, "kotsadm-rqlite", nil); err != nil { return errors.Wrap(err, "wait for rqlite to be ready") } diff --git a/pkg/addons/highavailability_test.go b/pkg/addons/highavailability_test.go index f522c6b0df..4d05f94995 100644 --- a/pkg/addons/highavailability_test.go +++ b/pkg/addons/highavailability_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" - "github.com/replicatedhq/embedded-cluster/pkg/constants" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" v12 "k8s.io/api/core/v1" diff --git a/pkg/addons/install.go b/pkg/addons/install.go index da62af1abd..3f5ddcde70 100644 --- a/pkg/addons/install.go +++ b/pkg/addons/install.go @@ -12,41 +12,105 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/addons/registry" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/addons/velero" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" ) type InstallOptions struct { - AdminConsolePwd string - License *kotsv1beta1.License - IsAirgap bool - Proxy *ecv1beta1.ProxySpec - TLSCertBytes []byte - TLSKeyBytes []byte - Hostname string - ServiceCIDR string + AdminConsolePwd string + AdminConsolePort int + License *kotsv1beta1.License + IsAirgap bool + TLSCertBytes []byte + TLSKeyBytes []byte + Hostname string + IsMultiNodeEnabled bool + EmbeddedConfigSpec *ecv1beta1.ConfigSpec + EndUserConfigSpec *ecv1beta1.ConfigSpec + KotsInstaller adminconsole.KotsInstaller + ProxySpec *ecv1beta1.ProxySpec + + // Linux only options + ClusterID string DisasterRecoveryEnabled bool - IsMultiNodeEnabled bool - EmbeddedConfigSpec *ecv1beta1.ConfigSpec - EndUserConfigSpec *ecv1beta1.ConfigSpec - KotsInstaller adminconsole.KotsInstaller - IsRestore bool + HostCABundlePath string + DataDir string + K0sDataDir string + OpenEBSDataDir string + ServiceCIDR string +} + +type KubernetesInstallOptions struct { + AdminConsolePwd string + AdminConsolePort int + License *kotsv1beta1.License + IsAirgap bool + TLSCertBytes []byte + TLSKeyBytes []byte + Hostname string + IsMultiNodeEnabled bool + EmbeddedConfigSpec *ecv1beta1.ConfigSpec + EndUserConfigSpec *ecv1beta1.ConfigSpec + KotsInstaller adminconsole.KotsInstaller + ProxySpec *ecv1beta1.ProxySpec } func (a *AddOns) Install(ctx context.Context, opts InstallOptions) error { addons := GetAddOnsForInstall(opts) - if opts.IsRestore { - addons = GetAddOnsForRestore(opts) + + for _, addon := range addons { + a.sendProgress(addon.Name(), apitypes.StateRunning, "Installing") + + overrides := a.addOnOverrides(addon, opts.EmbeddedConfigSpec, opts.EndUserConfigSpec) + + if err := addon.Install(ctx, a.logf, a.kcli, a.mcli, a.hcli, a.domains, overrides); err != nil { + a.sendProgress(addon.Name(), apitypes.StateFailed, err.Error()) + return errors.Wrapf(err, "install %s", addon.Name()) + } + + a.sendProgress(addon.Name(), apitypes.StateSucceeded, "Installed") + } + + return nil +} + +func (a *AddOns) InstallKubernetes(ctx context.Context, opts KubernetesInstallOptions) error { + addons := GetAddOnsForKubernetesInstall(opts) + + for _, addon := range addons { + a.sendProgress(addon.Name(), apitypes.StateRunning, "Installing") + + overrides := a.addOnOverrides(addon, opts.EmbeddedConfigSpec, opts.EndUserConfigSpec) + + if err := addon.Install(ctx, a.logf, a.kcli, a.mcli, a.hcli, a.domains, overrides); err != nil { + a.sendProgress(addon.Name(), apitypes.StateFailed, err.Error()) + return errors.Wrapf(err, "install %s", addon.Name()) + } + + a.sendProgress(addon.Name(), apitypes.StateSucceeded, "Installed") } - domains := runtimeconfig.GetDomains(opts.EmbeddedConfigSpec) + return nil +} + +type RestoreOptions struct { + EmbeddedConfigSpec *ecv1beta1.ConfigSpec + EndUserConfigSpec *ecv1beta1.ConfigSpec + ProxySpec *ecv1beta1.ProxySpec + HostCABundlePath string + DataDir string + OpenEBSDataDir string + K0sDataDir string +} + +func (a *AddOns) Restore(ctx context.Context, opts RestoreOptions) error { + addons := GetAddOnsForRestore(opts) for _, addon := range addons { a.sendProgress(addon.Name(), apitypes.StateRunning, "Installing") overrides := a.addOnOverrides(addon, opts.EmbeddedConfigSpec, opts.EndUserConfigSpec) - if err := addon.Install(ctx, a.logf, a.kcli, a.mcli, a.hcli, a.rc, domains, overrides); err != nil { + if err := addon.Install(ctx, a.logf, a.kcli, a.mcli, a.hcli, a.domains, overrides); err != nil { a.sendProgress(addon.Name(), apitypes.StateFailed, err.Error()) return errors.Wrapf(err, "install %s", addon.Name()) } @@ -59,47 +123,86 @@ func (a *AddOns) Install(ctx context.Context, opts InstallOptions) error { func GetAddOnsForInstall(opts InstallOptions) []types.AddOn { addOns := []types.AddOn{ - &openebs.OpenEBS{}, + &openebs.OpenEBS{ + OpenEBSDataDir: opts.OpenEBSDataDir, + }, &embeddedclusteroperator.EmbeddedClusterOperator{ - IsAirgap: opts.IsAirgap, - Proxy: opts.Proxy, + ClusterID: opts.ClusterID, + IsAirgap: opts.IsAirgap, + Proxy: opts.ProxySpec, + HostCABundlePath: opts.HostCABundlePath, }, } if opts.IsAirgap { addOns = append(addOns, ®istry.Registry{ ServiceCIDR: opts.ServiceCIDR, + IsHA: false, }) } if opts.DisasterRecoveryEnabled { addOns = append(addOns, &velero.Velero{ - Proxy: opts.Proxy, + Proxy: opts.ProxySpec, + HostCABundlePath: opts.HostCABundlePath, + K0sDataDir: opts.K0sDataDir, }) } adminConsoleAddOn := &adminconsole.AdminConsole{ + ClusterID: opts.ClusterID, IsAirgap: opts.IsAirgap, - Proxy: opts.Proxy, + IsHA: false, + Proxy: opts.ProxySpec, ServiceCIDR: opts.ServiceCIDR, - Password: opts.AdminConsolePwd, - TLSCertBytes: opts.TLSCertBytes, - TLSKeyBytes: opts.TLSKeyBytes, - Hostname: opts.Hostname, - KotsInstaller: opts.KotsInstaller, IsMultiNodeEnabled: opts.IsMultiNodeEnabled, + HostCABundlePath: opts.HostCABundlePath, + DataDir: opts.DataDir, + K0sDataDir: opts.K0sDataDir, + AdminConsolePort: opts.AdminConsolePort, + + Password: opts.AdminConsolePwd, + TLSCertBytes: opts.TLSCertBytes, + TLSKeyBytes: opts.TLSKeyBytes, + Hostname: opts.Hostname, + KotsInstaller: opts.KotsInstaller, } addOns = append(addOns, adminConsoleAddOn) return addOns } -func GetAddOnsForRestore(opts InstallOptions) []types.AddOn { +func GetAddOnsForRestore(opts RestoreOptions) []types.AddOn { addOns := []types.AddOn{ - &openebs.OpenEBS{}, + &openebs.OpenEBS{ + OpenEBSDataDir: opts.OpenEBSDataDir, + }, &velero.Velero{ - Proxy: opts.Proxy, + Proxy: opts.ProxySpec, + HostCABundlePath: opts.HostCABundlePath, + K0sDataDir: opts.K0sDataDir, }, } return addOns } + +func GetAddOnsForKubernetesInstall(opts KubernetesInstallOptions) []types.AddOn { + addOns := []types.AddOn{} + + adminConsoleAddOn := &adminconsole.AdminConsole{ + IsAirgap: opts.IsAirgap, + IsHA: false, + IsMultiNodeEnabled: opts.IsMultiNodeEnabled, + Proxy: opts.ProxySpec, + AdminConsolePort: opts.AdminConsolePort, + + Password: opts.AdminConsolePwd, + TLSCertBytes: opts.TLSCertBytes, + TLSKeyBytes: opts.TLSKeyBytes, + Hostname: opts.Hostname, + KotsInstaller: opts.KotsInstaller, + } + addOns = append(addOns, adminConsoleAddOn) + + return addOns +} diff --git a/pkg/addons/install_test.go b/pkg/addons/install_test.go index 5e283f4d24..f46fe2df6e 100644 --- a/pkg/addons/install_test.go +++ b/pkg/addons/install_test.go @@ -25,6 +25,7 @@ func Test_getAddOnsForInstall(t *testing.T) { { name: "online installation", opts: InstallOptions{ + ClusterID: "123", IsAirgap: false, DisasterRecoveryEnabled: false, AdminConsolePwd: "password123", @@ -47,6 +48,7 @@ func Test_getAddOnsForInstall(t *testing.T) { adminConsole, ok := addons[2].(*adminconsole.AdminConsole) require.True(t, ok, "third addon should be AdminConsole") + assert.Equal(t, "123", adminConsole.ClusterID) assert.False(t, adminConsole.IsAirgap, "AdminConsole should not be in airgap mode") assert.False(t, adminConsole.IsHA, "AdminConsole should not be in high availability mode") assert.Nil(t, adminConsole.Proxy, "AdminConsole should not have a proxy") @@ -57,10 +59,11 @@ func Test_getAddOnsForInstall(t *testing.T) { { name: "airgap installation", opts: InstallOptions{ + ClusterID: "123", IsAirgap: true, DisasterRecoveryEnabled: false, - ServiceCIDR: "10.96.0.0/12", AdminConsolePwd: "password123", + ServiceCIDR: "10.96.0.0/12", }, verify: func(t *testing.T, addons []types.AddOn) { assert.Len(t, addons, 4) @@ -84,6 +87,7 @@ func Test_getAddOnsForInstall(t *testing.T) { adminConsole, ok := addons[3].(*adminconsole.AdminConsole) require.True(t, ok, "fourth addon should be AdminConsole") + assert.Equal(t, "123", adminConsole.ClusterID) assert.True(t, adminConsole.IsAirgap, "AdminConsole should be in airgap mode") assert.False(t, adminConsole.IsHA, "AdminConsole should not be in high availability mode") assert.Nil(t, adminConsole.Proxy, "AdminConsole should not have a proxy") @@ -94,6 +98,7 @@ func Test_getAddOnsForInstall(t *testing.T) { { name: "disaster recovery enabled", opts: InstallOptions{ + ClusterID: "123", IsAirgap: false, DisasterRecoveryEnabled: true, AdminConsolePwd: "password123", @@ -121,6 +126,7 @@ func Test_getAddOnsForInstall(t *testing.T) { adminConsole, ok := addons[3].(*adminconsole.AdminConsole) require.True(t, ok, "fourth addon should be AdminConsole") + assert.Equal(t, "123", adminConsole.ClusterID) assert.False(t, eco.IsAirgap, "AdminConsole should not be in airgap mode") assert.False(t, adminConsole.IsHA, "AdminConsole should not be in high availability mode") assert.Nil(t, adminConsole.Proxy, "AdminConsole should not have a proxy") @@ -131,15 +137,16 @@ func Test_getAddOnsForInstall(t *testing.T) { { name: "airgap with disaster recovery and proxy", opts: InstallOptions{ + ClusterID: "123", IsAirgap: true, DisasterRecoveryEnabled: true, + AdminConsolePwd: "password123", ServiceCIDR: "10.96.0.0/12", - Proxy: &ecv1beta1.ProxySpec{ + ProxySpec: &ecv1beta1.ProxySpec{ HTTPProxy: "http://proxy.example.com", HTTPSProxy: "https://proxy.example.com", NoProxy: "localhost,127.0.0.1", }, - AdminConsolePwd: "password123", }, verify: func(t *testing.T, addons []types.AddOn) { assert.Len(t, addons, 5) @@ -172,6 +179,7 @@ func Test_getAddOnsForInstall(t *testing.T) { adminConsole, ok := addons[4].(*adminconsole.AdminConsole) require.True(t, ok, "fifth addon should be AdminConsole") + assert.Equal(t, "123", adminConsole.ClusterID) assert.True(t, adminConsole.IsAirgap, "AdminConsole should be in airgap mode") assert.False(t, adminConsole.IsHA, "AdminConsole should not be in high availability mode") assert.Equal(t, "http://proxy.example.com", adminConsole.Proxy.HTTPProxy) diff --git a/pkg/addons/interface.go b/pkg/addons/interface.go index d53cc34e42..170f5283d4 100644 --- a/pkg/addons/interface.go +++ b/pkg/addons/interface.go @@ -7,7 +7,6 @@ import ( ectypes "github.com/replicatedhq/embedded-cluster/kinds/types" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/pkg/spinner" "github.com/sirupsen/logrus" "k8s.io/client-go/kubernetes" @@ -19,13 +18,13 @@ type AddOnsInterface interface { // Install installs all addons Install(ctx context.Context, opts InstallOptions) error // Upgrade upgrades all addons - Upgrade(ctx context.Context, in *ecv1beta1.Installation, meta *ectypes.ReleaseMetadata) error + Upgrade(ctx context.Context, in *ecv1beta1.Installation, meta *ectypes.ReleaseMetadata, opts UpgradeOptions) error // CanEnableHA checks if high availability can be enabled in the cluster CanEnableHA(context.Context) (bool, string, error) // EnableHA enables high availability for the cluster - EnableHA(ctx context.Context, serviceCIDR string, inSpec ecv1beta1.InstallationSpec, spinner *spinner.MessageWriter) error + EnableHA(ctx context.Context, opts EnableHAOptions, spinner *spinner.MessageWriter) error // EnableAdminConsoleHA enables high availability for the admin console - EnableAdminConsoleHA(ctx context.Context, isAirgap bool, serviceCIDR string, proxy *ecv1beta1.ProxySpec, cfgspec *ecv1beta1.ConfigSpec, licenseInfo *ecv1beta1.LicenseInfo) error + EnableAdminConsoleHA(ctx context.Context, opts EnableHAOptions) error } var _ AddOnsInterface = (*AddOns)(nil) @@ -36,7 +35,7 @@ type AddOns struct { kcli client.Client mcli metadata.Interface kclient kubernetes.Interface - rc runtimeconfig.RuntimeConfig + domains ecv1beta1.Domains progress chan<- types.AddOnProgress } @@ -72,9 +71,9 @@ func WithKubernetesClientSet(kclient kubernetes.Interface) AddOnsOption { } } -func WithRuntimeConfig(rc runtimeconfig.RuntimeConfig) AddOnsOption { +func WithDomains(domains ecv1beta1.Domains) AddOnsOption { return func(a *AddOns) { - a.rc = rc + a.domains = domains } } @@ -94,9 +93,5 @@ func New(opts ...AddOnsOption) *AddOns { a.logf = logrus.Debugf } - if a.rc == nil { - a.rc = runtimeconfig.New(nil) - } - return a } diff --git a/pkg/addons/mock.go b/pkg/addons/mock.go index 6221516aae..4eae18d9ed 100644 --- a/pkg/addons/mock.go +++ b/pkg/addons/mock.go @@ -23,8 +23,8 @@ func (m *MockAddOns) Install(ctx context.Context, opts InstallOptions) error { } // Upgrade mocks the Upgrade method -func (m *MockAddOns) Upgrade(ctx context.Context, in *ecv1beta1.Installation, meta *ectypes.ReleaseMetadata) error { - args := m.Called(ctx, in, meta) +func (m *MockAddOns) Upgrade(ctx context.Context, in *ecv1beta1.Installation, meta *ectypes.ReleaseMetadata, opts UpgradeOptions) error { + args := m.Called(ctx, in, meta, opts) return args.Error(0) } @@ -35,13 +35,13 @@ func (m *MockAddOns) CanEnableHA(ctx context.Context) (bool, string, error) { } // EnableHA mocks the EnableHA method -func (m *MockAddOns) EnableHA(ctx context.Context, serviceCIDR string, inSpec ecv1beta1.InstallationSpec, spinner *spinner.MessageWriter) error { - args := m.Called(ctx, serviceCIDR, inSpec, spinner) +func (m *MockAddOns) EnableHA(ctx context.Context, opts EnableHAOptions, spinner *spinner.MessageWriter) error { + args := m.Called(ctx, opts, spinner) return args.Error(0) } // EnableAdminConsoleHA mocks the EnableAdminConsoleHA method -func (m *MockAddOns) EnableAdminConsoleHA(ctx context.Context, isAirgap bool, serviceCIDR string, proxy *ecv1beta1.ProxySpec, cfgspec *ecv1beta1.ConfigSpec, licenseInfo *ecv1beta1.LicenseInfo) error { - args := m.Called(ctx, isAirgap, serviceCIDR, proxy, cfgspec, licenseInfo) +func (m *MockAddOns) EnableAdminConsoleHA(ctx context.Context, opts EnableHAOptions) error { + args := m.Called(ctx, opts) return args.Error(0) } diff --git a/pkg/addons/openebs/install.go b/pkg/addons/openebs/install.go index f4801b1e42..d5752ba942 100644 --- a/pkg/addons/openebs/install.go +++ b/pkg/addons/openebs/install.go @@ -7,7 +7,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -15,10 +14,9 @@ import ( func (o *OpenEBS) Install( ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, - rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, - overrides []string, + domains ecv1beta1.Domains, overrides []string, ) error { - values, err := o.GenerateHelmValues(ctx, kcli, rc, domains, overrides) + values, err := o.GenerateHelmValues(ctx, kcli, domains, overrides) if err != nil { return errors.Wrap(err, "generate helm values") } diff --git a/pkg/addons/openebs/metadata.go b/pkg/addons/openebs/metadata.go index 3fe94269e9..bc5aad9a13 100644 --- a/pkg/addons/openebs/metadata.go +++ b/pkg/addons/openebs/metadata.go @@ -8,6 +8,7 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" + "gopkg.in/yaml.v3" "k8s.io/utils/ptr" ) @@ -18,6 +19,12 @@ var ( Metadata release.AddonMetadata ) +func init() { + if err := yaml.Unmarshal(rawmetadata, &Metadata); err != nil { + panic(errors.Wrap(err, "unable to unmarshal metadata")) + } +} + func Version() map[string]string { return map[string]string{"OpenEBS": "v" + Metadata.Version} } @@ -39,7 +46,12 @@ func GetAdditionalImages() []string { } func GenerateChartConfig() ([]ecv1beta1.Chart, []k0sv1beta1.Repository, error) { - values, err := helm.MarshalValues(helmValues) + hv, err := helmValues() + if err != nil { + return nil, nil, errors.Wrap(err, "get helm values") + } + + marshalled, err := helm.MarshalValues(hv) if err != nil { return nil, nil, errors.Wrap(err, "marshal helm values") } @@ -48,7 +60,7 @@ func GenerateChartConfig() ([]ecv1beta1.Chart, []k0sv1beta1.Repository, error) { Name: _releaseName, ChartName: (&OpenEBS{}).ChartLocation(ecv1beta1.Domains{}), Version: Metadata.Version, - Values: string(values), + Values: string(marshalled), TargetNS: _namespace, ForceUpgrade: ptr.To(false), Order: 1, diff --git a/pkg/addons/openebs/openebs.go b/pkg/addons/openebs/openebs.go index 6723caebcd..3c0c353f6e 100644 --- a/pkg/addons/openebs/openebs.go +++ b/pkg/addons/openebs/openebs.go @@ -15,6 +15,7 @@ const ( var _ types.AddOn = (*OpenEBS)(nil) type OpenEBS struct { + OpenEBSDataDir string } func (o *OpenEBS) Name() string { diff --git a/pkg/addons/openebs/static/values.tpl.yaml b/pkg/addons/openebs/static/values.tpl.yaml index 2c0ace1648..993ac51215 100644 --- a/pkg/addons/openebs/static/values.tpl.yaml +++ b/pkg/addons/openebs/static/values.tpl.yaml @@ -48,3 +48,7 @@ preUpgradeHook: {{- end }} zfs-localpv: enabled: false +alloy: + enabled: false +loki: + enabled: false diff --git a/pkg/addons/openebs/upgrade.go b/pkg/addons/openebs/upgrade.go index 99a4cea348..d891b95da5 100644 --- a/pkg/addons/openebs/upgrade.go +++ b/pkg/addons/openebs/upgrade.go @@ -7,7 +7,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/sirupsen/logrus" "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" @@ -16,7 +15,7 @@ import ( func (o *OpenEBS) Upgrade( ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, - rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, overrides []string, + domains ecv1beta1.Domains, overrides []string, ) error { exists, err := hcli.ReleaseExists(ctx, o.Namespace(), o.ReleaseName()) if err != nil { @@ -24,13 +23,13 @@ func (o *OpenEBS) Upgrade( } if !exists { logrus.Debugf("Release not found, installing release %s in namespace %s", o.ReleaseName(), o.Namespace()) - if err := o.Install(ctx, logf, kcli, mcli, hcli, rc, domains, overrides); err != nil { + if err := o.Install(ctx, logf, kcli, mcli, hcli, domains, overrides); err != nil { return errors.Wrap(err, "install") } return nil } - values, err := o.GenerateHelmValues(ctx, kcli, rc, domains, overrides) + values, err := o.GenerateHelmValues(ctx, kcli, domains, overrides) if err != nil { return errors.Wrap(err, "generate helm values") } diff --git a/pkg/addons/openebs/values.go b/pkg/addons/openebs/values.go index d3e4e0bf60..f54b315431 100644 --- a/pkg/addons/openebs/values.go +++ b/pkg/addons/openebs/values.go @@ -9,33 +9,21 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" - "gopkg.in/yaml.v3" "sigs.k8s.io/controller-runtime/pkg/client" ) var ( //go:embed static/values.tpl.yaml rawvalues []byte - // helmValues is the unmarshal version of rawvalues. - helmValues map[string]interface{} ) -func init() { - if err := yaml.Unmarshal(rawmetadata, &Metadata); err != nil { - panic(errors.Wrap(err, "unable to unmarshal metadata")) - } - - hv, err := release.RenderHelmValues(rawvalues, Metadata) +func (o *OpenEBS) GenerateHelmValues(ctx context.Context, kcli client.Client, domains ecv1beta1.Domains, overrides []string) (map[string]interface{}, error) { + hv, err := helmValues() if err != nil { - panic(errors.Wrap(err, "unable to unmarshal values")) + return nil, errors.Wrap(err, "get helm values") } - helmValues = hv -} -func (o *OpenEBS) GenerateHelmValues(ctx context.Context, kcli client.Client, rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, overrides []string) (map[string]interface{}, error) { - // create a copy of the helm values so we don't modify the original - marshalled, err := helm.MarshalValues(helmValues) + marshalled, err := helm.MarshalValues(hv) if err != nil { return nil, errors.Wrap(err, "marshal helm values") } @@ -50,7 +38,7 @@ func (o *OpenEBS) GenerateHelmValues(ctx context.Context, kcli client.Client, rc return nil, errors.Wrap(err, "unmarshal helm values") } - err = helm.SetValue(copiedValues, "localpv-provisioner.localpv.basePath", rc.EmbeddedClusterOpenEBSLocalSubDir()) + err = helm.SetValue(copiedValues, "localpv-provisioner.localpv.basePath", o.OpenEBSDataDir) if err != nil { return nil, errors.Wrap(err, "set localpv-provisioner.localpv.basePath") } @@ -64,3 +52,12 @@ func (o *OpenEBS) GenerateHelmValues(ctx context.Context, kcli client.Client, rc return copiedValues, nil } + +func helmValues() (map[string]interface{}, error) { + hv, err := release.RenderHelmValues(rawvalues, Metadata) + if err != nil { + return nil, errors.Wrap(err, "render helm values") + } + + return hv, nil +} diff --git a/pkg/addons/registry/install.go b/pkg/addons/registry/install.go index cb587c994d..d27030517f 100644 --- a/pkg/addons/registry/install.go +++ b/pkg/addons/registry/install.go @@ -10,7 +10,6 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/certs" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "golang.org/x/crypto/bcrypt" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -21,7 +20,7 @@ import ( func (r *Registry) Install( ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, - rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, + domains ecv1beta1.Domains, overrides []string, ) error { registryIP, err := GetRegistryClusterIP(r.ServiceCIDR) @@ -33,7 +32,7 @@ func (r *Registry) Install( return errors.Wrap(err, "create prerequisites") } - values, err := r.GenerateHelmValues(ctx, kcli, rc, domains, overrides) + values, err := r.GenerateHelmValues(ctx, kcli, domains, overrides) if err != nil { return errors.Wrap(err, "generate helm values") } diff --git a/pkg/addons/registry/metadata.go b/pkg/addons/registry/metadata.go index 6bacd52d26..5098b091c1 100644 --- a/pkg/addons/registry/metadata.go +++ b/pkg/addons/registry/metadata.go @@ -8,6 +8,7 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" + "gopkg.in/yaml.v3" "k8s.io/utils/ptr" ) @@ -18,6 +19,12 @@ var ( Metadata release.AddonMetadata ) +func init() { + if err := yaml.Unmarshal(rawmetadata, &Metadata); err != nil { + panic(errors.Wrap(err, "unable to unmarshal metadata")) + } +} + func Version() map[string]string { return map[string]string{"Registry": "v" + Metadata.Version} } @@ -35,14 +42,22 @@ func GetAdditionalImages() []string { } func GenerateChartConfig(isHA bool) ([]ecv1beta1.Chart, []k0sv1beta1.Repository, error) { - var v map[string]interface{} + var hv map[string]interface{} if isHA { - v = helmValuesHA + v, err := helmValuesHA() + if err != nil { + return nil, nil, errors.Wrap(err, "get helm values") + } + hv = v } else { - v = helmValues + v, err := helmValues() + if err != nil { + return nil, nil, errors.Wrap(err, "get helm values") + } + hv = v } - values, err := helm.MarshalValues(v) + marshalled, err := helm.MarshalValues(hv) if err != nil { return nil, nil, errors.Wrap(err, "marshal helm values") } @@ -51,7 +66,7 @@ func GenerateChartConfig(isHA bool) ([]ecv1beta1.Chart, []k0sv1beta1.Repository, Name: _releaseName, ChartName: (&Registry{}).ChartLocation(ecv1beta1.Domains{}), Version: Metadata.Version, - Values: string(values), + Values: string(marshalled), TargetNS: _namespace, ForceUpgrade: ptr.To(false), Order: 3, diff --git a/pkg/addons/registry/migrate/pod.go b/pkg/addons/registry/migrate/pod.go index 37694f3dac..b44dc243bc 100644 --- a/pkg/addons/registry/migrate/pod.go +++ b/pkg/addons/registry/migrate/pod.go @@ -10,9 +10,9 @@ import ( "time" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" "github.com/replicatedhq/embedded-cluster/pkg/addons/seaweedfs" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -92,7 +92,7 @@ func ensureDataMigrationPod(ctx context.Context, cli client.Client, image string func maybeDeleteDataMigrationPod(ctx context.Context, cli client.Client) error { pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ - Namespace: runtimeconfig.RegistryNamespace, + Namespace: constants.RegistryNamespace, Name: dataMigrationPodName, }, } @@ -105,7 +105,7 @@ func maybeDeleteDataMigrationPod(ctx context.Context, cli client.Client) error { return nil } - err = kubeutils.WaitForPodDeleted(ctx, cli, runtimeconfig.RegistryNamespace, dataMigrationPodName, &kubeutils.WaitOptions{ + err = kubeutils.WaitForPodDeleted(ctx, cli, constants.RegistryNamespace, dataMigrationPodName, &kubeutils.WaitOptions{ Backoff: &wait.Backoff{ Steps: 30, Duration: 2 * time.Second, @@ -132,7 +132,7 @@ func monitorPodStatus(ctx context.Context, cli client.Client, errCh chan<- error Jitter: 0.1, }, } - pod, err := kubeutils.WaitForPodComplete(ctx, cli, runtimeconfig.RegistryNamespace, dataMigrationPodName, opts) + pod, err := kubeutils.WaitForPodComplete(ctx, cli, constants.RegistryNamespace, dataMigrationPodName, opts) if err != nil { errCh <- err } @@ -178,7 +178,7 @@ func streamPodLogs(ctx context.Context, kclient kubernetes.Interface) (io.ReadCl Follow: true, TailLines: ptr.To(int64(100)), } - podLogs, err := kclient.CoreV1().Pods(runtimeconfig.RegistryNamespace).GetLogs(dataMigrationPodName, &logOpts).Stream(ctx) + podLogs, err := kclient.CoreV1().Pods(constants.RegistryNamespace).GetLogs(dataMigrationPodName, &logOpts).Stream(ctx) if err != nil { return nil, fmt.Errorf("get logs: %w", err) } @@ -225,7 +225,7 @@ func newMigrationPod(image string) *corev1.Pod { pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: dataMigrationPodName, - Namespace: runtimeconfig.RegistryNamespace, + Namespace: constants.RegistryNamespace, }, TypeMeta: metav1.TypeMeta{ Kind: "Pod", @@ -279,7 +279,7 @@ func ensureMigrationServiceAccount(ctx context.Context, cli client.Client) error newRole := rbacv1.Role{ ObjectMeta: metav1.ObjectMeta{ Name: "registry-data-migration-role", - Namespace: runtimeconfig.RegistryNamespace, + Namespace: constants.RegistryNamespace, }, TypeMeta: metav1.TypeMeta{ Kind: "Role", @@ -306,7 +306,7 @@ func ensureMigrationServiceAccount(ctx context.Context, cli client.Client) error newServiceAccount := corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ Name: serviceAccountName, - Namespace: runtimeconfig.RegistryNamespace, + Namespace: constants.RegistryNamespace, }, TypeMeta: metav1.TypeMeta{ Kind: "ServiceAccount", @@ -321,7 +321,7 @@ func ensureMigrationServiceAccount(ctx context.Context, cli client.Client) error newRoleBinding := rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ Name: "registry-data-migration-rolebinding", - Namespace: runtimeconfig.RegistryNamespace, + Namespace: constants.RegistryNamespace, }, TypeMeta: metav1.TypeMeta{ Kind: "RoleBinding", @@ -336,7 +336,7 @@ func ensureMigrationServiceAccount(ctx context.Context, cli client.Client) error { Kind: "ServiceAccount", Name: serviceAccountName, - Namespace: runtimeconfig.RegistryNamespace, + Namespace: constants.RegistryNamespace, }, }, } @@ -357,7 +357,7 @@ func ensureS3Secret(ctx context.Context, kcli client.Client) error { var secret corev1.Secret err = kcli.Get(ctx, client.ObjectKey{ - Namespace: runtimeconfig.RegistryNamespace, + Namespace: constants.RegistryNamespace, Name: seaweedfsS3SecretName, }, &secret) if client.IgnoreNotFound(err) != nil { @@ -377,7 +377,7 @@ func ensureS3Secret(ctx context.Context, kcli client.Client) error { secret = corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Namespace: runtimeconfig.RegistryNamespace, + Namespace: constants.RegistryNamespace, Name: seaweedfsS3SecretName, Labels: seaweedfs.ApplyLabels(secret.ObjectMeta.Labels, "s3"), }, diff --git a/pkg/addons/registry/registry.go b/pkg/addons/registry/registry.go index bce79d2864..91b752e398 100644 --- a/pkg/addons/registry/registry.go +++ b/pkg/addons/registry/registry.go @@ -7,9 +7,9 @@ import ( "github.com/pkg/errors" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helpers" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" appsv1 "k8s.io/api/apps/v1" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -23,7 +23,7 @@ type Registry struct { const ( _releaseName = "docker-registry" - _namespace = runtimeconfig.RegistryNamespace + _namespace = constants.RegistryNamespace _tlsSecretName = "registry-tls" _lowerBandIPIndex = 10 diff --git a/pkg/addons/registry/upgrade.go b/pkg/addons/registry/upgrade.go index 74db53e56a..b8a6a6081e 100644 --- a/pkg/addons/registry/upgrade.go +++ b/pkg/addons/registry/upgrade.go @@ -8,7 +8,6 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/addons/seaweedfs" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -27,7 +26,7 @@ const ( func (r *Registry) Upgrade( ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, - rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, overrides []string, + domains ecv1beta1.Domains, overrides []string, ) error { exists, err := hcli.ReleaseExists(ctx, r.Namespace(), r.ReleaseName()) if err != nil { @@ -35,7 +34,7 @@ func (r *Registry) Upgrade( } if !exists { logrus.Debugf("Release not found, installing release %s in namespace %s", r.ReleaseName(), r.Namespace()) - if err := r.Install(ctx, logf, kcli, mcli, hcli, rc, domains, overrides); err != nil { + if err := r.Install(ctx, logf, kcli, mcli, hcli, domains, overrides); err != nil { return errors.Wrap(err, "install") } return nil @@ -45,7 +44,7 @@ func (r *Registry) Upgrade( return errors.Wrap(err, "create prerequisites") } - values, err := r.GenerateHelmValues(ctx, kcli, rc, domains, overrides) + values, err := r.GenerateHelmValues(ctx, kcli, domains, overrides) if err != nil { return errors.Wrap(err, "generate helm values") } diff --git a/pkg/addons/registry/values.go b/pkg/addons/registry/values.go index 62b3f5a4a5..570f4132fd 100644 --- a/pkg/addons/registry/values.go +++ b/pkg/addons/registry/values.go @@ -10,7 +10,6 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/addons/seaweedfs" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "gopkg.in/yaml.v3" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -20,42 +19,27 @@ import ( var ( //go:embed static/values.tpl.yaml rawvalues []byte - // helmValues is the unmarshal version of rawvalues. - helmValues map[string]interface{} //go:embed static/values-ha.tpl.yaml rawvaluesha []byte - // helmValuesHA is the unmarshal version of rawvaluesha. - helmValuesHA map[string]interface{} ) -func init() { - if err := yaml.Unmarshal(rawmetadata, &Metadata); err != nil { - panic(errors.Wrap(err, "unable to unmarshal metadata")) - } - - hv, err := release.RenderHelmValues(rawvalues, Metadata) - if err != nil { - panic(errors.Wrap(err, "unable to unmarshal values")) - } - helmValues = hv - - hvHA, err := release.RenderHelmValues(rawvaluesha, Metadata) - if err != nil { - panic(errors.Wrap(err, "unable to unmarshal ha values")) - } - helmValuesHA = hvHA -} - -func (r *Registry) GenerateHelmValues(ctx context.Context, kcli client.Client, rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, overrides []string) (map[string]interface{}, error) { - var values map[string]interface{} +func (r *Registry) GenerateHelmValues(ctx context.Context, kcli client.Client, domains ecv1beta1.Domains, overrides []string) (map[string]interface{}, error) { + var hv map[string]interface{} if r.IsHA { - values = helmValuesHA + v, err := helmValuesHA() + if err != nil { + return nil, errors.Wrap(err, "get helm values ha") + } + hv = v } else { - values = helmValues + v, err := helmValues() + if err != nil { + return nil, errors.Wrap(err, "get helm values") + } + hv = v } - // create a copy of the helm values so we don't modify the original - marshalled, err := helm.MarshalValues(values) + marshalled, err := helm.MarshalValues(hv) if err != nil { return nil, errors.Wrap(err, "marshal helm values") } @@ -107,3 +91,25 @@ func (r *Registry) GenerateHelmValues(ctx context.Context, kcli client.Client, r return copiedValues, nil } + +func helmValues() (map[string]interface{}, error) { + if err := yaml.Unmarshal(rawmetadata, &Metadata); err != nil { + return nil, errors.Wrap(err, "unmarshal metadata") + } + + hv, err := release.RenderHelmValues(rawvalues, Metadata) + if err != nil { + return nil, errors.Wrap(err, "render helm values") + } + + return hv, nil +} + +func helmValuesHA() (map[string]interface{}, error) { + hvHA, err := release.RenderHelmValues(rawvaluesha, Metadata) + if err != nil { + return nil, errors.Wrap(err, "render helm values") + } + + return hvHA, nil +} diff --git a/pkg/addons/seaweedfs/install.go b/pkg/addons/seaweedfs/install.go index 92e854d51a..892fb68bfd 100644 --- a/pkg/addons/seaweedfs/install.go +++ b/pkg/addons/seaweedfs/install.go @@ -10,7 +10,6 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/helpers" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -22,14 +21,13 @@ import ( func (s *SeaweedFS) Install( ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, - rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, - overrides []string, + domains ecv1beta1.Domains, overrides []string, ) error { if err := s.ensurePreRequisites(ctx, kcli); err != nil { return errors.Wrap(err, "create prerequisites") } - values, err := s.GenerateHelmValues(ctx, kcli, rc, domains, overrides) + values, err := s.GenerateHelmValues(ctx, kcli, domains, overrides) if err != nil { return errors.Wrap(err, "generate helm values") } diff --git a/pkg/addons/seaweedfs/metadata.go b/pkg/addons/seaweedfs/metadata.go index aae86e7c2d..5965e611fe 100644 --- a/pkg/addons/seaweedfs/metadata.go +++ b/pkg/addons/seaweedfs/metadata.go @@ -8,6 +8,7 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" + "gopkg.in/yaml.v3" "k8s.io/utils/ptr" ) @@ -18,6 +19,12 @@ var ( Metadata release.AddonMetadata ) +func init() { + if err := yaml.Unmarshal(rawmetadata, &Metadata); err != nil { + panic(errors.Wrap(err, "unable to unmarshal metadata")) + } +} + func Version() map[string]string { return map[string]string{"SeaweedFS": "v" + Metadata.Version} } @@ -35,7 +42,12 @@ func GetAdditionalImages() []string { } func GenerateChartConfig() ([]ecv1beta1.Chart, []k0sv1beta1.Repository, error) { - values, err := helm.MarshalValues(helmValues) + hv, err := helmValues() + if err != nil { + return nil, nil, errors.Wrap(err, "get helm values") + } + + marshalled, err := helm.MarshalValues(hv) if err != nil { return nil, nil, errors.Wrap(err, "marshal helm values") } @@ -44,7 +56,7 @@ func GenerateChartConfig() ([]ecv1beta1.Chart, []k0sv1beta1.Repository, error) { Name: _releaseName, ChartName: (&SeaweedFS{}).ChartLocation(ecv1beta1.Domains{}), Version: Metadata.Version, - Values: string(values), + Values: string(marshalled), TargetNS: _namespace, ForceUpgrade: ptr.To(false), Order: 2, diff --git a/pkg/addons/seaweedfs/seaweedfs.go b/pkg/addons/seaweedfs/seaweedfs.go index 61da070068..f0a288cc14 100644 --- a/pkg/addons/seaweedfs/seaweedfs.go +++ b/pkg/addons/seaweedfs/seaweedfs.go @@ -4,13 +4,13 @@ import ( "strings" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" ) const ( _releaseName = "seaweedfs" - _namespace = runtimeconfig.SeaweedFSNamespace + _namespace = constants.SeaweedFSNamespace // _s3SVCName is the name of the Seaweedfs S3 service managed by the operator. // HACK: This service has a hardcoded service IP shared by the cli and operator as it is used @@ -28,7 +28,8 @@ const ( var _ types.AddOn = (*SeaweedFS)(nil) type SeaweedFS struct { - ServiceCIDR string + ServiceCIDR string + SeaweedFSDataDir string } func (s *SeaweedFS) Name() string { diff --git a/pkg/addons/seaweedfs/upgrade.go b/pkg/addons/seaweedfs/upgrade.go index ab4221a2f2..52e4c9b685 100644 --- a/pkg/addons/seaweedfs/upgrade.go +++ b/pkg/addons/seaweedfs/upgrade.go @@ -7,7 +7,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/sirupsen/logrus" "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" @@ -16,7 +15,7 @@ import ( func (s *SeaweedFS) Upgrade( ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, - rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, overrides []string, + domains ecv1beta1.Domains, overrides []string, ) error { exists, err := hcli.ReleaseExists(ctx, s.Namespace(), s.ReleaseName()) if err != nil { @@ -24,14 +23,14 @@ func (s *SeaweedFS) Upgrade( } if !exists { logrus.Debugf("Release not found, installing release %s in namespace %s", s.ReleaseName(), s.Namespace()) - return s.Install(ctx, logf, kcli, mcli, hcli, rc, domains, overrides) + return s.Install(ctx, logf, kcli, mcli, hcli, domains, overrides) } if err := s.ensurePreRequisites(ctx, kcli); err != nil { return errors.Wrap(err, "create prerequisites") } - values, err := s.GenerateHelmValues(ctx, kcli, rc, domains, overrides) + values, err := s.GenerateHelmValues(ctx, kcli, domains, overrides) if err != nil { return errors.Wrap(err, "generate helm values") } diff --git a/pkg/addons/seaweedfs/values.go b/pkg/addons/seaweedfs/values.go index 21b7b61181..68fd86a520 100644 --- a/pkg/addons/seaweedfs/values.go +++ b/pkg/addons/seaweedfs/values.go @@ -10,33 +10,21 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" - "gopkg.in/yaml.v3" "sigs.k8s.io/controller-runtime/pkg/client" ) var ( //go:embed static/values.tpl.yaml rawvalues []byte - // helmValues is the unmarshal version of rawvalues. - helmValues map[string]interface{} ) -func init() { - if err := yaml.Unmarshal(rawmetadata, &Metadata); err != nil { - panic(errors.Wrap(err, "unable to unmarshal metadata")) - } - - hv, err := release.RenderHelmValues(rawvalues, Metadata) +func (s *SeaweedFS) GenerateHelmValues(ctx context.Context, kcli client.Client, domains ecv1beta1.Domains, overrides []string) (map[string]interface{}, error) { + hv, err := helmValues() if err != nil { - panic(errors.Wrap(err, "unable to unmarshal values")) + return nil, errors.Wrap(err, "get helm values") } - helmValues = hv -} -func (s *SeaweedFS) GenerateHelmValues(ctx context.Context, kcli client.Client, rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, overrides []string) (map[string]interface{}, error) { - // create a copy of the helm values so we don't modify the original - marshalled, err := helm.MarshalValues(helmValues) + marshalled, err := helm.MarshalValues(hv) if err != nil { return nil, errors.Wrap(err, "marshal helm values") } @@ -51,13 +39,13 @@ func (s *SeaweedFS) GenerateHelmValues(ctx context.Context, kcli client.Client, return nil, errors.Wrap(err, "unmarshal helm values") } - dataPath := filepath.Join(rc.EmbeddedClusterSeaweedfsSubDir(), "ssd") + dataPath := filepath.Join(s.SeaweedFSDataDir, "ssd") err = helm.SetValue(copiedValues, "master.data.hostPathPrefix", dataPath) if err != nil { return nil, errors.Wrap(err, "set helm values global.data.hostPathPrefix") } - logsPath := filepath.Join(rc.EmbeddedClusterSeaweedfsSubDir(), "storage") + logsPath := filepath.Join(s.SeaweedFSDataDir, "storage") err = helm.SetValue(copiedValues, "master.logs.hostPathPrefix", logsPath) if err != nil { return nil, errors.Wrap(err, "set helm values global.logs.hostPathPrefix") @@ -72,3 +60,12 @@ func (s *SeaweedFS) GenerateHelmValues(ctx context.Context, kcli client.Client, return copiedValues, nil } + +func helmValues() (map[string]interface{}, error) { + hv, err := release.RenderHelmValues(rawvalues, Metadata) + if err != nil { + return nil, errors.Wrap(err, "render helm values") + } + + return hv, nil +} diff --git a/pkg/addons/types/types.go b/pkg/addons/types/types.go index a6990353e3..baa5ee1f82 100644 --- a/pkg/addons/types/types.go +++ b/pkg/addons/types/types.go @@ -6,7 +6,6 @@ import ( apitypes "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -18,9 +17,9 @@ type AddOn interface { Version() string ReleaseName() string Namespace() string - GenerateHelmValues(ctx context.Context, kcli client.Client, rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, overrides []string) (map[string]interface{}, error) - Install(ctx context.Context, logf LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, overrides []string) error - Upgrade(ctx context.Context, logf LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, overrides []string) error + GenerateHelmValues(ctx context.Context, kcli client.Client, domains ecv1beta1.Domains, overrides []string) (map[string]interface{}, error) + Install(ctx context.Context, logf LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, domains ecv1beta1.Domains, overrides []string) error + Upgrade(ctx context.Context, logf LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, domains ecv1beta1.Domains, overrides []string) error } type AddOnProgress struct { diff --git a/pkg/addons/upgrade.go b/pkg/addons/upgrade.go index 1d8d413bac..1a14dbf9ba 100644 --- a/pkg/addons/upgrade.go +++ b/pkg/addons/upgrade.go @@ -17,20 +17,35 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/addons/velero" "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func (a *AddOns) Upgrade(ctx context.Context, in *ecv1beta1.Installation, meta *ectypes.ReleaseMetadata) error { - domains := runtimeconfig.GetDomains(in.Spec.Config) +type UpgradeOptions struct { + ClusterID string + AdminConsolePort int + IsAirgap bool + IsHA bool + DisasterRecoveryEnabled bool + IsMultiNodeEnabled bool + EmbeddedConfigSpec *ecv1beta1.ConfigSpec + EndUserConfigSpec *ecv1beta1.ConfigSpec + ProxySpec *ecv1beta1.ProxySpec + HostCABundlePath string + DataDir string + K0sDataDir string + OpenEBSDataDir string + SeaweedFSDataDir string + ServiceCIDR string +} - addons, err := a.getAddOnsForUpgrade(domains, in, meta) +func (a *AddOns) Upgrade(ctx context.Context, in *ecv1beta1.Installation, meta *ectypes.ReleaseMetadata, opts UpgradeOptions) error { + addons, err := a.getAddOnsForUpgrade(meta, opts) if err != nil { return errors.Wrap(err, "get addons for upgrade") } for _, addon := range addons { - if err := a.upgradeAddOn(ctx, domains, in, addon); err != nil { + if err := a.upgradeAddOn(ctx, in, addon); err != nil { return errors.Wrapf(err, "addon %s", addon.Name()) } } @@ -38,14 +53,11 @@ func (a *AddOns) Upgrade(ctx context.Context, in *ecv1beta1.Installation, meta * return nil } -func (a *AddOns) getAddOnsForUpgrade(domains ecv1beta1.Domains, in *ecv1beta1.Installation, meta *ectypes.ReleaseMetadata) ([]types.AddOn, error) { +func (a *AddOns) getAddOnsForUpgrade(meta *ectypes.ReleaseMetadata, opts UpgradeOptions) ([]types.AddOn, error) { addOns := []types.AddOn{ - &openebs.OpenEBS{}, - } - - serviceCIDR := "" - if in.Spec.Network != nil { - serviceCIDR = in.Spec.Network.ServiceCIDR + &openebs.OpenEBS{ + OpenEBSDataDir: opts.OpenEBSDataDir, + }, } // ECO's embedded (wrong) metadata values do not match the published (correct) metadata values. @@ -56,13 +68,16 @@ func (a *AddOns) getAddOnsForUpgrade(domains ecv1beta1.Domains, in *ecv1beta1.In if err != nil { return nil, errors.Wrap(err, "get operator chart location") } - ecoImageRepo, ecoImageTag, ecoUtilsImage, err := a.operatorImages(meta.Images, domains.ProxyRegistryDomain) + ecoImageRepo, ecoImageTag, ecoUtilsImage, err := a.operatorImages(meta.Images) if err != nil { return nil, errors.Wrap(err, "get operator images") } addOns = append(addOns, &embeddedclusteroperator.EmbeddedClusterOperator{ - IsAirgap: in.Spec.AirGap, - Proxy: in.Spec.Proxy, + ClusterID: opts.ClusterID, + IsAirgap: opts.IsAirgap, + Proxy: opts.ProxySpec, + HostCABundlePath: opts.HostCABundlePath, + ChartLocationOverride: ecoChartLocation, ChartVersionOverride: ecoChartVersion, ImageRepoOverride: ecoImageRepo, @@ -70,37 +85,45 @@ func (a *AddOns) getAddOnsForUpgrade(domains ecv1beta1.Domains, in *ecv1beta1.In UtilsImageOverride: ecoUtilsImage, }) - if in.Spec.AirGap { + if opts.IsAirgap { addOns = append(addOns, ®istry.Registry{ - ServiceCIDR: serviceCIDR, - IsHA: in.Spec.HighAvailability, + ServiceCIDR: opts.ServiceCIDR, + IsHA: opts.IsHA, }) - if in.Spec.HighAvailability { + if opts.IsHA { addOns = append(addOns, &seaweedfs.SeaweedFS{ - ServiceCIDR: serviceCIDR, + ServiceCIDR: opts.ServiceCIDR, + SeaweedFSDataDir: opts.SeaweedFSDataDir, }) } } - if in.Spec.LicenseInfo != nil && in.Spec.LicenseInfo.IsDisasterRecoverySupported { + if opts.DisasterRecoveryEnabled { addOns = append(addOns, &velero.Velero{ - Proxy: in.Spec.Proxy, + Proxy: opts.ProxySpec, + HostCABundlePath: opts.HostCABundlePath, + K0sDataDir: opts.K0sDataDir, }) } addOns = append(addOns, &adminconsole.AdminConsole{ - IsAirgap: in.Spec.AirGap, - IsHA: in.Spec.HighAvailability, - Proxy: in.Spec.Proxy, - ServiceCIDR: serviceCIDR, - IsMultiNodeEnabled: in.Spec.LicenseInfo != nil && in.Spec.LicenseInfo.IsMultiNodeEnabled, + ClusterID: opts.ClusterID, + IsAirgap: opts.IsAirgap, + IsHA: opts.IsHA, + Proxy: opts.ProxySpec, + ServiceCIDR: opts.ServiceCIDR, + IsMultiNodeEnabled: opts.IsMultiNodeEnabled, + HostCABundlePath: opts.HostCABundlePath, + DataDir: opts.DataDir, + K0sDataDir: opts.K0sDataDir, + AdminConsolePort: opts.AdminConsolePort, }) return addOns, nil } -func (a *AddOns) upgradeAddOn(ctx context.Context, domains ecv1beta1.Domains, in *ecv1beta1.Installation, addon types.AddOn) error { +func (a *AddOns) upgradeAddOn(ctx context.Context, in *ecv1beta1.Installation, addon types.AddOn) error { // check if we already processed this addon if kubeutils.CheckInstallationConditionStatus(in.Status, a.conditionName(addon)) == metav1.ConditionTrue { slog.Info(addon.Name() + " is ready") @@ -117,7 +140,7 @@ func (a *AddOns) upgradeAddOn(ctx context.Context, domains ecv1beta1.Domains, in // TODO (@salah): add support for end user overrides overrides := a.addOnOverrides(addon, in.Spec.Config, nil) - err := addon.Upgrade(ctx, a.logf, a.kcli, a.mcli, a.hcli, a.rc, domains, overrides) + err := addon.Upgrade(ctx, a.logf, a.kcli, a.mcli, a.hcli, a.domains, overrides) if err != nil { message := helpers.CleanErrorMessage(err) if err := a.setCondition(ctx, in, a.conditionName(addon), metav1.ConditionFalse, "UpgradeFailed", message); err != nil { diff --git a/pkg/addons/upgrade_test.go b/pkg/addons/upgrade_test.go index 040ff7b683..0982ae71cd 100644 --- a/pkg/addons/upgrade_test.go +++ b/pkg/addons/upgrade_test.go @@ -12,7 +12,6 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/addons/seaweedfs" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/addons/velero" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -37,20 +36,18 @@ func Test_getAddOnsForUpgrade(t *testing.T) { tests := []struct { name string domains ecv1beta1.Domains - in *ecv1beta1.Installation meta *ectypes.ReleaseMetadata + opts UpgradeOptions verify func(t *testing.T, addons []types.AddOn, err error) }{ { name: "online installation", - in: &ecv1beta1.Installation{ - Spec: ecv1beta1.InstallationSpec{ - AirGap: false, - HighAvailability: false, - BinaryName: "test-binary-name", - }, - }, meta: meta, + opts: UpgradeOptions{ + ClusterID: "123", + IsAirgap: false, + IsHA: false, + }, verify: func(t *testing.T, addons []types.AddOn, err error) { assert.NoError(t, err) assert.Len(t, addons, 3) @@ -70,6 +67,7 @@ func Test_getAddOnsForUpgrade(t *testing.T) { adminConsole, ok := addons[2].(*adminconsole.AdminConsole) require.True(t, ok, "third addon should be AdminConsole") + assert.Equal(t, "123", adminConsole.ClusterID) assert.False(t, adminConsole.IsAirgap, "AdminConsole should not be in airgap mode") assert.False(t, adminConsole.IsHA, "AdminConsole should not be in high availability mode") assert.Nil(t, adminConsole.Proxy, "AdminConsole should not have a proxy") @@ -78,17 +76,13 @@ func Test_getAddOnsForUpgrade(t *testing.T) { }, { name: "airgap installation", - in: &ecv1beta1.Installation{ - Spec: ecv1beta1.InstallationSpec{ - AirGap: true, - HighAvailability: false, - Network: &ecv1beta1.NetworkSpec{ - ServiceCIDR: "10.96.0.0/12", - }, - BinaryName: "test-binary-name", - }, - }, meta: meta, + opts: UpgradeOptions{ + ClusterID: "123", + ServiceCIDR: "10.96.0.0/12", + IsAirgap: true, + IsHA: false, + }, verify: func(t *testing.T, addons []types.AddOn, err error) { assert.NoError(t, err) assert.Len(t, addons, 4) @@ -113,6 +107,7 @@ func Test_getAddOnsForUpgrade(t *testing.T) { adminConsole, ok := addons[3].(*adminconsole.AdminConsole) require.True(t, ok, "fourth addon should be AdminConsole") + assert.Equal(t, "123", adminConsole.ClusterID) assert.True(t, adminConsole.IsAirgap, "AdminConsole should be in airgap mode") assert.False(t, adminConsole.IsHA, "AdminConsole should not be in high availability mode") assert.Nil(t, adminConsole.Proxy, "AdminConsole should not have a proxy") @@ -121,20 +116,14 @@ func Test_getAddOnsForUpgrade(t *testing.T) { }, { name: "with disaster recovery", - in: &ecv1beta1.Installation{ - Spec: ecv1beta1.InstallationSpec{ - AirGap: false, - HighAvailability: false, - Network: &ecv1beta1.NetworkSpec{ - ServiceCIDR: "10.96.0.0/12", - }, - LicenseInfo: &ecv1beta1.LicenseInfo{ - IsDisasterRecoverySupported: true, - }, - BinaryName: "test-binary-name", - }, - }, meta: meta, + opts: UpgradeOptions{ + ClusterID: "123", + ServiceCIDR: "10.96.0.0/12", + IsAirgap: false, + IsHA: false, + DisasterRecoveryEnabled: true, + }, verify: func(t *testing.T, addons []types.AddOn, err error) { assert.NoError(t, err) assert.Len(t, addons, 4) @@ -158,6 +147,7 @@ func Test_getAddOnsForUpgrade(t *testing.T) { adminConsole, ok := addons[3].(*adminconsole.AdminConsole) require.True(t, ok, "fourth addon should be AdminConsole") + assert.Equal(t, "123", adminConsole.ClusterID) assert.False(t, adminConsole.IsAirgap, "AdminConsole should not be in airgap mode") assert.False(t, adminConsole.IsHA, "AdminConsole should not be in high availability mode") assert.Nil(t, adminConsole.Proxy, "AdminConsole should not have a proxy") @@ -166,25 +156,19 @@ func Test_getAddOnsForUpgrade(t *testing.T) { }, { name: "airgap HA with proxy and disaster recovery", - in: &ecv1beta1.Installation{ - Spec: ecv1beta1.InstallationSpec{ - AirGap: true, - HighAvailability: true, - Network: &ecv1beta1.NetworkSpec{ - ServiceCIDR: "10.96.0.0/12", - }, - LicenseInfo: &ecv1beta1.LicenseInfo{ - IsDisasterRecoverySupported: true, - }, - Proxy: &ecv1beta1.ProxySpec{ - HTTPProxy: "http://proxy.example.com", - HTTPSProxy: "https://proxy.example.com", - NoProxy: "localhost,127.0.0.1", - }, - BinaryName: "test-binary-name", + meta: meta, + opts: UpgradeOptions{ + ClusterID: "123", + ServiceCIDR: "10.96.0.0/12", + ProxySpec: &ecv1beta1.ProxySpec{ + HTTPProxy: "http://proxy.example.com", + HTTPSProxy: "https://proxy.example.com", + NoProxy: "localhost,127.0.0.1", }, + IsAirgap: true, + IsHA: true, + DisasterRecoveryEnabled: true, }, - meta: meta, verify: func(t *testing.T, addons []types.AddOn, err error) { assert.NoError(t, err) assert.Len(t, addons, 6) @@ -221,6 +205,7 @@ func Test_getAddOnsForUpgrade(t *testing.T) { adminConsole, ok := addons[5].(*adminconsole.AdminConsole) require.True(t, ok, "sixth addon should be AdminConsole") + assert.Equal(t, "123", adminConsole.ClusterID) assert.True(t, adminConsole.IsAirgap, "AdminConsole should be in airgap mode") assert.True(t, adminConsole.IsHA, "AdminConsole should be in high availability mode") assert.Equal(t, "http://proxy.example.com", adminConsole.Proxy.HTTPProxy) @@ -231,15 +216,13 @@ func Test_getAddOnsForUpgrade(t *testing.T) { }, { name: "invalid metadata - missing chart", - in: &ecv1beta1.Installation{ - Spec: ecv1beta1.InstallationSpec{}, - }, meta: &ectypes.ReleaseMetadata{ Configs: ecv1beta1.Helm{ Charts: []ecv1beta1.Chart{}, }, Images: meta.Images, }, + opts: UpgradeOptions{}, verify: func(t *testing.T, addons []types.AddOn, err error) { assert.Error(t, err) assert.Contains(t, err.Error(), "no embedded-cluster-operator chart found") @@ -247,13 +230,11 @@ func Test_getAddOnsForUpgrade(t *testing.T) { }, { name: "invalid metadata - missing images", - in: &ecv1beta1.Installation{ - Spec: ecv1beta1.InstallationSpec{}, - }, meta: &ectypes.ReleaseMetadata{ Configs: meta.Configs, Images: []string{}, }, + opts: UpgradeOptions{}, verify: func(t *testing.T, addons []types.AddOn, err error) { assert.Error(t, err) assert.Contains(t, err.Error(), "no embedded-cluster-operator-image found") @@ -263,9 +244,8 @@ func Test_getAddOnsForUpgrade(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rc := runtimeconfig.New(nil) - addOns := New(WithRuntimeConfig(rc)) - addons, err := addOns.getAddOnsForUpgrade(tt.domains, tt.in, tt.meta) + addOns := New() + addons, err := addOns.getAddOnsForUpgrade(tt.meta, tt.opts) tt.verify(t, addons, err) }) } diff --git a/pkg/addons/util.go b/pkg/addons/util.go index 04f3d4ac64..dadadd3eab 100644 --- a/pkg/addons/util.go +++ b/pkg/addons/util.go @@ -32,7 +32,7 @@ func (a *AddOns) operatorChart(meta *ectypes.ReleaseMetadata) (string, string, e return "", "", errors.New("no embedded-cluster-operator chart found in release metadata") } -func (a *AddOns) operatorImages(images []string, proxyRegistryDomain string) (string, string, string, error) { +func (a *AddOns) operatorImages(images []string) (string, string, string, error) { // determine the images to use for the operator chart ecOperatorImage := "" ecUtilsImage := "" @@ -54,9 +54,9 @@ func (a *AddOns) operatorImages(images []string, proxyRegistryDomain string) (st } // the override images for operator during upgrades also need to be updated to use a whitelabeled proxy registry - if proxyRegistryDomain != "" { - ecOperatorImage = strings.Replace(ecOperatorImage, "proxy.replicated.com", proxyRegistryDomain, 1) - ecUtilsImage = strings.Replace(ecUtilsImage, "proxy.replicated.com", proxyRegistryDomain, 1) + if a.domains.ProxyRegistryDomain != "" { + ecOperatorImage = strings.Replace(ecOperatorImage, "proxy.replicated.com", a.domains.ProxyRegistryDomain, 1) + ecUtilsImage = strings.Replace(ecUtilsImage, "proxy.replicated.com", a.domains.ProxyRegistryDomain, 1) } repo := strings.Split(ecOperatorImage, ":")[0] diff --git a/pkg/addons/util_test.go b/pkg/addons/util_test.go index 2983a29507..d6f55b4534 100644 --- a/pkg/addons/util_test.go +++ b/pkg/addons/util_test.go @@ -3,6 +3,7 @@ package addons import ( "testing" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/stretchr/testify/require" ) @@ -12,13 +13,14 @@ func Test_operatorImages(t *testing.T) { images []string wantRepo string wantTag string - proxyRegistry string + domains ecv1beta1.Domains wantUtilsImage string wantErr string }{ { name: "no images", images: []string{}, + domains: ecv1beta1.Domains{}, wantErr: "no embedded-cluster-operator-image found in images", }, { @@ -26,6 +28,7 @@ func Test_operatorImages(t *testing.T) { images: []string{ "docker.io/replicated/another-image:latest-arm64@sha256:a9ab9db181f9898283a87be0f79d85cb8f3d22a790b71f52c8a9d339e225dedd", }, + domains: ecv1beta1.Domains{}, wantErr: "no embedded-cluster-operator-image found in images", }, { @@ -34,6 +37,7 @@ func Test_operatorImages(t *testing.T) { "docker.io/replicated/another-image:latest-arm64@sha256:a9ab9db181f9898283a87be0f79d85cb8f3d22a790b71f52c8a9d339e225dedd", "docker.io/replicated/embedded-cluster-operator-image:latest-amd64@sha256:eeed01216b5d2192afbd90e2e1f70419a8758551d8708f9d4b4f50f41d106ce8", }, + domains: ecv1beta1.Domains{}, wantErr: "no ec-utils found in images", }, { @@ -42,6 +46,7 @@ func Test_operatorImages(t *testing.T) { "docker.io/replicated/embedded-cluster-operator-image:latest-amd64", "docker.io/replicated/ec-utils:latest-amd64", }, + domains: ecv1beta1.Domains{}, wantRepo: "docker.io/replicated/embedded-cluster-operator-image", wantTag: "latest-amd64", wantUtilsImage: "docker.io/replicated/ec-utils:latest-amd64", @@ -72,6 +77,7 @@ func Test_operatorImages(t *testing.T) { "proxy.replicated.com/anonymous/replicated/embedded-cluster-local-artifact-mirror:v1.14.2-k8s-1.29@sha256:54463ce6b6fba13a25138890aa1ac28ae4f93f53cdb78a99d15abfdc1b5eddf5", "proxy.replicated.com/anonymous/replicated/embedded-cluster-operator-image:v1.14.2-k8s-1.29-amd64@sha256:45a45e2ec6b73d2db029354cccfe7eb150dd7ef9dffe806db36de9b9ba0a66c6", }, + domains: ecv1beta1.Domains{}, wantRepo: "proxy.replicated.com/anonymous/replicated/embedded-cluster-operator-image", wantTag: "v1.14.2-k8s-1.29-amd64@sha256:45a45e2ec6b73d2db029354cccfe7eb150dd7ef9dffe806db36de9b9ba0a66c6", wantUtilsImage: "proxy.replicated.com/anonymous/replicated/ec-utils:latest-amd64@sha256:2f3c5d81565eae3aea22f408af9a8ee91cd4ba010612c50c6be564869390639f", @@ -82,7 +88,9 @@ func Test_operatorImages(t *testing.T) { "proxy.replicated.com/replicated/embedded-cluster-operator-image:latest-amd64", "proxy.replicated.com/replicated/ec-utils:latest-amd64", }, - proxyRegistry: "myproxy.test", + domains: ecv1beta1.Domains{ + ProxyRegistryDomain: "myproxy.test", + }, wantRepo: "myproxy.test/replicated/embedded-cluster-operator-image", wantTag: "latest-amd64", wantUtilsImage: "myproxy.test/replicated/ec-utils:latest-amd64", @@ -113,7 +121,9 @@ func Test_operatorImages(t *testing.T) { "proxy.replicated.com/anonymous/replicated/embedded-cluster-local-artifact-mirror:v1.14.2-k8s-1.29@sha256:54463ce6b6fba13a25138890aa1ac28ae4f93f53cdb78a99d15abfdc1b5eddf5", "proxy.replicated.com/anonymous/replicated/embedded-cluster-operator-image:v1.14.2-k8s-1.29-amd64@sha256:45a45e2ec6b73d2db029354cccfe7eb150dd7ef9dffe806db36de9b9ba0a66c6", }, - proxyRegistry: "myproxy.test", + domains: ecv1beta1.Domains{ + ProxyRegistryDomain: "myproxy.test", + }, wantRepo: "myproxy.test/anonymous/replicated/embedded-cluster-operator-image", wantTag: "v1.14.2-k8s-1.29-amd64@sha256:45a45e2ec6b73d2db029354cccfe7eb150dd7ef9dffe806db36de9b9ba0a66c6", wantUtilsImage: "myproxy.test/anonymous/replicated/ec-utils:latest-amd64@sha256:2f3c5d81565eae3aea22f408af9a8ee91cd4ba010612c50c6be564869390639f", @@ -122,7 +132,9 @@ func Test_operatorImages(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := require.New(t) - gotRepo, gotTag, gotUtilsImage, err := New().operatorImages(tt.images, tt.proxyRegistry) + + addOns := New(WithDomains(tt.domains)) + gotRepo, gotTag, gotUtilsImage, err := addOns.operatorImages(tt.images) if tt.wantErr != "" { req.Error(err) req.EqualError(err, tt.wantErr) diff --git a/pkg/addons/velero/install.go b/pkg/addons/velero/install.go index 7298205e30..dce9b5f2ea 100644 --- a/pkg/addons/velero/install.go +++ b/pkg/addons/velero/install.go @@ -8,7 +8,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -19,14 +18,13 @@ import ( func (v *Velero) Install( ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, - rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, - overrides []string, + domains ecv1beta1.Domains, overrides []string, ) error { if err := v.createPreRequisites(ctx, kcli); err != nil { return errors.Wrap(err, "create prerequisites") } - values, err := v.GenerateHelmValues(ctx, kcli, rc, domains, overrides) + values, err := v.GenerateHelmValues(ctx, kcli, domains, overrides) if err != nil { return errors.Wrap(err, "generate helm values") } diff --git a/pkg/addons/velero/integration/hostcabundle_test.go b/pkg/addons/velero/integration/hostcabundle_test.go index 8fbe5452ce..a51aa0e014 100644 --- a/pkg/addons/velero/integration/hostcabundle_test.go +++ b/pkg/addons/velero/integration/hostcabundle_test.go @@ -8,7 +8,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/velero" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" @@ -18,17 +17,15 @@ import ( ) func TestHostCABundle(t *testing.T) { - rc := runtimeconfig.New(nil) - rc.SetHostCABundlePath("/etc/ssl/certs/ca-certificates.crt") - addon := &velero.Velero{ - DryRun: true, + DryRun: true, + HostCABundlePath: "/etc/ssl/certs/ca-certificates.crt", } hcli, err := helm.NewClient(helm.HelmOptions{}) require.NoError(t, err, "NewClient should not return an error") - err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, rc, ecv1beta1.Domains{}, nil) + err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) require.NoError(t, err, "velero.Install should not return an error") manifests := addon.DryRunManifests() diff --git a/pkg/addons/velero/integration/k0ssubdir_test.go b/pkg/addons/velero/integration/k0ssubdir_test.go index 562579d5c6..41152e20d7 100644 --- a/pkg/addons/velero/integration/k0ssubdir_test.go +++ b/pkg/addons/velero/integration/k0ssubdir_test.go @@ -9,7 +9,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/velero" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" @@ -20,19 +19,15 @@ import ( func TestK0sDir(t *testing.T) { k0sDir := filepath.Join(t.TempDir(), "other-k0s") - rcSpec := ecv1beta1.GetDefaultRuntimeConfig() - rcSpec.K0sDataDirOverride = k0sDir - - rc := runtimeconfig.New(rcSpec) - addon := &velero.Velero{ - DryRun: true, + DryRun: true, + K0sDataDir: k0sDir, } hcli, err := helm.NewClient(helm.HelmOptions{}) require.NoError(t, err, "NewClient should not return an error") - err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, rc, ecv1beta1.Domains{}, nil) + err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) require.NoError(t, err, "velero.Install should not return an error") manifests := addon.DryRunManifests() diff --git a/pkg/addons/velero/metadata.go b/pkg/addons/velero/metadata.go index 8074b9526b..029e0619fc 100644 --- a/pkg/addons/velero/metadata.go +++ b/pkg/addons/velero/metadata.go @@ -8,6 +8,7 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" + "gopkg.in/yaml.v3" "k8s.io/utils/ptr" ) @@ -18,6 +19,12 @@ var ( Metadata release.AddonMetadata ) +func init() { + if err := yaml.Unmarshal(rawmetadata, &Metadata); err != nil { + panic(errors.Wrap(err, "unable to unmarshal metadata")) + } +} + func Version() map[string]string { return map[string]string{"Velero": "v" + Metadata.Version} } @@ -42,7 +49,12 @@ func GetAdditionalImages() []string { } func GenerateChartConfig() ([]ecv1beta1.Chart, []k0sv1beta1.Repository, error) { - values, err := helm.MarshalValues(helmValues) + hv, err := helmValues() + if err != nil { + return nil, nil, errors.Wrap(err, "get helm values") + } + + marshalled, err := helm.MarshalValues(hv) if err != nil { return nil, nil, errors.Wrap(err, "marshal helm values") } @@ -51,7 +63,7 @@ func GenerateChartConfig() ([]ecv1beta1.Chart, []k0sv1beta1.Repository, error) { Name: _releaseName, ChartName: (&Velero{}).ChartLocation(ecv1beta1.Domains{}), Version: Metadata.Version, - Values: string(values), + Values: string(marshalled), TargetNS: _namespace, ForceUpgrade: ptr.To(false), Order: 3, diff --git a/pkg/addons/velero/static/metadata.yaml b/pkg/addons/velero/static/metadata.yaml index 3180396097..fb4928b849 100644 --- a/pkg/addons/velero/static/metadata.yaml +++ b/pkg/addons/velero/static/metadata.yaml @@ -5,26 +5,26 @@ # $ make buildtools # $ output/bin/buildtools update addon # -version: 10.0.1 +version: 10.0.8 location: oci://proxy.replicated.com/anonymous/registry.replicated.com/ec-charts/velero images: kubectl: repo: proxy.replicated.com/anonymous/replicated/ec-kubectl tag: - amd64: 1.33.1-r2-amd64@sha256:d12713fa87e9bd0f8c276e909ec86cc385c45a41449fc4df288bfe53ee1404ce - arm64: 1.33.1-r2-arm64@sha256:829211426dd63d8bd54f6e93edfa360088bebc0dfe44bcdf4fc93d2a5736247a + amd64: 1.33.2-r0-amd64@sha256:e413037eeaf3c9fce040e6eefe9219fe057e89a0385339f6a9b7fd3a6dbaa6d6 + arm64: 1.33.2-r0-arm64@sha256:f62d083b84cba34849e6d3444be0d5e380c26d223a7ff3febc048c02788aad95 velero: repo: proxy.replicated.com/anonymous/replicated/ec-velero tag: - amd64: 1.16.1-r0-amd64@sha256:a4dc13ebad06ed8a4e04eaea7f7aaf46028c8c868bcf095b220392d85a467d75 - arm64: 1.16.1-r0-arm64@sha256:3f1752a3c6c8e97e1073aa093111e253c4987434f99fcf721ab92ef053536e16 + amd64: 1.16.1-r1-amd64@sha256:01e37e81626861d6d290d92a80326d38f8b1a5325a978a509b159987d13c6c63 + arm64: 1.16.1-r1-arm64@sha256:f655097a66670332dd2786c1e389ac99835c95c1b6027adf04ad466cad4e89e6 velero-plugin-for-aws: repo: proxy.replicated.com/anonymous/replicated/ec-velero-plugin-for-aws tag: - amd64: 1.12.1-r0-amd64@sha256:d37d0abacfafe9c1b4c6c8ae6540549897e00c99cca9d5bcdb21057eef7ce635 - arm64: 1.12.1-r0-arm64@sha256:ffa59576bb9eb876dc54eb7b7f0056018b9c96e691aacc443d3b15a0c5d9fa80 + amd64: 1.12.1-r1-amd64@sha256:dec822ff1db3a4adf6f7258229857efcd759c18acdd2f65668ccdd6ff6ea48fa + arm64: 1.12.1-r1-arm64@sha256:11a4f4b587a6c430b3a227da7debc67e13aa10fed3538d395e862ea34bcc7d17 velero-restore-helper: repo: proxy.replicated.com/anonymous/replicated/ec-velero-restore-helper tag: - amd64: 1.16.1-r0-amd64@sha256:0f910e5162c9502e068735d38fa4b594fec9af598c2afc83bdec279a4438f7b9 - arm64: 1.16.1-r0-arm64@sha256:7bdcdcc4a627cf44877fef4e9515fdcb02d9d6ec3855d1d136cec27a2bf4d08e + amd64: 1.16.1-r1-amd64@sha256:277379ba064c379ea05e0e60a5bfc3acf759ac1c96eab7a8c52c9e12df708479 + arm64: 1.16.1-r1-arm64@sha256:6de35ebe1fc7d4bafefb2bdcd0cd6f82dbdf470adcdaded8dc80e999be58ff68 diff --git a/pkg/addons/velero/upgrade.go b/pkg/addons/velero/upgrade.go index b41bf119f2..00560814ca 100644 --- a/pkg/addons/velero/upgrade.go +++ b/pkg/addons/velero/upgrade.go @@ -7,7 +7,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/sirupsen/logrus" "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" @@ -16,7 +15,7 @@ import ( func (v *Velero) Upgrade( ctx context.Context, logf types.LogFunc, kcli client.Client, mcli metadata.Interface, hcli helm.Client, - rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, overrides []string, + domains ecv1beta1.Domains, overrides []string, ) error { exists, err := hcli.ReleaseExists(ctx, v.Namespace(), v.ReleaseName()) if err != nil { @@ -24,13 +23,13 @@ func (v *Velero) Upgrade( } if !exists { logrus.Debugf("Release not found, installing release %s in namespace %s", v.ReleaseName(), v.Namespace()) - if err := v.Install(ctx, logf, kcli, mcli, hcli, rc, domains, overrides); err != nil { + if err := v.Install(ctx, logf, kcli, mcli, hcli, domains, overrides); err != nil { return errors.Wrap(err, "install") } return nil } - values, err := v.GenerateHelmValues(ctx, kcli, rc, domains, overrides) + values, err := v.GenerateHelmValues(ctx, kcli, domains, overrides) if err != nil { return errors.Wrap(err, "generate helm values") } diff --git a/pkg/addons/velero/values.go b/pkg/addons/velero/values.go index f2f1b123db..a38b99c818 100644 --- a/pkg/addons/velero/values.go +++ b/pkg/addons/velero/values.go @@ -10,33 +10,21 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" - "gopkg.in/yaml.v3" "sigs.k8s.io/controller-runtime/pkg/client" ) var ( //go:embed static/values.tpl.yaml rawvalues []byte - // helmValues is the unmarshal version of rawvalues. - helmValues map[string]interface{} ) -func init() { - if err := yaml.Unmarshal(rawmetadata, &Metadata); err != nil { - panic(errors.Wrap(err, "unable to unmarshal metadata")) - } - - hv, err := release.RenderHelmValues(rawvalues, Metadata) +func (v *Velero) GenerateHelmValues(ctx context.Context, kcli client.Client, domains ecv1beta1.Domains, overrides []string) (map[string]interface{}, error) { + hv, err := helmValues() if err != nil { - panic(errors.Wrap(err, "unable to unmarshal values")) + return nil, errors.Wrap(err, "get helm values") } - helmValues = hv -} -func (v *Velero) GenerateHelmValues(ctx context.Context, kcli client.Client, rc runtimeconfig.RuntimeConfig, domains ecv1beta1.Domains, overrides []string) (map[string]interface{}, error) { - // create a copy of the helm values so we don't modify the original - marshalled, err := helm.MarshalValues(helmValues) + marshalled, err := helm.MarshalValues(hv) if err != nil { return nil, errors.Wrap(err, "marshal helm values") } @@ -72,11 +60,11 @@ func (v *Velero) GenerateHelmValues(ctx context.Context, kcli client.Client, rc }...) } - if rc.HostCABundlePath() != "" { + if v.HostCABundlePath != "" { extraVolumes = append(extraVolumes, map[string]any{ "name": "host-ca-bundle", "hostPath": map[string]any{ - "path": rc.HostCABundlePath(), + "path": v.HostCABundlePath, "type": "FileOrCreate", }, }) @@ -103,12 +91,12 @@ func (v *Velero) GenerateHelmValues(ctx context.Context, kcli client.Client, rc "extraVolumeMounts": extraVolumeMounts, } - podVolumePath := filepath.Join(rc.EmbeddedClusterK0sSubDir(), "kubelet/pods") + podVolumePath := filepath.Join(v.K0sDataDir, "kubelet/pods") err = helm.SetValue(copiedValues, "nodeAgent.podVolumePath", podVolumePath) if err != nil { return nil, errors.Wrap(err, "set helm value nodeAgent.podVolumePath") } - pluginVolumePath := filepath.Join(rc.EmbeddedClusterK0sSubDir(), "kubelet/plugins") + pluginVolumePath := filepath.Join(v.K0sDataDir, "kubelet/plugins") err = helm.SetValue(copiedValues, "nodeAgent.pluginVolumePath", pluginVolumePath) if err != nil { return nil, errors.Wrap(err, "set helm value nodeAgent.pluginVolumePath") @@ -123,3 +111,12 @@ func (v *Velero) GenerateHelmValues(ctx context.Context, kcli client.Client, rc return copiedValues, nil } + +func helmValues() (map[string]interface{}, error) { + hv, err := release.RenderHelmValues(rawvalues, Metadata) + if err != nil { + return nil, errors.Wrap(err, "render helm values") + } + + return hv, nil +} diff --git a/pkg/addons/velero/values_test.go b/pkg/addons/velero/values_test.go index fd3085921b..9b1702c3da 100644 --- a/pkg/addons/velero/values_test.go +++ b/pkg/addons/velero/values_test.go @@ -5,19 +5,16 @@ import ( "testing" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGenerateHelmValues_HostCABundlePath(t *testing.T) { - rc := runtimeconfig.New(nil) - rc.SetDataDir(t.TempDir()) - rc.SetHostCABundlePath("/etc/ssl/certs/ca-certificates.crt") - - v := &Velero{} + v := &Velero{ + HostCABundlePath: "/etc/ssl/certs/ca-certificates.crt", + } - values, err := v.GenerateHelmValues(context.Background(), nil, rc, ecv1beta1.Domains{}, nil) + values, err := v.GenerateHelmValues(context.Background(), nil, ecv1beta1.Domains{}, nil) require.NoError(t, err, "GenerateHelmValues should not return an error") require.NotEmpty(t, values["extraVolumes"]) diff --git a/pkg/addons/velero/velero.go b/pkg/addons/velero/velero.go index 81c286f966..cf3f19b944 100644 --- a/pkg/addons/velero/velero.go +++ b/pkg/addons/velero/velero.go @@ -4,16 +4,17 @@ import ( "strings" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "k8s.io/apimachinery/pkg/runtime" jsonserializer "k8s.io/apimachinery/pkg/runtime/serializer/json" ) const ( _releaseName = "velero" - _namespace = runtimeconfig.VeleroNamespace + + _namespace = constants.VeleroNamespace _credentialsSecretName = "cloud-credentials" ) @@ -32,7 +33,9 @@ func init() { var _ types.AddOn = (*Velero)(nil) type Velero struct { - Proxy *ecv1beta1.ProxySpec + Proxy *ecv1beta1.ProxySpec + HostCABundlePath string + K0sDataDir string // DryRun is a flag to enable dry-run mode for Velero. // If true, Velero will only render the helm template and additional manifests, but not install diff --git a/pkg/config/config.go b/pkg/config/config.go index 8e8d18d44f..0d8a00b018 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -19,8 +19,7 @@ import ( ) const ( - DefaultServiceNodePortRange = "80-32767" - DefaultVendorChartOrder = 10 + DefaultVendorChartOrder = 10 ) // k0sConfigPathOverride is used during tests to override the path to the k0s config file. @@ -44,7 +43,7 @@ func RenderK0sConfig(proxyRegistryDomain string) *k0sconfig.ClusterConfig { if cfg.Spec.API.ExtraArgs == nil { cfg.Spec.API.ExtraArgs = map[string]string{} } - cfg.Spec.API.ExtraArgs["service-node-port-range"] = DefaultServiceNodePortRange + cfg.Spec.API.ExtraArgs["service-node-port-range"] = embeddedclusterv1beta1.DefaultNetworkNodePortRange cfg.Spec.API.SANs = append(cfg.Spec.API.SANs, "kubernetes.default.svc.cluster.local") cfg.Spec.Network.NodeLocalLoadBalancing.Enabled = true cfg.Spec.Network.NodeLocalLoadBalancing.Type = k0sconfig.NllbTypeEnvoyProxy diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 203088e889..b6f97e754e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -9,6 +9,7 @@ import ( "testing" k0sconfig "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" + embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/domains" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" @@ -104,7 +105,7 @@ func TestRenderK0sConfig(t *testing.T) { cfg := RenderK0sConfig(domains.DefaultProxyRegistryDomain) assert.Equal(t, "calico", cfg.Spec.Network.Provider) - assert.Equal(t, DefaultServiceNodePortRange, cfg.Spec.API.ExtraArgs["service-node-port-range"]) + assert.Equal(t, embeddedclusterv1beta1.DefaultNetworkNodePortRange, cfg.Spec.API.ExtraArgs["service-node-port-range"]) assert.Contains(t, cfg.Spec.API.SANs, "kubernetes.default.svc.cluster.local") val, err := json.Marshal(&cfg.Spec.Telemetry.Enabled) require.NoError(t, err) diff --git a/pkg/config/images.go b/pkg/config/images.go index 6fd5ac4cdf..e854da1156 100644 --- a/pkg/config/images.go +++ b/pkg/config/images.go @@ -3,12 +3,12 @@ package config import ( _ "embed" "fmt" - "runtime" "strings" "github.com/k0sproject/k0s/pkg/airgap" k0sv1beta1 "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" "github.com/k0sproject/k0s/pkg/constant" + "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/pkg/release" "gopkg.in/yaml.v2" ) @@ -84,12 +84,12 @@ func overrideK0sImages(cfg *k0sv1beta1.ClusterConfig, proxyRegistryDomain string cfg.Spec.Network.NodeLocalLoadBalancing.EnvoyProxy.Image.Image = strings.Replace(Metadata.Images["envoy-distroless"].Repo, "proxy.replicated.com", proxyRegistryDomain, 1) } - cfg.Spec.Images.CoreDNS.Version = Metadata.Images["coredns"].Tag[runtime.GOARCH] - cfg.Spec.Images.Calico.Node.Version = Metadata.Images["calico-node"].Tag[runtime.GOARCH] - cfg.Spec.Images.Calico.CNI.Version = Metadata.Images["calico-cni"].Tag[runtime.GOARCH] - cfg.Spec.Images.Calico.KubeControllers.Version = Metadata.Images["calico-kube-controllers"].Tag[runtime.GOARCH] - cfg.Spec.Images.MetricsServer.Version = Metadata.Images["metrics-server"].Tag[runtime.GOARCH] - cfg.Spec.Images.KubeProxy.Version = Metadata.Images["kube-proxy"].Tag[runtime.GOARCH] - cfg.Spec.Images.Pause.Version = Metadata.Images["pause"].Tag[runtime.GOARCH] - cfg.Spec.Network.NodeLocalLoadBalancing.EnvoyProxy.Image.Version = Metadata.Images["envoy-distroless"].Tag[runtime.GOARCH] + cfg.Spec.Images.CoreDNS.Version = Metadata.Images["coredns"].Tag[helpers.ClusterArch()] + cfg.Spec.Images.Calico.Node.Version = Metadata.Images["calico-node"].Tag[helpers.ClusterArch()] + cfg.Spec.Images.Calico.CNI.Version = Metadata.Images["calico-cni"].Tag[helpers.ClusterArch()] + cfg.Spec.Images.Calico.KubeControllers.Version = Metadata.Images["calico-kube-controllers"].Tag[helpers.ClusterArch()] + cfg.Spec.Images.MetricsServer.Version = Metadata.Images["metrics-server"].Tag[helpers.ClusterArch()] + cfg.Spec.Images.KubeProxy.Version = Metadata.Images["kube-proxy"].Tag[helpers.ClusterArch()] + cfg.Spec.Images.Pause.Version = Metadata.Images["pause"].Tag[helpers.ClusterArch()] + cfg.Spec.Network.NodeLocalLoadBalancing.EnvoyProxy.Image.Version = Metadata.Images["envoy-distroless"].Tag[helpers.ClusterArch()] } diff --git a/pkg/constants/restore.go b/pkg/constants/restore.go deleted file mode 100644 index 33954c2cee..0000000000 --- a/pkg/constants/restore.go +++ /dev/null @@ -1,3 +0,0 @@ -package constants - -const EcRestoreStateCMName = "embedded-cluster-restore-state" diff --git a/pkg/crds/resources.yaml b/pkg/crds/resources.yaml index b8aec7b648..e8b53ae965 100644 --- a/pkg/crds/resources.yaml +++ b/pkg/crds/resources.yaml @@ -580,8 +580,12 @@ spec: description: MetricsBaseURL holds the base URL for the metrics server. type: string network: - description: Network holds the network configuration. + description: NetworkSpec holds the network configuration. properties: + globalCIDR: + type: string + networkInterface: + type: string nodePortRange: type: string podCIDR: @@ -590,7 +594,7 @@ spec: type: string type: object proxy: - description: Proxy holds the proxy configuration. + description: ProxySpec holds the proxy configuration. properties: httpProxy: type: string @@ -638,11 +642,37 @@ spec: description: Port holds the port on which the manager will be served. type: integer type: object + network: + description: Network holds the network configuration. + properties: + globalCIDR: + type: string + networkInterface: + type: string + nodePortRange: + type: string + podCIDR: + type: string + serviceCIDR: + type: string + type: object openEBSDataDirOverride: description: |- OpenEBSDataDirOverride holds the override for the data directory for the OpenEBS storage provisioner. By default the data will be stored in a subdirectory of DataDir. type: string + proxy: + description: Proxy holds the proxy configuration. + properties: + httpProxy: + type: string + httpsProxy: + type: string + noProxy: + type: string + providedNoProxy: + type: string + type: object type: object sourceType: description: SourceType indicates where this Installation object is stored (CRD, ConfigMap, etc...). diff --git a/pkg/disasterrecovery/backup.go b/pkg/disasterrecovery/backup.go index c95a2a3946..8c71f94a43 100644 --- a/pkg/disasterrecovery/backup.go +++ b/pkg/disasterrecovery/backup.go @@ -8,8 +8,8 @@ import ( "strconv" "time" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -98,7 +98,7 @@ type ReplicatedBackup []velerov1.Backup // ListReplicatedBackups returns a sorted list of ReplicatedBackup backups by creation timestamp. func ListReplicatedBackups(ctx context.Context, cli client.Client) ([]ReplicatedBackup, error) { - backups, err := listBackups(ctx, cli, runtimeconfig.VeleroNamespace) + backups, err := listBackups(ctx, cli, constants.VeleroNamespace) if err != nil { return nil, err } diff --git a/pkg/dryrun/k0s.go b/pkg/dryrun/k0s.go index e729ac791e..997e391a05 100644 --- a/pkg/dryrun/k0s.go +++ b/pkg/dryrun/k0s.go @@ -3,6 +3,8 @@ package dryrun import ( "context" + k0sv1beta1 "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/k0s" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" ) @@ -17,14 +19,22 @@ func (c *K0s) GetStatus(ctx context.Context) (*k0s.K0sStatus, error) { return c.Status, nil } -func (c *K0s) Install(rc runtimeconfig.RuntimeConfig, networkInterface string) error { - return nil // TODO: implement +func (c *K0s) Install(rc runtimeconfig.RuntimeConfig) error { + return k0s.New().Install(rc) // actual implementation accounts for dryrun } func (c *K0s) IsInstalled() (bool, error) { return c.Status != nil, nil } +func (c *K0s) WriteK0sConfig(ctx context.Context, networkInterface string, airgapBundle string, podCIDR string, serviceCIDR string, eucfg *ecv1beta1.Config, mutate func(*k0sv1beta1.ClusterConfig) error) (*k0sv1beta1.ClusterConfig, error) { + return k0s.New().WriteK0sConfig(ctx, networkInterface, airgapBundle, podCIDR, serviceCIDR, eucfg, mutate) // actual implementation accounts for dryrun +} + +func (c *K0s) PatchK0sConfig(path string, patch string) error { + return k0s.New().PatchK0sConfig(path, patch) // actual implementation accounts for dryrun +} + func (c *K0s) WaitForK0s() error { return nil } diff --git a/pkg/dryrun/types/types.go b/pkg/dryrun/types/types.go index 5763ad3e95..9657ed1654 100644 --- a/pkg/dryrun/types/types.go +++ b/pkg/dryrun/types/types.go @@ -8,17 +8,16 @@ import ( "strings" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes" clientsetfake "k8s.io/client-go/kubernetes/fake" - k8scheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/metadata" metadatafake "k8s.io/client-go/metadata/fake" "sigs.k8s.io/controller-runtime/pkg/client" @@ -197,16 +196,7 @@ func (d *DryRun) K8sObjectsFromClient() ([]string, error) { func (d *DryRun) KubeClient() (client.Client, error) { if d.kcli == nil { - scheme := runtime.NewScheme() - if err := k8scheme.AddToScheme(scheme); err != nil { - return nil, fmt.Errorf("add k8s scheme: %w", err) - } - if err := apiextensionsv1.AddToScheme(scheme); err != nil { - return nil, fmt.Errorf("add apiextensions v1 scheme: %w", err) - } - if err := ecv1beta1.AddToScheme(scheme); err != nil { - return nil, fmt.Errorf("add ec v1beta1 scheme: %w", err) - } + scheme := kubeutils.Scheme clientObjs := []client.Object{} for _, o := range d.K8sObjects { var u unstructured.Unstructured @@ -226,16 +216,9 @@ func (d *DryRun) KubeClient() (client.Client, error) { func (d *DryRun) MetadataClient() (metadata.Interface, error) { if d.mcli == nil { - scheme := runtime.NewScheme() - if err := k8scheme.AddToScheme(scheme); err != nil { - return nil, fmt.Errorf("add k8s scheme: %w", err) - } - if err := apiextensionsv1.AddToScheme(scheme); err != nil { - return nil, fmt.Errorf("add apiextensions v1 scheme: %w", err) - } - if err := ecv1beta1.AddToScheme(scheme); err != nil { - return nil, fmt.Errorf("add ec v1beta1 scheme: %w", err) - } + scheme := metadatafake.NewTestScheme() + metav1.AddMetaToScheme(scheme) + corev1.AddToScheme(scheme) clientObjs := []runtime.Object{} for _, o := range d.K8sObjects { var m metav1.PartialObjectMetadata @@ -251,16 +234,6 @@ func (d *DryRun) MetadataClient() (metadata.Interface, error) { func (d *DryRun) GetClientset() (kubernetes.Interface, error) { if d.kclient == nil { - scheme := runtime.NewScheme() - if err := k8scheme.AddToScheme(scheme); err != nil { - return nil, fmt.Errorf("add k8s scheme: %w", err) - } - if err := apiextensionsv1.AddToScheme(scheme); err != nil { - return nil, fmt.Errorf("add apiextensions v1 scheme: %w", err) - } - if err := ecv1beta1.AddToScheme(scheme); err != nil { - return nil, fmt.Errorf("add ec v1beta1 scheme: %w", err) - } clientObjs := []runtime.Object{} for _, o := range d.K8sObjects { var u unstructured.Unstructured diff --git a/pkg/helm/client.go b/pkg/helm/client.go index 05c08f19d2..f743f2a8d2 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -185,6 +185,11 @@ func (h *HelmClient) AddRepo(repo *repo.Entry) error { } func (h *HelmClient) Latest(reponame, chart string) (string, error) { + stableConstraint, err := semver.NewConstraint(">0.0.0") // search only for stable versions + if err != nil { + return "", fmt.Errorf("create stable constraint: %w", err) + } + for _, repository := range h.repos { if repository.Name != reponame { continue @@ -207,14 +212,24 @@ func (h *HelmClient) Latest(reponame, chart string) (string, error) { versions, ok := repoidx.Entries[chart] if !ok { return "", fmt.Errorf("chart %s not found", chart) - } else if len(versions) == 0 { - return "", fmt.Errorf("chart %s has no versions", chart) } if len(versions) == 0 { return "", fmt.Errorf("chart %s has no versions", chart) } - return versions[0].Version, nil + + for _, version := range versions { + v, err := semver.NewVersion(version.Version) + if err != nil { + continue + } + + if stableConstraint.Check(v) { + return version.Version, nil + } + } + + return "", fmt.Errorf("no stable version found for chart %s", chart) } return "", fmt.Errorf("repository %s not found", reponame) } diff --git a/pkg/helpers/arch.go b/pkg/helpers/arch.go new file mode 100644 index 0000000000..933848754e --- /dev/null +++ b/pkg/helpers/arch.go @@ -0,0 +1,34 @@ +package helpers + +import ( + "os" + "runtime" +) + +var ( + _clusterArch = runtime.GOARCH + _clusterOS = "linux" +) + +func init() { + if val := os.Getenv("CLUSTER_ARCH"); val != "" { + SetClusterArch(val) + } +} + +// ClusterArch returns the architecture of the cluster. This defaults to the architecture the +// binary is compiled for (this should be the CPU architecture of the host). This can be overridden +// by setting the CLUSTER_ARCH environment variable or set via SetClusterArch. +func ClusterArch() string { + return _clusterArch +} + +// SetClusterArch sets the architecture of the cluster. +func SetClusterArch(arch string) { + _clusterArch = arch +} + +// ClusterOS returns the operating system of the cluster. This is hardcoded to "linux". +func ClusterOS() string { + return _clusterOS +} diff --git a/pkg/helpers/k0s.go b/pkg/helpers/k0s.go index d13403293b..709ff967bf 100644 --- a/pkg/helpers/k0s.go +++ b/pkg/helpers/k0s.go @@ -88,8 +88,8 @@ func objectToUnstructured(obj runtime.Object) (*unstructured.Unstructured, error return unstructuredObj, nil } -func NetworkSpecFromK0sConfig(k0sCfg *k0sv1beta1.ClusterConfig) *ecv1beta1.NetworkSpec { - network := &ecv1beta1.NetworkSpec{} +func NetworkSpecFromK0sConfig(k0sCfg *k0sv1beta1.ClusterConfig) ecv1beta1.NetworkSpec { + network := ecv1beta1.NetworkSpec{} if k0sCfg.Spec != nil && k0sCfg.Spec.Network != nil { network.PodCIDR = k0sCfg.Spec.Network.PodCIDR diff --git a/pkg/kubernetesinstallation/installation.go b/pkg/kubernetesinstallation/installation.go new file mode 100644 index 0000000000..89e9921616 --- /dev/null +++ b/pkg/kubernetesinstallation/installation.go @@ -0,0 +1,128 @@ +package kubernetesinstallation + +import ( + "os" + + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" +) + +var _ Installation = &kubernetesInstallation{} + +type Option func(*kubernetesInstallation) + +type EnvSetter interface { + Setenv(key string, val string) error +} + +type kubernetesInstallation struct { + installation *ecv1beta1.KubernetesInstallation + envSetter EnvSetter +} + +type osEnvSetter struct{} + +func (o *osEnvSetter) Setenv(key string, val string) error { + return os.Setenv(key, val) +} + +func WithEnvSetter(envSetter EnvSetter) Option { + return func(rc *kubernetesInstallation) { + rc.envSetter = envSetter + } +} + +// New creates a new KubernetesInstallation instance +func New(installation *ecv1beta1.KubernetesInstallation, opts ...Option) Installation { + if installation == nil { + installation = &ecv1beta1.KubernetesInstallation{ + Spec: ecv1beta1.GetDefaultKubernetesInstallationSpec(), + } + } + + ki := &kubernetesInstallation{installation: installation} + for _, opt := range opts { + opt(ki) + } + + if ki.envSetter == nil { + ki.envSetter = &osEnvSetter{} + } + + return ki +} + +// Get returns the KubernetesInstallation. +func (ki *kubernetesInstallation) Get() *ecv1beta1.KubernetesInstallation { + return ki.installation +} + +// Set sets the KubernetesInstallation. +func (ki *kubernetesInstallation) Set(installation *ecv1beta1.KubernetesInstallation) { + if installation == nil { + return + } + ki.installation = installation +} + +// GetSpec returns the spec for the KubernetesInstallation. +func (ki *kubernetesInstallation) GetSpec() ecv1beta1.KubernetesInstallationSpec { + return ki.installation.Spec +} + +// SetSpec sets the spec for the KubernetesInstallation. +func (ki *kubernetesInstallation) SetSpec(spec ecv1beta1.KubernetesInstallationSpec) { + ki.installation.Spec = spec +} + +// GetStatus returns the status for the KubernetesInstallation. +func (ki *kubernetesInstallation) GetStatus() ecv1beta1.KubernetesInstallationStatus { + return ki.installation.Status +} + +// SetStatus sets the status for the KubernetesInstallation. +func (ki *kubernetesInstallation) SetStatus(status ecv1beta1.KubernetesInstallationStatus) { + ki.installation.Status = status +} + +// SetEnv sets the environment variables for the KubernetesInstallation. +func (ki *kubernetesInstallation) SetEnv() error { + return nil +} + +// AdminConsolePort returns the configured port for the admin console or the default if not +// configured. +func (ki *kubernetesInstallation) AdminConsolePort() int { + if ki.installation.Spec.AdminConsole.Port > 0 { + return ki.installation.Spec.AdminConsole.Port + } + return ecv1beta1.DefaultAdminConsolePort +} + +// ManagerPort returns the configured port for the manager or the default if not +// configured. +func (ki *kubernetesInstallation) ManagerPort() int { + if ki.installation.Spec.Manager.Port > 0 { + return ki.installation.Spec.Manager.Port + } + return ecv1beta1.DefaultManagerPort +} + +// ProxySpec returns the configured proxy spec or nil if not configured. +func (ki *kubernetesInstallation) ProxySpec() *ecv1beta1.ProxySpec { + return ki.installation.Spec.Proxy +} + +// SetAdminConsolePort sets the port for the admin console. +func (ki *kubernetesInstallation) SetAdminConsolePort(port int) { + ki.installation.Spec.AdminConsole.Port = port +} + +// SetManagerPort sets the port for the manager. +func (ki *kubernetesInstallation) SetManagerPort(port int) { + ki.installation.Spec.Manager.Port = port +} + +// SetProxySpec sets the proxy spec for the kubernetes installation. +func (ki *kubernetesInstallation) SetProxySpec(proxySpec *ecv1beta1.ProxySpec) { + ki.installation.Spec.Proxy = proxySpec +} diff --git a/pkg/kubernetesinstallation/interface.go b/pkg/kubernetesinstallation/interface.go new file mode 100644 index 0000000000..2539c0d0ef --- /dev/null +++ b/pkg/kubernetesinstallation/interface.go @@ -0,0 +1,25 @@ +package kubernetesinstallation + +import ( + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" +) + +// Installation defines the interface for managing kubernetes installation +type Installation interface { + Get() *ecv1beta1.KubernetesInstallation + Set(installation *ecv1beta1.KubernetesInstallation) + + GetSpec() ecv1beta1.KubernetesInstallationSpec + SetSpec(spec ecv1beta1.KubernetesInstallationSpec) + + GetStatus() ecv1beta1.KubernetesInstallationStatus + SetStatus(status ecv1beta1.KubernetesInstallationStatus) + + AdminConsolePort() int + ManagerPort() int + ProxySpec() *ecv1beta1.ProxySpec + + SetAdminConsolePort(port int) + SetManagerPort(port int) + SetProxySpec(proxySpec *ecv1beta1.ProxySpec) +} diff --git a/pkg/kubernetesinstallation/mock.go b/pkg/kubernetesinstallation/mock.go new file mode 100644 index 0000000000..31cdf3e9ff --- /dev/null +++ b/pkg/kubernetesinstallation/mock.go @@ -0,0 +1,82 @@ +package kubernetesinstallation + +import ( + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/stretchr/testify/mock" +) + +var _ Installation = (*MockInstallation)(nil) + +// MockInstallation is a mock implementation of the KubernetesInstallation interface +type MockInstallation struct { + mock.Mock +} + +// Get mocks the Get method +func (m *MockInstallation) Get() *ecv1beta1.KubernetesInstallation { + args := m.Called() + if args.Get(0) == nil { + return nil + } + return args.Get(0).(*ecv1beta1.KubernetesInstallation) +} + +// Set mocks the Set method +func (m *MockInstallation) Set(installation *ecv1beta1.KubernetesInstallation) { + m.Called(installation) +} + +// GetSpec mocks the GetSpec method +func (m *MockInstallation) GetSpec() ecv1beta1.KubernetesInstallationSpec { + args := m.Called() + return args.Get(0).(ecv1beta1.KubernetesInstallationSpec) +} + +// SetSpec mocks the SetSpec method +func (m *MockInstallation) SetSpec(spec ecv1beta1.KubernetesInstallationSpec) { + m.Called(spec) +} + +// GetStatus mocks the GetStatus method +func (m *MockInstallation) GetStatus() ecv1beta1.KubernetesInstallationStatus { + args := m.Called() + return args.Get(0).(ecv1beta1.KubernetesInstallationStatus) +} + +// SetStatus mocks the SetStatus method +func (m *MockInstallation) SetStatus(status ecv1beta1.KubernetesInstallationStatus) { + m.Called(status) +} + +// AdminConsolePort mocks the AdminConsolePort method +func (m *MockInstallation) AdminConsolePort() int { + args := m.Called() + return args.Int(0) +} + +// ManagerPort mocks the ManagerPort method +func (m *MockInstallation) ManagerPort() int { + args := m.Called() + return args.Int(0) +} + +// ProxySpec mocks the ProxySpec method +func (m *MockInstallation) ProxySpec() *ecv1beta1.ProxySpec { + args := m.Called() + return args.Get(0).(*ecv1beta1.ProxySpec) +} + +// SetAdminConsolePort mocks the SetAdminConsolePort method +func (m *MockInstallation) SetAdminConsolePort(port int) { + m.Called(port) +} + +// SetManagerPort mocks the SetManagerPort method +func (m *MockInstallation) SetManagerPort(port int) { + m.Called(port) +} + +// SetProxySpec mocks the SetProxySpec method +func (m *MockInstallation) SetProxySpec(proxySpec *ecv1beta1.ProxySpec) { + m.Called(proxySpec) +} diff --git a/pkg/kubeutils/client.go b/pkg/kubeutils/client.go index f42a509c88..19ac421c21 100644 --- a/pkg/kubeutils/client.go +++ b/pkg/kubeutils/client.go @@ -9,7 +9,6 @@ import ( embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" utilruntime "k8s.io/apimachinery/pkg/util/runtime" - "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/metadata" @@ -52,16 +51,6 @@ func (k *KubeUtils) MetadataClient() (metadata.Interface, error) { return metadata.NewForConfig(cfg) } -// RESTClientGetterFactory is a factory function that can be used to create namespaced -// genericclioptions.RESTClientGetters. -func (k *KubeUtils) RESTClientGetterFactory(namespace string) genericclioptions.RESTClientGetter { - cfgFlags := genericclioptions.NewConfigFlags(false) - if namespace != "" { - cfgFlags.Namespace = &namespace - } - return cfgFlags -} - func (k *KubeUtils) GetClientset() (kubernetes.Interface, error) { cfg, err := config.GetConfig() if err != nil { diff --git a/pkg/kubeutils/installation.go b/pkg/kubeutils/installation.go index a98661e0a1..76db5600f2 100644 --- a/pkg/kubeutils/installation.go +++ b/pkg/kubeutils/installation.go @@ -10,11 +10,9 @@ import ( "time" "github.com/Masterminds/semver/v3" - k0sv1beta1 "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg-new/constants" "github.com/replicatedhq/embedded-cluster/pkg/crds" - "github.com/replicatedhq/embedded-cluster/pkg/helpers" - "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/pkg/spinner" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" @@ -123,9 +121,8 @@ func writeInstallationStatusMessage(writer *spinner.MessageWriter, install *ecv1 } type RecordInstallationOptions struct { + ClusterID string IsAirgap bool - Proxy *ecv1beta1.ProxySpec - K0sConfig *k0sv1beta1.ClusterConfig License *kotsv1beta1.License ConfigSpec *ecv1beta1.ConfigSpec MetricsBaseURL string @@ -137,7 +134,7 @@ func RecordInstallation(ctx context.Context, kcli client.Client, opts RecordInst // ensure that the embedded-cluster namespace exists ns := corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ - Name: runtimeconfig.EmbeddedClusterNamespace, + Name: constants.EmbeddedClusterNamespace, }, } if err := kcli.Create(ctx, &ns); err != nil && !k8serrors.IsAlreadyExists(err) { @@ -163,15 +160,13 @@ func RecordInstallation(ctx context.Context, kcli client.Client, opts RecordInst Name: time.Now().Format("20060102150405"), }, Spec: ecv1beta1.InstallationSpec{ - ClusterID: metrics.ClusterID().String(), + ClusterID: opts.ClusterID, MetricsBaseURL: opts.MetricsBaseURL, AirGap: opts.IsAirgap, - Proxy: opts.Proxy, - Network: helpers.NetworkSpecFromK0sConfig(opts.K0sConfig), Config: opts.ConfigSpec, RuntimeConfig: opts.RuntimeConfig, EndUserK0sConfigOverrides: euOverrides, - BinaryName: runtimeconfig.BinaryName(), + BinaryName: runtimeconfig.AppSlug(), LicenseInfo: &ecv1beta1.LicenseInfo{ IsDisasterRecoverySupported: opts.License.Spec.IsDisasterRecoverySupported, IsMultiNodeEnabled: opts.License.Spec.IsEmbeddedClusterMultiNodeEnabled, diff --git a/pkg/metrics/reporter.go b/pkg/metrics/reporter.go index 57cbc53091..35a8d34f58 100644 --- a/pkg/metrics/reporter.go +++ b/pkg/metrics/reporter.go @@ -6,9 +6,7 @@ import ( "errors" "os" "strings" - "sync" - "github.com/google/uuid" apitypes "github.com/replicatedhq/embedded-cluster/api/types" "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/pkg/metrics/types" @@ -18,9 +16,6 @@ import ( "github.com/sirupsen/logrus" ) -var _clusterIDMut sync.Mutex -var _clusterID *uuid.UUID - // ErrorNoFail is an error that is excluded from metrics failures. type ErrorNoFail struct { Err error @@ -50,37 +45,19 @@ func License(licenseFlag string) *kotsv1beta1.License { return license } -// ClusterID returns the cluster id. This is unique per 'install', but will be stored in the cluster and used by any future 'join' commands. -func ClusterID() uuid.UUID { - _clusterIDMut.Lock() - defer _clusterIDMut.Unlock() - if _clusterID != nil { - return *_clusterID - } - id := uuid.New() - _clusterID = &id - return id -} - -func SetClusterID(id uuid.UUID) { - _clusterIDMut.Lock() - defer _clusterIDMut.Unlock() - _clusterID = &id -} - // Reporter provides methods for reporting various events. type Reporter struct { version string executionID string baseURL string - clusterID uuid.UUID + clusterID string hostname string command string commandFlags []string } // NewReporter creates a new Reporter with the given parameters. -func NewReporter(executionID string, baseURL string, clusterID uuid.UUID, command string, commandFlags []string) *Reporter { +func NewReporter(executionID string, baseURL string, clusterID string, command string, commandFlags []string) *Reporter { return &Reporter{ version: versions.Version, executionID: executionID, diff --git a/pkg/metrics/reporter_mock.go b/pkg/metrics/reporter_mock.go index e6c4368b4e..9fdff200aa 100644 --- a/pkg/metrics/reporter_mock.go +++ b/pkg/metrics/reporter_mock.go @@ -15,47 +15,49 @@ type MockReporter struct { mock.Mock } +// TODO: all the methods in this file aren't passing over the context.Context to avoid potential data races when using this struct in state machine event handler tests. See: https://github.com/stretchr/testify/issues/1597 + // ReportInstallationStarted mocks the ReportInstallationStarted method func (m *MockReporter) ReportInstallationStarted(ctx context.Context, licenseID string, appSlug string) { - m.Called(ctx, licenseID, appSlug) + m.Called(mock.Anything, licenseID, appSlug) } // ReportInstallationSucceeded mocks the ReportInstallationSucceeded method func (m *MockReporter) ReportInstallationSucceeded(ctx context.Context) { - m.Called(ctx) + m.Called(mock.Anything) } // ReportInstallationFailed mocks the ReportInstallationFailed method func (m *MockReporter) ReportInstallationFailed(ctx context.Context, err error) { - m.Called(ctx, err) + m.Called(mock.Anything, err) } // ReportJoinStarted mocks the ReportJoinStarted method func (m *MockReporter) ReportJoinStarted(ctx context.Context) { - m.Called(ctx) + m.Called(mock.Anything) } // ReportJoinSucceeded mocks the ReportJoinSucceeded method func (m *MockReporter) ReportJoinSucceeded(ctx context.Context) { - m.Called(ctx) + m.Called(mock.Anything) } // ReportJoinFailed mocks the ReportJoinFailed method func (m *MockReporter) ReportJoinFailed(ctx context.Context, err error) { - m.Called(ctx, err) + m.Called(mock.Anything, err) } // ReportPreflightsFailed mocks the ReportPreflightsFailed method func (m *MockReporter) ReportPreflightsFailed(ctx context.Context, output *apitypes.HostPreflightsOutput) { - m.Called(ctx, output) + m.Called(mock.Anything, output) } // ReportPreflightsBypassed mocks the ReportPreflightsBypassed method func (m *MockReporter) ReportPreflightsBypassed(ctx context.Context, output *apitypes.HostPreflightsOutput) { - m.Called(ctx, output) + m.Called(mock.Anything, output) } // ReportSignalAborted mocks the ReportSignalAborted method func (m *MockReporter) ReportSignalAborted(ctx context.Context, signal os.Signal) { - m.Called(ctx, signal) + m.Called(mock.Anything, signal) } diff --git a/pkg/metrics/reporter_test.go b/pkg/metrics/reporter_test.go index c2dabcd9f5..d4108ffcee 100644 --- a/pkg/metrics/reporter_test.go +++ b/pkg/metrics/reporter_test.go @@ -51,8 +51,7 @@ func TestReportInstallationStarted(t *testing.T) { defer func() { os.Args = originalArgs }() os.Args = append([]string{os.Args[0]}, test.OSArgs...) - clusterID := ClusterID() - reporter := NewReporter("test-execution-id", server.URL, clusterID, "install", test.OSArgs[1:]) + reporter := NewReporter("test-execution-id", server.URL, "123", "install", test.OSArgs[1:]) reporter.ReportInstallationStarted(context.Background(), "license-id", "app-slug") }) } diff --git a/pkg/metrics/sender_test.go b/pkg/metrics/sender_test.go index dcbd6adaec..12dca78315 100644 --- a/pkg/metrics/sender_test.go +++ b/pkg/metrics/sender_test.go @@ -10,7 +10,6 @@ import ( "reflect" "testing" - "github.com/google/uuid" "github.com/replicatedhq/embedded-cluster/pkg/metrics/types" "github.com/stretchr/testify/assert" ) @@ -25,7 +24,7 @@ func TestSend(t *testing.T) { event: types.InstallationStarted{ GenericEvent: types.GenericEvent{ ExecutionID: "test-id", - ClusterID: uuid.New(), + ClusterID: "123", Version: "1.2.3", EntryCommand: "install", Flags: "--foo --bar --baz", @@ -40,7 +39,7 @@ func TestSend(t *testing.T) { event: types.InstallationSucceeded{ GenericEvent: types.GenericEvent{ ExecutionID: "test-id", - ClusterID: uuid.New(), + ClusterID: "123", Version: "1.2.3", EntryCommand: "install", Flags: "--foo --bar --baz", @@ -52,7 +51,7 @@ func TestSend(t *testing.T) { event: types.InstallationFailed{ GenericEvent: types.GenericEvent{ ExecutionID: "test-id", - ClusterID: uuid.New(), + ClusterID: "123", Version: "1.2.3", EntryCommand: "install", Flags: "--foo --bar --baz", @@ -65,7 +64,7 @@ func TestSend(t *testing.T) { event: types.JoinStarted{ GenericEvent: types.GenericEvent{ ExecutionID: "test-id", - ClusterID: uuid.New(), + ClusterID: "123", Version: "1.2.3", EntryCommand: "join", Flags: "--foo --bar --baz", @@ -78,7 +77,7 @@ func TestSend(t *testing.T) { event: types.JoinSucceeded{ GenericEvent: types.GenericEvent{ ExecutionID: "test-id", - ClusterID: uuid.New(), + ClusterID: "123", Version: "1.2.3", EntryCommand: "join", Flags: "--foo --bar --baz", @@ -91,7 +90,7 @@ func TestSend(t *testing.T) { event: types.JoinFailed{ GenericEvent: types.GenericEvent{ ExecutionID: "test-id", - ClusterID: uuid.New(), + ClusterID: "123", Version: "1.2.3", EntryCommand: "join", Flags: "--foo --bar --baz", diff --git a/pkg/metrics/types/types.go b/pkg/metrics/types/types.go index 8477fc29cf..d453f147c4 100644 --- a/pkg/metrics/types/types.go +++ b/pkg/metrics/types/types.go @@ -1,9 +1,5 @@ package types -import ( - "github.com/google/uuid" -) - // Event type constants const ( EventTypeInstallationStarted = "InstallationStarted" @@ -28,7 +24,7 @@ type GenericEvent struct { // ExecutionID is a unique identifier for the current execution ExecutionID string `json:"executionID"` // ClusterID is the unique identifier of the cluster - ClusterID uuid.UUID `json:"clusterID"` + ClusterID string `json:"clusterID"` // Version is the version of the embedded-cluster software Version string `json:"version"` // Hostname is the hostname of the server @@ -46,7 +42,7 @@ type GenericEvent struct { } // NewGenericEvent creates a new GenericEvent with the specified name. -func NewGenericEvent(eventName string, executionID string, clusterID uuid.UUID, version string, hostname string, +func NewGenericEvent(eventName string, executionID string, clusterID string, version string, hostname string, entryCommand string, flags string, reason string, isExitEvent bool) GenericEvent { return GenericEvent{ ExecutionID: executionID, diff --git a/pkg/release/addon.go b/pkg/release/addon.go index 73ade6c5f0..56acee0d4b 100644 --- a/pkg/release/addon.go +++ b/pkg/release/addon.go @@ -5,10 +5,10 @@ import ( "fmt" "os" "path/filepath" - "runtime" "strings" "text/template" + "github.com/replicatedhq/embedded-cluster/pkg/helpers" "gopkg.in/yaml.v3" ) @@ -49,12 +49,12 @@ type AddonImage struct { } func (i AddonImage) String() string { - if strings.HasPrefix(i.Tag[runtime.GOARCH], "latest@") { + if strings.HasPrefix(i.Tag[helpers.ClusterArch()], "latest@") { // The image appears in containerd images without the "latest" tag and causes an // ImagePullBackOff error - return fmt.Sprintf("%s@%s", i.Repo, strings.TrimPrefix(i.Tag[runtime.GOARCH], "latest@")) + return fmt.Sprintf("%s@%s", i.Repo, strings.TrimPrefix(i.Tag[helpers.ClusterArch()], "latest@")) } - return fmt.Sprintf("%s:%s", i.Repo, i.Tag[runtime.GOARCH]) + return fmt.Sprintf("%s:%s", i.Repo, i.Tag[helpers.ClusterArch()]) } var funcMap = template.FuncMap{ @@ -68,7 +68,7 @@ var funcMap = template.FuncMap{ func RenderHelmValues(rawvalues []byte, meta AddonMetadata) (map[string]interface{}, error) { meta.ReplaceImages = true - meta.GOARCH = runtime.GOARCH + meta.GOARCH = helpers.ClusterArch() tmpl, err := template.New("helmvalues").Funcs(funcMap).Parse(string(rawvalues)) if err != nil { return nil, fmt.Errorf("parse template: %w", err) diff --git a/pkg/release/release.go b/pkg/release/release.go index 4499b6cb87..9cc0e5e2fa 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -39,6 +39,15 @@ func GetReleaseData() *ReleaseData { return _releaseData } +// GetAppTitle returns the title from the kots application embedded as part of the +// release. If no application is found, returns an empty string. +func GetAppTitle() string { + if _releaseData.Application == nil { + return "" + } + return _releaseData.Application.Spec.Title +} + // GetHostPreflights returns a list of HostPreflight specs that are found in the // binary. These are part of the embedded Kots Application Release. func GetHostPreflights() *troubleshootv1beta2.HostPreflightSpec { diff --git a/pkg/release/release_test.go b/pkg/release/release_test.go index 4d3d60cbd4..f7fe1d0c77 100644 --- a/pkg/release/release_test.go +++ b/pkg/release/release_test.go @@ -11,6 +11,58 @@ import ( "gopkg.in/yaml.v2" ) +// Global test data to avoid regenerating for each test +var testReleaseData []byte + +func init() { + data, err := generateReleaseTGZ() + if err != nil { + panic("Failed to generate test release data: " + err.Error()) + } + testReleaseData = data +} + +func Test_newReleaseDataFrom(t *testing.T) { + release, err := newReleaseDataFrom([]byte{}) + assert.NoError(t, err) + assert.NotNil(t, release) + cfg := release.EmbeddedClusterConfig + assert.NoError(t, err) + assert.Nil(t, cfg) +} + +func TestGetApplication(t *testing.T) { + release, err := newReleaseDataFrom(testReleaseData) + assert.NoError(t, err) + app := release.Application + assert.NoError(t, err) + assert.NotNil(t, app) +} + +func TestGetEmbeddedClusterConfig(t *testing.T) { + release, err := newReleaseDataFrom(testReleaseData) + assert.NoError(t, err) + cfg := release.EmbeddedClusterConfig + assert.NoError(t, err) + assert.NotNil(t, cfg) +} + +func TestGetHostPreflights(t *testing.T) { + release, err := newReleaseDataFrom(testReleaseData) + assert.NoError(t, err) + preflights := release.HostPreflights + assert.NoError(t, err) + assert.NotNil(t, preflights) +} + +func TestGetAppTitle(t *testing.T) { + release, err := newReleaseDataFrom(testReleaseData) + assert.NoError(t, err) + title := release.Application.Spec.Title + assert.NoError(t, err) + assert.Equal(t, "Embedded Cluster Smoke Test App", title) +} + func generateReleaseTGZ() ([]byte, error) { content, err := os.ReadFile("testdata/release.yaml") if err != nil { @@ -50,42 +102,3 @@ func generateReleaseTGZ() ([]byte, error) { return buf.Bytes(), nil } - -func Test_newReleaseDataFrom(t *testing.T) { - release, err := newReleaseDataFrom([]byte{}) - assert.NoError(t, err) - assert.NotNil(t, release) - cfg := release.EmbeddedClusterConfig - assert.NoError(t, err) - assert.Nil(t, cfg) -} - -func TestGetApplication(t *testing.T) { - data, err := generateReleaseTGZ() - assert.NoError(t, err) - release, err := newReleaseDataFrom(data) - assert.NoError(t, err) - app := release.Application - assert.NoError(t, err) - assert.NotNil(t, app) -} - -func TestGetEmbeddedClusterConfig(t *testing.T) { - data, err := generateReleaseTGZ() - assert.NoError(t, err) - release, err := newReleaseDataFrom(data) - assert.NoError(t, err) - cfg := release.EmbeddedClusterConfig - assert.NoError(t, err) - assert.NotNil(t, cfg) -} - -func TestGetHostPreflights(t *testing.T) { - data, err := generateReleaseTGZ() - assert.NoError(t, err) - release, err := newReleaseDataFrom(data) - assert.NoError(t, err) - preflights := release.HostPreflights - assert.NoError(t, err) - assert.NotNil(t, preflights) -} diff --git a/pkg/runtimeconfig/defaults.go b/pkg/runtimeconfig/defaults.go index 87d724b825..299a14d733 100644 --- a/pkg/runtimeconfig/defaults.go +++ b/pkg/runtimeconfig/defaults.go @@ -5,8 +5,6 @@ import ( "path/filepath" "github.com/gosimple/slug" - ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" - "github.com/replicatedhq/embedded-cluster/pkg-new/domains" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/sirupsen/logrus" ) @@ -21,15 +19,6 @@ var DefaultNoProxy = []string{ "169.254.169.254", } -const ( - KotsadmNamespace = "kotsadm" - KotsadmServiceAccount = "kotsadm" - SeaweedFSNamespace = "seaweedfs" - RegistryNamespace = "registry" - VeleroNamespace = "velero" - EmbeddedClusterNamespace = "embedded-cluster" -) - const ( K0sBinaryPath = "/usr/local/bin/k0s" K0sStatusSocketPath = "/run/k0s/status.sock" @@ -38,11 +27,11 @@ const ( ECConfigPath = "/etc/embedded-cluster/ec.yaml" ) -// BinaryName returns the intended binary name. This is the app slug when a release is embedded, +// AppSlug returns the intended binary name. This is the app slug when a release is embedded, // otherwise it is the basename of the executable. This is useful for places where we need to // present the name of the binary to the user. We make sure the name does not contain invalid // characters for a filename. -func BinaryName() string { +func AppSlug() string { var name string if release := release.GetChannelRelease(); release != nil { name = release.AppSlug @@ -71,9 +60,3 @@ func EmbeddedClusterLogsSubDir() string { func PathToLog(name string) string { return filepath.Join(EmbeddedClusterLogsSubDir(), name) } - -// GetDomains returns the domains for the embedded cluster. The first priority is the domains configured within the provided config spec. -// The second priority is the domains configured within the channel release. If neither is configured, the default domains are returned. -func GetDomains(cfgspec *ecv1beta1.ConfigSpec) ecv1beta1.Domains { - return domains.GetDomains(cfgspec, release.GetChannelRelease()) -} diff --git a/pkg/runtimeconfig/interface.go b/pkg/runtimeconfig/interface.go index 4a3a47f197..fd98530474 100644 --- a/pkg/runtimeconfig/interface.go +++ b/pkg/runtimeconfig/interface.go @@ -8,7 +8,9 @@ import ( type RuntimeConfig interface { Get() *ecv1beta1.RuntimeConfigSpec Set(spec *ecv1beta1.RuntimeConfigSpec) + Cleanup() + EmbeddedClusterHomeDirectory() string EmbeddedClusterTmpSubDir() string EmbeddedClusterBinsSubDir() string @@ -16,21 +18,33 @@ type RuntimeConfig interface { EmbeddedClusterChartsSubDirNoCreate() string EmbeddedClusterImagesSubDir() string EmbeddedClusterK0sSubDir() string - EmbeddedClusterSeaweedfsSubDir() string + EmbeddedClusterSeaweedFSSubDir() string EmbeddedClusterOpenEBSLocalSubDir() string PathToEmbeddedClusterBinary(name string) string PathToKubeConfig() string PathToKubeletConfig() string EmbeddedClusterSupportSubDir() string PathToEmbeddedClusterSupportFile(name string) string + + SetEnv() error WriteToDisk() error + LocalArtifactMirrorPort() int AdminConsolePort() int ManagerPort() int + ProxySpec() *ecv1beta1.ProxySpec + NetworkInterface() string + GlobalCIDR() string + PodCIDR() string + ServiceCIDR() string + NodePortRange() string HostCABundlePath() string + SetDataDir(dataDir string) SetLocalArtifactMirrorPort(port int) SetAdminConsolePort(port int) SetManagerPort(port int) + SetProxySpec(proxySpec *ecv1beta1.ProxySpec) + SetNetworkSpec(networkSpec ecv1beta1.NetworkSpec) SetHostCABundlePath(hostCABundlePath string) } diff --git a/pkg/runtimeconfig/mock.go b/pkg/runtimeconfig/mock.go index bf7789cf0f..36c3753d9a 100644 --- a/pkg/runtimeconfig/mock.go +++ b/pkg/runtimeconfig/mock.go @@ -73,8 +73,8 @@ func (m *MockRuntimeConfig) EmbeddedClusterK0sSubDir() string { return args.String(0) } -// EmbeddedClusterSeaweedfsSubDir mocks the EmbeddedClusterSeaweedfsSubDir method -func (m *MockRuntimeConfig) EmbeddedClusterSeaweedfsSubDir() string { +// EmbeddedClusterSeaweedFSSubDir mocks the EmbeddedClusterSeaweedFSSubDir method +func (m *MockRuntimeConfig) EmbeddedClusterSeaweedFSSubDir() string { args := m.Called() return args.String(0) } @@ -115,6 +115,12 @@ func (m *MockRuntimeConfig) PathToEmbeddedClusterSupportFile(name string) string return args.String(0) } +// SetEnv mocks the SetEnv method +func (m *MockRuntimeConfig) SetEnv() error { + args := m.Called() + return args.Error(0) +} + // WriteToDisk mocks the WriteToDisk method func (m *MockRuntimeConfig) WriteToDisk() error { args := m.Called() @@ -139,6 +145,42 @@ func (m *MockRuntimeConfig) ManagerPort() int { return args.Int(0) } +// ProxySpec mocks the ProxySpec method +func (m *MockRuntimeConfig) ProxySpec() *ecv1beta1.ProxySpec { + args := m.Called() + return args.Get(0).(*ecv1beta1.ProxySpec) +} + +// GlobalCIDR returns the configured global CIDR or the default if not configured. +func (m *MockRuntimeConfig) GlobalCIDR() string { + args := m.Called() + return args.String(0) +} + +// PodCIDR returns the configured pod CIDR or the default if not configured. +func (m *MockRuntimeConfig) PodCIDR() string { + args := m.Called() + return args.String(0) +} + +// ServiceCIDR returns the configured service CIDR or the default if not configured. +func (m *MockRuntimeConfig) ServiceCIDR() string { + args := m.Called() + return args.String(0) +} + +// NetworkInterface returns the configured network interface or the default if not configured. +func (m *MockRuntimeConfig) NetworkInterface() string { + args := m.Called() + return args.String(0) +} + +// NodePortRange returns the configured node port range or the default if not configured. +func (m *MockRuntimeConfig) NodePortRange() string { + args := m.Called() + return args.String(0) +} + // HostCABundlePath mocks the HostCABundlePath method func (m *MockRuntimeConfig) HostCABundlePath() string { args := m.Called() @@ -165,6 +207,16 @@ func (m *MockRuntimeConfig) SetManagerPort(port int) { m.Called(port) } +// SetProxySpec mocks the SetProxySpec method +func (m *MockRuntimeConfig) SetProxySpec(proxySpec *ecv1beta1.ProxySpec) { + m.Called(proxySpec) +} + +// SetNetworkSpec mocks the SetNetworkSpec method +func (m *MockRuntimeConfig) SetNetworkSpec(networkSpec ecv1beta1.NetworkSpec) { + m.Called(networkSpec) +} + // SetHostCABundlePath mocks the SetHostCABundlePath method func (m *MockRuntimeConfig) SetHostCABundlePath(hostCABundlePath string) { m.Called(hostCABundlePath) diff --git a/pkg/runtimeconfig/runtimeconfig.go b/pkg/runtimeconfig/runtimeconfig.go index 9e154bdc30..9b67097af8 100644 --- a/pkg/runtimeconfig/runtimeconfig.go +++ b/pkg/runtimeconfig/runtimeconfig.go @@ -11,16 +11,46 @@ import ( "sigs.k8s.io/yaml" ) +var _ RuntimeConfig = &runtimeConfig{} + +type Option func(*runtimeConfig) + +type EnvSetter interface { + Setenv(key string, val string) error +} + type runtimeConfig struct { - spec *ecv1beta1.RuntimeConfigSpec + spec *ecv1beta1.RuntimeConfigSpec + envSetter EnvSetter +} + +type osEnvSetter struct{} + +func (o *osEnvSetter) Setenv(key string, val string) error { + return os.Setenv(key, val) +} + +func WithEnvSetter(envSetter EnvSetter) Option { + return func(rc *runtimeConfig) { + rc.envSetter = envSetter + } } // New creates a new RuntimeConfig instance -func New(spec *ecv1beta1.RuntimeConfigSpec) RuntimeConfig { +func New(spec *ecv1beta1.RuntimeConfigSpec, opts ...Option) RuntimeConfig { if spec == nil { spec = ecv1beta1.GetDefaultRuntimeConfig() } - return &runtimeConfig{spec: spec} + rc := &runtimeConfig{spec: spec} + for _, opt := range opts { + opt(rc) + } + + if rc.envSetter == nil { + rc.envSetter = &osEnvSetter{} + } + + return rc } // NewFromDisk creates a new RuntimeConfig instance from the runtime config file on disk at path @@ -119,8 +149,8 @@ func (rc *runtimeConfig) EmbeddedClusterK0sSubDir() string { return filepath.Join(rc.EmbeddedClusterHomeDirectory(), "k0s") } -// EmbeddedClusterSeaweedfsSubDir returns the path to the directory where seaweedfs data is stored. -func (rc *runtimeConfig) EmbeddedClusterSeaweedfsSubDir() string { +// EmbeddedClusterSeaweedFSSubDir returns the path to the directory where seaweedfs data is stored. +func (rc *runtimeConfig) EmbeddedClusterSeaweedFSSubDir() string { return filepath.Join(rc.EmbeddedClusterHomeDirectory(), "seaweedfs") } @@ -164,6 +194,17 @@ func (rc *runtimeConfig) PathToEmbeddedClusterSupportFile(name string) string { return filepath.Join(rc.EmbeddedClusterSupportSubDir(), name) } +// SetEnv sets the environment variables for the RuntimeConfig. +func (rc *runtimeConfig) SetEnv() error { + if err := rc.envSetter.Setenv("KUBECONFIG", rc.PathToKubeConfig()); err != nil { + return fmt.Errorf("set KUBECONFIG: %w", err) + } + if err := rc.envSetter.Setenv("TMPDIR", rc.EmbeddedClusterTmpSubDir()); err != nil { + return fmt.Errorf("set TMPDIR: %w", err) + } + return nil +} + // WriteToDisk writes the spec for the RuntimeConfig to the runtime config file on disk at path // /etc/embedded-cluster/ec.yaml. func (rc *runtimeConfig) WriteToDisk() error { @@ -216,6 +257,42 @@ func (rc *runtimeConfig) ManagerPort() int { return ecv1beta1.DefaultManagerPort } +// ProxySpec returns the configured proxy spec or nil if not configured. +func (rc *runtimeConfig) ProxySpec() *ecv1beta1.ProxySpec { + return rc.spec.Proxy +} + +// GlobalCIDR returns the configured global CIDR or the default if not configured. +func (rc *runtimeConfig) GlobalCIDR() string { + if rc.spec.Network.GlobalCIDR == "" && rc.spec.Network.PodCIDR == "" && rc.spec.Network.ServiceCIDR == "" { + return ecv1beta1.DefaultNetworkCIDR + } + return rc.spec.Network.GlobalCIDR +} + +// PodCIDR returns the configured pod CIDR or the default if not configured. +func (rc *runtimeConfig) PodCIDR() string { + return rc.spec.Network.PodCIDR +} + +// ServiceCIDR returns the configured service CIDR or the default if not configured. +func (rc *runtimeConfig) ServiceCIDR() string { + return rc.spec.Network.ServiceCIDR +} + +// NetworkInterface returns the configured network interface or the default if not configured. +func (rc *runtimeConfig) NetworkInterface() string { + return rc.spec.Network.NetworkInterface +} + +// NodePortRange returns the configured node port range or the default if not configured. +func (rc *runtimeConfig) NodePortRange() string { + if rc.spec.Network.NodePortRange == "" { + return ecv1beta1.DefaultNetworkNodePortRange + } + return rc.spec.Network.NodePortRange +} + // HostCABundlePath returns the path to the host CA bundle. func (rc *runtimeConfig) HostCABundlePath() string { return rc.spec.HostCABundlePath @@ -241,6 +318,16 @@ func (rc *runtimeConfig) SetManagerPort(port int) { rc.spec.Manager.Port = port } +// SetProxySpec sets the proxy spec for the runtime configuration. +func (rc *runtimeConfig) SetProxySpec(proxySpec *ecv1beta1.ProxySpec) { + rc.spec.Proxy = proxySpec +} + +// SetNetworkSpec sets the network spec for the runtime configuration. +func (rc *runtimeConfig) SetNetworkSpec(networkSpec ecv1beta1.NetworkSpec) { + rc.spec.Network = networkSpec +} + // SetHostCABundlePath sets the path to the host CA bundle. func (rc *runtimeConfig) SetHostCABundlePath(hostCABundlePath string) { rc.spec.HostCABundlePath = hostCABundlePath diff --git a/pkg/runtimeconfig/util/util.go b/pkg/runtimeconfig/util/util.go index 322a671918..f225551088 100644 --- a/pkg/runtimeconfig/util/util.go +++ b/pkg/runtimeconfig/util/util.go @@ -44,7 +44,7 @@ func GetRuntimeConfigFromCluster(ctx context.Context) (runtimeconfig.RuntimeConf status, err := k0s.GetStatus(ctx) if err != nil { if errors.Is(err, fs.ErrNotExist) { - return nil, fmt.Errorf("%s does not seem to be installed on this node", runtimeconfig.BinaryName()) + return nil, fmt.Errorf("%s does not seem to be installed on this node", runtimeconfig.AppSlug()) } return nil, fmt.Errorf("get k0s status: %w", err) } diff --git a/pkg/support/hostbundle.go b/pkg/support/hostbundle.go index eeef211424..b66740dcfa 100644 --- a/pkg/support/hostbundle.go +++ b/pkg/support/hostbundle.go @@ -6,13 +6,13 @@ import ( _ "embed" "fmt" - "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" sb "github.com/replicatedhq/troubleshoot/pkg/supportbundle" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" serializer "k8s.io/apimachinery/pkg/runtime/serializer/json" "k8s.io/kubectl/pkg/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" ) var ( @@ -24,7 +24,7 @@ func GetRemoteHostSupportBundleSpec() []byte { return _hostSupportBundleRemote } -func CreateHostSupportBundle() error { +func CreateHostSupportBundle(ctx context.Context, kcli client.Client) error { specFile := GetRemoteHostSupportBundleSpec() var b bytes.Buffer @@ -61,12 +61,6 @@ func CreateHostSupportBundle() error { }, } - ctx := context.Background() - kcli, err := kubeutils.KubeClient() - if err != nil { - return fmt.Errorf("unable to create kube client: %w", err) - } - err = kcli.Create(ctx, configMap) if err != nil && !errors.IsAlreadyExists(err) { return fmt.Errorf("unable to create config map: %w", err) diff --git a/pkg/support/materialize.go b/pkg/support/materialize.go index 98722ffbb9..0b74212068 100644 --- a/pkg/support/materialize.go +++ b/pkg/support/materialize.go @@ -6,21 +6,48 @@ import ( "os" "text/template" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg-new/domains" + "github.com/replicatedhq/embedded-cluster/pkg/netutils" + "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" ) type TemplateData struct { - DataDir string - K0sDataDir string - OpenEBSDataDir string + DataDir string + K0sDataDir string + OpenEBSDataDir string + IsAirgap bool + ReplicatedAppURL string + ProxyRegistryURL string + HTTPProxy string + HTTPSProxy string + NoProxy string } -func MaterializeSupportBundleSpec(rc runtimeconfig.RuntimeConfig) error { +func MaterializeSupportBundleSpec(rc runtimeconfig.RuntimeConfig, isAirgap bool) error { + var embCfgSpec *ecv1beta1.ConfigSpec + if embCfg := release.GetEmbeddedClusterConfig(); embCfg != nil { + embCfgSpec = &embCfg.Spec + } + domains := domains.GetDomains(embCfgSpec, nil) + data := TemplateData{ - DataDir: rc.EmbeddedClusterHomeDirectory(), - K0sDataDir: rc.EmbeddedClusterK0sSubDir(), - OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), + DataDir: rc.EmbeddedClusterHomeDirectory(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), + OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), + IsAirgap: isAirgap, + ReplicatedAppURL: netutils.MaybeAddHTTPS(domains.ReplicatedAppDomain), + ProxyRegistryURL: netutils.MaybeAddHTTPS(domains.ProxyRegistryDomain), } + + // Add proxy configuration if available + if proxy := rc.ProxySpec(); proxy != nil { + data.HTTPProxy = proxy.HTTPProxy + data.HTTPSProxy = proxy.HTTPSProxy + data.NoProxy = proxy.NoProxy + } + path := rc.PathToEmbeddedClusterSupportFile("host-support-bundle.tmpl.yaml") tmpl, err := os.ReadFile(path) if err != nil { diff --git a/pkg/support/materialize_test.go b/pkg/support/materialize_test.go new file mode 100644 index 0000000000..d20f95363c --- /dev/null +++ b/pkg/support/materialize_test.go @@ -0,0 +1,257 @@ +package support + +import ( + "os" + "path/filepath" + "strings" + "testing" + + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMaterializeSupportBundleSpec(t *testing.T) { + tests := []struct { + name string + isAirgap bool + proxySpec *ecv1beta1.ProxySpec + expectedInFile []string + notInFile []string + validateFunc func(t *testing.T, content string) + }{ + { + name: "airgap installation - HTTP collectors excluded", + isAirgap: true, + proxySpec: &ecv1beta1.ProxySpec{ + HTTPSProxy: "https://proxy:8080", + HTTPProxy: "http://proxy:8080", + NoProxy: "localhost,127.0.0.1", + }, + expectedInFile: []string{ + // Core collectors should always be present + "k8s-api-healthz-6443", + "free", + "embedded-cluster-path-usage", + // HTTP collectors are present in template (but will be excluded) + "http-replicated-app", + "curl-replicated-app", + }, + notInFile: []string{ + // Template variables should be substituted + "{{ .ReplicatedAppURL }}", + "{{ .ProxyRegistryURL }}", + "{{ .HTTPSProxy }}", + }, + validateFunc: func(t *testing.T, content string) { + // Validate that HTTP collectors have exclude: 'true' for airgap + assert.Contains(t, content, "collectorName: http-replicated-app") + + // Check that the http-replicated-app collector block has exclude: 'true' + httpCollectorStart := strings.Index(content, "collectorName: http-replicated-app") + require.Greater(t, httpCollectorStart, -1, "http-replicated-app collector should be present") + + // Find the next collector to limit our search scope + nextCollectorStart := strings.Index(content[httpCollectorStart+1:], "collectorName:") + var httpCollectorBlock string + if nextCollectorStart > -1 { + httpCollectorBlock = content[httpCollectorStart : httpCollectorStart+1+nextCollectorStart] + } else { + httpCollectorBlock = content[httpCollectorStart:] + } + + assert.Contains(t, httpCollectorBlock, "exclude: 'true'", + "http-replicated-app collector should be excluded in airgap mode") + + // Also validate curl-replicated-app is excluded + curlCollectorStart := strings.Index(content, "collectorName: curl-replicated-app") + require.Greater(t, curlCollectorStart, -1, "curl-replicated-app collector should be present") + + nextCurlCollectorStart := strings.Index(content[curlCollectorStart+1:], "collectorName:") + var curlCollectorBlock string + if nextCurlCollectorStart > -1 { + curlCollectorBlock = content[curlCollectorStart : curlCollectorStart+1+nextCurlCollectorStart] + } else { + curlCollectorBlock = content[curlCollectorStart:] + } + + assert.Contains(t, curlCollectorBlock, "exclude: 'true'", + "curl-replicated-app collector should be excluded in airgap mode") + }, + }, + { + name: "online installation with proxy - HTTP collectors included", + isAirgap: false, + proxySpec: &ecv1beta1.ProxySpec{ + HTTPSProxy: "https://proxy:8080", + HTTPProxy: "http://proxy:8080", + NoProxy: "localhost,127.0.0.1", + }, + expectedInFile: []string{ + // Core collectors + "k8s-api-healthz-6443", + "free", + "embedded-cluster-path-usage", + // HTTP collectors are included for online + "http-replicated-app", + "curl-replicated-app", + // URLs and proxy settings + "https://replicated.app/healthz", + "https://proxy.replicated.com/v2/", + "proxy: 'https://proxy:8080'", + }, + notInFile: []string{ + // Template variables should be substituted + "{{ .ReplicatedAppURL }}", + "{{ .HTTPSProxy }}", + }, + validateFunc: func(t *testing.T, content string) { + // Validate that HTTP collectors have exclude: 'false' for online + assert.Contains(t, content, "collectorName: http-replicated-app") + + // Check that the http-replicated-app collector block has exclude: 'false' + httpCollectorStart := strings.Index(content, "collectorName: http-replicated-app") + require.Greater(t, httpCollectorStart, -1, "http-replicated-app collector should be present") + + // Find the next collector to limit our search scope + nextCollectorStart := strings.Index(content[httpCollectorStart+1:], "collectorName:") + var httpCollectorBlock string + if nextCollectorStart > -1 { + httpCollectorBlock = content[httpCollectorStart : httpCollectorStart+1+nextCollectorStart] + } else { + httpCollectorBlock = content[httpCollectorStart:] + } + + assert.Contains(t, httpCollectorBlock, "exclude: 'false'", + "http-replicated-app collector should not be excluded in online mode") + }, + }, + { + name: "online installation without proxy - HTTP collectors included, no proxy config", + isAirgap: false, + proxySpec: nil, + expectedInFile: []string{ + // Core collectors + "k8s-api-healthz-6443", + "embedded-cluster-path-usage", + // HTTP collectors included + "http-replicated-app", + "curl-replicated-app", + // URLs populated + "https://replicated.app/healthz", + "https://proxy.replicated.com/v2/", + }, + notInFile: []string{ + // No proxy settings when proxy not configured + "proxy: 'https://proxy:8080'", + "proxy: 'http://proxy:8080'", + // Template variables should be substituted + "{{ .HTTPSProxy }}", + "{{ .HTTPProxy }}", + }, + validateFunc: func(t *testing.T, content string) { + // Validate that HTTP collectors have exclude: 'false' for online + httpCollectorStart := strings.Index(content, "collectorName: http-replicated-app") + require.Greater(t, httpCollectorStart, -1, "http-replicated-app collector should be present") + + nextCollectorStart := strings.Index(content[httpCollectorStart+1:], "collectorName:") + var httpCollectorBlock string + if nextCollectorStart > -1 { + httpCollectorBlock = content[httpCollectorStart : httpCollectorStart+1+nextCollectorStart] + } else { + httpCollectorBlock = content[httpCollectorStart:] + } + + assert.Contains(t, httpCollectorBlock, "exclude: 'false'", + "http-replicated-app collector should not be excluded in online mode") + + // Verify proxy is empty/not set in the collector block + assert.Contains(t, httpCollectorBlock, "proxy: ''", + "proxy should be empty when no proxy is configured") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a temporary directory for the test + tempDir := t.TempDir() + + // Create the support subdirectory + supportDir := filepath.Join(tempDir, "support") + err := os.MkdirAll(supportDir, 0755) + require.NoError(t, err) + + // Copy the actual customer template to the test directory + actualTemplatePath := filepath.Join("../../cmd/installer/goods/support/host-support-bundle.tmpl.yaml") + templateContent, err := os.ReadFile(actualTemplatePath) + require.NoError(t, err, "Should be able to read the actual customer template") + + // Write the actual template to the test directory + templatePath := filepath.Join(supportDir, "host-support-bundle.tmpl.yaml") + err = os.WriteFile(templatePath, templateContent, 0644) + require.NoError(t, err) + + // Create mock RuntimeConfig + mockRC := &runtimeconfig.MockRuntimeConfig{} + mockRC.On("EmbeddedClusterHomeDirectory").Return(tempDir) + mockRC.On("EmbeddedClusterK0sSubDir").Return(filepath.Join(tempDir, "k0s")) + mockRC.On("EmbeddedClusterOpenEBSLocalSubDir").Return(filepath.Join(tempDir, "openebs")) + mockRC.On("PathToEmbeddedClusterSupportFile", "host-support-bundle.tmpl.yaml").Return(templatePath) + mockRC.On("PathToEmbeddedClusterSupportFile", "host-support-bundle.yaml").Return( + filepath.Join(supportDir, "host-support-bundle.yaml")) + mockRC.On("ProxySpec").Return(tt.proxySpec) + + // Call the function under test + err = MaterializeSupportBundleSpec(mockRC, tt.isAirgap) + require.NoError(t, err) + + // Verify the file was created + outputFile := filepath.Join(supportDir, "host-support-bundle.yaml") + _, err = os.Stat(outputFile) + require.NoError(t, err, "Support bundle spec file should be created") + + // Read the generated file content + content, err := os.ReadFile(outputFile) + require.NoError(t, err) + contentStr := string(content) + + // Verify expected content is present + for _, expected := range tt.expectedInFile { + assert.Contains(t, contentStr, expected, + "Expected %q to be in the generated support bundle spec", expected) + } + + // Verify unwanted content is not present + for _, notExpected := range tt.notInFile { + assert.NotContains(t, contentStr, notExpected, + "Expected %q to NOT be in the generated support bundle spec", notExpected) + } + + // Verify that key template variables were properly substituted + assert.Contains(t, contentStr, tempDir, "Data directory should be substituted") + assert.Contains(t, contentStr, filepath.Join(tempDir, "k0s"), "K0s data directory should be substituted") + assert.Contains(t, contentStr, filepath.Join(tempDir, "openebs"), "OpenEBS data directory should be substituted") + + // Verify the YAML structure is valid + assert.Contains(t, contentStr, "apiVersion: troubleshoot.sh/v1beta2") + assert.Contains(t, contentStr, "kind: SupportBundle") + assert.Contains(t, contentStr, "hostCollectors:") + assert.Contains(t, contentStr, "hostAnalyzers:") + + // Verify key collectors that should always be present + assert.Contains(t, contentStr, "ipv4Interfaces", "Basic network collector should be present") + assert.Contains(t, contentStr, "memory", "Memory collector should be present") + assert.Contains(t, contentStr, "filesystem-write-latency-etcd", "Performance collector should be present") + + // Run the specific validation function for this test case + if tt.validateFunc != nil { + tt.validateFunc(t, contentStr) + } + + // Assert all mock expectations were met + mockRC.AssertExpectations(t) + }) + } +} diff --git a/tests/dryrun/install_test.go b/tests/dryrun/install_test.go index f1a4a0ad0b..889df4c5f7 100644 --- a/tests/dryrun/install_test.go +++ b/tests/dryrun/install_test.go @@ -39,6 +39,27 @@ func testDefaultInstallationImpl(t *testing.T) { dr := dryrunInstall(t, &dryrun.Client{HelmClient: hcli}) + kcli, err := dr.KubeClient() + if err != nil { + t.Fatalf("failed to create kube client: %v", err) + } + + // --- validate installation object --- // + in, err := kubeutils.GetLatestInstallation(context.TODO(), kcli) + if err != nil { + t.Fatalf("failed to get latest installation: %v", err) + } + + assert.NotEmpty(t, in.Spec.ClusterID) + assert.Equal(t, "80-32767", in.Spec.RuntimeConfig.Network.NodePortRange) + assert.Equal(t, "10.244.0.0/16", dr.Flags["cidr"]) + assert.Equal(t, "10.244.0.0/17", in.Spec.RuntimeConfig.Network.PodCIDR) + assert.Equal(t, "10.244.128.0/17", in.Spec.RuntimeConfig.Network.ServiceCIDR) + assert.Equal(t, 30000, in.Spec.RuntimeConfig.AdminConsole.Port) + assert.Equal(t, "/var/lib/embedded-cluster", in.Spec.RuntimeConfig.DataDir) + assert.Equal(t, 50000, in.Spec.RuntimeConfig.LocalArtifactMirror.Port) + assert.Equal(t, "ec-install", in.ObjectMeta.Labels["replicated.com/disaster-recovery"]) + // --- validate addons --- // // openebs @@ -57,7 +78,8 @@ func testDefaultInstallationImpl(t *testing.T) { operatorOpts := hcli.Calls[1].Arguments[1].(helm.InstallOptions) assert.Equal(t, "embedded-cluster-operator", operatorOpts.ReleaseName) assertHelmValues(t, operatorOpts.Values, map[string]interface{}{ - "image.repository": "fake-replicated-proxy.test.net/anonymous/replicated/embedded-cluster-operator-image", + "embeddedClusterID": in.Spec.ClusterID, + "image.repository": "fake-replicated-proxy.test.net/anonymous/replicated/embedded-cluster-operator-image", }) // velero @@ -76,6 +98,7 @@ func testDefaultInstallationImpl(t *testing.T) { assertHelmValues(t, adminConsoleOpts.Values, map[string]interface{}{ "isMultiNodeEnabled": true, "kurlProxy.nodePort": float64(30000), + "embeddedClusterID": in.Spec.ClusterID, "embeddedClusterDataDir": "/var/lib/embedded-cluster", "embeddedClusterK0sDir": "/var/lib/embedded-cluster/k0s", }) @@ -155,30 +178,11 @@ func testDefaultInstallationImpl(t *testing.T) { }) // --- validate cluster resources --- // - kcli, err := dr.KubeClient() - if err != nil { - t.Fatalf("failed to create kube client: %v", err) - } assertConfigMapExists(t, kcli, "embedded-cluster-host-support-bundle", "kotsadm") assertSecretExists(t, kcli, "kotsadm-password", "kotsadm") assertSecretExists(t, kcli, "cloud-credentials", "velero") - // --- validate installation object --- // - in, err := kubeutils.GetLatestInstallation(context.TODO(), kcli) - if err != nil { - t.Fatalf("failed to get latest installation: %v", err) - } - - assert.Equal(t, "80-32767", in.Spec.Network.NodePortRange) - assert.Equal(t, "10.244.0.0/16", dr.Flags["cidr"]) - assert.Equal(t, "10.244.0.0/17", in.Spec.Network.PodCIDR) - assert.Equal(t, "10.244.128.0/17", in.Spec.Network.ServiceCIDR) - assert.Equal(t, 30000, in.Spec.RuntimeConfig.AdminConsole.Port) - assert.Equal(t, "/var/lib/embedded-cluster", in.Spec.RuntimeConfig.DataDir) - assert.Equal(t, 50000, in.Spec.RuntimeConfig.LocalArtifactMirror.Port) - assert.Equal(t, "ec-install", in.ObjectMeta.Labels["replicated.com/disaster-recovery"]) - // --- validate k0s cluster config --- // k0sConfig := readK0sConfig(t) diff --git a/tests/dryrun/join_test.go b/tests/dryrun/join_test.go index d5709bbc6d..033dc2ea8e 100644 --- a/tests/dryrun/join_test.go +++ b/tests/dryrun/join_test.go @@ -12,22 +12,36 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/kinds/types/join" "github.com/replicatedhq/embedded-cluster/pkg/dryrun" + "github.com/replicatedhq/embedded-cluster/pkg/helm" + "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" ) func TestJoinControllerNode(t *testing.T) { - testJoinControllerNodeImpl(t, false) + testJoinControllerNodeImpl(t, false, false) } func TestJoinAirgapControllerNode(t *testing.T) { - testJoinControllerNodeImpl(t, true) + testJoinControllerNodeImpl(t, true, false) } -func testJoinControllerNodeImpl(t *testing.T, isAirgap bool) { +func TestJoinHAMigrationControllerNode(t *testing.T) { + testJoinControllerNodeImpl(t, false, true) +} + +func TestJoinHAMigrationAirgapControllerNode(t *testing.T) { + testJoinControllerNodeImpl(t, true, true) +} + +func testJoinControllerNodeImpl(t *testing.T, isAirgap bool, hasHAMigration bool) { clusterID := uuid.New() jcmd := &join.JoinCommandResponse{ K0sJoinCommand: "/usr/local/bin/k0s install controller --enable-worker --no-taints --labels kots.io/embedded-cluster-role=total-1,kots.io/embedded-cluster-role-0=controller-test,controller-label=controller-label-value", @@ -39,9 +53,11 @@ func testJoinControllerNodeImpl(t *testing.T, isAirgap bool) { MetricsBaseURL: "https://testing.com", Config: &ecv1beta1.ConfigSpec{UnsupportedOverrides: ecv1beta1.UnsupportedOverrides{}}, AirGap: isAirgap, - Network: &ecv1beta1.NetworkSpec{ - PodCIDR: "10.2.0.0/17", - ServiceCIDR: "10.2.128.0/17", + RuntimeConfig: &ecv1beta1.RuntimeConfigSpec{ + Network: ecv1beta1.NetworkSpec{ + PodCIDR: "10.2.0.0/17", + ServiceCIDR: "10.2.128.0/17", + }, }, }, TCPConnectionsRequired: []string{"10.0.0.1:6443", "10.0.0.1:9443"}, @@ -50,10 +66,14 @@ func testJoinControllerNodeImpl(t *testing.T, isAirgap bool) { kotsadm := dryrun.NewKotsadm() kubeUtils := &dryrun.KubeUtils{} + hcli := &helm.MockClient{} + hcli.On("Close").Once().Return(nil) + drFile := filepath.Join(t.TempDir(), "ec-dryrun.yaml") dryrun.Init(drFile, &dryrun.Client{ - Kotsadm: kotsadm, - KubeUtils: kubeUtils, + Kotsadm: kotsadm, + KubeUtils: kubeUtils, + HelmClient: hcli, }) kotsadm.SetGetJoinTokenResponse("10.0.0.1", "some-token", jcmd, nil) @@ -90,10 +110,10 @@ func testJoinControllerNodeImpl(t *testing.T, isAirgap bool) { kotsadm.SetGetECChartsResponse("10.0.0.1", testChartsFile, nil) } - kubeClient, err := kubeUtils.KubeClient() + kcli, err := kubeUtils.KubeClient() require.NoError(t, err) - kubeClient.Create(context.Background(), &ecv1beta1.Installation{ + kcli.Create(context.Background(), &ecv1beta1.Installation{ TypeMeta: metav1.TypeMeta{ Kind: "Installation", APIVersion: "v1beta1", @@ -102,6 +122,8 @@ func testJoinControllerNodeImpl(t *testing.T, isAirgap bool) { Name: "20241002205018", }, Spec: ecv1beta1.InstallationSpec{ + ClusterID: clusterID.String(), + HighAvailability: false, Config: &ecv1beta1.ConfigSpec{ Version: "2.2.0+k8s-1.30", }, @@ -109,6 +131,74 @@ func testJoinControllerNodeImpl(t *testing.T, isAirgap bool) { }, }, &ctrlclient.CreateOptions{}) + kcli.Create(context.Background(), &corev1.Node{ + TypeMeta: metav1.TypeMeta{ + Kind: "Node", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "node-1", + Labels: map[string]string{ + "node-role.kubernetes.io/control-plane": "true", + }, + }, + }, &ctrlclient.CreateOptions{}) + kcli.Create(context.Background(), &corev1.Node{ + TypeMeta: metav1.TypeMeta{ + Kind: "Node", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "node-2", + Labels: map[string]string{ + "node-role.kubernetes.io/control-plane": "true", + }, + }, + }, &ctrlclient.CreateOptions{}) + + if hasHAMigration { + kcli.Create(context.Background(), &corev1.Node{ + TypeMeta: metav1.TypeMeta{ + Kind: "Node", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "node-3", + Labels: map[string]string{ + "node-role.kubernetes.io/control-plane": "true", + }, + }, + }, &ctrlclient.CreateOptions{}) + kcli.Create(context.Background(), &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{ + Kind: "Deployment", + APIVersion: "apps/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "registry", + Namespace: "registry", + }, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + }, + }, &ctrlclient.CreateOptions{}) + + if isAirgap { + hcli.On("ReleaseExists", mock.Anything, "seaweedfs", "seaweedfs").Once().Return(true, nil) + hcli.On("Upgrade", mock.Anything, mock.MatchedBy(func(opts helm.UpgradeOptions) bool { + return opts.ReleaseName == "seaweedfs" + })).Once().Return(nil, nil) + hcli.On("ReleaseExists", mock.Anything, "registry", "docker-registry").Once().Return(true, nil) + hcli.On("Upgrade", mock.Anything, mock.MatchedBy(func(opts helm.UpgradeOptions) bool { + return opts.ReleaseName == "docker-registry" + })).Once().Return(nil, nil) + } + hcli.On("ReleaseExists", mock.Anything, "kotsadm", "admin-console").Once().Return(true, nil) + hcli.On("Upgrade", mock.Anything, mock.MatchedBy(func(opts helm.UpgradeOptions) bool { + return opts.ReleaseName == "admin-console" + })).Once().Return(nil, nil) + } + dr := dryrunJoin(t, "10.0.0.1", "some-token") // --- validate k0s images file and charts (if airgap) --- // @@ -264,6 +354,36 @@ func testJoinControllerNodeImpl(t *testing.T, isAirgap bool) { }, }) + // --- validate installation object --- // + in, err := kubeutils.GetLatestInstallation(context.TODO(), kcli) + if err != nil { + t.Fatalf("failed to get latest installation: %v", err) + } + + assert.Equal(t, clusterID.String(), in.Spec.ClusterID) + if hasHAMigration { + assert.True(t, in.Spec.HighAvailability, "HA should be true") + } else { + assert.False(t, in.Spec.HighAvailability, "HA should be false") + } + + hcli.AssertExpectations(t) + + // --- validate admin console values --- // + if hasHAMigration { + var adminConsoleValues map[string]interface{} + hcli.AssertCalled(t, "Upgrade", mock.Anything, mock.MatchedBy(func(opts helm.UpgradeOptions) bool { + if opts.ReleaseName == "admin-console" { + adminConsoleValues = opts.Values + return true + } + return false + })) + assertHelmValues(t, adminConsoleValues, map[string]interface{}{ + "embeddedClusterID": clusterID.String(), + }) + } + t.Logf("%s: test complete", time.Now().Format(time.RFC3339)) } diff --git a/tests/dryrun/update_test.go b/tests/dryrun/update_test.go index f01d4e1101..67f98a01b3 100644 --- a/tests/dryrun/update_test.go +++ b/tests/dryrun/update_test.go @@ -54,6 +54,7 @@ func TestUpdateAirgapCurrent(t *testing.T) { RuntimeConfig: &ecv1beta1.RuntimeConfigSpec{ DataDir: "/var/lib/embedded-cluster", }, + ClusterID: "123", }, }, &ctrlclient.CreateOptions{}) diff --git a/tests/dryrun/util.go b/tests/dryrun/util.go index c0ed00e5d6..a57db1e4fc 100644 --- a/tests/dryrun/util.go +++ b/tests/dryrun/util.go @@ -132,7 +132,7 @@ func runInstallerCmd(args ...string) error { fullArgs := append([]string{"dryrun"}, args...) os.Args = fullArgs // for reporting - installerCmd := cli.RootCmd(context.Background(), "dryrun") + installerCmd := cli.RootCmd(context.Background()) installerCmd.SetArgs(args) return installerCmd.Execute() } diff --git a/tests/integration/kind/Makefile b/tests/integration/kind/Makefile index 0a2f667c62..2812bb4453 100644 --- a/tests/integration/kind/Makefile +++ b/tests/integration/kind/Makefile @@ -6,7 +6,7 @@ RUN ?= GO_BUILD_TAGS ?= containers_image_openpgp,exclude_graphdriver_btrfs,exclude_graphdriver_devicemapper,exclude_graphdriver_overlay .PHONY: test -test: test-openebs test-registry test-velero +test: test-openebs test-registry test-velero test-adminconsole .PHONY: test-openebs test-openebs: openebs.test @@ -31,6 +31,13 @@ test-velero: velero.test -test.timeout=5m \ -test.run='$(value RUN)' +.PHONY: test-adminconsole +test-adminconsole: adminconsole.test + DEBUG=$(DEBUG) ./adminconsole.test \ + -test.v \ + -test.timeout=5m \ + -test.run='$(value RUN)' + .PHONY: clean clean: rm -f *.test @@ -46,3 +53,7 @@ registry.test: velero.test: go test -c -tags $(GO_BUILD_TAGS) \ ./velero + +adminconsole.test: + go test -c -tags $(GO_BUILD_TAGS) \ + ./adminconsole diff --git a/tests/integration/kind/adminconsole/embedded_test.go b/tests/integration/kind/adminconsole/embedded_test.go new file mode 100644 index 0000000000..9d797cd27b --- /dev/null +++ b/tests/integration/kind/adminconsole/embedded_test.go @@ -0,0 +1,108 @@ +package adminconsole + +import ( + "net" + "testing" + "time" + + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg-new/tlsutils" + "github.com/replicatedhq/embedded-cluster/pkg/addons/adminconsole" + "github.com/replicatedhq/embedded-cluster/pkg/addons/openebs" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + "github.com/replicatedhq/embedded-cluster/tests/integration/util" + "github.com/replicatedhq/embedded-cluster/tests/integration/util/kind" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" +) + +func TestAdminConsole_EmbeddedCluster(t *testing.T) { + ctx := t.Context() + + util.SetupCtrlLogging(t) + + clusterName := util.GenerateClusterName(t) + + kindConfig := util.NewKindClusterConfig(t, clusterName, nil) + + kindConfig.Nodes[0].ExtraPortMappings = append(kindConfig.Nodes[0].ExtraPortMappings, kind.PortMapping{ + ContainerPort: 30500, + }) + + // data and k0s directories are required for the admin console addon + ecDataDirMount := kind.Mount{ + HostPath: util.TempDirForHostMount(t, "data-dir-*"), + ContainerPath: "/var/lib/embedded-cluster", + } + k0sDirMount := kind.Mount{ + HostPath: util.TempDirForHostMount(t, "k0s-dir-*"), + ContainerPath: "/var/lib/embedded-cluster/k0s", + } + kindConfig.Nodes[0].ExtraMounts = append(kindConfig.Nodes[0].ExtraMounts, ecDataDirMount, k0sDirMount) + + kubeconfig := util.SetupKindClusterFromConfig(t, kindConfig) + + kcli := util.CtrlClient(t, kubeconfig) + mcli := util.MetadataClient(t, kubeconfig) + hcli := util.HelmClient(t, kubeconfig) + + rc := runtimeconfig.New(nil) + rc.SetNetworkSpec(ecv1beta1.NetworkSpec{ + PodCIDR: "10.85.0.0/12", + ServiceCIDR: "10.96.0.0/12", + }) + + domains := ecv1beta1.Domains{ + ReplicatedAppDomain: "replicated.app", + ProxyRegistryDomain: "proxy.replicated.com", + ReplicatedRegistryDomain: "registry.replicated.com", + } + + t.Logf("%s installing openebs", formattedTime()) + openebsAddon := &openebs.OpenEBS{ + OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), + } + if err := openebsAddon.Install(ctx, t.Logf, kcli, mcli, hcli, domains, nil); err != nil { + t.Fatalf("failed to install openebs: %v", err) + } + + t.Logf("%s waiting for storageclass", formattedTime()) + util.WaitForStorageClass(t, kubeconfig, "openebs-hostpath", 30*time.Second) + + t.Logf("%s generating tls certificate", formattedTime()) + _, certData, keyData, err := tlsutils.GenerateCertificate("localhost", []net.IP{net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatalf("generate tls certificate: %v", err) + } + + t.Logf("%s installing admin console", formattedTime()) + addon := &adminconsole.AdminConsole{ + IsAirgap: false, + IsHA: false, + IsMultiNodeEnabled: false, + Proxy: rc.ProxySpec(), + AdminConsolePort: rc.AdminConsolePort(), + + ClusterID: "123", + ServiceCIDR: "10.96.0.0/12", + HostCABundlePath: rc.HostCABundlePath(), + DataDir: rc.EmbeddedClusterHomeDirectory(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), + + Password: "password", + TLSCertBytes: certData, + TLSKeyBytes: keyData, + Hostname: "localhost", + } + require.NoError(t, addon.Install(ctx, t.Logf, kcli, mcli, hcli, domains, nil)) + + t.Logf("%s waiting for admin console to be ready", formattedTime()) + util.WaitForDeployment(t, kubeconfig, "kotsadm", "kotsadm", 1, 30*time.Second) + + deploy := util.GetDeployment(t, kubeconfig, addon.Namespace(), "kotsadm") + + assert.Contains(t, deploy.Spec.Template.Spec.Containers[0].Env, + corev1.EnvVar{Name: "EMBEDDED_CLUSTER_ID", Value: "123"}, + "admin console should have the EMBEDDED_CLUSTER_ID env var") +} diff --git a/tests/integration/kind/adminconsole/existing_test.go b/tests/integration/kind/adminconsole/existing_test.go new file mode 100644 index 0000000000..214546dda5 --- /dev/null +++ b/tests/integration/kind/adminconsole/existing_test.go @@ -0,0 +1,67 @@ +package adminconsole + +import ( + "net" + "testing" + "time" + + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg-new/tlsutils" + "github.com/replicatedhq/embedded-cluster/pkg/addons/adminconsole" + "github.com/replicatedhq/embedded-cluster/pkg/kubernetesinstallation" + "github.com/replicatedhq/embedded-cluster/tests/integration/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAdminConsole_ExistingCluster(t *testing.T) { + ctx := t.Context() + + util.SetupCtrlLogging(t) + + clusterName := util.GenerateClusterName(t) + kubeconfig := util.SetupKindCluster(t, clusterName, nil) + + kcli := util.CtrlClient(t, kubeconfig) + mcli := util.MetadataClient(t, kubeconfig) + hcli := util.HelmClient(t, kubeconfig) + + ki := kubernetesinstallation.New(nil) + + domains := ecv1beta1.Domains{ + ReplicatedAppDomain: "replicated.app", + ProxyRegistryDomain: "proxy.replicated.com", + ReplicatedRegistryDomain: "registry.replicated.com", + } + + t.Logf("%s generating tls certificate", formattedTime()) + _, certData, keyData, err := tlsutils.GenerateCertificate("localhost", []net.IP{net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatalf("generate tls certificate: %v", err) + } + + t.Logf("%s installing admin console", formattedTime()) + addon := &adminconsole.AdminConsole{ + IsAirgap: false, + IsHA: false, + IsMultiNodeEnabled: false, + Proxy: ki.ProxySpec(), + AdminConsolePort: ki.AdminConsolePort(), + + Password: "password", + TLSCertBytes: certData, + TLSKeyBytes: keyData, + Hostname: "localhost", + } + require.NoError(t, addon.Install(ctx, t.Logf, kcli, mcli, hcli, domains, nil)) + + t.Logf("%s waiting for admin console to be ready", formattedTime()) + util.WaitForDeployment(t, kubeconfig, "kotsadm", "kotsadm", 1, 30*time.Second) + + deploy := util.GetDeployment(t, kubeconfig, addon.Namespace(), "kotsadm") + + // should not have the EMBEDDED_CLUSTER_ID env var + for _, env := range deploy.Spec.Template.Spec.Containers[0].Env { + assert.NotEqual(t, "EMBEDDED_CLUSTER_ID", env.Name, "admin console should not have the EMBEDDED_CLUSTER_ID env var") + } +} diff --git a/tests/integration/kind/adminconsole/util.go b/tests/integration/kind/adminconsole/util.go new file mode 100644 index 0000000000..01b9fb798b --- /dev/null +++ b/tests/integration/kind/adminconsole/util.go @@ -0,0 +1,7 @@ +package adminconsole + +import "time" + +func formattedTime() string { + return time.Now().Format("2006-01-02 15:04:05") +} diff --git a/tests/integration/kind/openebs/analytics_test.go b/tests/integration/kind/openebs/analytics_test.go index 53cafcd223..6fd465bde6 100644 --- a/tests/integration/kind/openebs/analytics_test.go +++ b/tests/integration/kind/openebs/analytics_test.go @@ -5,7 +5,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/openebs" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/tests/integration/util" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" @@ -21,14 +20,12 @@ func TestOpenEBS_AnalyticsDisabled(t *testing.T) { mcli := util.MetadataClient(t, kubeconfig) hcli := util.HelmClient(t, kubeconfig) - rc := runtimeconfig.New(nil) - domains := ecv1beta1.Domains{ ProxyRegistryDomain: "proxy.replicated.com", } addon := &openebs.OpenEBS{} - if err := addon.Install(t.Context(), t.Logf, kcli, mcli, hcli, rc, domains, nil); err != nil { + if err := addon.Install(t.Context(), t.Logf, kcli, mcli, hcli, domains, nil); err != nil { t.Fatalf("failed to install openebs: %v", err) } diff --git a/tests/integration/kind/openebs/customdatadir_test.go b/tests/integration/kind/openebs/customdatadir_test.go index 1d317e9de6..8b4e6c4a50 100644 --- a/tests/integration/kind/openebs/customdatadir_test.go +++ b/tests/integration/kind/openebs/customdatadir_test.go @@ -8,7 +8,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/openebs" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/tests/integration/util" "github.com/replicatedhq/embedded-cluster/tests/integration/util/kind" "github.com/stretchr/testify/assert" @@ -28,9 +27,6 @@ func TestOpenEBS_CustomDataDir(t *testing.T) { }) kubeconfig := util.SetupKindClusterFromConfig(t, kindConfig) - rc := runtimeconfig.New(nil) - rc.SetDataDir("/custom") - kcli := util.CtrlClient(t, kubeconfig) mcli := util.MetadataClient(t, kubeconfig) hcli := util.HelmClient(t, kubeconfig) @@ -39,8 +35,10 @@ func TestOpenEBS_CustomDataDir(t *testing.T) { ProxyRegistryDomain: "proxy.replicated.com", } - addon := &openebs.OpenEBS{} - if err := addon.Install(t.Context(), t.Logf, kcli, mcli, hcli, rc, domains, nil); err != nil { + addon := &openebs.OpenEBS{ + OpenEBSDataDir: "/custom/openebs-local", + } + if err := addon.Install(t.Context(), t.Logf, kcli, mcli, hcli, domains, nil); err != nil { t.Fatalf("failed to install openebs: %v", err) } @@ -50,7 +48,7 @@ func TestOpenEBS_CustomDataDir(t *testing.T) { createPodAndPVC(t, kubeconfig) _, err := os.Stat(filepath.Join(dataDir, "openebs-local")) - require.NoError(t, err, "failed to find %s data dir") + require.NoError(t, err, "failed to find openebs data dir") entries, err := os.ReadDir(dataDir) require.NoError(t, err, "failed to read openebs data dir") assert.Len(t, entries, 1, "expected pvc dir file in openebs data dir") diff --git a/tests/integration/kind/registry/ha_test.go b/tests/integration/kind/registry/ha_test.go index 7d9e16dd05..15112daca1 100644 --- a/tests/integration/kind/registry/ha_test.go +++ b/tests/integration/kind/registry/ha_test.go @@ -67,14 +67,22 @@ func TestRegistry_EnableHAAirgap(t *testing.T) { hcli := util.HelmClient(t, kubeconfig) rc := runtimeconfig.New(nil) + rc.SetNetworkSpec(ecv1beta1.NetworkSpec{ + PodCIDR: "10.85.0.0/12", + ServiceCIDR: "10.96.0.0/12", + }) domains := ecv1beta1.Domains{ - ProxyRegistryDomain: "proxy.replicated.com", + ReplicatedAppDomain: "replicated.app", + ProxyRegistryDomain: "proxy.replicated.com", + ReplicatedRegistryDomain: "registry.replicated.com", } t.Logf("%s installing openebs", formattedTime()) - addon := &openebs.OpenEBS{} - if err := addon.Install(ctx, t.Logf, kcli, mcli, hcli, rc, domains, nil); err != nil { + addon := &openebs.OpenEBS{ + OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), + } + if err := addon.Install(ctx, t.Logf, kcli, mcli, hcli, domains, nil); err != nil { t.Fatalf("failed to install openebs: %v", err) } @@ -86,18 +94,25 @@ func TestRegistry_EnableHAAirgap(t *testing.T) { ServiceCIDR: "10.96.0.0/12", IsHA: false, } - require.NoError(t, registryAddon.Install(ctx, t.Logf, kcli, mcli, hcli, rc, domains, nil)) + require.NoError(t, registryAddon.Install(ctx, t.Logf, kcli, mcli, hcli, domains, nil)) t.Logf("%s creating hostport service", formattedTime()) registryAddr := createHostPortService(t, clusterName, kubeconfig) t.Logf("%s installing admin console", formattedTime()) adminConsoleAddon := &adminconsole.AdminConsole{ - IsAirgap: true, - IsHA: false, - ServiceCIDR: "10.96.0.0/12", + ClusterID: "123", + IsAirgap: true, + IsHA: false, + Proxy: rc.ProxySpec(), + ServiceCIDR: "10.96.0.0/12", + IsMultiNodeEnabled: false, + HostCABundlePath: rc.HostCABundlePath(), + DataDir: rc.EmbeddedClusterHomeDirectory(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), + AdminConsolePort: rc.AdminConsolePort(), } - require.NoError(t, adminConsoleAddon.Install(ctx, t.Logf, kcli, mcli, hcli, rc, domains, nil)) + require.NoError(t, adminConsoleAddon.Install(ctx, t.Logf, kcli, mcli, hcli, domains, nil)) t.Logf("%s pushing image to registry", formattedTime()) copyImageToRegistry(t, registryAddr, "docker.io/library/busybox:1.36.1") @@ -113,10 +128,9 @@ func TestRegistry_EnableHAAirgap(t *testing.T) { inSpec := ecv1beta1.InstallationSpec{ AirGap: true, Config: &ecv1beta1.ConfigSpec{ - Domains: ecv1beta1.Domains{ - ProxyRegistryDomain: "proxy.replicated.com", - }, + Domains: domains, }, + RuntimeConfig: rc.Get(), } addOns := addons.New( @@ -124,7 +138,7 @@ func TestRegistry_EnableHAAirgap(t *testing.T) { addons.WithKubernetesClientSet(kclient), addons.WithMetadataClient(mcli), addons.WithHelmClient(hcli), - addons.WithRuntimeConfig(rc), + addons.WithDomains(domains), ) enableHAAndCancelContextOnMessage(t, addOns, inSpec, @@ -147,7 +161,12 @@ func TestRegistry_EnableHAAirgap(t *testing.T) { loading := newTestingSpinner(t) func() { defer loading.Close() - err = addOns.EnableHA(t.Context(), "10.96.0.0/12", inSpec, loading) + opts := addons.EnableHAOptions{ + ClusterID: "123", + ServiceCIDR: rc.ServiceCIDR(), + ProxySpec: rc.ProxySpec(), + } + err = addOns.EnableHA(t.Context(), opts, loading) require.NoError(t, err) }() @@ -204,8 +223,24 @@ func enableHAAndCancelContextOnMessage(t *testing.T, addOns *addons.AddOns, inSp loading := newTestingSpinner(t) defer loading.Close() + rc := runtimeconfig.New(inSpec.RuntimeConfig) + t.Logf("%s enabling HA and cancelling context on message", formattedTime()) - err = addOns.EnableHA(ctx, "10.96.0.0/12", inSpec, loading) + opts := addons.EnableHAOptions{ + ClusterID: "123", + AdminConsolePort: rc.AdminConsolePort(), + IsAirgap: true, + IsMultiNodeEnabled: false, + EmbeddedConfigSpec: inSpec.Config, + EndUserConfigSpec: inSpec.Config, + ProxySpec: rc.ProxySpec(), + HostCABundlePath: rc.HostCABundlePath(), + DataDir: rc.EmbeddedClusterHomeDirectory(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), + SeaweedFSDataDir: rc.EmbeddedClusterSeaweedFSSubDir(), + ServiceCIDR: inSpec.RuntimeConfig.Network.ServiceCIDR, + } + err = addOns.EnableHA(ctx, opts, loading) require.ErrorIs(t, err, context.Canceled, "expected context to be cancelled") t.Logf("%s cancelled context and got error: %v", formattedTime(), err) } diff --git a/tests/integration/kind/registry/util.go b/tests/integration/kind/registry/util.go index 3d196965dc..a780a8dd94 100644 --- a/tests/integration/kind/registry/util.go +++ b/tests/integration/kind/registry/util.go @@ -3,7 +3,6 @@ package registry import ( "fmt" "net" - "runtime" "strings" "testing" "time" @@ -13,6 +12,7 @@ import ( "github.com/containers/image/v5/transports/alltransports" imagetypes "github.com/containers/image/v5/types" "github.com/replicatedhq/embedded-cluster/pkg/addons/registry" + "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/tests/integration/kind/registry/static" "github.com/replicatedhq/embedded-cluster/tests/integration/util" ) @@ -54,7 +54,7 @@ func copyImageToRegistry(t *testing.T, registryAddr string, image string) { _, err = copy.Image(t.Context(), policyContext, dstRef, srcRef, ©.Options{ SourceCtx: &imagetypes.SystemContext{ - ArchitectureChoice: runtime.GOARCH, + ArchitectureChoice: helpers.ClusterArch(), OSChoice: "linux", }, DestinationCtx: &imagetypes.SystemContext{ diff --git a/tests/integration/kind/velero/ca_test.go b/tests/integration/kind/velero/ca_test.go index d9ebed4635..470594f694 100644 --- a/tests/integration/kind/velero/ca_test.go +++ b/tests/integration/kind/velero/ca_test.go @@ -5,7 +5,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/velero" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/tests/integration/util" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" @@ -23,15 +22,15 @@ func TestVelero_HostCABundle(t *testing.T) { mcli := util.MetadataClient(t, kubeconfig) hcli := util.HelmClient(t, kubeconfig) - rc := runtimeconfig.New(nil) - rc.SetHostCABundlePath("/etc/ssl/certs/ca-certificates.crt") - domains := ecv1beta1.Domains{ ProxyRegistryDomain: "proxy.replicated.com", } - addon := &velero.Velero{} - if err := addon.Install(t.Context(), t.Logf, kcli, mcli, hcli, rc, domains, nil); err != nil { + addon := &velero.Velero{ + HostCABundlePath: "/etc/ssl/certs/ca-certificates.crt", + } + + if err := addon.Install(t.Context(), t.Logf, kcli, mcli, hcli, domains, nil); err != nil { t.Fatalf("failed to install velero: %v", err) } diff --git a/web/package-lock.json b/web/package-lock.json index 56799522bb..463cef5e10 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,36 +9,36 @@ "version": "0.0.0", "dependencies": { "@tailwindcss/forms": "^0.5.10", - "@tanstack/react-query": "^5.80.7", - "lucide-react": "^0.515.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.22.3" + "@tanstack/react-query": "^5.81.5", + "lucide-react": "^0.525.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.3" }, "devDependencies": { - "@eslint/js": "^9.29.0", - "@faker-js/faker": "^8.0.2", + "@eslint/js": "^9.30.0", + "@faker-js/faker": "^9.8.0", "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.4.3", - "@types/node": "^24.0.1", - "@types/react": "^18.3.5", + "@types/node": "^24.0.7", + "@types/react": "^19.1.8", "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.5.2", + "@vitejs/plugin-react": "^4.6.0", "autoprefixer": "^10.4.21", - "eslint": "^9.29.0", + "eslint": "^9.30.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", - "globals": "^15.9.0", + "globals": "^16.2.0", "jsdom": "^26.1.0", "msw": "2.10.2", - "postcss": "^8.5.5", + "postcss": "^8.5.6", "tailwindcss": "^3.4.1", "typescript": "^5.8.3", - "typescript-eslint": "^8.34.0", - "vite": "^5.4.2", - "vite-plugin-static-copy": "^3.0.0", - "vitest": "^0.32.2" + "typescript-eslint": "^8.35.0", + "vite": "^6.3.5", + "vite-plugin-static-copy": "^3.1.0", + "vitest": "^3.2.4" } }, "node_modules/@adobe/css-tools": { @@ -533,371 +533,428 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -942,11 +999,10 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", - "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", @@ -957,11 +1013,10 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", - "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -1017,11 +1072,10 @@ } }, "node_modules/@eslint/js": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", - "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", + "version": "9.30.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.0.tgz", + "integrity": "sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1034,7 +1088,6 @@ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -1054,9 +1107,9 @@ } }, "node_modules/@faker-js/faker": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", - "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.8.0.tgz", + "integrity": "sha512-U9wpuSrJC93jZBxx/Qq2wPjCuYISBueyVUGK7qqdmj7r/nxaxwW8AQDCLeRO7wZnjj94sh3p246cAYjUKuqgfg==", "dev": true, "funding": [ { @@ -1066,8 +1119,8 @@ ], "license": "MIT", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0", - "npm": ">=6.14.13" + "node": ">=18.0.0", + "npm": ">=9.0.0" } }, "node_modules/@humanfs/core": { @@ -1572,225 +1625,287 @@ "node": ">=14" } }, - "node_modules/@remix-run/router": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", - "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.11", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz", - "integrity": "sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==", - "dev": true, - "license": "MIT" + "version": "1.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", + "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", + "dev": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", - "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.43.0.tgz", + "integrity": "sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz", - "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.43.0.tgz", + "integrity": "sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz", - "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.43.0.tgz", + "integrity": "sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz", - "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.43.0.tgz", + "integrity": "sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.43.0.tgz", + "integrity": "sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.43.0.tgz", + "integrity": "sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz", - "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.43.0.tgz", + "integrity": "sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz", - "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.43.0.tgz", + "integrity": "sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz", - "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.43.0.tgz", + "integrity": "sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz", - "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.43.0.tgz", + "integrity": "sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.43.0.tgz", + "integrity": "sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz", - "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.43.0.tgz", + "integrity": "sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz", - "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.43.0.tgz", + "integrity": "sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.43.0.tgz", + "integrity": "sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz", - "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.43.0.tgz", + "integrity": "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz", - "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.43.0.tgz", + "integrity": "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz", - "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.43.0.tgz", + "integrity": "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz", - "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.43.0.tgz", + "integrity": "sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz", - "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.43.0.tgz", + "integrity": "sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz", - "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.43.0.tgz", + "integrity": "sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1816,22 +1931,20 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.80.7", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.80.7.tgz", - "integrity": "sha512-s09l5zeUKC8q7DCCCIkVSns8zZrK4ZDT6ryEjxNBFi68G4z2EBobBS7rdOY3r6W1WbUDpc1fe5oY+YO/+2UVUg==", - "license": "MIT", + "version": "5.81.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.81.5.tgz", + "integrity": "sha512-ZJOgCy/z2qpZXWaj/oxvodDx07XcQa9BF92c0oINjHkoqUPsmm3uG08HpTaviviZ/N9eP1f9CM7mKSEkIo7O1Q==", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/react-query": { - "version": "5.80.7", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.80.7.tgz", - "integrity": "sha512-u2F0VK6+anItoEvB3+rfvTO9GEh2vb00Je05OwlUe/A0lkJBgW1HckiY3f9YZa+jx6IOe4dHPh10dyp9aY3iRQ==", - "license": "MIT", + "version": "5.81.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz", + "integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==", "dependencies": { - "@tanstack/query-core": "5.80.7" + "@tanstack/query-core": "5.81.5" }, "funding": { "type": "github", @@ -2041,181 +2154,85 @@ } }, "node_modules/@testing-library/react": { - "version": "14.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", - "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^9.0.0", - "@types/react-dom": "^18.0.0" + "@babel/runtime": "^7.12.5" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@testing-library/react/node_modules/@testing-library/dom": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", - "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, "engines": { - "node": ">=14" + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@testing-library/react/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } + "peer": true }, - "node_modules/@testing-library/react/node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "deep-equal": "^2.0.5" + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" } }, - "node_modules/@testing-library/react/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", "dev": true, - "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "@babel/types": "^7.0.0" } }, - "node_modules/@testing-library/react/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "dev": true, - "license": "MIT", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@testing-library/react/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/react/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" } }, "node_modules/@types/babel__traverse": { @@ -2228,20 +2245,13 @@ } }, "node_modules/@types/chai": { - "version": "4.3.20", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", - "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/chai-subset": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.6.tgz", - "integrity": "sha512-m8lERkkQj+uek18hXOZuec3W/fCRTrU4hrnXjH3qhHy96ytuPaPiWGgu7sJb7tZxZonO75vYAjCvpe/e4VUwRw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", "dev": true, "license": "MIT", - "peerDependencies": { - "@types/chai": "<5.2.0" + "dependencies": { + "@types/deep-eql": "*" } }, "node_modules/@types/cookie": { @@ -2251,11 +2261,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -2337,28 +2355,20 @@ "dev": true }, "node_modules/@types/node": { - "version": "24.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.1.tgz", - "integrity": "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==", + "version": "24.0.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.7.tgz", + "integrity": "sha512-YIEUUr4yf8q8oQoXPpSlnvKNVKDQlPMWrmOcgzoduo7kvA2UF0/BwJ/eMKFTiTtkNL17I0M6Xe2tvwFU7be6iw==", "dev": true, - "license": "MIT", "dependencies": { "undici-types": "~7.8.0" } }, - "node_modules/@types/prop-types": { - "version": "15.7.13", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", - "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", - "dev": true - }, "node_modules/@types/react": { - "version": "18.3.11", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz", - "integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==", + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "dev": true, "dependencies": { - "@types/prop-types": "*", "csstype": "^3.0.2" } }, @@ -2420,17 +2430,16 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.0.tgz", - "integrity": "sha512-QXwAlHlbcAwNlEEMKQS2RCgJsgXrTJdjXT08xEgbPFa2yYQgVjBymxP5DrfrE7X7iodSzd9qBUHUycdyVJTW1w==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz", + "integrity": "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.34.0", - "@typescript-eslint/type-utils": "8.34.0", - "@typescript-eslint/utils": "8.34.0", - "@typescript-eslint/visitor-keys": "8.34.0", + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/type-utils": "8.35.0", + "@typescript-eslint/utils": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -2444,7 +2453,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.34.0", + "@typescript-eslint/parser": "^8.35.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } @@ -2454,22 +2463,20 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.34.0.tgz", - "integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.0.tgz", + "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.34.0", - "@typescript-eslint/types": "8.34.0", - "@typescript-eslint/typescript-estree": "8.34.0", - "@typescript-eslint/visitor-keys": "8.34.0", + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/typescript-estree": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", "debug": "^4.3.4" }, "engines": { @@ -2485,14 +2492,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.34.0.tgz", - "integrity": "sha512-iEgDALRf970/B2YExmtPMPF54NenZUf4xpL3wsCRx/lgjz6ul/l13R81ozP/ZNuXfnLCS+oPmG7JIxfdNYKELw==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.0.tgz", + "integrity": "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.34.0", - "@typescript-eslint/types": "^8.34.0", + "@typescript-eslint/tsconfig-utils": "^8.35.0", + "@typescript-eslint/types": "^8.35.0", "debug": "^4.3.4" }, "engines": { @@ -2507,14 +2513,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.34.0.tgz", - "integrity": "sha512-9Ac0X8WiLykl0aj1oYQNcLZjHgBojT6cW68yAgZ19letYu+Hxd0rE0veI1XznSSst1X5lwnxhPbVdwjDRIomRw==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz", + "integrity": "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.34.0", - "@typescript-eslint/visitor-keys": "8.34.0" + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2525,11 +2530,10 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.34.0.tgz", - "integrity": "sha512-+W9VYHKFIzA5cBeooqQxqNriAP0QeQ7xTiDuIOr71hzgffm3EL2hxwWBIIj4GuofIbKxGNarpKqIq6Q6YrShOA==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz", + "integrity": "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2542,14 +2546,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.34.0.tgz", - "integrity": "sha512-n7zSmOcUVhcRYC75W2pnPpbO1iwhJY3NLoHEtbJwJSNlVAZuwqu05zY3f3s2SDWWDSo9FdN5szqc73DCtDObAg==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz", + "integrity": "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.34.0", - "@typescript-eslint/utils": "8.34.0", + "@typescript-eslint/typescript-estree": "8.35.0", + "@typescript-eslint/utils": "8.35.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -2566,11 +2569,10 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.34.0.tgz", - "integrity": "sha512-9V24k/paICYPniajHfJ4cuAWETnt7Ssy+R0Rbcqo5sSFr3QEZ/8TSoUi9XeXVBGXCaLtwTOKSLGcInCAvyZeMA==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz", + "integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2580,16 +2582,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.34.0.tgz", - "integrity": "sha512-rOi4KZxI7E0+BMqG7emPSK1bB4RICCpF7QD3KCLXn9ZvWoESsOMlHyZPAHyG04ujVplPaHbmEvs34m+wjgtVtg==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz", + "integrity": "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.34.0", - "@typescript-eslint/tsconfig-utils": "8.34.0", - "@typescript-eslint/types": "8.34.0", - "@typescript-eslint/visitor-keys": "8.34.0", + "@typescript-eslint/project-service": "8.35.0", + "@typescript-eslint/tsconfig-utils": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2613,7 +2614,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -2623,7 +2623,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -2639,7 +2638,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -2648,16 +2646,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.34.0.tgz", - "integrity": "sha512-8L4tWatGchV9A1cKbjaavS6mwYwp39jql8xUmIIKJdm+qiaeHy5KMKlBrf30akXAWBzn2SqKsNOtSENWUwg7XQ==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz", + "integrity": "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.34.0", - "@typescript-eslint/types": "8.34.0", - "@typescript-eslint/typescript-estree": "8.34.0" + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/typescript-estree": "8.35.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2672,14 +2669,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.34.0.tgz", - "integrity": "sha512-qHV7pW7E85A0x6qyrFn+O+q1k1p3tQCsqIZ1KZ5ESLXY57aTvUd3/a4rdPTeXisvhXn2VQG0VSKUqs8KHF2zcA==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz", + "integrity": "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.34.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.35.0", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2690,16 +2686,15 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.2.tgz", - "integrity": "sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", + "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", "dev": true, - "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.11", + "@rolldown/pluginutils": "1.0.0-beta.19", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, @@ -2711,177 +2706,120 @@ } }, "node_modules/@vitest/expect": { - "version": "0.32.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.32.4.tgz", - "integrity": "sha512-m7EPUqmGIwIeoU763N+ivkFjTzbaBn0n9evsTOcde03ugy2avPs3kZbYmw3DkcH1j5mxhMhdamJkLQ6dM1bk/A==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "0.32.4", - "@vitest/utils": "0.32.4", - "chai": "^4.3.7" + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner": { - "version": "0.32.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.32.4.tgz", - "integrity": "sha512-cHOVCkiRazobgdKLnczmz2oaKK9GJOw6ZyRcaPdssO1ej+wzHVIkWiCiNacb3TTYPdzMddYkCgMjZ4r8C0JFCw==", + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "0.32.4", - "p-limit": "^4.0.0", - "pathe": "^1.1.1" + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" }, "funding": { "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner/node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vitest/runner/node_modules/yocto-queue": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", - "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@vitest/snapshot": { - "version": "0.32.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.32.4.tgz", - "integrity": "sha512-IRpyqn9t14uqsFlVI2d7DFMImGMs1Q9218of40bdQQgMePwVdmix33yMNnebXcTzDU5eiV3eUsoxxH5v0x/IQA==", + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", "dependencies": { - "magic-string": "^0.30.0", - "pathe": "^1.1.1", - "pretty-format": "^29.5.0" + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/spy": { - "version": "0.32.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.32.4.tgz", - "integrity": "sha512-oA7rCOqVOOpE6rEoXuCOADX7Lla1LIa4hljI2MSccbpec54q+oifhziZIJXxlE/CvI2E+ElhBHzVu0VEvJGQKQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, "license": "MIT", "dependencies": { - "tinyspy": "^2.1.1" + "tinyspy": "^4.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "0.32.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.32.4.tgz", - "integrity": "sha512-Gwnl8dhd1uJ+HXrYyV0eRqfmk9ek1ASE/LWfTCuWMw+d07ogHqp4hEAV28NiecimK6UY9DpSEPh+pXBA5gtTBg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, "license": "MIT", "dependencies": { - "diff-sequences": "^29.4.3", - "loupe": "^2.3.6", - "pretty-format": "^29.5.0" + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@vitest/utils/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@vitest/utils/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2905,19 +2843,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -3024,31 +2949,14 @@ "dequal": "^2.0.3" } }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { - "node": "*" + "node": ">=12" } }, "node_modules/autoprefixer": { @@ -3089,22 +2997,6 @@ "postcss": "^8.1.0" } }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3186,70 +3078,20 @@ "node": ">=8" } }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", "engines": { "node": ">= 6" } @@ -3276,35 +3118,30 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", "dev": true, "license": "MIT", "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" }, "engines": { - "node": ">=4" + "node": ">=12" } }, "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.2" - }, "engines": { - "node": "*" + "node": ">= 16" } }, "node_modules/chokidar": { @@ -3496,13 +3333,6 @@ "dev": true, "license": "MIT" }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3587,10 +3417,11 @@ } }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -3611,93 +3442,21 @@ "license": "MIT" }, "node_modules/deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, "engines": { "node": ">=6" } }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3735,21 +3494,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3780,96 +3524,52 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } + "license": "MIT" }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" } }, "node_modules/escalade": { @@ -3882,19 +3582,18 @@ } }, "node_modules/eslint": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", - "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", + "version": "9.30.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.0.tgz", + "integrity": "sha512-iN/SiPxmQu6EVkf+m1qpBxzUhE12YqFLOSySuOyVLJLEF9nzTf+h/1AJYc1JWzCnktggeNrjvQGLngDzXirU6g==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.1", - "@eslint/config-helpers": "^0.2.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.29.0", + "@eslint/js": "9.30.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -4129,6 +3828,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4155,6 +3864,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", + "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4267,22 +3986,6 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/foreground-child": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", @@ -4347,16 +4050,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4377,55 +4070,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -4479,10 +4123,11 @@ } }, "node_modules/globals": { - "version": "15.11.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.11.0.tgz", - "integrity": "sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz", + "integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -4490,19 +4135,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4514,8 +4146,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/graphql": { "version": "16.11.0", @@ -4527,61 +4158,6 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4699,136 +4275,23 @@ "node": ">=8" } }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" + "binary-extensions": "^2.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=8" } }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dev": true, - "license": "MIT", + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", - "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -4864,19 +4327,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-node-process": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", @@ -4892,23 +4342,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -4916,126 +4349,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5556,7 +4869,8 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true }, "node_modules/js-yaml": { "version": "4.1.0", @@ -5717,19 +5031,6 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, - "node_modules/local-pkg": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", - "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5758,26 +5059,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.4.tgz", + "integrity": "sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==", "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } + "license": "MIT" }, "node_modules/lru-cache": { "version": "5.1.1", @@ -5790,10 +5077,9 @@ } }, "node_modules/lucide-react": { - "version": "0.515.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.515.0.tgz", - "integrity": "sha512-Sy7bY0MeicRm2pzrnoHm2h6C1iVoeHyBU2fjdQDsXGP51fhkhau1/ZV/dzrcxEmAKsxYb6bGaIsMnGHuQ5s0dw==", - "license": "ISC", + "version": "0.525.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz", + "integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -5804,6 +5090,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5818,16 +5105,6 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5888,26 +5165,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mlly": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", - "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.14.0", - "pathe": "^2.0.1", - "pkg-types": "^1.3.0", - "ufo": "^1.5.4" - } - }, - "node_modules/mlly/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6050,98 +5307,37 @@ "node": ">= 6" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">= 0.8.0" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, - "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/outvariant": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", - "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", - "dev": true, - "license": "MIT" - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" + "yocto-queue": "^0.1.0" }, "engines": { "node": ">=10" @@ -6259,20 +5455,20 @@ "license": "MIT" }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "dev": true, "license": "MIT", "engines": { - "node": "*" + "node": ">= 14.16" } }, "node_modules/picocolors": { @@ -6308,39 +5504,10 @@ "node": ">= 6" } }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/pkg-types/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/postcss": { - "version": "8.5.5", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.5.tgz", - "integrity": "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -6500,6 +5667,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6515,6 +5683,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -6525,6 +5694,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6581,26 +5751,22 @@ ] }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dependencies": { - "loose-envify": "^1.1.0" - }, + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.26.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.1.0" } }, "node_modules/react-is": { @@ -6608,7 +5774,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.17.0", @@ -6621,35 +5788,47 @@ } }, "node_modules/react-router": { - "version": "6.30.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz", - "integrity": "sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==", - "license": "MIT", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.3.tgz", + "integrity": "sha512-zf45LZp5skDC6I3jDLXQUu0u26jtuP4lEGbc7BbdyxenBN1vJSTA18czM2D+h5qyMBuMrD+9uB+mU37HIoKGRA==", "dependencies": { - "@remix-run/router": "1.23.0" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "node_modules/react-router-dom": { - "version": "6.30.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.0.tgz", - "integrity": "sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==", - "license": "MIT", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.3.tgz", + "integrity": "sha512-DiWJm9qdUAmiJrVWaeJdu4TKu13+iB/8IEi0EW/XgaHCjW/vWGrwzup0GVvaMteuZjKnh5bEvJP/K0MDnzawHw==", "dependencies": { - "@remix-run/router": "1.23.0", - "react-router": "6.30.0" + "react-router": "7.6.3" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "engines": { + "node": ">=18" } }, "node_modules/read-cache": { @@ -6685,27 +5864,6 @@ "node": ">=8" } }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6759,12 +5917,13 @@ } }, "node_modules/rollup": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", - "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.43.0.tgz", + "integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.7" }, "bin": { "rollup": "dist/bin/rollup" @@ -6774,22 +5933,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.24.0", - "@rollup/rollup-android-arm64": "4.24.0", - "@rollup/rollup-darwin-arm64": "4.24.0", - "@rollup/rollup-darwin-x64": "4.24.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", - "@rollup/rollup-linux-arm-musleabihf": "4.24.0", - "@rollup/rollup-linux-arm64-gnu": "4.24.0", - "@rollup/rollup-linux-arm64-musl": "4.24.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", - "@rollup/rollup-linux-riscv64-gnu": "4.24.0", - "@rollup/rollup-linux-s390x-gnu": "4.24.0", - "@rollup/rollup-linux-x64-gnu": "4.24.0", - "@rollup/rollup-linux-x64-musl": "4.24.0", - "@rollup/rollup-win32-arm64-msvc": "4.24.0", - "@rollup/rollup-win32-ia32-msvc": "4.24.0", - "@rollup/rollup-win32-x64-msvc": "4.24.0", + "@rollup/rollup-android-arm-eabi": "4.43.0", + "@rollup/rollup-android-arm64": "4.43.0", + "@rollup/rollup-darwin-arm64": "4.43.0", + "@rollup/rollup-darwin-x64": "4.43.0", + "@rollup/rollup-freebsd-arm64": "4.43.0", + "@rollup/rollup-freebsd-x64": "4.43.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.43.0", + "@rollup/rollup-linux-arm-musleabihf": "4.43.0", + "@rollup/rollup-linux-arm64-gnu": "4.43.0", + "@rollup/rollup-linux-arm64-musl": "4.43.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.43.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.43.0", + "@rollup/rollup-linux-riscv64-gnu": "4.43.0", + "@rollup/rollup-linux-riscv64-musl": "4.43.0", + "@rollup/rollup-linux-s390x-gnu": "4.43.0", + "@rollup/rollup-linux-x64-gnu": "4.43.0", + "@rollup/rollup-linux-x64-musl": "4.43.0", + "@rollup/rollup-win32-arm64-msvc": "4.43.0", + "@rollup/rollup-win32-ia32-msvc": "4.43.0", + "@rollup/rollup-win32-x64-msvc": "4.43.0", "fsevents": "~2.3.2" } }, @@ -6822,24 +5985,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -6861,12 +6006,9 @@ } }, "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dependencies": { - "loose-envify": "^1.1.0" - } + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" }, "node_modules/semver": { "version": "6.3.1", @@ -6878,39 +6020,10 @@ "semver": "bin/semver.js" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -6931,82 +6044,6 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -7090,20 +6127,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", @@ -7226,18 +6249,25 @@ } }, "node_modules/strip-literal": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", - "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", + "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", "dev": true, "license": "MIT", "dependencies": { - "acorn": "^8.10.0" + "js-tokens": "^9.0.1" }, "funding": { "url": "https://github.com/sponsors/antfu" } }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -7339,6 +6369,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -7385,9 +6422,19 @@ } }, "node_modules/tinypool": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.5.0.tgz", - "integrity": "sha512-paHQtnrlS1QZYKF/GnLoOM/DN9fqaGOFbCbxzAhwniySnzl9Ebk8w73/dd34DAhe/obUbPAOldTyYXQZxnPBPQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", "engines": { @@ -7395,9 +6442,9 @@ } }, "node_modules/tinyspy": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", - "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", "dev": true, "license": "MIT", "engines": { @@ -7479,7 +6526,6 @@ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=18.12" }, @@ -7504,16 +6550,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -7542,15 +6578,14 @@ } }, "node_modules/typescript-eslint": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.34.0.tgz", - "integrity": "sha512-MRpfN7uYjTrTGigFCt8sRyNqJFhjN0WwZecldaqhWm+wy0gaRt8Edb/3cuUy0zdq2opJWT6iXINKAtewnDOltQ==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.0.tgz", + "integrity": "sha512-uEnz70b7kBz6eg/j0Czy6K5NivaYopgxRjsnAJ2Fx5oTLo3wefTHIbL7AkQr1+7tJCRVpTs/wiM8JR/11Loq9A==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.34.0", - "@typescript-eslint/parser": "8.34.0", - "@typescript-eslint/utils": "8.34.0" + "@typescript-eslint/eslint-plugin": "8.35.0", + "@typescript-eslint/parser": "8.35.0", + "@typescript-eslint/utils": "8.35.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7564,13 +6599,6 @@ "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", - "dev": true, - "license": "MIT" - }, "node_modules/undici-types": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", @@ -7646,20 +6674,24 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/vite": { - "version": "5.4.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", - "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, + "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -7668,19 +6700,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -7701,1101 +6739,171 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, "node_modules/vite-node": { - "version": "0.32.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.32.4.tgz", - "integrity": "sha512-L2gIw+dCxO0LK14QnUMoqSYpa9XRGnTTTDjW2h19Mr+GR0EFj4vx52W41gFXfMLqpA00eK4ZjOVYo1Xk//LFEw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.4", - "mlly": "^1.4.0", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "vite": "^3.0.0 || ^4.0.0" + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" }, "engines": { - "node": ">=v14.18.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/vite-node/node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", - "cpu": [ - "arm" - ], + "node_modules/vite-plugin-static-copy": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.0.tgz", + "integrity": "sha512-ONFBaYoN1qIiCxMCfeHI96lqLza7ujx/QClIXp4kEULUbyH2qLgYoaL8JHhk3FWjSB4TpzoaN3iMCyCFldyXzw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" - } - }, - "node_modules/vite-node/node_modules/rollup": { - "version": "3.29.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", - "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", - "dev": true, - "license": "MIT", - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=14.18.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/vite-node/node_modules/vite": { - "version": "4.5.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", - "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.18.10", - "postcss": "^8.4.27", - "rollup": "^3.27.1" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - }, - "peerDependencies": { - "@types/node": ">= 14", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-plugin-static-copy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.0.0.tgz", - "integrity": "sha512-Uki9pPUQ4ZnoMEdIFabvoh9h6Bh9Q1m3iF7BrZvoiF30reREpJh2gZb4jOnW1/uYFzyRiLCmFSkM+8hwiq1vWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.3", - "fs-extra": "^11.3.0", - "p-map": "^7.0.3", - "picocolors": "^1.1.1", - "tinyglobby": "^0.2.13" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0" - } - }, - "node_modules/vitest": { - "version": "0.32.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.32.4.tgz", - "integrity": "sha512-3czFm8RnrsWwIzVDu/Ca48Y/M+qh3vOnF16czJm98Q/AN1y3B6PBsyV8Re91Ty5s7txKNjEhpgtGPcfdbh2MZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^4.3.5", - "@types/chai-subset": "^1.3.3", - "@types/node": "*", - "@vitest/expect": "0.32.4", - "@vitest/runner": "0.32.4", - "@vitest/snapshot": "0.32.4", - "@vitest/spy": "0.32.4", - "@vitest/utils": "0.32.4", - "acorn": "^8.9.0", - "acorn-walk": "^8.2.0", - "cac": "^6.7.14", - "chai": "^4.3.7", - "debug": "^4.3.4", - "local-pkg": "^0.4.3", - "magic-string": "^0.30.0", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.3.3", - "strip-literal": "^1.0.1", - "tinybench": "^2.5.0", - "tinypool": "^0.5.0", - "vite": "^3.0.0 || ^4.0.0", - "vite-node": "0.32.4", - "why-is-node-running": "^2.2.2" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": ">=v14.18.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@vitest/browser": "*", - "@vitest/ui": "*", - "happy-dom": "*", - "jsdom": "*", - "playwright": "*", - "safaridriver": "*", - "webdriverio": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "playwright": { - "optional": true - }, - "safaridriver": { - "optional": true - }, - "webdriverio": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "chokidar": "^3.5.3", + "fs-extra": "^11.3.0", + "p-map": "^7.0.3", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.14" + }, "engines": { - "node": ">=12" + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", - "cpu": [ - "x64" - ], + "node_modules/vite/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/vitest/node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, "engines": { "node": ">=12" }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" - } - }, - "node_modules/vitest/node_modules/rollup": { - "version": "3.29.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", - "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", - "dev": true, - "license": "MIT", - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=14.18.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vitest/node_modules/vite": { - "version": "4.5.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", - "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.18.10", - "postcss": "^8.4.27", - "rollup": "^3.27.1" + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" }, "bin": { - "vite": "bin/vite.js" + "vitest": "vitest.mjs" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@types/node": ">= 14", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" }, "peerDependenciesMeta": { - "@types/node": { + "@edge-runtime/vm": { "optional": true }, - "less": { + "@types/debug": { "optional": true }, - "lightningcss": { + "@types/node": { "optional": true }, - "sass": { + "@vitest/browser": { "optional": true }, - "stylus": { + "@vitest/ui": { "optional": true }, - "sugarss": { + "happy-dom": { "optional": true }, - "terser": { + "jsdom": { "optional": true } } }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -8870,67 +6978,6 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/web/package.json b/web/package.json index 4ef07cfa0d..b66d64f8bb 100644 --- a/web/package.json +++ b/web/package.json @@ -13,35 +13,35 @@ }, "dependencies": { "@tailwindcss/forms": "^0.5.10", - "@tanstack/react-query": "^5.80.7", - "lucide-react": "^0.515.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.22.3" + "@tanstack/react-query": "^5.81.5", + "lucide-react": "^0.525.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.3" }, "devDependencies": { - "@eslint/js": "^9.29.0", - "@faker-js/faker": "^8.0.2", + "@eslint/js": "^9.30.0", + "@faker-js/faker": "^9.8.0", "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.4.3", - "@types/node": "^24.0.1", - "@types/react": "^18.3.5", + "@types/node": "^24.0.7", + "@types/react": "^19.1.8", "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.5.2", + "@vitejs/plugin-react": "^4.6.0", "autoprefixer": "^10.4.21", - "eslint": "^9.29.0", + "eslint": "^9.30.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", - "globals": "^15.9.0", + "globals": "^16.2.0", "jsdom": "^26.1.0", "msw": "2.10.2", - "postcss": "^8.5.5", + "postcss": "^8.5.6", "tailwindcss": "^3.4.1", "typescript": "^5.8.3", - "typescript-eslint": "^8.34.0", - "vite": "^5.4.2", - "vite-plugin-static-copy": "^3.0.0", - "vitest": "^0.32.2" + "typescript-eslint": "^8.35.0", + "vite": "^6.3.5", + "vite-plugin-static-copy": "^3.1.0", + "vitest": "^3.2.4" } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 341ea5c7bb..4599a966c7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,8 +1,11 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; -import { ConfigProvider } from "./contexts/ConfigContext"; -import { WizardModeProvider } from "./contexts/WizardModeContext"; +import { LinuxConfigProvider } from "./contexts/LinuxConfigContext"; +import { KubernetesConfigProvider } from "./contexts/KubernetesConfigContext"; +import { SettingsProvider } from "./contexts/SettingsContext"; +import { WizardProvider } from "./contexts/WizardModeContext"; import { BrandingProvider } from "./contexts/BrandingContext"; import { AuthProvider } from "./contexts/AuthContext"; +import ConnectionMonitor from "./components/common/ConnectionMonitor"; import InstallWizard from "./components/wizard/InstallWizard"; import { QueryClientProvider } from "@tanstack/react-query"; import { getQueryClient } from "./query-client"; @@ -12,27 +15,32 @@ function App() { return ( - - -
- - - - - - } - /> + + + + +
+ + + + + + } + /> - } /> - - -
-
- + } /> +
+
+
+
+ + +
+
); } diff --git a/web/src/components/common/Button.tsx b/web/src/components/common/Button.tsx index 3f2d9d2f1f..9cdd6838ae 100644 --- a/web/src/components/common/Button.tsx +++ b/web/src/components/common/Button.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useConfig } from '../../contexts/ConfigContext'; +import { useSettings } from '../../contexts/SettingsContext'; interface ButtonProps { children: React.ReactNode; @@ -10,6 +10,7 @@ interface ButtonProps { disabled?: boolean; className?: string; icon?: React.ReactNode; + dataTestId?: string; } const Button: React.FC = ({ @@ -21,9 +22,10 @@ const Button: React.FC = ({ disabled = false, className = '', icon, + dataTestId }) => { - const { prototypeSettings } = useConfig(); - const themeColor = prototypeSettings.themeColor; + const { settings } = useSettings(); + const themeColor = settings.themeColor; const baseStyles = 'inline-flex items-center justify-center font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-md'; @@ -53,6 +55,7 @@ const Button: React.FC = ({ backgroundColor: variant === 'primary' ? themeColor : undefined, borderColor: variant === 'outline' ? 'currentColor' : undefined, } as React.CSSProperties} + data-testid={dataTestId} > {icon && {icon}} {children} diff --git a/web/src/components/common/ConnectionMonitor.tsx b/web/src/components/common/ConnectionMonitor.tsx new file mode 100644 index 0000000000..c5f45dc33a --- /dev/null +++ b/web/src/components/common/ConnectionMonitor.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useState, useCallback } from 'react'; + +const RETRY_INTERVAL = 10000; // 10 seconds + +// Reusable spinner component +const Spinner: React.FC = () => ( +
+); + +// Connection modal component +const ConnectionModal: React.FC<{ + nextRetryTime?: number; +}> = ({ nextRetryTime }) => { + const [secondsUntilRetry, setSecondsUntilRetry] = useState(0); + + useEffect(() => { + if (!nextRetryTime) return; + + const updateCountdown = () => { + const now = Date.now(); + const remaining = Math.max(0, Math.floor((nextRetryTime - now) / 1000)); + setSecondsUntilRetry(remaining); + }; + + // Update immediately + updateCountdown(); + + // Update every second + const interval = setInterval(updateCountdown, 1000); + return () => clearInterval(interval); + }, [nextRetryTime]); + + return ( +
+
+
+
+ + + +
+
+ +

+ Cannot connect +

+ +

+ We're unable to reach the server right now. Please check that the + installer is running and accessible. +

+ +
+
+ {secondsUntilRetry > 0 ? ( + <> + + Retrying in {secondsUntilRetry} second{secondsUntilRetry !== 1 ? 's' : ''} + + ) : ( + <> + + Retrying now... + + )} +
+
+
+
+ ); +}; + +// Custom hook for connection monitoring logic +const useConnectionMonitor = () => { + const [isConnected, setIsConnected] = useState(true); + const [nextRetryTime, setNextRetryTime] = useState(); + const [checkInterval, setCheckInterval] = useState(null); + + const checkConnection = useCallback(async () => { + try { + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), 5000) + ); + + const fetchPromise = fetch('/api/health', { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + const response = await Promise.race([fetchPromise, timeoutPromise]) as Response; + + if (response.ok) { + setIsConnected(true); + setNextRetryTime(undefined); + } else { + throw new Error(`HTTP ${response.status}`); + } + } catch { + // Connection failed - set up countdown for next retry + setIsConnected(false); + const retryTime = Date.now() + RETRY_INTERVAL; + setNextRetryTime(retryTime); + } + }, []); + + useEffect(() => { + // Initial check + checkConnection(); + + // Set up regular interval checks + const interval = setInterval(checkConnection, RETRY_INTERVAL); + setCheckInterval(interval); + + // Cleanup on unmount + return () => { + if (interval) { + clearInterval(interval); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Empty dependency array to prevent infinite loops + + // Cleanup interval when it changes + useEffect(() => { + return () => { + if (checkInterval) { + clearInterval(checkInterval); + } + }; + }, [checkInterval]); + + return { + isConnected, + nextRetryTime, + }; +}; + +const ConnectionMonitor: React.FC = () => { + const { isConnected, nextRetryTime } = useConnectionMonitor(); + + return ( + <> + {!isConnected && ( + + )} + + ); +}; + +export default ConnectionMonitor; diff --git a/web/src/components/common/Input.tsx b/web/src/components/common/Input.tsx index 56b40482d1..354c3ccf5e 100644 --- a/web/src/components/common/Input.tsx +++ b/web/src/components/common/Input.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useConfig } from '../../contexts/ConfigContext'; +import { useSettings } from '../../contexts/SettingsContext'; interface InputProps { id: string; @@ -34,8 +34,8 @@ const Input: React.FC = ({ labelClassName = '', icon, }) => { - const { prototypeSettings } = useConfig(); - const themeColor = prototypeSettings.themeColor; + const { settings } = useSettings(); + const themeColor = settings.themeColor; return (
diff --git a/web/src/components/common/Modal.tsx b/web/src/components/common/Modal.tsx new file mode 100644 index 0000000000..bff81af566 --- /dev/null +++ b/web/src/components/common/Modal.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { X } from 'lucide-react'; + +interface ModalProps { + onClose: () => void; + title: string; + children: React.ReactNode; + footer?: React.ReactNode; +} + +export const Modal: React.FC = ({ onClose, title, children, footer }) => { + return ( +
+
+ {/* Background overlay */} +
+ + {/* Modal panel */} +
+
+
+

+ {title} +

+ +
+
+ {children} +
+
+ {footer && ( +
+ {footer} +
+ )} +
+
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/common/Select.tsx b/web/src/components/common/Select.tsx index 7f4c6f6e3e..18aa14d7c6 100644 --- a/web/src/components/common/Select.tsx +++ b/web/src/components/common/Select.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { useConfig } from "../../contexts/ConfigContext"; +import { useSettings } from "../../contexts/SettingsContext"; interface SelectOption { value: string; @@ -35,8 +35,8 @@ const Select: React.FC = ({ labelClassName = "", placeholder, }) => { - const { prototypeSettings } = useConfig(); - const themeColor = prototypeSettings.themeColor; + const { settings } = useSettings(); + const themeColor = settings.themeColor; return (
diff --git a/web/src/components/common/tests/ConnectionMonitor.test.tsx b/web/src/components/common/tests/ConnectionMonitor.test.tsx new file mode 100644 index 0000000000..8f9fe7041b --- /dev/null +++ b/web/src/components/common/tests/ConnectionMonitor.test.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import ConnectionMonitor from '../ConnectionMonitor'; + +const server = setupServer( + http.get('*/api/health', () => { + return new HttpResponse(JSON.stringify({ status: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) +); + +describe('ConnectionMonitor', () => { + beforeEach(() => { + server.listen({ onUnhandledRequest: 'warn' }); + }); + + afterEach(() => { + server.resetHandlers(); + vi.clearAllMocks(); + }); + + it('should not show modal when API is available', async () => { + render(); + + // Modal should not appear when connected + await new Promise(resolve => setTimeout(resolve, 100)); + expect(screen.queryByText('Cannot connect')).not.toBeInTheDocument(); + }); + + it('should show modal when health check fails', async () => { + server.use( + http.get('*/api/health', () => { + return HttpResponse.error(); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Cannot connect')).toBeInTheDocument(); + }, { timeout: 4000 }); + }, 6000); + + it('should handle automatic retry', async () => { + let retryCount = 0; + + server.use( + http.get('*/api/health', () => { + retryCount++; + + // Fail first time, succeed on second automatic retry + if (retryCount === 1) { + return HttpResponse.error(); + } + + return new HttpResponse(JSON.stringify({ status: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) + ); + + render(); + + // Wait for modal to appear after first health check fails + await waitFor(() => { + expect(screen.getByText('Cannot connect')).toBeInTheDocument(); + }, { timeout: 6000 }); + + // Should show countdown - wait for it to appear since it's async + await waitFor(() => { + expect(screen.getByText(/Retrying in \d+ second/)).toBeInTheDocument(); + }, { timeout: 2000 }); + + // Modal should disappear when automatic retry succeeds + // This should happen after the RETRY_INTERVAL (10 seconds) + await waitFor(() => { + expect(screen.queryByText('Cannot connect')).not.toBeInTheDocument(); + }, { timeout: 12000 }); + }, 15000); + + it('should show retry countdown timer', async () => { + server.use( + http.get('*/api/health', () => { + return HttpResponse.error(); + }) + ); + + render(); + + // Wait for modal to appear + await waitFor(() => { + expect(screen.getByText('Cannot connect')).toBeInTheDocument(); + }, { timeout: 4000 }); + + await waitFor(() => { + expect(screen.getByText(/Retrying in \d+ second/)).toBeInTheDocument(); + }, { timeout: 2000 }); + + // Verify the countdown is actually working by checking it's a reasonable value + const countdownElement = screen.getByText(/Retrying in \d+ second/); + const countdownText = countdownElement.textContent || ''; + const secondsMatch = countdownText.match(/Retrying in (\d+) second/); + + if (secondsMatch) { + const seconds = parseInt(secondsMatch[1], 10); + // Should be between 1-10 seconds (since RETRY_INTERVAL is 10 seconds) + expect(seconds).toBeGreaterThan(0); + expect(seconds).toBeLessThanOrEqual(10); + } + }, 8000); +}); diff --git a/web/src/components/wizard/InstallWizard.tsx b/web/src/components/wizard/InstallWizard.tsx index ed5f0a3889..fb3ac52450 100644 --- a/web/src/components/wizard/InstallWizard.tsx +++ b/web/src/components/wizard/InstallWizard.tsx @@ -1,20 +1,31 @@ import React, { useState } from "react"; import StepNavigation from "./StepNavigation"; import WelcomeStep from "./WelcomeStep"; -import SetupStep from "./SetupStep"; -import ValidationStep from "./ValidationStep"; -import InstallationStep from "./InstallationStep"; +import LinuxSetupStep from "./setup/LinuxSetupStep"; +import KubernetesSetupStep from "./setup/KubernetesSetupStep"; +import LinuxValidationStep from "./validation/LinuxValidationStep"; +import LinuxInstallationStep from "./installation/LinuxInstallationStep"; +import KubernetesInstallationStep from "./installation/KubernetesInstallationStep"; +import LinuxCompletionStep from "./completion/LinuxCompletionStep"; +import KubernetesCompletionStep from "./completion/KubernetesCompletionStep"; import { WizardStep } from "../../types"; import { AppIcon } from "../common/Logo"; -import { useWizardMode } from "../../contexts/WizardModeContext"; -import CompletionStep from "./CompletionStep"; +import { useWizard } from "../../contexts/WizardModeContext"; const InstallWizard: React.FC = () => { const [currentStep, setCurrentStep] = useState("welcome"); - const { text } = useWizardMode(); + const { text, target } = useWizard(); + + const getSteps = (): WizardStep[] => { + if (target === "kubernetes") { + return ["welcome", "kubernetes-setup", "kubernetes-installation", "kubernetes-completion"]; + } else { + return ["welcome", "linux-setup", "linux-validation", "linux-installation", "linux-completion"]; + } + } const goToNextStep = () => { - const steps: WizardStep[] = ["welcome", "setup", "validation", "installation", "completion"]; + const steps = getSteps(); const currentIndex = steps.indexOf(currentStep); if (currentIndex < steps.length - 1) { setCurrentStep(steps[currentIndex + 1]); @@ -22,7 +33,7 @@ const InstallWizard: React.FC = () => { }; const goToPreviousStep = () => { - const steps: WizardStep[] = ["welcome", "setup", "validation", "installation", "completion"]; + const steps = getSteps(); const currentIndex = steps.indexOf(currentStep); if (currentIndex > 0) { setCurrentStep(steps[currentIndex - 1]); @@ -33,14 +44,20 @@ const InstallWizard: React.FC = () => { switch (currentStep) { case "welcome": return ; - case "setup": - return ; - case "validation": - return ; - case "installation": - return ; - case "completion": - return ; + case "linux-setup": + return ; + case "kubernetes-setup": + return ; + case "linux-validation": + return ; + case "linux-installation": + return ; + case "kubernetes-installation": + return ; + case "linux-completion": + return ; + case "kubernetes-completion": + return ; default: return null; } diff --git a/web/src/components/wizard/SetupStep.tsx b/web/src/components/wizard/SetupStep.tsx deleted file mode 100644 index b1908898e6..0000000000 --- a/web/src/components/wizard/SetupStep.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import React, { useState, useEffect } from "react"; -import Card from "../common/Card"; -import Button from "../common/Button"; -import { useConfig } from "../../contexts/ConfigContext"; -import { useWizardMode } from "../../contexts/WizardModeContext"; -import { ChevronRight } from "lucide-react"; -import LinuxSetup from "./setup/LinuxSetup"; -import { useQuery, useMutation } from "@tanstack/react-query"; -import { useAuth } from "../../contexts/AuthContext"; -import { handleUnauthorized } from "../../utils/auth"; - -interface SetupStepProps { - onNext: () => void; -} - -interface Status { - state: string; - description?: string; -} - -interface ConfigError extends Error { - errors?: { field: string; message: string }[]; -} - -const SetupStep: React.FC = ({ onNext }) => { - const { config, updateConfig, prototypeSettings } = useConfig(); - const { text } = useWizardMode(); - const [showAdvanced, setShowAdvanced] = useState(false); - const [error, setError] = useState(null); - const { token } = useAuth(); - - // Query for fetching install configuration - const { isLoading: isConfigLoading } = useQuery({ - queryKey: ["installConfig"], - queryFn: async () => { - const response = await fetch("/api/install/installation/config", { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (response.status === 401) { - handleUnauthorized(errorData); - throw new Error("Session expired. Please log in again."); - } - throw new Error(errorData.message || "Failed to fetch install configuration"); - } - const config = await response.json(); - updateConfig(config); - return config; - }, - }); - - // Query for fetching network interfaces - const { data: networkInterfacesData, isLoading: isInterfacesLoading } = useQuery({ - queryKey: ["networkInterfaces"], - queryFn: async () => { - const response = await fetch("/api/console/available-network-interfaces", { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (response.status === 401) { - handleUnauthorized(errorData); - throw new Error("Session expired. Please log in again."); - } - throw new Error(errorData.message || "Failed to fetch network interfaces"); - } - return response.json(); - }, - }); - - // Mutation for submitting the configuration - const { mutate: submitConfig, error: submitError } = useMutation({ - mutationFn: async (configData: typeof config) => { - const response = await fetch("/api/install/installation/configure", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(configData), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (response.status === 401) { - handleUnauthorized(errorData); - throw new Error("Session expired. Please log in again."); - } - throw errorData; - } - return response.json(); - }, - onSuccess: () => { - onNext(); - }, - onError: (err: ConfigError) => { - setError(err.message || "Failed to setup cluster"); - return err; - }, - }); - - // Expand advanced settings if there is an error in an advanced field - useEffect(() => { - if (submitError?.errors) { - if (submitError.errors.some(e => e.field === "networkInterface" || e.field === "globalCidr")) { - setShowAdvanced(true); - } - } - }, [submitError]); - - const handleInputChange = (e: React.ChangeEvent) => { - const { id, value } = e.target; - if (id === "adminConsolePort" || id === "localArtifactMirrorPort") { - // Only update if the value is empty or a valid number - if (value === "" || !isNaN(Number(value))) { - updateConfig({ [id]: value === "" ? undefined : Number(value) }); - } - } else { - updateConfig({ [id]: value }); - } - }; - - const handleSelectChange = (e: React.ChangeEvent) => { - const { id, value } = e.target; - updateConfig({ [id]: value }); - }; - - const handleNext = async () => { - submitConfig(config); - }; - - const isLoading = isConfigLoading || isInterfacesLoading; - const availableNetworkInterfaces = networkInterfacesData?.networkInterfaces || []; - - return ( -
- -
-

{text.setupTitle}

-

Configure the installation settings.

-
- - {isLoading ? ( -
-
-

Loading configuration...

-
- ) : ( - - )} - - {error && ( -
- Please fix the errors in the form above before proceeding. -
- )} -
- -
- -
-
- ); -}; - -export default SetupStep; diff --git a/web/src/components/wizard/StepNavigation.tsx b/web/src/components/wizard/StepNavigation.tsx index 635a7b7aab..c29dede074 100644 --- a/web/src/components/wizard/StepNavigation.tsx +++ b/web/src/components/wizard/StepNavigation.tsx @@ -1,29 +1,51 @@ import React from 'react'; import { WizardStep } from '../../types'; -import { ClipboardList, Settings, Download, CheckCircle } from 'lucide-react'; -import { useWizardMode } from '../../contexts/WizardModeContext'; -import { useConfig } from '../../contexts/ConfigContext'; +import { ClipboardList, Settings, Shield, Download, CheckCircle } from 'lucide-react'; +import { useWizard } from '../../contexts/WizardModeContext'; +import { useSettings } from '../../contexts/SettingsContext'; interface StepNavigationProps { currentStep: WizardStep; } -const StepNavigation: React.FC = ({ currentStep }) => { - const { mode } = useWizardMode(); - const { prototypeSettings } = useConfig(); - const themeColor = prototypeSettings.themeColor; +interface NavigationStep { + id: WizardStep; + name: string; + icon: React.ElementType; + hidden?: boolean; + parentId?: WizardStep; +} + +const StepNavigation: React.FC = ({ currentStep: currentStepId }) => { + const { mode, target } = useWizard(); + const { settings } = useSettings(); + const themeColor = settings.themeColor; + + const getSteps = (): NavigationStep[] => { + if (target === 'kubernetes') { + return [ + { id: 'welcome', name: 'Welcome', icon: ClipboardList }, + { id: 'kubernetes-setup', name: 'Setup', icon: Settings }, + { id: 'kubernetes-installation', name: mode === 'upgrade' ? 'Upgrade' : 'Installation', icon: Download }, + { id: 'kubernetes-completion', name: 'Completion', icon: CheckCircle }, + ]; + } else { + return [ + { id: 'welcome', name: 'Welcome', icon: ClipboardList }, + { id: 'linux-setup', name: 'Setup', icon: Settings }, + { id: 'linux-validation', name: 'Validation', icon: Shield, hidden: true, parentId: 'linux-setup' }, + { id: 'linux-installation', name: mode === 'upgrade' ? 'Upgrade' : 'Installation', icon: Download }, + { id: 'linux-completion', name: 'Completion', icon: CheckCircle }, + ]; + } + } - const steps = [ - { id: 'welcome', name: 'Welcome', icon: ClipboardList }, - { id: 'setup', name: 'Setup', icon: Settings }, - { id: 'validation', name: 'Validation', icon: CheckCircle }, - { id: 'installation', name: mode === 'upgrade' ? 'Upgrade' : 'Installation', icon: Download }, - { id: 'completion', name: 'Completion', icon: CheckCircle }, - ]; + const steps = getSteps(); + const currentStep = steps.find(step => step.id === currentStepId); - const getStepStatus = (step: { id: string }) => { + const getStepStatus = (step: NavigationStep) => { const stepIndex = steps.findIndex((s) => s.id === step.id); - const currentIndex = steps.findIndex((s) => s.id === currentStep); + const currentIndex = steps.findIndex((s) => currentStep?.hidden ? s.id === currentStep.parentId : s.id === currentStepId); if (stepIndex < currentIndex) return 'complete'; if (stepIndex === currentIndex) return 'current'; @@ -33,8 +55,8 @@ const StepNavigation: React.FC = ({ currentStep }) => { return (