diff --git a/api/controllers/app/controller.go b/api/controllers/app/controller.go index 258b43be76..970525a3dc 100644 --- a/api/controllers/app/controller.go +++ b/api/controllers/app/controller.go @@ -20,6 +20,7 @@ import ( kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/sirupsen/logrus" helmcli "helm.sh/helm/v3/pkg/cli" + "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" kyaml "sigs.k8s.io/yaml" ) @@ -32,7 +33,7 @@ type Controller interface { GetAppPreflightStatus(ctx context.Context) (types.Status, error) GetAppPreflightOutput(ctx context.Context) (*types.PreflightsOutput, error) GetAppPreflightTitles(ctx context.Context) ([]string, error) - InstallApp(ctx context.Context, ignoreAppPreflights bool) error + InstallApp(ctx context.Context, opts InstallAppOptions) error GetAppInstallStatus(ctx context.Context) (types.AppInstall, error) UpgradeApp(ctx context.Context, ignoreAppPreflights bool) error GetAppUpgradeStatus(ctx context.Context) (types.AppUpgrade, error) @@ -52,6 +53,7 @@ type AppController struct { releaseData *release.ReleaseData hcli helm.Client kcli client.Client + mcli metadata.Interface preflightRunner preflights.PreflightRunnerInterface kubernetesEnvSettings *helmcli.EnvSettings store store.Store @@ -129,6 +131,12 @@ func WithKubeClient(kcli client.Client) AppControllerOption { } } +func WithMetadataClient(mcli metadata.Interface) AppControllerOption { + return func(c *AppController) { + c.mcli = mcli + } +} + func WithKubernetesEnvSettings(envSettings *helmcli.EnvSettings) AppControllerOption { return func(c *AppController) { c.kubernetesEnvSettings = envSettings @@ -262,7 +270,9 @@ func NewAppController(opts ...AppControllerOption) (*AppController, error) { appinstallmanager.WithAirgapBundle(controller.airgapBundle), appinstallmanager.WithAppInstallStore(controller.store.AppInstallStore()), appinstallmanager.WithKubeClient(controller.kcli), + appinstallmanager.WithMetadataClient(controller.mcli), appinstallmanager.WithKubernetesEnvSettings(controller.kubernetesEnvSettings), + appinstallmanager.WithHelmClient(controller.hcli), ) if err != nil { return nil, fmt.Errorf("create app install manager: %w", err) diff --git a/api/controllers/app/controller_mock.go b/api/controllers/app/controller_mock.go index 8544b58f35..11d1f9ef11 100644 --- a/api/controllers/app/controller_mock.go +++ b/api/controllers/app/controller_mock.go @@ -69,8 +69,8 @@ func (m *MockController) GetAppPreflightTitles(ctx context.Context) ([]string, e } // InstallApp mocks the InstallApp method -func (m *MockController) InstallApp(ctx context.Context, ignoreAppPreflights bool) error { - args := m.Called(ctx, ignoreAppPreflights) +func (m *MockController) InstallApp(ctx context.Context, opts InstallAppOptions) error { + args := m.Called(ctx, opts) return args.Error(0) } diff --git a/api/controllers/app/install.go b/api/controllers/app/install.go index cd708c82c9..21405acbcd 100644 --- a/api/controllers/app/install.go +++ b/api/controllers/app/install.go @@ -9,14 +9,22 @@ import ( "github.com/replicatedhq/embedded-cluster/api/internal/states" "github.com/replicatedhq/embedded-cluster/api/types" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" ) var ( ErrAppPreflightChecksFailed = errors.New("app preflight checks failed") ) +type InstallAppOptions struct { + IgnoreAppPreflights bool + ProxySpec *ecv1beta1.ProxySpec + RegistrySettings *types.RegistrySettings + HostCABundlePath string +} + // InstallApp triggers app installation with proper state transitions and panic handling -func (c *AppController) InstallApp(ctx context.Context, ignoreAppPreflights bool) (finalErr error) { +func (c *AppController) InstallApp(ctx context.Context, opts InstallAppOptions) (finalErr error) { logger := c.logger.WithField("operation", "install-app") lock, err := c.stateMachine.AcquireLock() @@ -45,7 +53,7 @@ func (c *AppController) InstallApp(ctx context.Context, ignoreAppPreflights bool } allowIgnoreAppPreflights := true // TODO: implement if we decide to support a ignore-app-preflights CLI flag for V3 - if !ignoreAppPreflights || !allowIgnoreAppPreflights { + if !opts.IgnoreAppPreflights || !allowIgnoreAppPreflights { return types.NewBadRequestError(ErrAppPreflightChecksFailed) } @@ -60,9 +68,15 @@ func (c *AppController) InstallApp(ctx context.Context, ignoreAppPreflights bool } // Get config values for app installation - configValues, err := c.appConfigManager.GetKotsadmConfigValues() + appConfigValues, err := c.GetAppConfigValues(ctx) + if err != nil { + return fmt.Errorf("get app config values for app install: %w", err) + } + + // Extract installable Helm charts from release manager + installableCharts, err := c.appReleaseManager.ExtractInstallableHelmCharts(ctx, appConfigValues, opts.ProxySpec, opts.RegistrySettings) if err != nil { - return fmt.Errorf("get kotsadm config values for app install: %w", err) + return fmt.Errorf("extract installable helm charts: %w", err) } err = c.stateMachine.Transition(lock, states.StateAppInstalling, nil) @@ -97,9 +111,8 @@ func (c *AppController) InstallApp(ctx context.Context, ignoreAppPreflights bool return fmt.Errorf("set status to running: %w", err) } - // Install the app - err := c.appInstallManager.Install(ctx, configValues) - if err != nil { + // Install the app with installable charts + if err := c.appInstallManager.Install(ctx, installableCharts, appConfigValues, opts.RegistrySettings, opts.HostCABundlePath); err != nil { return fmt.Errorf("install app: %w", err) } diff --git a/api/controllers/app/tests/test_suite.go b/api/controllers/app/tests/test_suite.go index 4983156a98..d5c1145883 100644 --- a/api/controllers/app/tests/test_suite.go +++ b/api/controllers/app/tests/test_suite.go @@ -16,6 +16,8 @@ import ( "github.com/replicatedhq/embedded-cluster/api/internal/states" "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/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" @@ -468,16 +470,18 @@ func (s *AppControllerTestSuite) TestInstallApp() { tests := []struct { name string ignoreAppPreflights bool + proxySpec *ecv1beta1.ProxySpec + registrySettings *types.RegistrySettings currentState statemachine.State expectedState statemachine.State - setupMocks func(*appconfig.MockAppConfigManager, *appinstallmanager.MockAppInstallManager, *apppreflightmanager.MockAppPreflightManager, *store.MockStore) + setupMocks func(*appconfig.MockAppConfigManager, *appreleasemanager.MockAppReleaseManager, *appinstallmanager.MockAppInstallManager, *apppreflightmanager.MockAppPreflightManager, *store.MockStore) expectedErr bool }{ { name: "invalid state transition from succeeded state", currentState: states.StateSucceeded, expectedState: states.StateSucceeded, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { // No mocks needed for invalid state transition }, expectedErr: true, @@ -486,7 +490,7 @@ func (s *AppControllerTestSuite) TestInstallApp() { name: "invalid state transition from infrastructure installing state", currentState: states.StateInfrastructureInstalling, expectedState: states.StateInfrastructureInstalling, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { // No mocks needed for invalid state transition }, expectedErr: true, @@ -495,23 +499,25 @@ func (s *AppControllerTestSuite) TestInstallApp() { name: "successful app installation from app preflights succeeded state", currentState: states.StateAppPreflightsSucceeded, expectedState: states.StateSucceeded, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { + expectedCharts := []types.InstallableHelmChart{ + { + Archive: []byte("chart-archive-data"), + Values: map[string]any{"key": "value"}, + }, + } + appConfigValues := types.AppConfigValues{ + "test-key": types.AppConfigValue{Value: "test-value"}, + } mock.InOrder( - acm.On("GetKotsadmConfigValues").Return(kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "test-key": {Value: "test-value"}, - }, - }, - }, nil), + acm.On("GetConfigValues").Return(appConfigValues, nil), + arm.On("ExtractInstallableHelmCharts", mock.Anything, appConfigValues, mock.AnythingOfType("*v1beta1.ProxySpec"), mock.AnythingOfType("*types.RegistrySettings")).Return(expectedCharts, nil), store.AppInstallMockStore.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { return status.State == types.StateRunning })).Return(nil), - aim.On("Install", mock.Anything, mock.MatchedBy(func(cv kotsv1beta1.ConfigValues) bool { - return cv.Spec.Values["test-key"].Value == "test-value" - }), mock.Anything).Return(nil), + aim.On("Install", mock.Anything, expectedCharts, mock.AnythingOfType("types.AppConfigValues"), mock.AnythingOfType("*types.RegistrySettings"), mock.AnythingOfType("string")).Return(nil), store.AppInstallMockStore.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { return status.State == types.StateSucceeded @@ -524,23 +530,19 @@ func (s *AppControllerTestSuite) TestInstallApp() { name: "successful app installation from app preflights failed bypassed state", currentState: states.StateAppPreflightsFailedBypassed, expectedState: states.StateSucceeded, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { + appConfigValues := types.AppConfigValues{ + "test-key": types.AppConfigValue{Value: "test-value"}, + } mock.InOrder( - acm.On("GetKotsadmConfigValues").Return(kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "test-key": {Value: "test-value"}, - }, - }, - }, nil), + acm.On("GetConfigValues").Return(appConfigValues, nil), + arm.On("ExtractInstallableHelmCharts", mock.Anything, appConfigValues, mock.AnythingOfType("*v1beta1.ProxySpec"), mock.AnythingOfType("*types.RegistrySettings")).Return([]types.InstallableHelmChart{}, nil), store.AppInstallMockStore.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { return status.State == types.StateRunning })).Return(nil), - aim.On("Install", mock.Anything, mock.MatchedBy(func(cv kotsv1beta1.ConfigValues) bool { - return cv.Spec.Values["test-key"].Value == "test-value" - }), mock.Anything).Return(nil), + aim.On("Install", mock.Anything, []types.InstallableHelmChart{}, mock.AnythingOfType("types.AppConfigValues"), mock.AnythingOfType("*types.RegistrySettings"), mock.AnythingOfType("string")).Return(nil), store.AppInstallMockStore.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { return status.State == types.StateSucceeded @@ -553,23 +555,19 @@ func (s *AppControllerTestSuite) TestInstallApp() { name: "failed app installation from app preflights succeeded state", currentState: states.StateAppPreflightsSucceeded, expectedState: states.StateAppInstallFailed, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { + appConfigValues := types.AppConfigValues{ + "test-key": types.AppConfigValue{Value: "test-value"}, + } mock.InOrder( - acm.On("GetKotsadmConfigValues").Return(kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "test-key": {Value: "test-value"}, - }, - }, - }, nil), + acm.On("GetConfigValues").Return(appConfigValues, nil), + arm.On("ExtractInstallableHelmCharts", mock.Anything, appConfigValues, mock.AnythingOfType("*v1beta1.ProxySpec"), mock.AnythingOfType("*types.RegistrySettings")).Return([]types.InstallableHelmChart{}, nil), store.AppInstallMockStore.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { return status.State == types.StateRunning })).Return(nil), - aim.On("Install", mock.Anything, mock.MatchedBy(func(cv kotsv1beta1.ConfigValues) bool { - return cv.Spec.Values["test-key"].Value == "test-value" - }), mock.Anything).Return(errors.New("install error")), + aim.On("Install", mock.Anything, []types.InstallableHelmChart{}, mock.AnythingOfType("types.AppConfigValues"), mock.AnythingOfType("*types.RegistrySettings"), mock.AnythingOfType("string")).Return(errors.New("install error")), store.AppInstallMockStore.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { return status.State == types.StateFailed && strings.Contains(status.Description, "install error") @@ -582,9 +580,9 @@ func (s *AppControllerTestSuite) TestInstallApp() { name: "get config values error", currentState: states.StateAppPreflightsSucceeded, expectedState: states.StateAppPreflightsSucceeded, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { mock.InOrder( - acm.On("GetKotsadmConfigValues").Return(kotsv1beta1.ConfigValues{}, errors.New("config values error")), + acm.On("GetConfigValues").Return(types.AppConfigValues{}, errors.New("config values error")), ) }, expectedErr: true, @@ -594,7 +592,10 @@ func (s *AppControllerTestSuite) TestInstallApp() { ignoreAppPreflights: true, currentState: states.StateAppPreflightsFailed, expectedState: states.StateSucceeded, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { + appConfigValues := types.AppConfigValues{ + "test-key": types.AppConfigValue{Value: "test-value"}, + } mock.InOrder( // Mock GetAppPreflightOutput to return non-strict failures (can be bypassed) apm.On("GetAppPreflightOutput", mock.Anything).Return(&types.PreflightsOutput{ @@ -607,21 +608,14 @@ func (s *AppControllerTestSuite) TestInstallApp() { }, }, nil), - acm.On("GetKotsadmConfigValues").Return(kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "test-key": {Value: "test-value"}, - }, - }, - }, nil), + acm.On("GetConfigValues").Return(appConfigValues, nil), + arm.On("ExtractInstallableHelmCharts", mock.Anything, appConfigValues, mock.AnythingOfType("*v1beta1.ProxySpec"), mock.AnythingOfType("*types.RegistrySettings")).Return([]types.InstallableHelmChart{}, nil), store.AppInstallMockStore.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { return status.State == types.StateRunning })).Return(nil), - aim.On("Install", mock.Anything, mock.MatchedBy(func(cv kotsv1beta1.ConfigValues) bool { - return cv.Spec.Values["test-key"].Value == "test-value" - }), mock.Anything).Return(nil), + aim.On("Install", mock.Anything, []types.InstallableHelmChart{}, mock.AnythingOfType("types.AppConfigValues"), mock.AnythingOfType("*types.RegistrySettings"), mock.AnythingOfType("string")).Return(nil), store.AppInstallMockStore.On("SetStatus", mock.MatchedBy(func(status types.Status) bool { return status.State == types.StateSucceeded @@ -635,7 +629,7 @@ func (s *AppControllerTestSuite) TestInstallApp() { ignoreAppPreflights: false, currentState: states.StateAppPreflightsFailed, expectedState: states.StateAppPreflightsFailed, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { mock.InOrder( // Mock GetAppPreflightOutput to return non-strict failures (method should be called but bypass denied) apm.On("GetAppPreflightOutput", mock.Anything).Return(&types.PreflightsOutput{ @@ -656,7 +650,7 @@ func (s *AppControllerTestSuite) TestInstallApp() { ignoreAppPreflights: true, currentState: states.StateAppPreflightsFailed, expectedState: states.StateAppPreflightsFailed, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager, apm *apppreflightmanager.MockAppPreflightManager, store *store.MockStore) { mock.InOrder( // Mock GetAppPreflightOutput to return strict failures (cannot be bypassed) apm.On("GetAppPreflightOutput", mock.Anything).Return(&types.PreflightsOutput{ @@ -679,6 +673,7 @@ func (s *AppControllerTestSuite) TestInstallApp() { appPreflightManager := &apppreflightmanager.MockAppPreflightManager{} appReleaseManager := &appreleasemanager.MockAppReleaseManager{} appInstallManager := &appinstallmanager.MockAppInstallManager{} + mockHelmClient := &helm.MockClient{} sm := s.CreateInstallStateMachine(tt.currentState) appConfigManager := &appconfig.MockAppConfigManager{} @@ -694,11 +689,16 @@ func (s *AppControllerTestSuite) TestInstallApp() { appcontroller.WithAppInstallManager(appInstallManager), appcontroller.WithStore(store), appcontroller.WithReleaseData(&release.ReleaseData{}), + appcontroller.WithHelmClient(mockHelmClient), ) require.NoError(t, err, "failed to create install controller") - tt.setupMocks(appConfigManager, appInstallManager, appPreflightManager, store) - err = controller.InstallApp(t.Context(), tt.ignoreAppPreflights) + tt.setupMocks(appConfigManager, appReleaseManager, appInstallManager, appPreflightManager, store) + err = controller.InstallApp(t.Context(), appcontroller.InstallAppOptions{ + IgnoreAppPreflights: tt.ignoreAppPreflights, + ProxySpec: tt.proxySpec, + RegistrySettings: tt.registrySettings, + }) if tt.expectedErr { assert.Error(t, err) diff --git a/api/controllers/kubernetes/install/controller.go b/api/controllers/kubernetes/install/controller.go index 2f81aac023..8e34c34258 100644 --- a/api/controllers/kubernetes/install/controller.go +++ b/api/controllers/kubernetes/install/controller.go @@ -225,6 +225,7 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, appcontroller.WithPrivateCACertConfigMapName(""), // Private CA ConfigMap functionality not yet implemented for Kubernetes installations appcontroller.WithHelmClient(controller.hcli), appcontroller.WithKubeClient(controller.kcli), + appcontroller.WithMetadataClient(controller.mcli), appcontroller.WithKubernetesEnvSettings(controller.kubernetesEnvSettings), appcontroller.WithPreflightRunner(controller.preflightRunner), ) diff --git a/api/controllers/kubernetes/install/controller_mock.go b/api/controllers/kubernetes/install/controller_mock.go index 9cd176066a..e989750fd3 100644 --- a/api/controllers/kubernetes/install/controller_mock.go +++ b/api/controllers/kubernetes/install/controller_mock.go @@ -109,8 +109,8 @@ func (m *MockController) RunAppPreflights(ctx context.Context, opts appcontrolle } // InstallApp mocks the InstallApp method -func (m *MockController) InstallApp(ctx context.Context, ignoreAppPreflights bool) error { - args := m.Called(ctx, ignoreAppPreflights) +func (m *MockController) InstallApp(ctx context.Context, opts appcontroller.InstallAppOptions) error { + args := m.Called(ctx, opts) return args.Error(0) } diff --git a/api/controllers/kubernetes/upgrade/controller.go b/api/controllers/kubernetes/upgrade/controller.go index 6f7092f302..d74c73cd70 100644 --- a/api/controllers/kubernetes/upgrade/controller.go +++ b/api/controllers/kubernetes/upgrade/controller.go @@ -15,6 +15,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/sirupsen/logrus" helmcli "helm.sh/helm/v3/pkg/cli" + "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -28,6 +29,7 @@ var _ Controller = (*UpgradeController)(nil) type UpgradeController struct { hcli helm.Client kcli client.Client + mcli metadata.Interface preflightRunner preflights.PreflightRunnerInterface kubernetesEnvSettings *helmcli.EnvSettings releaseData *release.ReleaseData @@ -61,6 +63,12 @@ func WithKubeClient(kcli client.Client) UpgradeControllerOption { } } +func WithMetadataClient(mcli metadata.Interface) UpgradeControllerOption { + return func(c *UpgradeController) { + c.mcli = mcli + } +} + func WithPreflightRunner(preflightRunner preflights.PreflightRunnerInterface) UpgradeControllerOption { return func(c *UpgradeController) { c.preflightRunner = preflightRunner @@ -151,6 +159,7 @@ func NewUpgradeController(opts ...UpgradeControllerOption) (*UpgradeController, appcontroller.WithPrivateCACertConfigMapName(""), // Private CA ConfigMap functionality not yet implemented for Kubernetes installations appcontroller.WithHelmClient(controller.hcli), appcontroller.WithKubeClient(controller.kcli), + appcontroller.WithMetadataClient(controller.mcli), appcontroller.WithKubernetesEnvSettings(controller.kubernetesEnvSettings), appcontroller.WithPreflightRunner(controller.preflightRunner), ) diff --git a/api/controllers/linux/install/controller.go b/api/controllers/linux/install/controller.go index f5ec362727..2e6a5dff12 100644 --- a/api/controllers/linux/install/controller.go +++ b/api/controllers/linux/install/controller.go @@ -314,6 +314,7 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, appcontroller.WithPrivateCACertConfigMapName(adminconsole.PrivateCASConfigMapName), // Linux installations use the ConfigMap appcontroller.WithHelmClient(controller.hcli), appcontroller.WithKubeClient(controller.kcli), + appcontroller.WithMetadataClient(controller.mcli), appcontroller.WithKubernetesEnvSettings(controller.rc.GetKubernetesEnvSettings()), appcontroller.WithPreflightRunner(controller.preflightRunner), ) diff --git a/api/controllers/linux/install/controller_mock.go b/api/controllers/linux/install/controller_mock.go index 678945baf5..0bbc778284 100644 --- a/api/controllers/linux/install/controller_mock.go +++ b/api/controllers/linux/install/controller_mock.go @@ -142,8 +142,8 @@ func (m *MockController) RunAppPreflights(ctx context.Context, opts appcontrolle } // InstallApp mocks the InstallApp method -func (m *MockController) InstallApp(ctx context.Context, ignoreAppPreflights bool) error { - args := m.Called(ctx, ignoreAppPreflights) +func (m *MockController) InstallApp(ctx context.Context, opts appcontroller.InstallAppOptions) error { + args := m.Called(ctx, opts) return args.Error(0) } diff --git a/api/controllers/linux/install/controller_test.go b/api/controllers/linux/install/controller_test.go index 0c070c4d84..131bd2abcc 100644 --- a/api/controllers/linux/install/controller_test.go +++ b/api/controllers/linux/install/controller_test.go @@ -1177,8 +1177,8 @@ func TestProcessAirgap(t *testing.T) { // Setup expected registry settings expectedRegistrySettings := &types.RegistrySettings{ - Host: "registry.local:5000", - HasLocalRegistry: true, + LocalRegistryHost: "registry.local:5000", + HasLocalRegistry: true, } tt.setupMocks(mockAirgapManager, mockInstallationManager, expectedRegistrySettings, rc, mockStore) diff --git a/api/controllers/linux/upgrade/controller.go b/api/controllers/linux/upgrade/controller.go index 8e170aafb6..4050577ee8 100644 --- a/api/controllers/linux/upgrade/controller.go +++ b/api/controllers/linux/upgrade/controller.go @@ -321,6 +321,7 @@ func NewUpgradeController(opts ...UpgradeControllerOption) (*UpgradeController, appcontroller.WithPrivateCACertConfigMapName(adminconsole.PrivateCASConfigMapName), // Linux upgrades use the ConfigMap appcontroller.WithHelmClient(controller.hcli), appcontroller.WithKubeClient(controller.kcli), + appcontroller.WithMetadataClient(controller.mcli), appcontroller.WithKubernetesEnvSettings(controller.rc.GetKubernetesEnvSettings()), appcontroller.WithPreflightRunner(controller.preflightRunner), ) diff --git a/api/controllers/linux/upgrade/controller_test.go b/api/controllers/linux/upgrade/controller_test.go index 33ef8a2b5a..1af1c41d8a 100644 --- a/api/controllers/linux/upgrade/controller_test.go +++ b/api/controllers/linux/upgrade/controller_test.go @@ -240,8 +240,8 @@ func TestProcessAirgap(t *testing.T) { // Setup expected registry settings expectedRegistrySettings := &types.RegistrySettings{ - Host: "registry.local:5000", - HasLocalRegistry: true, + LocalRegistryHost: "registry.local:5000", + HasLocalRegistry: true, } tt.setupMocks(mockAirgapManager, mockInstallationManager, expectedRegistrySettings, rc, mockStore) diff --git a/api/docs/docs.go b/api/docs/docs.go index c5074c6265..620f1da3ef 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -6,7 +6,7 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"github_com_replicatedhq_embedded-cluster_api_types.Health":{"properties":{"status":{"type":"string"}},"required":["status"],"type":"object"},"github_com_replicatedhq_kotskinds_multitype.BoolOrString":{"type":"object"},"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"statusCode":{"type":"integer"}},"required":["message"],"type":"object"},"types.Airgap":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["logs","status"],"type":"object"},"types.AppConfig":{"properties":{"groups":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigGroup"},"type":"array","uniqueItems":false}},"required":["groups"],"type":"object"},"types.AppConfigValue":{"properties":{"data":{"type":"string"},"dataPlaintext":{"type":"string"},"default":{"type":"string"},"filename":{"type":"string"},"repeatableItem":{"type":"string"},"value":{"type":"string"}},"required":["value"],"type":"object"},"types.AppConfigValues":{"additionalProperties":{"$ref":"#/components/schemas/types.AppConfigValue"},"type":"object"},"types.AppConfigValuesResponse":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"required":["values"],"type":"object"},"types.AppInstall":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["logs","status"],"type":"object"},"types.AppUpgrade":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["logs","status"],"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"required":["password"],"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"required":["token"],"type":"object"},"types.GetListAvailableNetworkInterfacesResponse":{"properties":{"networkInterfaces":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["networkInterfaces"],"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"}},"required":["components","logs","status"],"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["name","status"],"type":"object"},"types.InstallAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["allowIgnoreAppPreflights","hasStrictAppPreflightFailures","status","titles"],"type":"object"},"types.InstallAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"required":["ignoreAppPreflights"],"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"allowIgnoreHostPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["allowIgnoreHostPreflights","titles"],"type":"object"},"types.KURLMigrationPhase":{"description":"Phase is the current phase of the kURL migration process","enum":["Discovery","Preparation","ECInstall","DataTransfer","Completed"],"example":"Discovery","type":"string","x-enum-varnames":["KURLMigrationPhaseDiscovery","KURLMigrationPhasePreparation","KURLMigrationPhaseECInstall","KURLMigrationPhaseDataTransfer","KURLMigrationPhaseCompleted"]},"types.KURLMigrationState":{"description":"State is the current state of the kURL migration","enum":["NotStarted","InProgress","Completed","Failed"],"example":"InProgress","type":"string","x-enum-varnames":["KURLMigrationStateNotStarted","KURLMigrationStateInProgress","KURLMigrationStateCompleted","KURLMigrationStateFailed"]},"types.KURLMigrationStatusResponse":{"description":"Current status and progress of a kURL migration","properties":{"error":{"description":"Error contains the error message if the kURL migration failed","example":"","type":"string"},"message":{"description":"Message is a user-facing message describing the current status","example":"Discovering kURL cluster configuration","type":"string"},"phase":{"$ref":"#/components/schemas/types.KURLMigrationPhase"},"progress":{"description":"Progress is the completion percentage (0-100)","example":25,"type":"integer"},"state":{"$ref":"#/components/schemas/types.KURLMigrationState"}},"required":["message","phase","progress","state"],"type":"object"},"types.KubernetesInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"noProxy":{"type":"string"}},"type":"object"},"types.KubernetesInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"values":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}},"required":["defaults","resolved","values"],"type":"object"},"types.LinuxInfraSetupRequest":{"properties":{"ignoreHostPreflights":{"type":"boolean"}},"required":["ignoreHostPreflights"],"type":"object"},"types.LinuxInstallationConfig":{"description":"Config contains optional installation configuration that will be merged with defaults","properties":{"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.LinuxInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"values":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}},"required":["defaults","resolved","values"],"type":"object"},"types.PatchAppConfigValuesRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"required":["values"],"type":"object"},"types.PostInstallRunHostPreflightsRequest":{"properties":{"isUi":{"type":"boolean"}},"required":["isUi"],"type":"object"},"types.PreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false}},"required":["fail","pass","warn"],"type":"object"},"types.PreflightsRecord":{"properties":{"message":{"type":"string"},"strict":{"type":"boolean"},"title":{"type":"string"}},"required":["message","strict","title"],"type":"object"},"types.StartKURLMigrationRequest":{"description":"Request body for starting a migration from kURL to Embedded Cluster","properties":{"config":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"transferMode":{"$ref":"#/components/schemas/types.TransferMode"}},"required":["transferMode"],"type":"object"},"types.StartKURLMigrationResponse":{"description":"Response returned when a kURL migration is successfully started","properties":{"message":{"description":"Message is a user-facing message about the kURL migration status","example":"kURL migration started successfully","type":"string"},"migrationId":{"description":"MigrationID is the unique identifier for this migration","example":"550e8400-e29b-41d4-a716-446655440000","type":"string"}},"required":["message","migrationId"],"type":"object"},"types.State":{"enum":["Pending","Running","Succeeded","Failed"],"example":"Succeeded","type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"required":["description","lastUpdated","state"],"type":"object"},"types.TemplateAppConfigRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"required":["values"],"type":"object"},"types.TransferMode":{"description":"TransferMode specifies whether to copy or move data during kURL migration","enum":["copy","move"],"example":"copy","type":"string","x-enum-varnames":["TransferModeCopy","TransferModeMove"]},"types.UpgradeAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["allowIgnoreAppPreflights","hasStrictAppPreflightFailures","status","titles"],"type":"object"},"types.UpgradeAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"required":["ignoreAppPreflights"],"type":"object"},"v1beta1.ConfigChildItem":{"properties":{"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"name":{"type":"string"},"recommended":{"type":"boolean"},"title":{"type":"string"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"}},"required":["default","name","recommended","title","value"],"type":"object"},"v1beta1.ConfigGroup":{"properties":{"description":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigItem"},"type":"array","uniqueItems":false},"name":{"type":"string"},"title":{"type":"string"},"when":{"type":"string"}},"required":["description","items","name","title","when"],"type":"object"},"v1beta1.ConfigItem":{"properties":{"affix":{"type":"string"},"countByGroup":{"additionalProperties":{"type":"integer"},"type":"object"},"data":{"type":"string"},"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"error":{"type":"string"},"filename":{"type":"string"},"help_text":{"type":"string"},"hidden":{"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigChildItem"},"type":"array","uniqueItems":false},"minimumCount":{"type":"integer"},"multi_value":{"items":{"type":"string"},"type":"array","uniqueItems":false},"multiple":{"type":"boolean"},"name":{"type":"string"},"readonly":{"type":"boolean"},"recommended":{"type":"boolean"},"repeatable":{"type":"boolean"},"required":{"type":"boolean"},"templates":{"items":{"$ref":"#/components/schemas/v1beta1.RepeatTemplate"},"type":"array","uniqueItems":false},"title":{"type":"string"},"type":{"type":"string"},"validation":{"$ref":"#/components/schemas/v1beta1.ConfigItemValidation"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"valuesByGroup":{"$ref":"#/components/schemas/v1beta1.ValuesByGroup"},"when":{"type":"string"},"write_once":{"type":"boolean"}},"required":["affix","countByGroup","data","default","error","filename","help_text","hidden","items","minimumCount","multi_value","multiple","name","readonly","recommended","repeatable","required","templates","title","type","validation","value","valuesByGroup","when","write_once"],"type":"object"},"v1beta1.ConfigItemValidation":{"properties":{"regex":{"$ref":"#/components/schemas/v1beta1.RegexValidator"}},"required":["regex"],"type":"object"},"v1beta1.GroupValues":{"additionalProperties":{"type":"string"},"type":"object"},"v1beta1.RegexValidator":{"properties":{"message":{"type":"string"},"pattern":{"type":"string"}},"required":["message","pattern"],"type":"object"},"v1beta1.RepeatTemplate":{"properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"name":{"type":"string"},"namespace":{"type":"string"},"yamlPath":{"type":"string"}},"required":["apiVersion","kind","name","namespace","yamlPath"],"type":"object"},"v1beta1.ValuesByGroup":{"additionalProperties":{"$ref":"#/components/schemas/v1beta1.GroupValues"},"type":"object"}},"securitySchemes":{"bearerauth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}}, + "components": {"schemas":{"github_com_replicatedhq_embedded-cluster_api_types.Health":{"properties":{"status":{"type":"string"}},"required":["status"],"type":"object"},"github_com_replicatedhq_kotskinds_multitype.BoolOrString":{"type":"object"},"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"statusCode":{"type":"integer"}},"required":["message"],"type":"object"},"types.Airgap":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["logs","status"],"type":"object"},"types.AppComponent":{"properties":{"name":{"description":"Chart name","type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["name","status"],"type":"object"},"types.AppConfig":{"properties":{"groups":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigGroup"},"type":"array","uniqueItems":false}},"required":["groups"],"type":"object"},"types.AppConfigValue":{"properties":{"data":{"type":"string"},"dataPlaintext":{"type":"string"},"default":{"type":"string"},"filename":{"type":"string"},"repeatableItem":{"type":"string"},"value":{"type":"string"}},"required":["value"],"type":"object"},"types.AppConfigValues":{"additionalProperties":{"$ref":"#/components/schemas/types.AppConfigValue"},"type":"object"},"types.AppConfigValuesResponse":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"required":["values"],"type":"object"},"types.AppInstall":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.AppComponent"},"type":"array","uniqueItems":false},"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["components","logs","status"],"type":"object"},"types.AppUpgrade":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["logs","status"],"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"required":["password"],"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"required":["token"],"type":"object"},"types.GetListAvailableNetworkInterfacesResponse":{"properties":{"networkInterfaces":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["networkInterfaces"],"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"}},"required":["components","logs","status"],"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["name","status"],"type":"object"},"types.InstallAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["allowIgnoreAppPreflights","hasStrictAppPreflightFailures","status","titles"],"type":"object"},"types.InstallAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"required":["ignoreAppPreflights"],"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"allowIgnoreHostPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["allowIgnoreHostPreflights","titles"],"type":"object"},"types.KURLMigrationPhase":{"description":"Phase is the current phase of the kURL migration process","enum":["Discovery","Preparation","ECInstall","DataTransfer","Completed"],"example":"Discovery","type":"string","x-enum-varnames":["KURLMigrationPhaseDiscovery","KURLMigrationPhasePreparation","KURLMigrationPhaseECInstall","KURLMigrationPhaseDataTransfer","KURLMigrationPhaseCompleted"]},"types.KURLMigrationState":{"description":"State is the current state of the kURL migration","enum":["NotStarted","InProgress","Completed","Failed"],"example":"InProgress","type":"string","x-enum-varnames":["KURLMigrationStateNotStarted","KURLMigrationStateInProgress","KURLMigrationStateCompleted","KURLMigrationStateFailed"]},"types.KURLMigrationStatusResponse":{"description":"Current status and progress of a kURL migration","properties":{"error":{"description":"Error contains the error message if the kURL migration failed","example":"","type":"string"},"message":{"description":"Message is a user-facing message describing the current status","example":"Discovering kURL cluster configuration","type":"string"},"phase":{"$ref":"#/components/schemas/types.KURLMigrationPhase"},"progress":{"description":"Progress is the completion percentage (0-100)","example":25,"type":"integer"},"state":{"$ref":"#/components/schemas/types.KURLMigrationState"}},"required":["message","phase","progress","state"],"type":"object"},"types.KubernetesInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"noProxy":{"type":"string"}},"type":"object"},"types.KubernetesInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"values":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}},"required":["defaults","resolved","values"],"type":"object"},"types.LinuxInfraSetupRequest":{"properties":{"ignoreHostPreflights":{"type":"boolean"}},"required":["ignoreHostPreflights"],"type":"object"},"types.LinuxInstallationConfig":{"description":"Config contains optional installation configuration that will be merged with defaults","properties":{"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.LinuxInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"values":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}},"required":["defaults","resolved","values"],"type":"object"},"types.PatchAppConfigValuesRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"required":["values"],"type":"object"},"types.PostInstallRunHostPreflightsRequest":{"properties":{"isUi":{"type":"boolean"}},"required":["isUi"],"type":"object"},"types.PreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false}},"required":["fail","pass","warn"],"type":"object"},"types.PreflightsRecord":{"properties":{"message":{"type":"string"},"strict":{"type":"boolean"},"title":{"type":"string"}},"required":["message","strict","title"],"type":"object"},"types.StartKURLMigrationRequest":{"description":"Request body for starting a migration from kURL to Embedded Cluster","properties":{"config":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"transferMode":{"$ref":"#/components/schemas/types.TransferMode"}},"required":["transferMode"],"type":"object"},"types.StartKURLMigrationResponse":{"description":"Response returned when a kURL migration is successfully started","properties":{"message":{"description":"Message is a user-facing message about the kURL migration status","example":"kURL migration started successfully","type":"string"},"migrationId":{"description":"MigrationID is the unique identifier for this migration","example":"550e8400-e29b-41d4-a716-446655440000","type":"string"}},"required":["message","migrationId"],"type":"object"},"types.State":{"enum":["Pending","Running","Succeeded","Failed"],"example":"Succeeded","type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"description":"Uses existing Status type","properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"required":["description","lastUpdated","state"],"type":"object"},"types.TemplateAppConfigRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"required":["values"],"type":"object"},"types.TransferMode":{"description":"TransferMode specifies whether to copy or move data during kURL migration","enum":["copy","move"],"example":"copy","type":"string","x-enum-varnames":["TransferModeCopy","TransferModeMove"]},"types.UpgradeAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["allowIgnoreAppPreflights","hasStrictAppPreflightFailures","status","titles"],"type":"object"},"types.UpgradeAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"required":["ignoreAppPreflights"],"type":"object"},"v1beta1.ConfigChildItem":{"properties":{"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"name":{"type":"string"},"recommended":{"type":"boolean"},"title":{"type":"string"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"}},"required":["default","name","recommended","title","value"],"type":"object"},"v1beta1.ConfigGroup":{"properties":{"description":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigItem"},"type":"array","uniqueItems":false},"name":{"type":"string"},"title":{"type":"string"},"when":{"type":"string"}},"required":["description","items","name","title","when"],"type":"object"},"v1beta1.ConfigItem":{"properties":{"affix":{"type":"string"},"countByGroup":{"additionalProperties":{"type":"integer"},"type":"object"},"data":{"type":"string"},"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"error":{"type":"string"},"filename":{"type":"string"},"help_text":{"type":"string"},"hidden":{"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigChildItem"},"type":"array","uniqueItems":false},"minimumCount":{"type":"integer"},"multi_value":{"items":{"type":"string"},"type":"array","uniqueItems":false},"multiple":{"type":"boolean"},"name":{"type":"string"},"readonly":{"type":"boolean"},"recommended":{"type":"boolean"},"repeatable":{"type":"boolean"},"required":{"type":"boolean"},"templates":{"items":{"$ref":"#/components/schemas/v1beta1.RepeatTemplate"},"type":"array","uniqueItems":false},"title":{"type":"string"},"type":{"type":"string"},"validation":{"$ref":"#/components/schemas/v1beta1.ConfigItemValidation"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"valuesByGroup":{"$ref":"#/components/schemas/v1beta1.ValuesByGroup"},"when":{"type":"string"},"write_once":{"type":"boolean"}},"required":["affix","countByGroup","data","default","error","filename","help_text","hidden","items","minimumCount","multi_value","multiple","name","readonly","recommended","repeatable","required","templates","title","type","validation","value","valuesByGroup","when","write_once"],"type":"object"},"v1beta1.ConfigItemValidation":{"properties":{"regex":{"$ref":"#/components/schemas/v1beta1.RegexValidator"}},"required":["regex"],"type":"object"},"v1beta1.GroupValues":{"additionalProperties":{"type":"string"},"type":"object"},"v1beta1.RegexValidator":{"properties":{"message":{"type":"string"},"pattern":{"type":"string"}},"required":["message","pattern"],"type":"object"},"v1beta1.RepeatTemplate":{"properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"name":{"type":"string"},"namespace":{"type":"string"},"yamlPath":{"type":"string"}},"required":["apiVersion","kind","name","namespace","yamlPath"],"type":"object"},"v1beta1.ValuesByGroup":{"additionalProperties":{"$ref":"#/components/schemas/v1beta1.GroupValues"},"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","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/github_com_replicatedhq_embedded-cluster_api_types.Health"}}},"description":"OK"}},"summary":"Get the health of the API","tags":["health"]}},"/kubernetes/install/app-preflights/run":{"post":{"description":"Run install app preflight checks using current app configuration","operationId":"postKubernetesInstallRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run install app preflight checks","tags":["kubernetes-install"]}},"/kubernetes/install/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for install","operationId":"getKubernetesInstallAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for install","tags":["kubernetes-install"]}},"/kubernetes/install/app/config/template":{"post":{"description":"Template the app config with provided values and return the templated config","operationId":"postKubernetesInstallTemplateAppConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template the app config with provided values","tags":["kubernetes-install"]}},"/kubernetes/install/app/config/values":{"get":{"description":"Get the current app config values","operationId":"getKubernetesInstallAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values","tags":["kubernetes-install"]},"patch":{"description":"Set the app config values with partial updates","operationId":"patchKubernetesInstallAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values","tags":["kubernetes-install"]}},"/kubernetes/install/app/install":{"post":{"description":"Install the app using current configuration","operationId":"postKubernetesInstallApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppRequest"}}},"description":"Install App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Install the app","tags":["kubernetes-install"]}},"/kubernetes/install/app/status":{"get":{"description":"Get the current status of app installation","operationId":"getKubernetesInstallAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app install status","tags":["kubernetes-install"]}},"/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.KubernetesInstallationConfigResponse"}}},"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"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"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"]}},"/kubernetes/upgrade/app-preflights/run":{"post":{"description":"Run upgrade app preflight checks using current app configuration","operationId":"postKubernetesUpgradeRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run upgrade app preflight checks","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for upgrade","operationId":"getKubernetesUpgradeAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for upgrade","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/config/template":{"post":{"description":"Template the app configuration with values for upgrade","operationId":"postKubernetesUpgradeAppConfigTemplate","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template app config for upgrade","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/config/values":{"get":{"description":"Get the current app config values for upgrade","operationId":"getKubernetesUpgradeAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values for upgrade","tags":["kubernetes-upgrade"]},"patch":{"description":"Set the app config values with partial updates for upgrade","operationId":"patchKubernetesUpgradeAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values for upgrade","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/status":{"get":{"description":"Get the current status of app upgrade","operationId":"getKubernetesUpgradeAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app upgrade status","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/upgrade":{"post":{"description":"Upgrade the app using current configuration","operationId":"postKubernetesUpgradeApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppRequest"}}},"description":"Upgrade App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Upgrade the app","tags":["kubernetes-upgrade"]}},"/linux/install/airgap/process":{"post":{"description":"Process the airgap bundle for install","operationId":"postLinuxInstallProcessAirgap","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Airgap"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Process the airgap bundle","tags":["linux-install"]}},"/linux/install/airgap/status":{"get":{"description":"Get the current status of the airgap processing for install","operationId":"getLinuxInstallAirgapStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Airgap"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the airgap processing","tags":["linux-install"]}},"/linux/install/app-preflights/run":{"post":{"description":"Run install app preflight checks using current app configuration","operationId":"postLinuxInstallRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run install app preflight checks","tags":["linux-install"]}},"/linux/install/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for install","operationId":"getLinuxInstallAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for install","tags":["linux-install"]}},"/linux/install/app/config/template":{"post":{"description":"Template the app config with provided values and return the templated config","operationId":"postLinuxInstallTemplateAppConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template the app config with provided values","tags":["linux-install"]}},"/linux/install/app/config/values":{"get":{"description":"Get the current app config values","operationId":"getLinuxInstallAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values","tags":["linux-install"]},"patch":{"description":"Set the app config values with partial updates","operationId":"patchLinuxInstallAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values","tags":["linux-install"]}},"/linux/install/app/install":{"post":{"description":"Install the app using current configuration","operationId":"postLinuxInstallApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppRequest"}}},"description":"Install App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Install the app","tags":["linux-install"]}},"/linux/install/app/status":{"get":{"description":"Get the current status of app installation","operationId":"getLinuxInstallAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app install status","tags":["linux-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.LinuxInstallationConfigResponse"}}},"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"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"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"]}},"/linux/kurl-migration/config":{"get":{"description":"Get the installation config extracted from kURL merged with EC defaults","operationId":"getKURLMigrationInstallationConfig","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInstallationConfigResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the installation config for kURL migration","tags":["kurl-migration"]}},"/linux/kurl-migration/start":{"post":{"description":"Start a migration from kURL to Embedded Cluster with the provided configuration","operationId":"postStartMigration","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.StartKURLMigrationRequest"}}},"description":"Start kURL Migration Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.StartKURLMigrationResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Conflict"}},"security":[{"bearerauth":[]}],"summary":"Start a migration from kURL to Embedded Cluster","tags":["kurl-migration"]}},"/linux/kurl-migration/status":{"get":{"description":"Get the current status and progress of the kURL migration","operationId":"getKURLMigrationStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.KURLMigrationStatusResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Not Found"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the kURL migration","tags":["kurl-migration"]}},"/linux/upgrade/airgap/process":{"post":{"description":"Process the airgap bundle for upgrade","operationId":"postLinuxUpgradeProcessAirgap","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Airgap"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Process the airgap bundle","tags":["linux-upgrade"]}},"/linux/upgrade/airgap/status":{"get":{"description":"Get the current status of the airgap processing for upgrade","operationId":"getLinuxUpgradeAirgapStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Airgap"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the airgap processing","tags":["linux-upgrade"]}},"/linux/upgrade/app-preflights/run":{"post":{"description":"Run upgrade app preflight checks using current app configuration","operationId":"postLinuxUpgradeRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run upgrade app preflight checks","tags":["linux-upgrade"]}},"/linux/upgrade/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for upgrade","operationId":"getLinuxUpgradeAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for upgrade","tags":["linux-upgrade"]}},"/linux/upgrade/app/config/template":{"post":{"description":"Template the app configuration with values for upgrade","operationId":"postLinuxUpgradeAppConfigTemplate","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template app config for upgrade","tags":["linux-upgrade"]}},"/linux/upgrade/app/config/values":{"get":{"description":"Get the current app config values for upgrade","operationId":"getLinuxUpgradeAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values for upgrade","tags":["linux-upgrade"]},"patch":{"description":"Set the app config values with partial updates for upgrade","operationId":"patchLinuxUpgradeAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values for upgrade","tags":["linux-upgrade"]}},"/linux/upgrade/app/status":{"get":{"description":"Get the current status of app upgrade","operationId":"getLinuxUpgradeAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app upgrade status","tags":["linux-upgrade"]}},"/linux/upgrade/app/upgrade":{"post":{"description":"Upgrade the app using current configuration","operationId":"postLinuxUpgradeApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppRequest"}}},"description":"Upgrade App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Upgrade the app","tags":["linux-upgrade"]}},"/linux/upgrade/infra/status":{"get":{"description":"Get the current status of the infrastructure upgrade","operationId":"getLinuxUpgradeInfraStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Internal Server Error"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra upgrade","tags":["linux-upgrade"]}},"/linux/upgrade/infra/upgrade":{"post":{"description":"Upgrade the infrastructure (k0s, addons, extensions)","operationId":"postLinuxUpgradeInfra","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Unauthorized"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Conflict"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Internal Server Error"}},"security":[{"bearerauth":[]}],"summary":"Upgrade the infrastructure","tags":["linux-upgrade"]}}}, diff --git a/api/docs/swagger.json b/api/docs/swagger.json index addf659448..2e0d74438c 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1,5 +1,5 @@ { - "components": {"schemas":{"github_com_replicatedhq_embedded-cluster_api_types.Health":{"properties":{"status":{"type":"string"}},"required":["status"],"type":"object"},"github_com_replicatedhq_kotskinds_multitype.BoolOrString":{"type":"object"},"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"statusCode":{"type":"integer"}},"required":["message"],"type":"object"},"types.Airgap":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["logs","status"],"type":"object"},"types.AppConfig":{"properties":{"groups":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigGroup"},"type":"array","uniqueItems":false}},"required":["groups"],"type":"object"},"types.AppConfigValue":{"properties":{"data":{"type":"string"},"dataPlaintext":{"type":"string"},"default":{"type":"string"},"filename":{"type":"string"},"repeatableItem":{"type":"string"},"value":{"type":"string"}},"required":["value"],"type":"object"},"types.AppConfigValues":{"additionalProperties":{"$ref":"#/components/schemas/types.AppConfigValue"},"type":"object"},"types.AppConfigValuesResponse":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"required":["values"],"type":"object"},"types.AppInstall":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["logs","status"],"type":"object"},"types.AppUpgrade":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["logs","status"],"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"required":["password"],"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"required":["token"],"type":"object"},"types.GetListAvailableNetworkInterfacesResponse":{"properties":{"networkInterfaces":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["networkInterfaces"],"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"}},"required":["components","logs","status"],"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["name","status"],"type":"object"},"types.InstallAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["allowIgnoreAppPreflights","hasStrictAppPreflightFailures","status","titles"],"type":"object"},"types.InstallAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"required":["ignoreAppPreflights"],"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"allowIgnoreHostPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["allowIgnoreHostPreflights","titles"],"type":"object"},"types.KURLMigrationPhase":{"description":"Phase is the current phase of the kURL migration process","enum":["Discovery","Preparation","ECInstall","DataTransfer","Completed"],"example":"Discovery","type":"string","x-enum-varnames":["KURLMigrationPhaseDiscovery","KURLMigrationPhasePreparation","KURLMigrationPhaseECInstall","KURLMigrationPhaseDataTransfer","KURLMigrationPhaseCompleted"]},"types.KURLMigrationState":{"description":"State is the current state of the kURL migration","enum":["NotStarted","InProgress","Completed","Failed"],"example":"InProgress","type":"string","x-enum-varnames":["KURLMigrationStateNotStarted","KURLMigrationStateInProgress","KURLMigrationStateCompleted","KURLMigrationStateFailed"]},"types.KURLMigrationStatusResponse":{"description":"Current status and progress of a kURL migration","properties":{"error":{"description":"Error contains the error message if the kURL migration failed","example":"","type":"string"},"message":{"description":"Message is a user-facing message describing the current status","example":"Discovering kURL cluster configuration","type":"string"},"phase":{"$ref":"#/components/schemas/types.KURLMigrationPhase"},"progress":{"description":"Progress is the completion percentage (0-100)","example":25,"type":"integer"},"state":{"$ref":"#/components/schemas/types.KURLMigrationState"}},"required":["message","phase","progress","state"],"type":"object"},"types.KubernetesInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"noProxy":{"type":"string"}},"type":"object"},"types.KubernetesInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"values":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}},"required":["defaults","resolved","values"],"type":"object"},"types.LinuxInfraSetupRequest":{"properties":{"ignoreHostPreflights":{"type":"boolean"}},"required":["ignoreHostPreflights"],"type":"object"},"types.LinuxInstallationConfig":{"description":"Config contains optional installation configuration that will be merged with defaults","properties":{"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.LinuxInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"values":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}},"required":["defaults","resolved","values"],"type":"object"},"types.PatchAppConfigValuesRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"required":["values"],"type":"object"},"types.PostInstallRunHostPreflightsRequest":{"properties":{"isUi":{"type":"boolean"}},"required":["isUi"],"type":"object"},"types.PreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false}},"required":["fail","pass","warn"],"type":"object"},"types.PreflightsRecord":{"properties":{"message":{"type":"string"},"strict":{"type":"boolean"},"title":{"type":"string"}},"required":["message","strict","title"],"type":"object"},"types.StartKURLMigrationRequest":{"description":"Request body for starting a migration from kURL to Embedded Cluster","properties":{"config":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"transferMode":{"$ref":"#/components/schemas/types.TransferMode"}},"required":["transferMode"],"type":"object"},"types.StartKURLMigrationResponse":{"description":"Response returned when a kURL migration is successfully started","properties":{"message":{"description":"Message is a user-facing message about the kURL migration status","example":"kURL migration started successfully","type":"string"},"migrationId":{"description":"MigrationID is the unique identifier for this migration","example":"550e8400-e29b-41d4-a716-446655440000","type":"string"}},"required":["message","migrationId"],"type":"object"},"types.State":{"enum":["Pending","Running","Succeeded","Failed"],"example":"Succeeded","type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"required":["description","lastUpdated","state"],"type":"object"},"types.TemplateAppConfigRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"required":["values"],"type":"object"},"types.TransferMode":{"description":"TransferMode specifies whether to copy or move data during kURL migration","enum":["copy","move"],"example":"copy","type":"string","x-enum-varnames":["TransferModeCopy","TransferModeMove"]},"types.UpgradeAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["allowIgnoreAppPreflights","hasStrictAppPreflightFailures","status","titles"],"type":"object"},"types.UpgradeAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"required":["ignoreAppPreflights"],"type":"object"},"v1beta1.ConfigChildItem":{"properties":{"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"name":{"type":"string"},"recommended":{"type":"boolean"},"title":{"type":"string"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"}},"required":["default","name","recommended","title","value"],"type":"object"},"v1beta1.ConfigGroup":{"properties":{"description":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigItem"},"type":"array","uniqueItems":false},"name":{"type":"string"},"title":{"type":"string"},"when":{"type":"string"}},"required":["description","items","name","title","when"],"type":"object"},"v1beta1.ConfigItem":{"properties":{"affix":{"type":"string"},"countByGroup":{"additionalProperties":{"type":"integer"},"type":"object"},"data":{"type":"string"},"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"error":{"type":"string"},"filename":{"type":"string"},"help_text":{"type":"string"},"hidden":{"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigChildItem"},"type":"array","uniqueItems":false},"minimumCount":{"type":"integer"},"multi_value":{"items":{"type":"string"},"type":"array","uniqueItems":false},"multiple":{"type":"boolean"},"name":{"type":"string"},"readonly":{"type":"boolean"},"recommended":{"type":"boolean"},"repeatable":{"type":"boolean"},"required":{"type":"boolean"},"templates":{"items":{"$ref":"#/components/schemas/v1beta1.RepeatTemplate"},"type":"array","uniqueItems":false},"title":{"type":"string"},"type":{"type":"string"},"validation":{"$ref":"#/components/schemas/v1beta1.ConfigItemValidation"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"valuesByGroup":{"$ref":"#/components/schemas/v1beta1.ValuesByGroup"},"when":{"type":"string"},"write_once":{"type":"boolean"}},"required":["affix","countByGroup","data","default","error","filename","help_text","hidden","items","minimumCount","multi_value","multiple","name","readonly","recommended","repeatable","required","templates","title","type","validation","value","valuesByGroup","when","write_once"],"type":"object"},"v1beta1.ConfigItemValidation":{"properties":{"regex":{"$ref":"#/components/schemas/v1beta1.RegexValidator"}},"required":["regex"],"type":"object"},"v1beta1.GroupValues":{"additionalProperties":{"type":"string"},"type":"object"},"v1beta1.RegexValidator":{"properties":{"message":{"type":"string"},"pattern":{"type":"string"}},"required":["message","pattern"],"type":"object"},"v1beta1.RepeatTemplate":{"properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"name":{"type":"string"},"namespace":{"type":"string"},"yamlPath":{"type":"string"}},"required":["apiVersion","kind","name","namespace","yamlPath"],"type":"object"},"v1beta1.ValuesByGroup":{"additionalProperties":{"$ref":"#/components/schemas/v1beta1.GroupValues"},"type":"object"}},"securitySchemes":{"bearerauth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}}, + "components": {"schemas":{"github_com_replicatedhq_embedded-cluster_api_types.Health":{"properties":{"status":{"type":"string"}},"required":["status"],"type":"object"},"github_com_replicatedhq_kotskinds_multitype.BoolOrString":{"type":"object"},"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"statusCode":{"type":"integer"}},"required":["message"],"type":"object"},"types.Airgap":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["logs","status"],"type":"object"},"types.AppComponent":{"properties":{"name":{"description":"Chart name","type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["name","status"],"type":"object"},"types.AppConfig":{"properties":{"groups":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigGroup"},"type":"array","uniqueItems":false}},"required":["groups"],"type":"object"},"types.AppConfigValue":{"properties":{"data":{"type":"string"},"dataPlaintext":{"type":"string"},"default":{"type":"string"},"filename":{"type":"string"},"repeatableItem":{"type":"string"},"value":{"type":"string"}},"required":["value"],"type":"object"},"types.AppConfigValues":{"additionalProperties":{"$ref":"#/components/schemas/types.AppConfigValue"},"type":"object"},"types.AppConfigValuesResponse":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"required":["values"],"type":"object"},"types.AppInstall":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.AppComponent"},"type":"array","uniqueItems":false},"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["components","logs","status"],"type":"object"},"types.AppUpgrade":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["logs","status"],"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"required":["password"],"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"required":["token"],"type":"object"},"types.GetListAvailableNetworkInterfacesResponse":{"properties":{"networkInterfaces":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["networkInterfaces"],"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"}},"required":["components","logs","status"],"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"required":["name","status"],"type":"object"},"types.InstallAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["allowIgnoreAppPreflights","hasStrictAppPreflightFailures","status","titles"],"type":"object"},"types.InstallAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"required":["ignoreAppPreflights"],"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"allowIgnoreHostPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["allowIgnoreHostPreflights","titles"],"type":"object"},"types.KURLMigrationPhase":{"description":"Phase is the current phase of the kURL migration process","enum":["Discovery","Preparation","ECInstall","DataTransfer","Completed"],"example":"Discovery","type":"string","x-enum-varnames":["KURLMigrationPhaseDiscovery","KURLMigrationPhasePreparation","KURLMigrationPhaseECInstall","KURLMigrationPhaseDataTransfer","KURLMigrationPhaseCompleted"]},"types.KURLMigrationState":{"description":"State is the current state of the kURL migration","enum":["NotStarted","InProgress","Completed","Failed"],"example":"InProgress","type":"string","x-enum-varnames":["KURLMigrationStateNotStarted","KURLMigrationStateInProgress","KURLMigrationStateCompleted","KURLMigrationStateFailed"]},"types.KURLMigrationStatusResponse":{"description":"Current status and progress of a kURL migration","properties":{"error":{"description":"Error contains the error message if the kURL migration failed","example":"","type":"string"},"message":{"description":"Message is a user-facing message describing the current status","example":"Discovering kURL cluster configuration","type":"string"},"phase":{"$ref":"#/components/schemas/types.KURLMigrationPhase"},"progress":{"description":"Progress is the completion percentage (0-100)","example":25,"type":"integer"},"state":{"$ref":"#/components/schemas/types.KURLMigrationState"}},"required":["message","phase","progress","state"],"type":"object"},"types.KubernetesInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"noProxy":{"type":"string"}},"type":"object"},"types.KubernetesInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"},"values":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}},"required":["defaults","resolved","values"],"type":"object"},"types.LinuxInfraSetupRequest":{"properties":{"ignoreHostPreflights":{"type":"boolean"}},"required":["ignoreHostPreflights"],"type":"object"},"types.LinuxInstallationConfig":{"description":"Config contains optional installation configuration that will be merged with defaults","properties":{"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.LinuxInstallationConfigResponse":{"properties":{"defaults":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"resolved":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"values":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}},"required":["defaults","resolved","values"],"type":"object"},"types.PatchAppConfigValuesRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"required":["values"],"type":"object"},"types.PostInstallRunHostPreflightsRequest":{"properties":{"isUi":{"type":"boolean"}},"required":["isUi"],"type":"object"},"types.PreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false}},"required":["fail","pass","warn"],"type":"object"},"types.PreflightsRecord":{"properties":{"message":{"type":"string"},"strict":{"type":"boolean"},"title":{"type":"string"}},"required":["message","strict","title"],"type":"object"},"types.StartKURLMigrationRequest":{"description":"Request body for starting a migration from kURL to Embedded Cluster","properties":{"config":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"},"transferMode":{"$ref":"#/components/schemas/types.TransferMode"}},"required":["transferMode"],"type":"object"},"types.StartKURLMigrationResponse":{"description":"Response returned when a kURL migration is successfully started","properties":{"message":{"description":"Message is a user-facing message about the kURL migration status","example":"kURL migration started successfully","type":"string"},"migrationId":{"description":"MigrationID is the unique identifier for this migration","example":"550e8400-e29b-41d4-a716-446655440000","type":"string"}},"required":["message","migrationId"],"type":"object"},"types.State":{"enum":["Pending","Running","Succeeded","Failed"],"example":"Succeeded","type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"description":"Uses existing Status type","properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"required":["description","lastUpdated","state"],"type":"object"},"types.TemplateAppConfigRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"required":["values"],"type":"object"},"types.TransferMode":{"description":"TransferMode specifies whether to copy or move data during kURL migration","enum":["copy","move"],"example":"copy","type":"string","x-enum-varnames":["TransferModeCopy","TransferModeMove"]},"types.UpgradeAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"hasStrictAppPreflightFailures":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"required":["allowIgnoreAppPreflights","hasStrictAppPreflightFailures","status","titles"],"type":"object"},"types.UpgradeAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"required":["ignoreAppPreflights"],"type":"object"},"v1beta1.ConfigChildItem":{"properties":{"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"name":{"type":"string"},"recommended":{"type":"boolean"},"title":{"type":"string"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"}},"required":["default","name","recommended","title","value"],"type":"object"},"v1beta1.ConfigGroup":{"properties":{"description":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigItem"},"type":"array","uniqueItems":false},"name":{"type":"string"},"title":{"type":"string"},"when":{"type":"string"}},"required":["description","items","name","title","when"],"type":"object"},"v1beta1.ConfigItem":{"properties":{"affix":{"type":"string"},"countByGroup":{"additionalProperties":{"type":"integer"},"type":"object"},"data":{"type":"string"},"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"error":{"type":"string"},"filename":{"type":"string"},"help_text":{"type":"string"},"hidden":{"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigChildItem"},"type":"array","uniqueItems":false},"minimumCount":{"type":"integer"},"multi_value":{"items":{"type":"string"},"type":"array","uniqueItems":false},"multiple":{"type":"boolean"},"name":{"type":"string"},"readonly":{"type":"boolean"},"recommended":{"type":"boolean"},"repeatable":{"type":"boolean"},"required":{"type":"boolean"},"templates":{"items":{"$ref":"#/components/schemas/v1beta1.RepeatTemplate"},"type":"array","uniqueItems":false},"title":{"type":"string"},"type":{"type":"string"},"validation":{"$ref":"#/components/schemas/v1beta1.ConfigItemValidation"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"valuesByGroup":{"$ref":"#/components/schemas/v1beta1.ValuesByGroup"},"when":{"type":"string"},"write_once":{"type":"boolean"}},"required":["affix","countByGroup","data","default","error","filename","help_text","hidden","items","minimumCount","multi_value","multiple","name","readonly","recommended","repeatable","required","templates","title","type","validation","value","valuesByGroup","when","write_once"],"type":"object"},"v1beta1.ConfigItemValidation":{"properties":{"regex":{"$ref":"#/components/schemas/v1beta1.RegexValidator"}},"required":["regex"],"type":"object"},"v1beta1.GroupValues":{"additionalProperties":{"type":"string"},"type":"object"},"v1beta1.RegexValidator":{"properties":{"message":{"type":"string"},"pattern":{"type":"string"}},"required":["message","pattern"],"type":"object"},"v1beta1.RepeatTemplate":{"properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"name":{"type":"string"},"namespace":{"type":"string"},"yamlPath":{"type":"string"}},"required":["apiVersion","kind","name","namespace","yamlPath"],"type":"object"},"v1beta1.ValuesByGroup":{"additionalProperties":{"$ref":"#/components/schemas/v1beta1.GroupValues"},"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","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/github_com_replicatedhq_embedded-cluster_api_types.Health"}}},"description":"OK"}},"summary":"Get the health of the API","tags":["health"]}},"/kubernetes/install/app-preflights/run":{"post":{"description":"Run install app preflight checks using current app configuration","operationId":"postKubernetesInstallRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run install app preflight checks","tags":["kubernetes-install"]}},"/kubernetes/install/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for install","operationId":"getKubernetesInstallAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for install","tags":["kubernetes-install"]}},"/kubernetes/install/app/config/template":{"post":{"description":"Template the app config with provided values and return the templated config","operationId":"postKubernetesInstallTemplateAppConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template the app config with provided values","tags":["kubernetes-install"]}},"/kubernetes/install/app/config/values":{"get":{"description":"Get the current app config values","operationId":"getKubernetesInstallAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values","tags":["kubernetes-install"]},"patch":{"description":"Set the app config values with partial updates","operationId":"patchKubernetesInstallAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values","tags":["kubernetes-install"]}},"/kubernetes/install/app/install":{"post":{"description":"Install the app using current configuration","operationId":"postKubernetesInstallApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppRequest"}}},"description":"Install App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Install the app","tags":["kubernetes-install"]}},"/kubernetes/install/app/status":{"get":{"description":"Get the current status of app installation","operationId":"getKubernetesInstallAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app install status","tags":["kubernetes-install"]}},"/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.KubernetesInstallationConfigResponse"}}},"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"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"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"]}},"/kubernetes/upgrade/app-preflights/run":{"post":{"description":"Run upgrade app preflight checks using current app configuration","operationId":"postKubernetesUpgradeRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run upgrade app preflight checks","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for upgrade","operationId":"getKubernetesUpgradeAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for upgrade","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/config/template":{"post":{"description":"Template the app configuration with values for upgrade","operationId":"postKubernetesUpgradeAppConfigTemplate","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template app config for upgrade","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/config/values":{"get":{"description":"Get the current app config values for upgrade","operationId":"getKubernetesUpgradeAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values for upgrade","tags":["kubernetes-upgrade"]},"patch":{"description":"Set the app config values with partial updates for upgrade","operationId":"patchKubernetesUpgradeAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values for upgrade","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/status":{"get":{"description":"Get the current status of app upgrade","operationId":"getKubernetesUpgradeAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app upgrade status","tags":["kubernetes-upgrade"]}},"/kubernetes/upgrade/app/upgrade":{"post":{"description":"Upgrade the app using current configuration","operationId":"postKubernetesUpgradeApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppRequest"}}},"description":"Upgrade App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Upgrade the app","tags":["kubernetes-upgrade"]}},"/linux/install/airgap/process":{"post":{"description":"Process the airgap bundle for install","operationId":"postLinuxInstallProcessAirgap","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Airgap"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Process the airgap bundle","tags":["linux-install"]}},"/linux/install/airgap/status":{"get":{"description":"Get the current status of the airgap processing for install","operationId":"getLinuxInstallAirgapStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Airgap"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the airgap processing","tags":["linux-install"]}},"/linux/install/app-preflights/run":{"post":{"description":"Run install app preflight checks using current app configuration","operationId":"postLinuxInstallRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run install app preflight checks","tags":["linux-install"]}},"/linux/install/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for install","operationId":"getLinuxInstallAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for install","tags":["linux-install"]}},"/linux/install/app/config/template":{"post":{"description":"Template the app config with provided values and return the templated config","operationId":"postLinuxInstallTemplateAppConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template the app config with provided values","tags":["linux-install"]}},"/linux/install/app/config/values":{"get":{"description":"Get the current app config values","operationId":"getLinuxInstallAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values","tags":["linux-install"]},"patch":{"description":"Set the app config values with partial updates","operationId":"patchLinuxInstallAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values","tags":["linux-install"]}},"/linux/install/app/install":{"post":{"description":"Install the app using current configuration","operationId":"postLinuxInstallApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppRequest"}}},"description":"Install App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Install the app","tags":["linux-install"]}},"/linux/install/app/status":{"get":{"description":"Get the current status of app installation","operationId":"getLinuxInstallAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app install status","tags":["linux-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.LinuxInstallationConfigResponse"}}},"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"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"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"]}},"/linux/kurl-migration/config":{"get":{"description":"Get the installation config extracted from kURL merged with EC defaults","operationId":"getKURLMigrationInstallationConfig","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInstallationConfigResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the installation config for kURL migration","tags":["kurl-migration"]}},"/linux/kurl-migration/start":{"post":{"description":"Start a migration from kURL to Embedded Cluster with the provided configuration","operationId":"postStartMigration","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.StartKURLMigrationRequest"}}},"description":"Start kURL Migration Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.StartKURLMigrationResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Conflict"}},"security":[{"bearerauth":[]}],"summary":"Start a migration from kURL to Embedded Cluster","tags":["kurl-migration"]}},"/linux/kurl-migration/status":{"get":{"description":"Get the current status and progress of the kURL migration","operationId":"getKURLMigrationStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.KURLMigrationStatusResponse"}}},"description":"OK"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Not Found"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the kURL migration","tags":["kurl-migration"]}},"/linux/upgrade/airgap/process":{"post":{"description":"Process the airgap bundle for upgrade","operationId":"postLinuxUpgradeProcessAirgap","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Airgap"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Process the airgap bundle","tags":["linux-upgrade"]}},"/linux/upgrade/airgap/status":{"get":{"description":"Get the current status of the airgap processing for upgrade","operationId":"getLinuxUpgradeAirgapStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Airgap"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the airgap processing","tags":["linux-upgrade"]}},"/linux/upgrade/app-preflights/run":{"post":{"description":"Run upgrade app preflight checks using current app configuration","operationId":"postLinuxUpgradeRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run upgrade app preflight checks","tags":["linux-upgrade"]}},"/linux/upgrade/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for upgrade","operationId":"getLinuxUpgradeAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for upgrade","tags":["linux-upgrade"]}},"/linux/upgrade/app/config/template":{"post":{"description":"Template the app configuration with values for upgrade","operationId":"postLinuxUpgradeAppConfigTemplate","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template app config for upgrade","tags":["linux-upgrade"]}},"/linux/upgrade/app/config/values":{"get":{"description":"Get the current app config values for upgrade","operationId":"getLinuxUpgradeAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values for upgrade","tags":["linux-upgrade"]},"patch":{"description":"Set the app config values with partial updates for upgrade","operationId":"patchLinuxUpgradeAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values for upgrade","tags":["linux-upgrade"]}},"/linux/upgrade/app/status":{"get":{"description":"Get the current status of app upgrade","operationId":"getLinuxUpgradeAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app upgrade status","tags":["linux-upgrade"]}},"/linux/upgrade/app/upgrade":{"post":{"description":"Upgrade the app using current configuration","operationId":"postLinuxUpgradeApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.UpgradeAppRequest"}}},"description":"Upgrade App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppUpgrade"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Upgrade the app","tags":["linux-upgrade"]}},"/linux/upgrade/infra/status":{"get":{"description":"Get the current status of the infrastructure upgrade","operationId":"getLinuxUpgradeInfraStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Internal Server Error"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra upgrade","tags":["linux-upgrade"]}},"/linux/upgrade/infra/upgrade":{"post":{"description":"Upgrade the infrastructure (k0s, addons, extensions)","operationId":"postLinuxUpgradeInfra","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Unauthorized"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Conflict"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Internal Server Error"}},"security":[{"bearerauth":[]}],"summary":"Upgrade the infrastructure","tags":["linux-upgrade"]}}}, diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index faceaa193a..679b1812cb 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -35,6 +35,17 @@ components: - logs - status type: object + types.AppComponent: + properties: + name: + description: Chart name + type: string + status: + $ref: '#/components/schemas/types.Status' + required: + - name + - status + type: object types.AppConfig: properties: groups: @@ -75,11 +86,17 @@ components: type: object types.AppInstall: properties: + components: + items: + $ref: '#/components/schemas/types.AppComponent' + type: array + uniqueItems: false logs: type: string status: $ref: '#/components/schemas/types.Status' required: + - components - logs - status type: object @@ -398,6 +415,7 @@ components: - StateSucceeded - StateFailed types.Status: + description: Uses existing Status type properties: description: type: string diff --git a/api/internal/handlers/kubernetes/install/handler.go b/api/internal/handlers/kubernetes/install/handler.go index 183f683d31..51d81e1393 100644 --- a/api/internal/handlers/kubernetes/install/handler.go +++ b/api/internal/handlers/kubernetes/install/handler.go @@ -143,6 +143,8 @@ func (h *Handler) PostRunAppPreflights(w http.ResponseWriter, r *http.Request) { return } + // TODO: support registry settings + err = h.controller.RunAppPreflights(r.Context(), appcontroller.RunAppPreflightOptions{ PreflightBinaryPath: preflightBinary, ProxySpec: h.cfg.Installation.ProxySpec(), @@ -352,7 +354,13 @@ func (h *Handler) PostInstallApp(w http.ResponseWriter, r *http.Request) { return } - err := h.controller.InstallApp(r.Context(), req.IgnoreAppPreflights) + // TODO: support registry settings + + err := h.controller.InstallApp(r.Context(), appcontroller.InstallAppOptions{ + IgnoreAppPreflights: req.IgnoreAppPreflights, + ProxySpec: h.cfg.Installation.ProxySpec(), + RegistrySettings: nil, + }) if err != nil { utils.LogError(r, err, h.logger, "failed to install app") utils.JSONError(w, r, err, h.logger) diff --git a/api/internal/handlers/kubernetes/kubernetes.go b/api/internal/handlers/kubernetes/kubernetes.go index 080d781971..8e8a4c85b1 100644 --- a/api/internal/handlers/kubernetes/kubernetes.go +++ b/api/internal/handlers/kubernetes/kubernetes.go @@ -138,6 +138,7 @@ func New(cfg types.APIConfig, opts ...Option) (*Handler, error) { upgrade.WithConfigValues(h.cfg.ConfigValues), upgrade.WithHelmClient(h.hcli), upgrade.WithKubeClient(h.kcli), + upgrade.WithMetadataClient(h.mcli), upgrade.WithPreflightRunner(h.preflightRunner), ) if err != nil { diff --git a/api/internal/handlers/linux/install/handler.go b/api/internal/handlers/linux/install/handler.go index 2b5de29415..4ddfeeaf9d 100644 --- a/api/internal/handlers/linux/install/handler.go +++ b/api/internal/handlers/linux/install/handler.go @@ -483,7 +483,19 @@ func (h *Handler) PostInstallApp(w http.ResponseWriter, r *http.Request) { return } - err := h.controller.InstallApp(r.Context(), req.IgnoreAppPreflights) + registrySettings, err := h.controller.CalculateRegistrySettings(r.Context()) + if err != nil { + utils.LogError(r, err, h.logger, "failed to calculate registry settings") + utils.JSONError(w, r, err, h.logger) + return + } + + err = h.controller.InstallApp(r.Context(), appcontroller.InstallAppOptions{ + IgnoreAppPreflights: req.IgnoreAppPreflights, + ProxySpec: h.cfg.RuntimeConfig.ProxySpec(), + RegistrySettings: registrySettings, + HostCABundlePath: h.cfg.RuntimeConfig.HostCABundlePath(), + }) if err != nil { utils.LogError(r, err, h.logger, "failed to install app") utils.JSONError(w, r, err, h.logger) diff --git a/api/internal/managers/airgap/process.go b/api/internal/managers/airgap/process.go index 7ce0ee4016..9ed7b34e6b 100644 --- a/api/internal/managers/airgap/process.go +++ b/api/internal/managers/airgap/process.go @@ -24,9 +24,9 @@ func (m *airgapManager) Process(ctx context.Context, registrySettings *types.Reg err := kotscli.PushImages(kotscli.PushImagesOptions{ AirgapBundle: m.airgapBundle, - RegistryAddress: registrySettings.Address, - RegistryUsername: registrySettings.Username, - RegistryPassword: registrySettings.Password, + RegistryAddress: registrySettings.LocalRegistryAddress, + RegistryUsername: registrySettings.LocalRegistryUsername, + RegistryPassword: registrySettings.LocalRegistryPassword, ClusterID: m.clusterID, Stdout: m.newLogWriter(), }) diff --git a/api/internal/managers/app/install/install.go b/api/internal/managers/app/install/install.go index 2a3e30dc0a..b9f5d71f14 100644 --- a/api/internal/managers/app/install/install.go +++ b/api/internal/managers/app/install/install.go @@ -4,10 +4,11 @@ import ( "context" "fmt" "os" + "runtime/debug" "github.com/replicatedhq/embedded-cluster/api/internal/utils" - kotscli "github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli" - "github.com/replicatedhq/embedded-cluster/pkg/netutils" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" corev1 "k8s.io/api/core/v1" @@ -17,82 +18,57 @@ import ( kyaml "sigs.k8s.io/yaml" ) -// Install installs the app with the provided config values -func (m *appInstallManager) Install(ctx context.Context, configValues kotsv1beta1.ConfigValues) error { - license := &kotsv1beta1.License{} - if err := kyaml.Unmarshal(m.license, license); err != nil { - return fmt.Errorf("parse license: %w", err) +// Install installs the app with the provided Helm charts +func (m *appInstallManager) Install(ctx context.Context, installableCharts []types.InstallableHelmChart, configValues types.AppConfigValues, registrySettings *types.RegistrySettings, hostCABundlePath string) error { + if err := m.setupClients(); err != nil { + return fmt.Errorf("setup clients: %w", err) } - if err := m.initKubeClient(); err != nil { - return fmt.Errorf("init kube client: %w", err) + if m.releaseData == nil || m.releaseData.ChannelRelease == nil { + return fmt.Errorf("release data is required for app installation") } - kotsadmNamespace, err := runtimeconfig.KotsadmNamespace(ctx, m.kcli) + // Start the namespace reconciler to ensure image pull secrets and other required resources in app namespaces + nsReconciler, err := newNamespaceReconciler( + ctx, m.kcli, m.mcli, registrySettings, hostCABundlePath, + m.releaseData.ChannelRelease.AppSlug, m.releaseData.ChannelRelease.VersionLabel, + m.logger, + ) if err != nil { - return fmt.Errorf("get kotsadm namespace: %w", err) - } - - // Create or update secret with config values before installing - if err := m.createConfigValuesSecret(ctx, configValues, kotsadmNamespace); err != nil { - return fmt.Errorf("creating config values secret: %w", err) + return fmt.Errorf("create namespace reconciler: %w", err) } - ecDomains := utils.GetDomains(m.releaseData) - - installOpts := kotscli.InstallOptions{ - AppSlug: license.Spec.AppSlug, - License: m.license, - Namespace: kotsadmNamespace, - ClusterID: m.clusterID, - AirgapBundle: m.airgapBundle, - // Skip running the KOTS app preflights in the Admin Console; they run in the manager experience installer when ENABLE_V3 is enabled - SkipPreflights: true, - // Skip pushing images to the registry since we do it separately earlier in the install process - DisableImagePush: true, - ReplicatedAppEndpoint: netutils.MaybeAddHTTPS(ecDomains.ReplicatedAppDomain), - Stdout: m.newLogWriter(), + if err := nsReconciler.reconcile(ctx); err != nil { + return fmt.Errorf("reconcile namespaces: %w", err) } - configValuesFile, err := m.createConfigValuesFile(configValues) + kotsadmNamespace, err := runtimeconfig.KotsadmNamespace(ctx, m.kcli) if err != nil { - return fmt.Errorf("creating config values file: %w", err) - } - installOpts.ConfigValuesFile = configValuesFile - - if m.kotsCLI != nil { - return m.kotsCLI.Install(installOpts) + return fmt.Errorf("get kotsadm namespace: %w", err) } - return kotscli.Install(installOpts) -} - -// createConfigValuesFile creates a temporary file with the config values -func (m *appInstallManager) createConfigValuesFile(configValues kotsv1beta1.ConfigValues) (string, error) { - // Use Kubernetes-specific YAML serialization to properly handle TypeMeta and ObjectMeta - data, err := kyaml.Marshal(configValues) - if err != nil { - return "", fmt.Errorf("marshal config values: %w", err) + // Create or update secret with config values before installing + if err := m.createConfigValuesSecret(ctx, configValues, kotsadmNamespace); err != nil { + return fmt.Errorf("creating config values secret: %w", err) } - configValuesFile, err := os.CreateTemp("", "config-values*.yaml") - if err != nil { - return "", fmt.Errorf("create temp file: %w", err) + // Initialize components for tracking + if err := m.initializeComponents(installableCharts); err != nil { + return fmt.Errorf("initialize components: %w", err) } - defer configValuesFile.Close() - if _, err := configValuesFile.Write(data); err != nil { - _ = os.Remove(configValuesFile.Name()) - return "", fmt.Errorf("write config values to temp file: %w", err) + // Install Helm charts + if err := m.installHelmCharts(ctx, installableCharts, kotsadmNamespace); err != nil { + return fmt.Errorf("install helm charts: %w", err) } - return configValuesFile.Name(), nil + return nil } // createConfigValuesSecret creates or updates a Kubernetes secret with the config values. // TODO: Handle 1MB size limitation by storing large file data fields as pointers to other secrets // TODO: Consider maintaining history of config values for potential rollbacks -func (m *appInstallManager) createConfigValuesSecret(ctx context.Context, configValues kotsv1beta1.ConfigValues, namespace string) error { +func (m *appInstallManager) createConfigValuesSecret(ctx context.Context, configValues types.AppConfigValues, namespace string) error { // Get app slug and version from release data license := &kotsv1beta1.License{} if err := kyaml.Unmarshal(m.license, license); err != nil { @@ -120,13 +96,7 @@ func (m *appInstallManager) createConfigValuesSecret(ctx context.Context, config ObjectMeta: metav1.ObjectMeta{ Name: secretName, Namespace: namespace, - Labels: map[string]string{ - "app.kubernetes.io/name": license.Spec.AppSlug, - "app.kubernetes.io/version": m.releaseData.ChannelRelease.VersionLabel, - "app.kubernetes.io/component": "config", - "app.kubernetes.io/part-of": "embedded-cluster", - "app.kubernetes.io/managed-by": "embedded-cluster-installer", - }, + Labels: utils.GetK8sObjectMetaLabels(license.Spec.AppSlug, m.releaseData.ChannelRelease.VersionLabel, "config"), }, Type: corev1.SecretTypeOpaque, Data: map[string][]byte{ @@ -160,3 +130,100 @@ func (m *appInstallManager) createConfigValuesSecret(ctx context.Context, config return nil } + +func (m *appInstallManager) installHelmCharts(ctx context.Context, installableCharts []types.InstallableHelmChart, kotsadmNamespace string) error { + logFn := m.logFn("app") + + if len(installableCharts) == 0 { + logFn("no helm charts to install") + return nil + } + + logFn("installing %d helm charts", len(installableCharts)) + + for _, installableChart := range installableCharts { + chartName := getChartDisplayName(installableChart) + logFn("installing %s chart", chartName) + + if err := m.installHelmChart(ctx, installableChart, kotsadmNamespace); err != nil { + return fmt.Errorf("install %s helm chart: %w", chartName, err) + } + + logFn("successfully installed %s chart", chartName) + } + + logFn("successfully installed all %d helm charts", len(installableCharts)) + + return nil +} + +func (m *appInstallManager) installHelmChart(ctx context.Context, installableChart types.InstallableHelmChart, kotsadmNamespace string) (finalErr error) { + chartName := getChartDisplayName(installableChart) + + if err := m.setComponentStatus(chartName, types.StateRunning, "Installing"); err != nil { + return fmt.Errorf("set component status: %w", err) + } + + defer func() { + if r := recover(); r != nil { + finalErr = fmt.Errorf("recovered from panic: %v: %s", r, string(debug.Stack())) + } + + if finalErr != nil { + if err := m.setComponentStatus(chartName, types.StateFailed, finalErr.Error()); err != nil { + m.logger.WithError(err).Errorf("failed to set %s chart failed status", chartName) + } + } else { + if err := m.setComponentStatus(chartName, types.StateSucceeded, ""); err != nil { + m.logger.WithError(err).Errorf("failed to set %s chart succeeded status", chartName) + } + } + }() + + // Write chart archive to temp file + chartPath, err := m.writeChartArchiveToTemp(installableChart.Archive) + if err != nil { + return fmt.Errorf("write chart archive to temp: %w", err) + } + defer os.Remove(chartPath) + + // Fallback to admin console namespace if namespace is not set + namespace := installableChart.CR.GetNamespace() + if namespace == "" { + namespace = kotsadmNamespace + } + + // Install chart using Helm client with pre-processed values + _, err = m.hcli.Install(ctx, helm.InstallOptions{ + ChartPath: chartPath, + Namespace: namespace, + ReleaseName: installableChart.CR.GetReleaseName(), + Values: installableChart.Values, + LogFn: m.logFn("helm"), + }) + if err != nil { + return err // do not wrap as wrapping is repetitive, e.g. "helm install: helm install: context deadline exceeded" + } + + return nil +} + +// initializeComponents initializes the component tracking with chart names +func (m *appInstallManager) initializeComponents(charts []types.InstallableHelmChart) error { + chartNames := make([]string, 0, len(charts)) + for _, chart := range charts { + chartNames = append(chartNames, getChartDisplayName(chart)) + } + + return m.appInstallStore.RegisterComponents(chartNames) +} + +// getChartDisplayName returns the name of the chart for display purposes. It prefers the +// metadata.name field if available and falls back to the chart name. +func getChartDisplayName(chart types.InstallableHelmChart) string { + chartName := chart.CR.GetName() + if chartName == "" { + chartName = chart.CR.GetChartName() + } + return chartName +} diff --git a/api/internal/managers/app/install/install_test.go b/api/internal/managers/app/install/install_test.go index 19b291ca9d..8e5177b4f0 100644 --- a/api/internal/managers/app/install/install_test.go +++ b/api/internal/managers/app/install/install_test.go @@ -1,15 +1,24 @@ package install import ( + "archive/tar" + "bytes" + "compress/gzip" "context" + "encoding/base64" + "errors" "fmt" "os" "testing" + "time" + appinstallstore "github.com/replicatedhq/embedded-cluster/api/internal/store/app/install" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" - kotscli "github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + kotsv1beta2 "github.com/replicatedhq/kotskinds/apis/kots/v1beta2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -17,8 +26,9 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" + metadatafake "k8s.io/client-go/metadata/fake" "sigs.k8s.io/controller-runtime/pkg/client" - clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" kyaml "sigs.k8s.io/yaml" ) @@ -27,6 +37,9 @@ func TestAppInstallManager_Install(t *testing.T) { // Setup environment variable for V3 t.Setenv("ENABLE_V3", "1") + // Create valid helm chart archive + mockChartArchive := createTestChartArchive(t, "test-chart", "0.1.0") + // Create test license license := &kotsv1beta1.License{ Spec: kotsv1beta1.LicenseSpec{ @@ -36,8 +49,9 @@ func TestAppInstallManager_Install(t *testing.T) { licenseBytes, err := kyaml.Marshal(license) require.NoError(t, err) - // Create test release data + // Create test release data with helm chart archives releaseData := &release.ReleaseData{ + HelmChartArchives: [][]byte{mockChartArchive}, ChannelRelease: &release.ChannelRelease{ DefaultDomains: release.Domains{ ReplicatedAppDomain: "replicated.app", @@ -51,134 +65,461 @@ func TestAppInstallManager_Install(t *testing.T) { }) require.NoError(t, err) - t.Run("Config values should be passed to the installer", func(t *testing.T) { - configValues := kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "key1": { - Value: "value1", - }, - "key2": { - Value: "value2", - }, + // create fake kube client with kotsadm namespace + kotsadmNamespace := &corev1.Namespace{ + TypeMeta: metav1.TypeMeta{ + Kind: "Namespace", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + }, + } + fakeKcli := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(kotsadmNamespace).Build() + + t.Run("Success", func(t *testing.T) { + configValues := types.AppConfigValues{ + "key1": {Value: "value1"}, + "key2": {Value: "value2"}, + "key3": {Value: "value3"}, + } + + // Create InstallableHelmCharts with weights - should already be sorted at this stage + installableCharts := []types.InstallableHelmChart{ + createTestInstallableHelmChart(t, "nginx-chart", "1.0.0", "web-server", "web", 10, map[string]any{ + "image": map[string]any{ + "repository": "nginx", + "tag": "latest", }, - }, + "replicas": 3, + }), + createTestInstallableHelmChart(t, "postgres-chart", "2.0.0", "database", "data", 20, map[string]any{ + "database": map[string]any{ + "host": "postgres.example.com", + "password": "secret", + }, + }), } - // Create mock installer with detailed verification - mockKotsCLI := &kotscli.MockKotsCLI{} - mockKotsCLI.On("Install", mock.MatchedBy(func(opts kotscli.InstallOptions) bool { - // Verify basic install options - if opts.AppSlug != "test-app" { - t.Logf("AppSlug mismatch: expected 'test-app', got '%s'", opts.AppSlug) - return false - } - if opts.License == nil { - t.Logf("License is nil") - return false - } - if opts.Namespace != "test-app" { - t.Logf("Namespace mismatch: expected 'test-app', got '%s'", opts.Namespace) - return false - } - if opts.ClusterID != "test-cluster" { - t.Logf("ClusterID mismatch: expected 'test-cluster', got '%s'", opts.ClusterID) - return false - } - if opts.AirgapBundle != "test-airgap.tar.gz" { - t.Logf("AirgapBundle mismatch: expected 'test-airgap.tar.gz', got '%s'", opts.AirgapBundle) - return false - } - if opts.ReplicatedAppEndpoint == "" { - t.Logf("ReplicatedAppEndpoint is empty") + // Create registry settings for testing image pull secret creation + dockerConfigJSON := `{"auths":{"registry.example.com":{"auth":"dXNlcjpwYXNz"}}}` + registrySettings := &types.RegistrySettings{ + ImagePullSecretName: "test-pull-secret", + ImagePullSecretValue: base64.StdEncoding.EncodeToString([]byte(dockerConfigJSON)), + } + + // Create a temp CA bundle file for testing + caContent := "-----BEGIN CERTIFICATE-----\ntest-ca-content\n-----END CERTIFICATE-----" + tmpCAFile, err := os.CreateTemp("", "ca-bundle-*.crt") + require.NoError(t, err) + defer os.Remove(tmpCAFile.Name()) + _, err = tmpCAFile.WriteString(caContent) + require.NoError(t, err) + tmpCAFile.Close() + + // Create fake metadata client for CA configmap creation + fakeMcli := metadatafake.NewSimpleMetadataClient(metadatafake.NewTestScheme()) + + // Create mock helm client that validates pre-processed values + mockHelmClient := &helm.MockClient{} + + // Chart 1 installation (nginx chart) + nginxCall := mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool { + if opts.ReleaseName != "web-server" { return false } - if opts.ConfigValuesFile == "" { - t.Logf("ConfigValuesFile is empty") + if opts.Namespace != "web" { return false } - if !opts.DisableImagePush { - t.Logf("DisableImagePush is false") - return false + // Check if values contain expected pre-processed data for nginx chart + if vals, ok := opts.Values["image"].(map[string]any); ok { + return vals["repository"] == "nginx" && vals["tag"] == "latest" && opts.Values["replicas"] == 3 } + return false + })).Return("Release \"web-server\" has been installed.", nil) - // Verify config values file content - b, err := os.ReadFile(opts.ConfigValuesFile) - if err != nil { - t.Logf("Failed to read config values file: %v", err) - return false - } - var cv kotsv1beta1.ConfigValues - if err := kyaml.Unmarshal(b, &cv); err != nil { - t.Logf("Failed to unmarshal config values: %v", err) + // Chart 2 installation (database chart) + databaseCall := mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool { + if opts.ReleaseName != "database" { return false } - if cv.Spec.Values["key1"].Value != "value1" { - t.Logf("Config value key1 mismatch: expected 'value1', got '%s'", cv.Spec.Values["key1"].Value) + if opts.Namespace != "data" { return false } - if cv.Spec.Values["key2"].Value != "value2" { - t.Logf("Config value key2 mismatch: expected 'value2', got '%s'", cv.Spec.Values["key2"].Value) - return false + // Check if values contain expected pre-processed database data + if vals, ok := opts.Values["database"].(map[string]any); ok { + return vals["host"] == "postgres.example.com" && vals["password"] == "secret" } - return true - })).Return(nil) + return false + })).Return("Release \"database\" has been installed.", nil) - // Create fake kube client - sch := runtime.NewScheme() - require.NoError(t, corev1.AddToScheme(sch)) - require.NoError(t, scheme.AddToScheme(sch)) - fakeKcli := clientfake.NewClientBuilder().WithScheme(sch).Build() + // Verify installation order + mock.InOrder( + nginxCall, + databaseCall, + ) // Create manager manager, err := NewAppInstallManager( - WithLicense(licenseBytes), WithClusterID("test-cluster"), WithAirgapBundle("test-airgap.tar.gz"), WithReleaseData(releaseData), - WithKotsCLI(mockKotsCLI), + WithLicense(licenseBytes), + WithHelmClient(mockHelmClient), WithLogger(logger.NewDiscardLogger()), WithKubeClient(fakeKcli), + WithMetadataClient(fakeMcli), ) require.NoError(t, err) + // Run installation with registry settings and host CA bundle path + err = manager.Install(t.Context(), installableCharts, configValues, registrySettings, tmpCAFile.Name()) + require.NoError(t, err) + + mockHelmClient.AssertExpectations(t) + + // Verify image pull secret was created in the app namespace + secret := &corev1.Secret{} + err = fakeKcli.Get(t.Context(), client.ObjectKey{ + Namespace: "test-app", + Name: "test-pull-secret", + }, secret) + require.NoError(t, err) + assert.Equal(t, corev1.SecretTypeDockerConfigJson, secret.Type) + assert.Equal(t, dockerConfigJSON, string(secret.Data[".dockerconfigjson"])) + + // Verify CA configmap was created in the app namespace + configMap := &corev1.ConfigMap{} + err = fakeKcli.Get(t.Context(), client.ObjectKey{ + Namespace: "test-app", + Name: "kotsadm-private-cas", + }, configMap) + require.NoError(t, err) + assert.Contains(t, configMap.Data["ca_0.crt"], "test-ca-content") + }) + + t.Run("Install updates status correctly", func(t *testing.T) { + configValues := types.AppConfigValues{ + "key1": {Value: "value1"}, + } + + installableCharts := []types.InstallableHelmChart{ + createTestInstallableHelmChart(t, "monitoring-chart", "1.0.0", "prometheus", "monitoring", 0, map[string]any{"key": "value"}), + } + + // Create mock helm client + mockHelmClient := &helm.MockClient{} + mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool { + return opts.ChartPath != "" && opts.ReleaseName == "prometheus" && opts.Namespace == "monitoring" + })).Return("Release \"prometheus\" has been installed.", nil) + + // Create manager with initialized store + store := appinstallstore.NewMemoryStore(appinstallstore.WithAppInstall(types.AppInstall{ + Status: types.Status{State: types.StatePending}, + })) + manager, err := NewAppInstallManager( + WithClusterID("test-cluster"), + WithReleaseData(releaseData), + WithLicense(licenseBytes), + WithHelmClient(mockHelmClient), + WithLogger(logger.NewDiscardLogger()), + WithAppInstallStore(store), + WithKubeClient(fakeKcli), + ) + require.NoError(t, err) + + // Verify initial status + appInstall, err := manager.GetStatus() + require.NoError(t, err) + assert.Equal(t, types.StatePending, appInstall.Status.State) + // Run installation - err = manager.Install(context.Background(), configValues) + err = manager.Install(t.Context(), installableCharts, configValues, nil, "") + require.NoError(t, err) + + // Verify components status + appInstall, err = manager.GetStatus() + require.NoError(t, err) + assert.NotEmpty(t, appInstall.Components) + + mockHelmClient.AssertExpectations(t) + }) + + t.Run("Install handles errors correctly", func(t *testing.T) { + configValues := types.AppConfigValues{ + "key1": {Value: "value1"}, + } + + installableCharts := []types.InstallableHelmChart{ + createTestInstallableHelmChart(t, "logging-chart", "1.0.0", "fluentd", "logging", 0, map[string]any{"key": "value"}), + } + + // Create mock helm client that fails + mockHelmClient := &helm.MockClient{} + mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool { + return opts.ChartPath != "" && opts.ReleaseName == "fluentd" && opts.Namespace == "logging" + })).Return("", assert.AnError) + + // Create manager with initialized store + store := appinstallstore.NewMemoryStore(appinstallstore.WithAppInstall(types.AppInstall{ + Status: types.Status{State: types.StatePending}, + })) + manager, err := NewAppInstallManager( + WithClusterID("test-cluster"), + WithReleaseData(releaseData), + WithLicense(licenseBytes), + WithHelmClient(mockHelmClient), + WithLogger(logger.NewDiscardLogger()), + WithAppInstallStore(store), + WithKubeClient(fakeKcli), + ) require.NoError(t, err) - // Verify mock was called - mockKotsCLI.AssertExpectations(t) + // Run installation (should fail) + err = manager.Install(t.Context(), installableCharts, configValues, nil, "") + assert.Error(t, err) + + mockHelmClient.AssertExpectations(t) + }) + + t.Run("GetStatus returns current app install state", func(t *testing.T) { + // Create test store with known status + store := appinstallstore.NewMemoryStore(appinstallstore.WithAppInstall(types.AppInstall{ + Status: types.Status{ + State: types.StateRunning, + Description: "Installing application", + LastUpdated: time.Now(), + }, + Logs: "Installation started\n", + })) + + // Create manager with test store + manager, err := NewAppInstallManager( + WithLogger(logger.NewDiscardLogger()), + WithAppInstallStore(store), + WithHelmClient(&helm.MockClient{}), + WithKubeClient(fakeKcli), + ) + require.NoError(t, err) + + // Test GetStatus + appInstall, err := manager.GetStatus() + require.NoError(t, err) + assert.Equal(t, types.StateRunning, appInstall.Status.State) + assert.Equal(t, "Installing application", appInstall.Status.Description) + assert.Equal(t, "Installation started\n", appInstall.Logs) }) } -func TestAppInstallManager_createConfigValuesFile(t *testing.T) { - manager := &appInstallManager{} +// createTarGzArchive creates a tar.gz archive with the given files +func createTarGzArchive(t *testing.T, files map[string]string) []byte { + t.Helper() - configValues := kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "testKey": { - Value: "testValue", - }, + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + for filename, content := range files { + header := &tar.Header{ + Name: filename, + Mode: 0600, + Size: int64(len(content)), + } + require.NoError(t, tw.WriteHeader(header)) + _, err := tw.Write([]byte(content)) + require.NoError(t, err) + } + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + return buf.Bytes() +} + +func createTestChartArchive(t *testing.T, name, version string) []byte { + chartYaml := fmt.Sprintf(`apiVersion: v2 +name: %s +version: %s +description: A test Helm chart +type: application +`, name, version) + + return createTarGzArchive(t, map[string]string{ + fmt.Sprintf("%s/Chart.yaml", name): chartYaml, + }) +} + +// Helper functions to create test data that can be reused across test cases +func createTestHelmChartCR(name, releaseName, namespace string, weight int64) *kotsv1beta2.HelmChart { + return &kotsv1beta2.HelmChart{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "kots.io/v1beta2", + Kind: "HelmChart", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: kotsv1beta2.HelmChartSpec{ + Chart: kotsv1beta2.ChartIdentifier{ + Name: name, + ChartVersion: "1.0.0", }, + ReleaseName: releaseName, + Namespace: namespace, + Weight: weight, + }, + } +} + +func createTestInstallableHelmChart(t *testing.T, chartName, chartVersion, releaseName, namespace string, weight int64, values map[string]any) types.InstallableHelmChart { + return types.InstallableHelmChart{ + Archive: createTestChartArchive(t, chartName, chartVersion), + Values: values, + CR: createTestHelmChartCR(chartName, releaseName, namespace, weight), + } +} + +// TestComponentStatusTracking tests that components are properly initialized and tracked +func TestComponentStatusTracking(t *testing.T) { + // Create test license + license := &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "test-app", + }, + } + licenseBytes, err := kyaml.Marshal(license) + require.NoError(t, err) + + // create fake kube client with kotsadm namespace + kotsadmNamespace := &corev1.Namespace{ + TypeMeta: metav1.TypeMeta{ + Kind: "Namespace", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "kotsadm", }, } + fakeKcli := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(kotsadmNamespace).Build() + + t.Run("Components are registered and status is tracked", func(t *testing.T) { + configValues := types.AppConfigValues{ + "key1": {Value: "value1"}, + "key2": {Value: "value2"}, + "key3": {Value: "value3"}, + } + + // Create test charts with different weights + installableCharts := []types.InstallableHelmChart{ + createTestInstallableHelmChart(t, "database-chart", "1.0.0", "postgres", "data", 10, map[string]any{"key": "value1"}), + createTestInstallableHelmChart(t, "web-chart", "2.0.0", "nginx", "web", 20, map[string]any{"key": "value2"}), + } + + // Create mock helm client + mockHelmClient := &helm.MockClient{} + + // Database chart installation (should be first due to lower weight) + mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool { + return opts.ReleaseName == "postgres" && opts.Namespace == "data" + })).Return("Release \"postgres\" has been installed.", nil).Once() + + // Web chart installation (should be second due to higher weight) + mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool { + return opts.ReleaseName == "nginx" && opts.Namespace == "web" + })).Return("Release \"nginx\" has been installed.", nil).Once() + + // Create manager with in-memory store + appInstallStore := appinstallstore.NewMemoryStore(appinstallstore.WithAppInstall(types.AppInstall{ + Status: types.Status{State: types.StatePending}, + })) + manager, err := NewAppInstallManager( + WithAppInstallStore(appInstallStore), + WithReleaseData(&release.ReleaseData{ + ChannelRelease: &release.ChannelRelease{ + VersionLabel: "v1.0.0", + }, + }), + WithLicense(licenseBytes), + WithClusterID("test-cluster"), + WithHelmClient(mockHelmClient), + WithKubeClient(fakeKcli), + ) + require.NoError(t, err) + + // Install the charts + err = manager.Install(t.Context(), installableCharts, configValues, nil, "") + require.NoError(t, err) - filename, err := manager.createConfigValuesFile(configValues) - assert.NoError(t, err) - assert.NotEmpty(t, filename) + // Verify that components were registered and have correct status + appInstall, err := manager.GetStatus() + require.NoError(t, err) + + // Should have 2 components + assert.Len(t, appInstall.Components, 2, "Should have 2 components") + + // Components should be sorted by weight (database first with weight 10, web second with weight 20) + assert.Equal(t, "database-chart", appInstall.Components[0].Name) + assert.Equal(t, types.StateSucceeded, appInstall.Components[0].Status.State) + + assert.Equal(t, "web-chart", appInstall.Components[1].Name) + assert.Equal(t, types.StateSucceeded, appInstall.Components[1].Status.State) + + mockHelmClient.AssertExpectations(t) + }) + + t.Run("Component failure is tracked correctly", func(t *testing.T) { + configValues := types.AppConfigValues{ + "key1": {Value: "value1"}, + } + + // Create test chart + installableCharts := []types.InstallableHelmChart{ + createTestInstallableHelmChart(t, "failing-chart", "1.0.0", "failing-app", "default", 0, map[string]any{"key": "value"}), + } + + // Create mock helm client that fails + mockHelmClient := &helm.MockClient{} + mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool { + return opts.ReleaseName == "failing-app" + })).Return("", errors.New("helm install failed")) + + // Create manager with in-memory store + appInstallStore := appinstallstore.NewMemoryStore(appinstallstore.WithAppInstall(types.AppInstall{ + Status: types.Status{State: types.StatePending}, + })) + manager, err := NewAppInstallManager( + WithAppInstallStore(appInstallStore), + WithReleaseData(&release.ReleaseData{ + ChannelRelease: &release.ChannelRelease{ + VersionLabel: "v1.0.0", + }, + }), + WithLicense(licenseBytes), + WithClusterID("test-cluster"), + WithHelmClient(mockHelmClient), + WithKubeClient(fakeKcli), + ) + require.NoError(t, err) + + // Install the charts (should fail) + err = manager.Install(t.Context(), installableCharts, configValues, nil, "") + require.Error(t, err) + + // Verify that component failure is tracked + appInstall, err := manager.GetStatus() + require.NoError(t, err) - // Verify file exists and contains correct content - data, err := os.ReadFile(filename) - assert.NoError(t, err) + // Should have 1 component + assert.Len(t, appInstall.Components, 1, "Should have 1 component") - var unmarshaled kotsv1beta1.ConfigValues - err = kyaml.Unmarshal(data, &unmarshaled) - assert.NoError(t, err) - assert.Equal(t, "testValue", unmarshaled.Spec.Values["testKey"].Value) + // Component should be marked as failed + failedComponent := appInstall.Components[0] + assert.Equal(t, "failing-chart", failedComponent.Name) + assert.Equal(t, types.StateFailed, failedComponent.Status.State) + assert.Contains(t, failedComponent.Status.Description, "helm install failed") - // Clean up - os.Remove(filename) + mockHelmClient.AssertExpectations(t) + }) } func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { @@ -192,12 +533,11 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { tests := []struct { name string releaseData *release.ReleaseData - configValues kotsv1beta1.ConfigValues + configValues types.AppConfigValues setupClient func(t *testing.T) client.Client expectError bool expectedErrorContains string validateSecret func(t *testing.T, kcli client.Client) - validateKotsCalled bool }{ { name: "first install creates secret with multiple config values", @@ -209,26 +549,22 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { }, }, }, - configValues: kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "key1": {Value: "value1"}, - "key2": {Value: "value2"}, - "key3": {Value: "value3"}, - }, - }, + configValues: types.AppConfigValues{ + "key1": {Value: "value1"}, + "key2": {Value: "value2"}, + "key3": {Value: "value3"}, }, setupClient: func(t *testing.T) client.Client { sch := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(sch)) require.NoError(t, scheme.AddToScheme(sch)) - return clientfake.NewClientBuilder().WithScheme(sch).Build() + return fake.NewClientBuilder().WithScheme(sch).Build() }, expectError: false, validateSecret: func(t *testing.T, kcli client.Client) { // Get and verify secret secret := &corev1.Secret{} - err := kcli.Get(context.Background(), client.ObjectKey{ + err := kcli.Get(t.Context(), client.ObjectKey{ Name: "test-app-config-values", Namespace: "test-app", }, secret) @@ -249,14 +585,13 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { require.True(t, ok) // Unmarshal and verify values - var cv kotsv1beta1.ConfigValues + var cv types.AppConfigValues err = kyaml.Unmarshal(data, &cv) require.NoError(t, err) - assert.Equal(t, "value1", cv.Spec.Values["key1"].Value) - assert.Equal(t, "value2", cv.Spec.Values["key2"].Value) - assert.Equal(t, "value3", cv.Spec.Values["key3"].Value) + assert.Equal(t, "value1", cv["key1"].Value) + assert.Equal(t, "value2", cv["key2"].Value) + assert.Equal(t, "value3", cv["key3"].Value) }, - validateKotsCalled: true, }, { name: "existing secret is fetched and updated", @@ -268,12 +603,8 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { }, }, }, - configValues: kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "newkey": {Value: "newvalue"}, - }, - }, + configValues: types.AppConfigValues{ + "newkey": {Value: "newvalue"}, }, setupClient: func(t *testing.T) client.Client { sch := runtime.NewScheme() @@ -294,7 +625,7 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { }, } - return clientfake.NewClientBuilder(). + return fake.NewClientBuilder(). WithScheme(sch). WithObjects(existingSecret). Build() @@ -303,7 +634,7 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { validateSecret: func(t *testing.T, kcli client.Client) { // Get and verify secret was recreated secret := &corev1.Secret{} - err := kcli.Get(context.Background(), client.ObjectKey{ + err := kcli.Get(t.Context(), client.ObjectKey{ Name: "test-app-config-values", Namespace: "test-app", }, secret) @@ -316,54 +647,43 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { data, ok := secret.Data["config-values.yaml"] require.True(t, ok) - var cv kotsv1beta1.ConfigValues + var cv types.AppConfigValues err = kyaml.Unmarshal(data, &cv) require.NoError(t, err) - assert.Equal(t, "newvalue", cv.Spec.Values["newkey"].Value) + assert.Equal(t, "newvalue", cv["newkey"].Value) }, - validateKotsCalled: true, }, { name: "fails when release data is missing", releaseData: nil, - configValues: kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "key1": {Value: "value1"}, - }, - }, + configValues: types.AppConfigValues{ + "key1": {Value: "value1"}, }, setupClient: func(t *testing.T) client.Client { sch := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(sch)) require.NoError(t, scheme.AddToScheme(sch)) - return clientfake.NewClientBuilder().WithScheme(sch).Build() + return fake.NewClientBuilder().WithScheme(sch).Build() }, expectError: true, expectedErrorContains: "release data is required", - validateKotsCalled: false, }, { name: "fails when channel release is missing", releaseData: &release.ReleaseData{ ChannelRelease: nil, }, - configValues: kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "key1": {Value: "value1"}, - }, - }, + configValues: types.AppConfigValues{ + "key1": {Value: "value1"}, }, setupClient: func(t *testing.T) client.Client { sch := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(sch)) require.NoError(t, scheme.AddToScheme(sch)) - return clientfake.NewClientBuilder().WithScheme(sch).Build() + return fake.NewClientBuilder().WithScheme(sch).Build() }, expectError: true, expectedErrorContains: "release data is required", - validateKotsCalled: false, }, { name: "fails when get returns error", @@ -375,12 +695,8 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { }, }, }, - configValues: kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "key1": {Value: "value1"}, - }, - }, + configValues: types.AppConfigValues{ + "key1": {Value: "value1"}, }, setupClient: func(t *testing.T) client.Client { sch := runtime.NewScheme() @@ -395,7 +711,7 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { }, } - return clientfake.NewClientBuilder(). + return fake.NewClientBuilder(). WithScheme(sch). WithObjects(existingSecret). WithInterceptorFuncs(interceptor.Funcs{ @@ -410,7 +726,6 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { }, expectError: true, expectedErrorContains: "get existing config values secret", - validateKotsCalled: false, }, { name: "fails when update returns error", @@ -422,12 +737,8 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { }, }, }, - configValues: kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "key1": {Value: "value1"}, - }, - }, + configValues: types.AppConfigValues{ + "key1": {Value: "value1"}, }, setupClient: func(t *testing.T) client.Client { sch := runtime.NewScheme() @@ -442,7 +753,7 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { }, } - return clientfake.NewClientBuilder(). + return fake.NewClientBuilder(). WithScheme(sch). WithObjects(existingSecret). WithInterceptorFuncs(interceptor.Funcs{ @@ -457,7 +768,6 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { }, expectError: true, expectedErrorContains: "update config values secret", - validateKotsCalled: false, }, { name: "fails when create returns non-AlreadyExists error", @@ -469,19 +779,15 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { }, }, }, - configValues: kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "key1": {Value: "value1"}, - }, - }, + configValues: types.AppConfigValues{ + "key1": {Value: "value1"}, }, setupClient: func(t *testing.T) client.Client { sch := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(sch)) require.NoError(t, scheme.AddToScheme(sch)) - return clientfake.NewClientBuilder(). + return fake.NewClientBuilder(). WithScheme(sch). WithInterceptorFuncs(interceptor.Funcs{ Create: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.CreateOption) error { @@ -495,7 +801,6 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { }, expectError: true, expectedErrorContains: "create config values secret", - validateKotsCalled: false, }, { name: "handles empty config values", @@ -507,22 +812,20 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { }, }, }, - configValues: kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{}, - }, + configValues: types.AppConfigValues{ + "key1": {Value: "value1"}, }, setupClient: func(t *testing.T) client.Client { sch := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(sch)) require.NoError(t, scheme.AddToScheme(sch)) - return clientfake.NewClientBuilder().WithScheme(sch).Build() + return fake.NewClientBuilder().WithScheme(sch).Build() }, expectError: false, validateSecret: func(t *testing.T, kcli client.Client) { // Get and verify secret was created even with empty values secret := &corev1.Secret{} - err := kcli.Get(context.Background(), client.ObjectKey{ + err := kcli.Get(t.Context(), client.ObjectKey{ Name: "test-app-config-values", Namespace: "test-app", }, secret) @@ -533,7 +836,6 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { require.True(t, ok) require.NotEmpty(t, data) }, - validateKotsCalled: true, }, } @@ -546,26 +848,24 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { licenseBytes, err := kyaml.Marshal(license) require.NoError(t, err) - mockKotsCLI := &kotscli.MockKotsCLI{} - if tt.validateKotsCalled { - mockKotsCLI.On("Install", mock.Anything).Return(nil) - } - kcli := tt.setupClient(t) + // Create mock helm client + mockHelmClient := &helm.MockClient{} + manager, err := NewAppInstallManager( WithLicense(licenseBytes), WithClusterID("test-cluster"), WithAirgapBundle("test-airgap.tar.gz"), WithReleaseData(tt.releaseData), - WithKotsCLI(mockKotsCLI), WithLogger(logger.NewDiscardLogger()), WithKubeClient(kcli), + WithHelmClient(mockHelmClient), ) require.NoError(t, err) // Execute - err = manager.Install(context.Background(), tt.configValues) + err = manager.Install(t.Context(), nil, tt.configValues, nil, "") // Verify if tt.expectError { @@ -577,12 +877,6 @@ func TestAppInstallManager_Install_ConfigValuesSecret(t *testing.T) { tt.validateSecret(t, kcli) } } - - if tt.validateKotsCalled { - mockKotsCLI.AssertExpectations(t) - } else { - mockKotsCLI.AssertNotCalled(t, "Install") - } }) } } diff --git a/api/internal/managers/app/install/manager.go b/api/internal/managers/app/install/manager.go index acf52b4263..9805df2e8d 100644 --- a/api/internal/managers/app/install/manager.go +++ b/api/internal/managers/app/install/manager.go @@ -5,11 +5,12 @@ import ( appinstallstore "github.com/replicatedhq/embedded-cluster/api/internal/store/app/install" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" - kotscli "github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/sirupsen/logrus" helmcli "helm.sh/helm/v3/pkg/cli" + "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -17,8 +18,8 @@ var _ AppInstallManager = &appInstallManager{} // AppInstallManager provides methods for managing app installation type AppInstallManager interface { - // Install installs the app with the provided config values - Install(ctx context.Context, configValues kotsv1beta1.ConfigValues) error + // Install installs the app with the provided Helm charts + Install(ctx context.Context, installableCharts []types.InstallableHelmChart, configValues types.AppConfigValues, registrySettings *types.RegistrySettings, hostCABundlePath string) error } // appInstallManager is an implementation of the AppInstallManager interface @@ -28,8 +29,9 @@ type appInstallManager struct { license []byte clusterID string airgapBundle string - kotsCLI kotscli.KotsCLI + hcli helm.Client kcli client.Client + mcli metadata.Interface kubernetesEnvSettings *helmcli.EnvSettings logger logrus.FieldLogger } @@ -72,9 +74,9 @@ func WithAirgapBundle(airgapBundle string) AppInstallManagerOption { } } -func WithKotsCLI(kotsCLI kotscli.KotsCLI) AppInstallManagerOption { +func WithHelmClient(hcli helm.Client) AppInstallManagerOption { return func(m *appInstallManager) { - m.kotsCLI = kotsCLI + m.hcli = hcli } } @@ -84,6 +86,12 @@ func WithKubeClient(kcli client.Client) AppInstallManagerOption { } } +func WithMetadataClient(mcli metadata.Interface) AppInstallManagerOption { + return func(m *appInstallManager) { + m.mcli = mcli + } +} + func WithKubernetesEnvSettings(envSettings *helmcli.EnvSettings) AppInstallManagerOption { return func(m *appInstallManager) { m.kubernetesEnvSettings = envSettings diff --git a/api/internal/managers/app/install/mock.go b/api/internal/managers/app/install/mock.go index 129b0f6176..100a6fa50a 100644 --- a/api/internal/managers/app/install/mock.go +++ b/api/internal/managers/app/install/mock.go @@ -4,7 +4,6 @@ import ( "context" "github.com/replicatedhq/embedded-cluster/api/types" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/stretchr/testify/mock" ) @@ -14,13 +13,7 @@ type MockAppInstallManager struct { } // Install mocks the Install method -func (m *MockAppInstallManager) Install(ctx context.Context, configValues kotsv1beta1.ConfigValues) error { - args := m.Called(ctx, configValues) +func (m *MockAppInstallManager) Install(ctx context.Context, installableCharts []types.InstallableHelmChart, configValues types.AppConfigValues, registrySettings *types.RegistrySettings, hostCABundlePath string) error { + args := m.Called(ctx, installableCharts, configValues, registrySettings, hostCABundlePath) return args.Error(0) } - -// GetStatus mocks the GetStatus method -func (m *MockAppInstallManager) GetStatus() (types.AppInstall, error) { - args := m.Called() - return args.Get(0).(types.AppInstall), args.Error(1) -} diff --git a/api/internal/managers/app/install/namespaces_reconciler.go b/api/internal/managers/app/install/namespaces_reconciler.go new file mode 100644 index 0000000000..a9965fe940 --- /dev/null +++ b/api/internal/managers/app/install/namespaces_reconciler.go @@ -0,0 +1,176 @@ +package install + +import ( + "context" + "encoding/base64" + "fmt" + + "github.com/replicatedhq/embedded-cluster/api/internal/utils" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/addons/adminconsole" + "github.com/replicatedhq/embedded-cluster/pkg/release" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + "github.com/sirupsen/logrus" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/metadata" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// namespaceReconciler handles ensuring image pull secrets and CA configmaps in app namespaces. +// It reads additionalNamespaces from the Application CR and ensures secrets and configmaps exist +// in those namespaces plus the kotsadm namespace. +type namespaceReconciler struct { + kcli client.Client + mcli metadata.Interface + registrySettings *types.RegistrySettings + hostCABundlePath string + appSlug string + versionLabel string + logger logrus.FieldLogger + + namespaces []string +} + +// newNamespaceReconciler creates a new namespace reconciler +func newNamespaceReconciler( + ctx context.Context, + kcli client.Client, + mcli metadata.Interface, + registrySettings *types.RegistrySettings, + hostCABundlePath string, + appSlug string, + versionLabel string, + logger logrus.FieldLogger, +) (*namespaceReconciler, error) { + // Get kotsadm namespace + kotsadmNamespace, err := runtimeconfig.KotsadmNamespace(ctx, kcli) + if err != nil { + return nil, fmt.Errorf("get kotsadm namespace: %w", err) + } + + // Get watched namespaces from Application CR + watchedNamespaces := []string{kotsadmNamespace} + if app := release.GetApplication(); app != nil { + for _, ns := range app.Spec.AdditionalNamespaces { + // NOTE: we no longer support watching all namespaces ("*") + if ns == "*" { + logger.Warn("watching all namespaces is not supported (\"*\")") + } else { + watchedNamespaces = append(watchedNamespaces, ns) + } + } + } + + r := &namespaceReconciler{ + kcli: kcli, + mcli: mcli, + registrySettings: registrySettings, + hostCABundlePath: hostCABundlePath, + appSlug: appSlug, + versionLabel: versionLabel, + logger: logger, + namespaces: watchedNamespaces, + } + + return r, nil +} + +// reconcile ensures all watched namespaces have the required resources +func (r *namespaceReconciler) reconcile(ctx context.Context) error { + for _, ns := range r.namespaces { + if err := r.reconcileNamespace(ctx, ns); err != nil { + return fmt.Errorf("reconcile namespace %s: %w", ns, err) + } + } + return nil +} + +// reconcileNamespace creates namespace if needed and ensures required resources exist +func (r *namespaceReconciler) reconcileNamespace(ctx context.Context, namespace string) error { + // Create namespace if it doesn't exist + ns := &corev1.Namespace{} + err := r.kcli.Get(ctx, client.ObjectKey{Name: namespace}, ns) + if k8serrors.IsNotFound(err) { + ns = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: namespace}, + } + if err := r.kcli.Create(ctx, ns); err != nil && !k8serrors.IsAlreadyExists(err) { + return fmt.Errorf("create namespace: %w", err) + } + r.logger.Infof("created namespace %s", namespace) + } else if err != nil { + return fmt.Errorf("get namespace: %w", err) + } + + if err := r.ensureImagePullSecret(ctx, namespace); err != nil { + return fmt.Errorf("ensure image pull secret: %w", err) + } + + if err := r.ensureCAConfigmap(ctx, namespace); err != nil { + return fmt.Errorf("ensure ca configmap: %w", err) + } + + return nil +} + +// ensureImagePullSecret creates or updates the image pull secret in a namespace +func (r *namespaceReconciler) ensureImagePullSecret(ctx context.Context, namespace string) error { + // Skip if no registry settings + if r.registrySettings == nil || r.registrySettings.ImagePullSecretName == "" || r.registrySettings.ImagePullSecretValue == "" { + return nil + } + + secretData, err := base64.StdEncoding.DecodeString(r.registrySettings.ImagePullSecretValue) + if err != nil { + return fmt.Errorf("decode secret value: %w", err) + } + + secret := &corev1.Secret{} + key := client.ObjectKey{Namespace: namespace, Name: r.registrySettings.ImagePullSecretName} + err = r.kcli.Get(ctx, key, secret) + + if k8serrors.IsNotFound(err) { + secret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: r.registrySettings.ImagePullSecretName, + Namespace: namespace, + Labels: utils.GetK8sObjectMetaLabels(r.appSlug, r.versionLabel, "registry"), + }, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{ + ".dockerconfigjson": secretData, + }, + } + if err := r.kcli.Create(ctx, secret); err != nil { + return fmt.Errorf("create secret: %w", err) + } + r.logger.Infof("created image pull secret %s in namespace %s", r.registrySettings.ImagePullSecretName, namespace) + return nil + } + if err != nil { + return fmt.Errorf("get secret: %w", err) + } + + // Update existing secret if data differs + if string(secret.Data[".dockerconfigjson"]) != string(secretData) { + secret.Data[".dockerconfigjson"] = secretData + if err := r.kcli.Update(ctx, secret); err != nil { + return fmt.Errorf("update secret: %w", err) + } + r.logger.Infof("updated image pull secret %s in namespace %s", r.registrySettings.ImagePullSecretName, namespace) + } + + return nil +} + +// ensureCAConfigmap ensures the CA configmap exists in the namespace +func (r *namespaceReconciler) ensureCAConfigmap(ctx context.Context, namespace string) error { + // Skip if no CA bundle path + if r.hostCABundlePath == "" { + return nil + } + + return adminconsole.EnsureCAConfigmap(ctx, r.logger.Infof, r.kcli, r.mcli, namespace, r.hostCABundlePath) +} diff --git a/api/internal/managers/app/install/namespaces_reconciler_test.go b/api/internal/managers/app/install/namespaces_reconciler_test.go new file mode 100644 index 0000000000..3954f3d965 --- /dev/null +++ b/api/internal/managers/app/install/namespaces_reconciler_test.go @@ -0,0 +1,341 @@ +package install + +import ( + "encoding/base64" + "os" + "testing" + + "github.com/replicatedhq/embedded-cluster/api/pkg/logger" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/release" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + metadatafake "k8s.io/client-go/metadata/fake" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func Test_namespaceReconciler_reconcile(t *testing.T) { + dockerConfigJSON := `{"auths":{"registry.example.com":{"auth":"dXNlcjpwYXNz"}}}` + + appSlug := "test-app" + versionLabel := "1.0.0" + + tests := []struct { + name string + applicationYAML string + registrySettings *types.RegistrySettings + withCABundle bool + existingNamespaces []string + existingSecrets []corev1.Secret + existingConfigMaps []corev1.ConfigMap + + wantNamespaces []string + wantCreatedNs []string + wantSecretInNs []string + wantNoSecretInNs []string + wantCAConfigmapInNs []string + wantNoCAConfigmapInNs []string + wantErr bool + }{ + { + name: "no application - only app namespace", + applicationYAML: "", + registrySettings: &types.RegistrySettings{ + ImagePullSecretName: "test-secret", + ImagePullSecretValue: base64.StdEncoding.EncodeToString([]byte(dockerConfigJSON)), + }, + existingNamespaces: []string{appSlug}, + wantNamespaces: []string{appSlug}, + wantCreatedNs: []string{}, + wantSecretInNs: []string{appSlug}, + wantNoCAConfigmapInNs: []string{appSlug}, + }, + { + name: "application with no additional namespaces", + applicationYAML: `apiVersion: kots.io/v1beta1 +kind: Application +metadata: + name: test-app +spec: + title: Test App`, + registrySettings: &types.RegistrySettings{ + ImagePullSecretName: "test-secret", + ImagePullSecretValue: base64.StdEncoding.EncodeToString([]byte(dockerConfigJSON)), + }, + existingNamespaces: []string{appSlug}, + wantNamespaces: []string{appSlug}, + wantCreatedNs: []string{}, + wantSecretInNs: []string{appSlug}, + wantNoCAConfigmapInNs: []string{appSlug}, + }, + { + name: "application with additional namespaces", + applicationYAML: `apiVersion: kots.io/v1beta1 +kind: Application +metadata: + name: test-app +spec: + title: Test App + additionalNamespaces: + - app-ns-1 + - app-ns-2`, + registrySettings: &types.RegistrySettings{ + ImagePullSecretName: "test-secret", + ImagePullSecretValue: base64.StdEncoding.EncodeToString([]byte(dockerConfigJSON)), + }, + existingNamespaces: []string{appSlug}, + wantNamespaces: []string{appSlug, "app-ns-1", "app-ns-2"}, + wantCreatedNs: []string{"app-ns-1", "app-ns-2"}, + wantSecretInNs: []string{appSlug, "app-ns-1", "app-ns-2"}, + wantNoCAConfigmapInNs: []string{appSlug, "app-ns-1", "app-ns-2"}, + }, + { + name: "application with wildcard namespace - now skipped with warning", + applicationYAML: `apiVersion: kots.io/v1beta1 +kind: Application +metadata: + name: test-app +spec: + title: Test App + additionalNamespaces: + - "*"`, + registrySettings: &types.RegistrySettings{ + ImagePullSecretName: "test-secret", + ImagePullSecretValue: base64.StdEncoding.EncodeToString([]byte(dockerConfigJSON)), + }, + existingNamespaces: []string{appSlug, "existing-ns-1", "existing-ns-2"}, + wantNamespaces: []string{appSlug}, // "*" is now skipped + wantCreatedNs: []string{}, + wantSecretInNs: []string{appSlug}, // Only appSlug gets the secret + wantNoSecretInNs: []string{"existing-ns-1", "existing-ns-2"}, + wantNoCAConfigmapInNs: []string{appSlug, "existing-ns-1", "existing-ns-2"}, + }, + { + name: "no registry settings - no secrets created", + applicationYAML: `apiVersion: kots.io/v1beta1 +kind: Application +metadata: + name: test-app +spec: + title: Test App + additionalNamespaces: + - app-ns`, + registrySettings: nil, + existingNamespaces: []string{appSlug}, + wantNamespaces: []string{appSlug, "app-ns"}, + wantCreatedNs: []string{"app-ns"}, + wantSecretInNs: []string{}, + wantNoSecretInNs: []string{appSlug, "app-ns"}, + wantNoCAConfigmapInNs: []string{appSlug, "app-ns"}, + }, + { + name: "with CA bundle path - configmaps created", + applicationYAML: `apiVersion: kots.io/v1beta1 +kind: Application +metadata: + name: test-app +spec: + title: Test App + additionalNamespaces: + - app-ns`, + registrySettings: &types.RegistrySettings{ + ImagePullSecretName: "test-secret", + ImagePullSecretValue: base64.StdEncoding.EncodeToString([]byte(dockerConfigJSON)), + }, + withCABundle: true, + existingNamespaces: []string{appSlug}, + wantNamespaces: []string{appSlug, "app-ns"}, + wantCreatedNs: []string{"app-ns"}, + wantSecretInNs: []string{appSlug, "app-ns"}, + wantCAConfigmapInNs: []string{appSlug, "app-ns"}, + }, + { + name: "updates existing secret with different data", + applicationYAML: `apiVersion: kots.io/v1beta1 +kind: Application +metadata: + name: test-app +spec: + title: Test App`, + registrySettings: &types.RegistrySettings{ + ImagePullSecretName: "test-secret", + ImagePullSecretValue: base64.StdEncoding.EncodeToString([]byte(dockerConfigJSON)), + }, + existingNamespaces: []string{appSlug}, + existingSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: appSlug, + }, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{ + ".dockerconfigjson": []byte(`{"auths":{"old.registry.com":{}}}`), + }, + }, + }, + wantNamespaces: []string{appSlug}, + wantCreatedNs: []string{}, + wantSecretInNs: []string{appSlug}, + }, + { + name: "updates existing CA configmap with different data", + applicationYAML: `apiVersion: kots.io/v1beta1 +kind: Application +metadata: + name: test-app +spec: + title: Test App`, + registrySettings: &types.RegistrySettings{ + ImagePullSecretName: "test-secret", + ImagePullSecretValue: base64.StdEncoding.EncodeToString([]byte(dockerConfigJSON)), + }, + withCABundle: true, + existingNamespaces: []string{appSlug}, + existingConfigMaps: []corev1.ConfigMap{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "kotsadm-private-cas", + Namespace: appSlug, + Annotations: map[string]string{ + "replicated.com/cas-checksum": "old-checksum", + }, + }, + Data: map[string]string{ + "ca_0.crt": "old-ca-content", + }, + }, + }, + wantNamespaces: []string{appSlug}, + wantCreatedNs: []string{}, + wantSecretInNs: []string{appSlug}, + wantCAConfigmapInNs: []string{appSlug}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("ENABLE_V3", "1") + + // Set up release data + releaseData := map[string][]byte{ + "channelrelease.yaml": []byte("# channel release object\nappSlug: test-app"), + } + if tt.applicationYAML != "" { + releaseData["application.yaml"] = []byte(tt.applicationYAML) + } + err := release.SetReleaseDataForTests(releaseData) + require.NoError(t, err) + + // Build fake client with existing namespaces, secrets, and configmaps + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme) + for _, nsName := range tt.existingNamespaces { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: nsName}, + } + builder = builder.WithObjects(ns) + } + for i := range tt.existingSecrets { + builder = builder.WithObjects(&tt.existingSecrets[i]) + } + for i := range tt.existingConfigMaps { + builder = builder.WithObjects(&tt.existingConfigMaps[i]) + } + fakeKcli := builder.Build() + + // Create fake metadata client + fakeMcli := metadatafake.NewSimpleMetadataClient(metadatafake.NewTestScheme()) + + // Handle temp CA file + var hostCABundlePath string + if tt.withCABundle { + tmpFile, err := os.CreateTemp("", "ca-bundle-*.crt") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + _, err = tmpFile.WriteString("-----BEGIN CERTIFICATE-----\ntest-ca-content\n-----END CERTIFICATE-----") + require.NoError(t, err) + tmpFile.Close() + hostCABundlePath = tmpFile.Name() + } + + // Create the reconciler + reconciler, err := newNamespaceReconciler( + t.Context(), + fakeKcli, + fakeMcli, + tt.registrySettings, + hostCABundlePath, + appSlug, + versionLabel, + logger.NewDiscardLogger(), + ) + require.NoError(t, err) + require.NotNil(t, reconciler) + + // Verify namespaces to be reconciled + assert.Equal(t, tt.wantNamespaces, reconciler.namespaces) + + // Run the reconciler + err = reconciler.reconcile(t.Context()) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + // Verify namespaces were created + for _, nsName := range tt.wantCreatedNs { + ns := &corev1.Namespace{} + err := fakeKcli.Get(t.Context(), client.ObjectKey{Name: nsName}, ns) + require.NoError(t, err, "namespace %s should be created", nsName) + } + + // Verify secrets were created in expected namespaces + for _, nsName := range tt.wantSecretInNs { + secret := &corev1.Secret{} + err := fakeKcli.Get(t.Context(), client.ObjectKey{ + Namespace: nsName, + Name: tt.registrySettings.ImagePullSecretName, + }, secret) + require.NoError(t, err, "secret should exist in namespace %s", nsName) + assert.Equal(t, corev1.SecretTypeDockerConfigJson, secret.Type) + assert.Equal(t, dockerConfigJSON, string(secret.Data[".dockerconfigjson"])) + } + + // Verify CA configmaps were created in expected namespaces + for _, nsName := range tt.wantCAConfigmapInNs { + configMap := &corev1.ConfigMap{} + err := fakeKcli.Get(t.Context(), client.ObjectKey{ + Namespace: nsName, + Name: "kotsadm-private-cas", + }, configMap) + require.NoError(t, err, "CA configmap should exist in namespace %s", nsName) + assert.Contains(t, configMap.Data["ca_0.crt"], "test-ca-content") + } + + // Verify secrets were NOT created in namespaces where they shouldn't be + for _, nsName := range tt.wantNoSecretInNs { + secret := &corev1.Secret{} + err := fakeKcli.Get(t.Context(), client.ObjectKey{ + Namespace: nsName, + Name: "test-secret", + }, secret) + assert.Error(t, err, "secret should not exist in namespace %s", nsName) + } + + // Verify CA configmaps were NOT created in namespaces where they shouldn't be + for _, nsName := range tt.wantNoCAConfigmapInNs { + configMap := &corev1.ConfigMap{} + err := fakeKcli.Get(t.Context(), client.ObjectKey{ + Namespace: nsName, + Name: "kotsadm-private-cas", + }, configMap) + assert.Error(t, err, "CA configmap should not exist in namespace %s", nsName) + } + }) + } +} diff --git a/api/internal/managers/app/install/status.go b/api/internal/managers/app/install/status.go index 192244feaa..d9e5cb62aa 100644 --- a/api/internal/managers/app/install/status.go +++ b/api/internal/managers/app/install/status.go @@ -1,12 +1,19 @@ package install import ( - "fmt" + "time" + + "github.com/replicatedhq/embedded-cluster/api/types" ) -func (m *appInstallManager) addLogs(format string, v ...any) { - msg := fmt.Sprintf(format, v...) - if err := m.appInstallStore.AddLogs(msg); err != nil { - m.logger.WithError(err).Error("add log") - } +func (m *appInstallManager) GetStatus() (types.AppInstall, error) { + return m.appInstallStore.Get() +} + +func (m *appInstallManager) setComponentStatus(componentName string, state types.State, description string) error { + return m.appInstallStore.SetComponentStatus(componentName, types.Status{ + State: state, + Description: description, + LastUpdated: time.Now(), + }) } diff --git a/api/internal/managers/app/install/util.go b/api/internal/managers/app/install/util.go index 086e49d90a..e725ec76da 100644 --- a/api/internal/managers/app/install/util.go +++ b/api/internal/managers/app/install/util.go @@ -2,38 +2,49 @@ package install import ( "fmt" - "io" - "strings" + "os" "github.com/replicatedhq/embedded-cluster/api/internal/clients" "k8s.io/cli-runtime/pkg/genericclioptions" ) -// logWriter is an io.Writer that captures output and feeds it to the logs -type logWriter struct { - manager *appInstallManager +func (m *appInstallManager) 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 *appInstallManager) newLogWriter() io.Writer { - return &logWriter{manager: m} +func (m *appInstallManager) addLogs(component string, format string, v ...interface{}) { + msg := fmt.Sprintf("[%s] %s", component, fmt.Sprintf(format, v...)) + if err := m.appInstallStore.AddLogs(msg); err != nil { + m.logger.WithError(err).Error("add log") + } } -func (lw *logWriter) Write(p []byte) (n int, err error) { - output := strings.TrimSpace(string(p)) - if output != "" { - lw.manager.addLogs("[kots] %s", output) - lw.manager.logger.WithField("component", "kots").Debug(output) +func (m *appInstallManager) writeChartArchiveToTemp(chartArchive []byte) (string, error) { + tmpFile, err := os.CreateTemp("", "helm-chart-*.tgz") + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + defer tmpFile.Close() + + if _, err := tmpFile.Write(chartArchive); err != nil { + _ = os.Remove(tmpFile.Name()) + return "", fmt.Errorf("write chart archive: %w", err) } - return len(p), nil + + return tmpFile.Name(), nil } -func (m *appInstallManager) initKubeClient() error { - if m.kcli == nil { - var restClientGetter genericclioptions.RESTClientGetter - if m.kubernetesEnvSettings != nil { - restClientGetter = m.kubernetesEnvSettings.RESTClientGetter() - } +// setupClients initializes the kube, metadata, and helm clients if they are not already set. +func (m *appInstallManager) setupClients() error { + var restClientGetter genericclioptions.RESTClientGetter + if m.kubernetesEnvSettings != nil { + restClientGetter = m.kubernetesEnvSettings.RESTClientGetter() + } + if m.kcli == nil { kcli, err := clients.NewKubeClient(clients.KubeClientOptions{RESTClientGetter: restClientGetter}) if err != nil { return fmt.Errorf("create kube client: %w", err) @@ -41,5 +52,17 @@ func (m *appInstallManager) initKubeClient() error { m.kcli = kcli } + if m.mcli == nil && restClientGetter != nil { + mcli, err := clients.NewMetadataClient(clients.KubeClientOptions{RESTClientGetter: restClientGetter}) + if err != nil { + return fmt.Errorf("create metadata client: %w", err) + } + m.mcli = mcli + } + + if m.hcli == nil { + return fmt.Errorf("helm client is required") + } + return nil } diff --git a/api/internal/managers/app/install/util_test.go b/api/internal/managers/app/install/util_test.go index 7410244db0..a07e084120 100644 --- a/api/internal/managers/app/install/util_test.go +++ b/api/internal/managers/app/install/util_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestLogWriter_Write(t *testing.T) { +func TestLogFn_Write(t *testing.T) { // Create store store := appinstallstore.NewMemoryStore() @@ -29,31 +29,31 @@ func TestLogWriter_Write(t *testing.T) { { name: "Single line output", input: "Installing package X", - expectedOutput: "[kots] Installing package X", + expectedOutput: "[app] Installing package X", expectedInLogs: true, }, { name: "Output with newline", input: "Installing package Y\n", - expectedOutput: "[kots] Installing package Y", + expectedOutput: "[app] Installing package Y", expectedInLogs: true, }, { name: "Empty string", input: "", - expectedOutput: "", - expectedInLogs: false, + expectedOutput: "[app]", + expectedInLogs: true, }, { name: "Whitespace only", input: " \n\t ", - expectedOutput: "", - expectedInLogs: false, + expectedOutput: "[app]", + expectedInLogs: true, }, { name: "Multiple lines", input: "Line 1\nLine 2\n", - expectedOutput: "[kots] Line 1\nLine 2", + expectedOutput: "[app] Line 1\nLine 2", expectedInLogs: true, }, } @@ -65,12 +65,10 @@ func TestLogWriter_Write(t *testing.T) { concreteManager.appInstallStore = newStore // Create new writer for the test - testWriter := concreteManager.newLogWriter() + logFn := concreteManager.logFn("app") // Write to log writer - n, err := testWriter.Write([]byte(tt.input)) - assert.NoError(t, err) - assert.Equal(t, len(tt.input), n) + logFn(tt.input) // Check if logs were added logs, err := concreteManager.appInstallStore.GetLogs() @@ -85,15 +83,15 @@ func TestLogWriter_Write(t *testing.T) { } } -func TestLogWriter_WriteMultipleOperations(t *testing.T) { +func TestLogFn_MultipleOperations(t *testing.T) { // Create concrete manager directly for testing utilities concreteManager := &appInstallManager{ appInstallStore: appinstallstore.NewMemoryStore(), logger: logger.NewDiscardLogger(), } - // Create log writer - writer := concreteManager.newLogWriter() + // Create log function + logFn := concreteManager.logFn("app") // Write multiple entries entries := []string{ @@ -104,9 +102,7 @@ func TestLogWriter_WriteMultipleOperations(t *testing.T) { } for _, entry := range entries { - n, err := writer.Write([]byte(entry)) - assert.NoError(t, err) - assert.Equal(t, len(entry), n) + logFn(entry) } // Verify all entries are in logs @@ -114,7 +110,7 @@ func TestLogWriter_WriteMultipleOperations(t *testing.T) { require.NoError(t, err) for _, entry := range entries { - expected := "[kots] " + entry + expected := "[app] " + entry assert.Contains(t, logs, expected) } @@ -122,12 +118,12 @@ func TestLogWriter_WriteMultipleOperations(t *testing.T) { lines := strings.Split(strings.TrimSpace(logs), "\n") assert.Len(t, lines, len(entries)) for i, entry := range entries { - expected := "[kots] " + entry + expected := "[app] " + entry assert.Equal(t, expected, lines[i]) } } -func TestLogWriter_LargeOutput(t *testing.T) { +func TestLogFn_LargeOutput(t *testing.T) { // Create concrete manager directly for testing utilities concreteManager := &appInstallManager{ appInstallStore: appinstallstore.NewMemoryStore(), @@ -135,24 +131,22 @@ func TestLogWriter_LargeOutput(t *testing.T) { } // Create log writer - writer := concreteManager.newLogWriter() + logFn := concreteManager.logFn("app") // Create a large output string largeOutput := strings.Repeat("A", 1000) // Write large output - n, err := writer.Write([]byte(largeOutput)) - assert.NoError(t, err) - assert.Equal(t, 1000, n) + logFn(largeOutput) // Verify it was logged with prefix logs, err := concreteManager.appInstallStore.GetLogs() require.NoError(t, err) - expected := "[kots] " + largeOutput + expected := "[app] " + largeOutput assert.Contains(t, logs, expected) } -func TestLogWriter_BinaryData(t *testing.T) { +func TestLogFn_BinaryData(t *testing.T) { // Create concrete manager directly for testing utilities concreteManager := &appInstallManager{ appInstallStore: appinstallstore.NewMemoryStore(), @@ -160,17 +154,15 @@ func TestLogWriter_BinaryData(t *testing.T) { } // Create log writer - writer := concreteManager.newLogWriter() + logFn := concreteManager.logFn("app") // Write binary data (should still work as io.Writer) binaryData := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE} - n, err := writer.Write(binaryData) - assert.NoError(t, err) - assert.Equal(t, len(binaryData), n) + logFn(string(binaryData)) // Verify it was processed (though it may not be readable text) logs, err := concreteManager.appInstallStore.GetLogs() require.NoError(t, err) - // Should contain the [kots] prefix at minimum since binary data gets converted to string - assert.Contains(t, logs, "[kots]") + // Should contain the [app] prefix at minimum since binary data gets converted to string + assert.Contains(t, logs, "[app]") } diff --git a/api/internal/managers/app/release/manager.go b/api/internal/managers/app/release/manager.go index 8622f862ac..4b00010c26 100644 --- a/api/internal/managers/app/release/manager.go +++ b/api/internal/managers/app/release/manager.go @@ -19,6 +19,7 @@ import ( // AppReleaseManager provides methods for managing the release of an app type AppReleaseManager interface { ExtractAppPreflightSpec(ctx context.Context, configValues types.AppConfigValues, proxySpec *ecv1beta1.ProxySpec, registrySettings *types.RegistrySettings) (*troubleshootv1beta2.PreflightSpec, error) + ExtractInstallableHelmCharts(ctx context.Context, configValues types.AppConfigValues, proxySpec *ecv1beta1.ProxySpec, registrySettings *types.RegistrySettings) ([]types.InstallableHelmChart, error) } type appReleaseManager struct { diff --git a/api/internal/managers/app/release/manager_mock.go b/api/internal/managers/app/release/manager_mock.go index 4bdeb2652e..941e72ac2f 100644 --- a/api/internal/managers/app/release/manager_mock.go +++ b/api/internal/managers/app/release/manager_mock.go @@ -24,3 +24,12 @@ func (m *MockAppReleaseManager) ExtractAppPreflightSpec(ctx context.Context, con } return args.Get(0).(*troubleshootv1beta2.PreflightSpec), args.Error(1) } + +// ExtractInstallableHelmCharts mocks the ExtractInstallableHelmCharts method +func (m *MockAppReleaseManager) ExtractInstallableHelmCharts(ctx context.Context, configValues types.AppConfigValues, proxySpec *ecv1beta1.ProxySpec, registrySettings *types.RegistrySettings) ([]types.InstallableHelmChart, error) { + args := m.Called(ctx, configValues, proxySpec, registrySettings) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]types.InstallableHelmChart), args.Error(1) +} diff --git a/api/internal/managers/app/release/template.go b/api/internal/managers/app/release/template.go index 6344de6c98..d0c6a810bd 100644 --- a/api/internal/managers/app/release/template.go +++ b/api/internal/managers/app/release/template.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" "os" + "sort" "strconv" "github.com/replicatedhq/embedded-cluster/api/pkg/template" @@ -56,6 +57,59 @@ func (m *appReleaseManager) ExtractAppPreflightSpec(ctx context.Context, configV return mergedSpec, nil } +// ExtractInstallableHelmCharts extracts and processes installable Helm charts from app releases +func (m *appReleaseManager) ExtractInstallableHelmCharts(ctx context.Context, configValues types.AppConfigValues, proxySpec *ecv1beta1.ProxySpec, registrySettings *types.RegistrySettings) ([]types.InstallableHelmChart, error) { + // Template Helm chart CRs with config values + templatedCRs, err := m.templateHelmChartCRs(configValues, proxySpec, registrySettings) + if err != nil { + return nil, fmt.Errorf("template helm chart CRs: %w", err) + } + + var installableCharts []types.InstallableHelmChart + + // Iterate over each templated CR and create installable chart with processed values + for _, cr := range templatedCRs { + // Check if the chart should be excluded + if !cr.Spec.Exclude.IsEmpty() { + exclude, err := cr.Spec.Exclude.Boolean() + if err != nil { + return nil, fmt.Errorf("parse templated CR exclude for %s: %w", cr.Name, err) + } + if exclude { + continue + } + } + + // Find the corresponding chart archive for this HelmChart CR + chartArchive, err := findChartArchive(m.releaseData.HelmChartArchives, cr) + if err != nil { + return nil, fmt.Errorf("find chart archive for %s: %w", cr.Name, err) + } + + // Generate Helm values from the templated CR + values, err := generateHelmValues(cr) + if err != nil { + return nil, fmt.Errorf("generate helm values for chart %s: %w", cr.Name, err) + } + + // Create installable chart with archive, processed values, and CR + installableChart := types.InstallableHelmChart{ + Archive: chartArchive, + Values: values, + CR: cr, + } + + installableCharts = append(installableCharts, installableChart) + } + + // Sort charts by weight field before returning + sort.Slice(installableCharts, func(i, j int) bool { + return installableCharts[i].CR.GetWeight() < installableCharts[j].CR.GetWeight() + }) + + return installableCharts, nil +} + // templateHelmChartCRs templates the HelmChart CRs from release data using the template engine and config values func (m *appReleaseManager) templateHelmChartCRs(configValues types.AppConfigValues, proxySpec *ecv1beta1.ProxySpec, registrySettings *types.RegistrySettings) ([]*kotsv1beta2.HelmChart, error) { if m.templateEngine == nil { @@ -172,7 +226,7 @@ func generateHelmValues(templatedCR *kotsv1beta2.HelmChart) (map[string]any, err } // Start with the base values - mergedValues := templatedCR.Spec.Values + mergedValues := maps.Clone(templatedCR.Spec.Values) if mergedValues == nil { mergedValues = map[string]kotsv1beta2.MappedChartValue{} } diff --git a/api/internal/managers/app/release/template_test.go b/api/internal/managers/app/release/template_test.go index 8deda941b4..0a33dc1a3d 100644 --- a/api/internal/managers/app/release/template_test.go +++ b/api/internal/managers/app/release/template_test.go @@ -746,12 +746,12 @@ spec: configValues: types.AppConfigValues{}, proxySpec: &ecv1beta1.ProxySpec{}, registrySettings: &types.RegistrySettings{ - HasLocalRegistry: true, - Host: "10.128.0.11:5000", - Address: "10.128.0.11:5000/myapp", - Namespace: "myapp", - ImagePullSecretName: "embedded-cluster-registry", - ImagePullSecretValue: "dGVzdC1zZWNyZXQtdmFsdWU=", + HasLocalRegistry: true, + LocalRegistryHost: "10.128.0.11:5000", + LocalRegistryAddress: "10.128.0.11:5000/myapp", + LocalRegistryNamespace: "myapp", + ImagePullSecretName: "test-app-registry", + ImagePullSecretValue: "dGVzdC1zZWNyZXQtdmFsdWU=", }, expected: []*kotsv1beta2.HelmChart{ createHelmChartCRFromYAML(` @@ -768,7 +768,7 @@ spec: image: repository: "10.128.0.11:5000/myapp/nginx" imagePullSecrets: - - name: "embedded-cluster-registry" + - name: "test-app-registry" registry: host: "10.128.0.11:5000" address: "10.128.0.11:5000/myapp" @@ -1974,6 +1974,738 @@ data: } } +func TestAppReleaseManager_ExtractInstallableHelmCharts(t *testing.T) { + tests := []struct { + name string + helmChartCRs [][]byte + chartArchives [][]byte + configValues types.AppConfigValues + proxySpec *ecv1beta1.ProxySpec + registrySettings *types.RegistrySettings + expectError bool + errorContains string + expected []types.InstallableHelmChart + }{ + { + name: "no helm charts returns empty slice", + helmChartCRs: [][]byte{}, + chartArchives: [][]byte{}, + configValues: types.AppConfigValues{}, + expectError: false, + expected: nil, + }, + { + name: "single chart with basic configuration", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: nginx-chart +spec: + namespace: repl{{ConfigOption "namespace"}} + releaseName: repl{{ConfigOption "release_name"}} + chart: + name: nginx + chartVersion: "1.0.0" + values: + replicaCount: "3" + image: + repository: nginx + tag: '{{repl ConfigOption "image_tag"}}' + service: + type: ClusterIP + port: 80 + optionalValues: + - when: '{{repl ConfigOptionEquals "enable_ingress" "true"}}' + values: + ingress: + enabled: true + host: '{{repl ConfigOption "ingress_host"}}'`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + }, + configValues: types.AppConfigValues{ + "namespace": {Value: "custom-namespace"}, + "release_name": {Value: "custom-release-name"}, + "image_tag": {Value: "1.20.0"}, + "enable_ingress": {Value: "true"}, + "ingress_host": {Value: "nginx.example.com"}, + }, + expectError: false, + expected: []types.InstallableHelmChart{ + { + Archive: createTestChartArchive(t, "nginx", "1.0.0"), + Values: map[string]any{ + "replicaCount": "3", + "image": map[string]any{ + "repository": "nginx", + "tag": "1.20.0", + }, + "service": map[string]any{ + "type": "ClusterIP", + "port": float64(80), + }, + "ingress": map[string]any{ + "enabled": true, + "host": "nginx.example.com", + }, + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: nginx-chart +spec: + namespace: custom-namespace + releaseName: custom-release-name + chart: + name: nginx + chartVersion: "1.0.0" + values: + replicaCount: "3" + image: + repository: nginx + tag: "1.20.0" + service: + type: ClusterIP + port: 80 + optionalValues: + - when: "true" + values: + ingress: + enabled: true + host: "nginx.example.com"`), + }, + }, + }, + { + name: "chart with exclude=true should be skipped", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: excluded-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + exclude: '{{repl ConfigOptionEquals "skip_nginx" "true"}}' + values: + replicaCount: "2"`), + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: included-chart +spec: + chart: + name: redis + chartVersion: "2.0.0" + exclude: false + values: + persistence: + enabled: true`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + createTestChartArchive(t, "redis", "2.0.0"), + }, + configValues: types.AppConfigValues{ + "skip_nginx": {Value: "true"}, + }, + expectError: false, + expected: []types.InstallableHelmChart{ + { + Archive: createTestChartArchive(t, "redis", "2.0.0"), + Values: map[string]any{ + "persistence": map[string]any{ + "enabled": true, + }, + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: included-chart +spec: + chart: + name: redis + chartVersion: "2.0.0" + exclude: false + values: + persistence: + enabled: true`), + }, + }, + }, + { + name: "chart with recursive merge optional values", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: merge-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + service: + type: '{{repl ConfigOption "service_type"}}' + port: 80 + replicaCount: "1" + optionalValues: + - when: '{{repl ConfigOption "enable_ssl"}}' + recursiveMerge: true + values: + service: + type: LoadBalancer + ssl: + enabled: true`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + }, + configValues: types.AppConfigValues{ + "service_type": {Value: "ClusterIP"}, + "enable_ssl": {Value: "true"}, + }, + expectError: false, + expected: []types.InstallableHelmChart{ + { + Archive: createTestChartArchive(t, "nginx", "1.0.0"), + Values: map[string]any{ + "replicaCount": "1", + "service": map[string]any{ + "type": "LoadBalancer", // from optional values (overrode base value) + "port": float64(80), // from base values (preserved) + }, + "ssl": map[string]any{ + "enabled": true, // from optional values (added) + }, + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: merge-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + service: + type: "ClusterIP" + port: 80 + replicaCount: "1" + optionalValues: + - when: "true" + recursiveMerge: true + values: + service: + type: LoadBalancer + ssl: + enabled: true`), + }, + }, + }, + { + name: "chart with direct replacement optional values", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: replace-chart +spec: + chart: + name: redis + chartVersion: "2.0.0" + values: + persistence: + enabled: '{{repl ConfigOption "enable_persistence"}}' + size: "5Gi" + optionalValues: + - when: '{{repl ConfigOption "redis_persistence"}}' + recursiveMerge: false + values: + persistence: + size: "20Gi"`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "redis", "2.0.0"), + }, + configValues: types.AppConfigValues{ + "enable_persistence": {Value: "true"}, + "redis_persistence": {Value: "true"}, + }, + expectError: false, + expected: []types.InstallableHelmChart{ + { + Archive: createTestChartArchive(t, "redis", "2.0.0"), + Values: map[string]any{ + "persistence": map[string]any{ + "size": "20Gi", // from optional values (direct replacement) + // Note: enabled=true is GONE because entire persistence key was replaced + }, + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: replace-chart +spec: + chart: + name: redis + chartVersion: "2.0.0" + values: + persistence: + enabled: "true" + size: "5Gi" + optionalValues: + - when: "true" + recursiveMerge: false + values: + persistence: + size: "20Gi"`), + }, + }, + }, + { + name: "chart with proxy template functions", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: proxy-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + proxy: + http: '{{repl HTTPProxy}}' + https: '{{repl HTTPSProxy}}' + noProxy: '{{repl NoProxy | join ","}}' + optionalValues: + - when: '{{repl if HTTPProxy}}true{{repl else}}false{{repl end}}' + values: + proxyEnabled: true`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + }, + configValues: types.AppConfigValues{}, + proxySpec: &ecv1beta1.ProxySpec{ + HTTPProxy: "http://proxy.example.com:8080", + HTTPSProxy: "https://proxy.example.com:8443", + NoProxy: "localhost,127.0.0.1,.cluster.local", + }, + expectError: false, + expected: []types.InstallableHelmChart{ + { + Archive: createTestChartArchive(t, "nginx", "1.0.0"), + Values: map[string]any{ + "proxy": map[string]any{ + "http": "http://proxy.example.com:8080", + "https": "https://proxy.example.com:8443", + "noProxy": "localhost,127.0.0.1,.cluster.local", + }, + "proxyEnabled": true, + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: proxy-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + proxy: + http: "http://proxy.example.com:8080" + https: "https://proxy.example.com:8443" + noProxy: "localhost,127.0.0.1,.cluster.local" + optionalValues: + - when: "true" + values: + proxyEnabled: true`), + }, + }, + }, + { + name: "chart archive not found", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: missing-chart +spec: + chart: + name: nonexistent + chartVersion: "1.0.0" + values: + replicaCount: "1"`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), // Different chart + }, + configValues: types.AppConfigValues{}, + expectError: true, + errorContains: "find chart archive for missing-chart", + }, + { + name: "invalid when condition in optional values", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: invalid-when-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + replicaCount: "1" + optionalValues: + - when: "not-a-boolean-value" + values: + debug: true`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + }, + configValues: types.AppConfigValues{}, + expectError: true, + errorContains: "generate helm values for chart invalid-when-chart", + }, + { + name: "chart with mixed when conditions", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: mixed-conditions-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + replicaCount: "1" + optionalValues: + - when: '{{repl ConfigOption "enable_persistence"}}' + values: + persistence: + enabled: true + - when: '{{repl ConfigOption "disable_monitoring"}}' + values: + monitoring: + enabled: false`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + }, + configValues: types.AppConfigValues{ + "enable_persistence": {Value: "true"}, + "disable_monitoring": {Value: "false"}, + }, + expectError: false, + expected: []types.InstallableHelmChart{ + { + Archive: createTestChartArchive(t, "nginx", "1.0.0"), + Values: map[string]any{ + "replicaCount": "1", // from base values + "persistence": map[string]any{ + "enabled": true, // from optional values (when=true) + }, + // monitoring should NOT be present (when condition evaluated to false) + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: mixed-conditions-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + replicaCount: "1" + optionalValues: + - when: "true" + values: + persistence: + enabled: true + - when: "false" + values: + monitoring: + enabled: false`), + }, + }, + }, + { + name: "nil helm chart CRs", + helmChartCRs: nil, + chartArchives: [][]byte{}, + configValues: types.AppConfigValues{}, + expectError: false, + expected: nil, + }, + { + name: "skip nil helm chart CR in collection", + helmChartCRs: [][]byte{ + nil, + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: valid-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + replicaCount: "2"`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + }, + configValues: types.AppConfigValues{}, + expectError: false, + expected: []types.InstallableHelmChart{ + { + Archive: createTestChartArchive(t, "nginx", "1.0.0"), + Values: map[string]any{ + "replicaCount": "2", + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: valid-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + replicaCount: "2"`), + }, + }, + }, + { + name: "chart with registry template functions - airgap mode", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: registry-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + image: + repository: '{{repl HasLocalRegistry | ternary LocalRegistryHost "proxy.replicated.com"}}/{{repl HasLocalRegistry | ternary LocalRegistryNamespace "external/path"}}/nginx' + tag: "1.20.0" + imagePullSecrets: + - name: '{{repl ImagePullSecretName}}' + registry: + host: '{{repl LocalRegistryHost}}' + address: '{{repl LocalRegistryAddress}}' + namespace: '{{repl LocalRegistryNamespace}}' + secret: '{{repl LocalRegistryImagePullSecret}}' + optionalValues: + - when: '{{repl HasLocalRegistry}}' + values: + airgapMode: true`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + }, + configValues: types.AppConfigValues{}, + registrySettings: &types.RegistrySettings{ + HasLocalRegistry: true, + LocalRegistryHost: "10.128.0.11:5000", + LocalRegistryAddress: "10.128.0.11:5000/myapp", + LocalRegistryNamespace: "myapp", + ImagePullSecretName: "test-app-registry", + ImagePullSecretValue: "dGVzdC1zZWNyZXQtdmFsdWU=", + }, + expectError: false, + expected: []types.InstallableHelmChart{ + { + Archive: createTestChartArchive(t, "nginx", "1.0.0"), + Values: map[string]any{ + "image": map[string]any{ + "repository": "10.128.0.11:5000/myapp/nginx", + "tag": "1.20.0", + }, + "imagePullSecrets": []any{ + map[string]any{"name": "test-app-registry"}, + }, + "registry": map[string]any{ + "host": "10.128.0.11:5000", + "address": "10.128.0.11:5000/myapp", + "namespace": "myapp", + "secret": "dGVzdC1zZWNyZXQtdmFsdWU=", + }, + "airgapMode": true, + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: registry-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + image: + repository: "10.128.0.11:5000/myapp/nginx" + tag: "1.20.0" + imagePullSecrets: + - name: "test-app-registry" + registry: + host: "10.128.0.11:5000" + address: "10.128.0.11:5000/myapp" + namespace: "myapp" + secret: "dGVzdC1zZWNyZXQtdmFsdWU=" + optionalValues: + - when: "true" + values: + airgapMode: true`), + }, + }, + }, + { + name: "charts sorted by weight - negative, zero, positive", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: positive-weight-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + weight: 100 + values: + name: "positive"`), + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: no-weight-chart +spec: + chart: + name: redis + chartVersion: "2.0.0" + values: + name: "zero"`), // No weight specified, defaults to 0 + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: negative-weight-chart +spec: + chart: + name: postgresql + chartVersion: "1.0.0" + weight: -10 + values: + name: "negative"`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + createTestChartArchive(t, "redis", "2.0.0"), + createTestChartArchive(t, "postgresql", "1.0.0"), + }, + configValues: types.AppConfigValues{}, + expectError: false, + expected: []types.InstallableHelmChart{ + // Should be sorted by weight: postgresql (-10), redis (0), nginx (100) + { + Archive: createTestChartArchive(t, "postgresql", "1.0.0"), + Values: map[string]any{ + "name": "negative", + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: negative-weight-chart +spec: + chart: + name: postgresql + chartVersion: "1.0.0" + weight: -10 + values: + name: "negative"`), + }, + { + Archive: createTestChartArchive(t, "redis", "2.0.0"), + Values: map[string]any{ + "name": "zero", + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: no-weight-chart +spec: + chart: + name: redis + chartVersion: "2.0.0" + values: + name: "zero"`), + }, + { + Archive: createTestChartArchive(t, "nginx", "1.0.0"), + Values: map[string]any{ + "name": "positive", + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: positive-weight-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + weight: 100 + values: + name: "positive"`), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create release data + releaseData := &release.ReleaseData{ + HelmChartCRs: tt.helmChartCRs, + HelmChartArchives: tt.chartArchives, + } + + // Create manager + config := createTestConfig() + manager, err := NewAppReleaseManager( + config, + WithReleaseData(releaseData), + WithHelmClient(&helm.MockClient{}), + ) + require.NoError(t, err) + + // Execute the function + result, err := manager.ExtractInstallableHelmCharts(context.Background(), tt.configValues, tt.proxySpec, tt.registrySettings) + + // Check error expectation + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + assert.Nil(t, result) + return + } + + require.NoError(t, err) + + // Validate expected results + assert.Equal(t, tt.expected, result) + }) + } +} + // Helper function to create HelmChart from YAML string func createHelmChartCRFromYAML(yamlStr string) *kotsv1beta2.HelmChart { var chart kotsv1beta2.HelmChart @@ -2101,6 +2833,8 @@ func createTestConfig() kotsv1beta1.Config { Name: "test_group", Items: []kotsv1beta1.ConfigItem{ {Name: "chart_name", Type: "text", Value: multitype.FromString("nginx")}, + {Name: "namespace", Type: "text", Value: multitype.FromString("default-namespace")}, + {Name: "release_name", Type: "text", Value: multitype.FromString("default-release-name")}, {Name: "image_tag", Type: "text", Value: multitype.FromString("1.20.0")}, {Name: "app_name", Type: "text", Value: multitype.FromString("myapp")}, {Name: "chart1_name", Type: "text", Value: multitype.FromString("nginx")}, @@ -2120,6 +2854,16 @@ func createTestConfig() kotsv1beta1.Config { {Name: "node_count", Type: "text", Value: multitype.FromString("3")}, {Name: "version_check_name", Type: "text", Value: multitype.FromString("Custom K8s Version Check")}, {Name: "resource_check_name", Type: "text", Value: multitype.FromString("Custom Node Resource Check")}, + // Additional items for ExtractInstallableHelmCharts test + {Name: "image_tag", Type: "text", Value: multitype.FromString("1.20.0")}, + {Name: "enable_ingress", Type: "text", Value: multitype.FromString("true")}, + {Name: "ingress_host", Type: "text", Value: multitype.FromString("nginx.example.com")}, + {Name: "skip_nginx", Type: "text", Value: multitype.FromString("true")}, + {Name: "frontend_replicas", Type: "text", Value: multitype.FromString("3")}, + {Name: "frontend_tag", Type: "text", Value: multitype.FromString("1.20.0")}, + {Name: "enable_ssl", Type: "text", Value: multitype.FromString("true")}, + {Name: "redis_persistence", Type: "text", Value: multitype.FromString("true")}, + {Name: "invalid_boolean", Type: "text", Value: multitype.FromString("not-a-boolean")}, }, }, }, diff --git a/api/internal/managers/app/release/util.go b/api/internal/managers/app/release/util.go index 4f2c282a65..e4f74e0229 100644 --- a/api/internal/managers/app/release/util.go +++ b/api/internal/managers/app/release/util.go @@ -62,7 +62,7 @@ func writeChartArchiveToTemp(chartArchive []byte) (string, error) { // Write the chart archive to the temporary file if _, err := tmpFile.Write(chartArchive); err != nil { - os.Remove(tmpFile.Name()) + _ = os.Remove(tmpFile.Name()) return "", fmt.Errorf("write chart archive: %w", err) } diff --git a/api/internal/managers/linux/infra/image_test.go b/api/internal/managers/linux/infra/image_test.go index 450d2f326a..d90c9aa7a6 100644 --- a/api/internal/managers/linux/infra/image_test.go +++ b/api/internal/managers/linux/infra/image_test.go @@ -10,8 +10,8 @@ import ( func Test_DestECImage(t *testing.T) { registryOps := &types.RegistrySettings{ - Host: "localhost:5000", - Namespace: "somebigbank", + LocalRegistryHost: "localhost:5000", + LocalRegistryNamespace: "somebigbank", } type args struct { @@ -29,7 +29,7 @@ func Test_DestECImage(t *testing.T) { registry: registryOps, srcImage: "411111111111.dkr.ecr.us-west-1.amazonaws.com/myrepo:v0.0.1", }, - want: fmt.Sprintf("%s/%s/embedded-cluster/myrepo:v0.0.1", registryOps.Host, registryOps.Namespace), + want: fmt.Sprintf("%s/%s/embedded-cluster/myrepo:v0.0.1", registryOps.LocalRegistryHost, registryOps.LocalRegistryNamespace), }, { name: "Quay image with tag", @@ -37,7 +37,7 @@ func Test_DestECImage(t *testing.T) { registry: registryOps, srcImage: "quay.io/someorg/debian:0.1", }, - want: fmt.Sprintf("%s/%s/embedded-cluster/debian:0.1", registryOps.Host, registryOps.Namespace), + want: fmt.Sprintf("%s/%s/embedded-cluster/debian:0.1", registryOps.LocalRegistryHost, registryOps.LocalRegistryNamespace), }, { name: "Quay image with digest", @@ -45,7 +45,7 @@ func Test_DestECImage(t *testing.T) { registry: registryOps, srcImage: "quay.io/someorg/debian@sha256:17c5f462c92fc39303e6363c65e074559f8d6a1354150027ed5053557e3298c5", }, - want: fmt.Sprintf("%s/%s/embedded-cluster/debian@sha256:17c5f462c92fc39303e6363c65e074559f8d6a1354150027ed5053557e3298c5", registryOps.Host, registryOps.Namespace), + want: fmt.Sprintf("%s/%s/embedded-cluster/debian@sha256:17c5f462c92fc39303e6363c65e074559f8d6a1354150027ed5053557e3298c5", registryOps.LocalRegistryHost, registryOps.LocalRegistryNamespace), }, { name: "Image with tag and digest", @@ -53,17 +53,17 @@ func Test_DestECImage(t *testing.T) { registry: registryOps, srcImage: "quay.io/someorg/debian:0.1@sha256:17c5f462c92fc39303e6363c65e074559f8d6a1354150027ed5053557e3298c5", }, - want: fmt.Sprintf("%s/%s/embedded-cluster/debian@sha256:17c5f462c92fc39303e6363c65e074559f8d6a1354150027ed5053557e3298c5", registryOps.Host, registryOps.Namespace), + want: fmt.Sprintf("%s/%s/embedded-cluster/debian@sha256:17c5f462c92fc39303e6363c65e074559f8d6a1354150027ed5053557e3298c5", registryOps.LocalRegistryHost, registryOps.LocalRegistryNamespace), }, { name: "No Namespace", args: args{ registry: &types.RegistrySettings{ - Host: "localhost:5000", + LocalRegistryHost: "localhost:5000", }, srcImage: "quay.io/someorg/debian:0.1", }, - want: fmt.Sprintf("%s/embedded-cluster/debian:0.1", registryOps.Host), + want: fmt.Sprintf("%s/embedded-cluster/debian:0.1", registryOps.LocalRegistryHost), }, } for _, tt := range tests { diff --git a/api/internal/managers/linux/infra/upgrade.go b/api/internal/managers/linux/infra/upgrade.go index 348eb98221..e8ce4b60d7 100644 --- a/api/internal/managers/linux/infra/upgrade.go +++ b/api/internal/managers/linux/infra/upgrade.go @@ -138,8 +138,8 @@ func (m *infraManager) getECArtifacts(registrySettings *types.RegistrySettings) } opts := ECArtifactOCIPathOptions{ - RegistryHost: registrySettings.Host, - RegistryNamespace: registrySettings.Namespace, + RegistryHost: registrySettings.LocalRegistryHost, + RegistryNamespace: registrySettings.LocalRegistryNamespace, ChannelID: airgapInfo.Spec.ChannelID, UpdateCursor: airgapInfo.Spec.UpdateCursor, VersionLabel: airgapInfo.Spec.VersionLabel, @@ -276,7 +276,7 @@ func destECImage(registrySettings *types.RegistrySettings, srcImage string) (str imageParts := strings.Split(srcImage, "/") lastPart := imageParts[len(imageParts)-1] - return path.Join(registrySettings.Host, registrySettings.Namespace, "embedded-cluster", lastPart), nil + return path.Join(registrySettings.LocalRegistryHost, registrySettings.LocalRegistryNamespace, "embedded-cluster", lastPart), nil } func (m *infraManager) upgradeAddOns(ctx context.Context, in *ecv1beta1.Installation) error { diff --git a/api/internal/managers/linux/installation/config.go b/api/internal/managers/linux/installation/config.go index eca339a3a1..f9991f9499 100644 --- a/api/internal/managers/linux/installation/config.go +++ b/api/internal/managers/linux/installation/config.go @@ -8,6 +8,7 @@ import ( "fmt" "github.com/replicatedhq/embedded-cluster/api/internal/clients" + "github.com/replicatedhq/embedded-cluster/api/internal/utils" "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" @@ -15,8 +16,10 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/addons/registry" "github.com/replicatedhq/embedded-cluster/pkg/netutils" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" corev1 "k8s.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" + kyaml "sigs.k8s.io/yaml" ) // GetConfig returns the resolved installation configuration, with the user provided values AND defaults applied @@ -229,13 +232,22 @@ func (m *installationManager) ConfigureHost(ctx context.Context, rc runtimeconfi return nil } -// CalculateRegistrySettings calculates registry settings for airgap installations (should be used for new installations) +// CalculateRegistrySettings calculates registry settings for both online and airgap installations func (m *installationManager) CalculateRegistrySettings(ctx context.Context, rc runtimeconfig.RuntimeConfig) (*types.RegistrySettings, error) { - // Only return registry settings for airgap installations if m.airgapBundle == "" { - return nil, nil + registrySettings, err := m.getOnlineRegistrySettings() + if err != nil { + return nil, fmt.Errorf("failed to get online registry settings: %w", err) + } + return registrySettings, nil } + // Get app slug for secret name + if m.releaseData == nil || m.releaseData.ChannelRelease == nil || m.releaseData.ChannelRelease.AppSlug == "" { + return nil, fmt.Errorf("release data with app slug is required for registry settings") + } + appSlug := m.releaseData.ChannelRelease.AppSlug + // Use runtime config as the authoritative source for service CIDR serviceCIDR := rc.ServiceCIDR() @@ -247,13 +259,8 @@ func (m *installationManager) CalculateRegistrySettings(ctx context.Context, rc // Construct registry host with port registryHost := fmt.Sprintf("%s:5000", registryIP) - // Get app namespace from release data - required for app preflights - if m.releaseData == nil || m.releaseData.ChannelRelease == nil || m.releaseData.ChannelRelease.AppSlug == "" { - return nil, fmt.Errorf("release data with app slug is required for registry settings") - } - appNamespace := m.releaseData.ChannelRelease.AppSlug - // Construct full registry address with namespace + appNamespace := appSlug // registry namespace is the same as the app slug in linux target registryAddress := fmt.Sprintf("%s/%s", registryHost, appNamespace) // Get registry credentials @@ -267,24 +274,34 @@ func (m *installationManager) CalculateRegistrySettings(ctx context.Context, rc imagePullSecretValue := base64.StdEncoding.EncodeToString([]byte(authConfig)) return &types.RegistrySettings{ - HasLocalRegistry: true, - Host: registryHost, - Address: registryAddress, - Namespace: appNamespace, - Username: registryUsername, - Password: registryPassword, - ImagePullSecretName: "embedded-cluster-registry", - ImagePullSecretValue: imagePullSecretValue, + HasLocalRegistry: true, + LocalRegistryHost: registryHost, + LocalRegistryAddress: registryAddress, + LocalRegistryNamespace: appNamespace, + LocalRegistryUsername: registryUsername, + LocalRegistryPassword: registryPassword, + ImagePullSecretName: fmt.Sprintf("%s-registry", appSlug), + ImagePullSecretValue: imagePullSecretValue, }, nil } -// GetRegistrySettings reads registry settings from the cluster for airgap installations (should be used for upgrades) +// GetRegistrySettings reads registry settings from the cluster for airgap installations, and calculates them for online installations (should be used for upgrades) func (m *installationManager) GetRegistrySettings(ctx context.Context, rc runtimeconfig.RuntimeConfig) (*types.RegistrySettings, error) { // If no airgap bundle, no registry settings needed if m.airgapBundle == "" { - return nil, nil + registrySettings, err := m.getOnlineRegistrySettings() + if err != nil { + return nil, fmt.Errorf("failed to get online registry settings: %w", err) + } + return registrySettings, nil } + // Get app slug for secret name + if m.releaseData == nil || m.releaseData.ChannelRelease == nil || m.releaseData.ChannelRelease.AppSlug == "" { + return nil, fmt.Errorf("release data with app slug is required for registry settings") + } + appSlug := m.releaseData.ChannelRelease.AppSlug + if m.kcli == nil { kcli, err := clients.NewKubeClient(clients.KubeClientOptions{KubeConfigPath: rc.PathToKubeConfig()}) if err != nil { @@ -346,26 +363,52 @@ func (m *installationManager) GetRegistrySettings(ctx context.Context, rc runtim return nil, fmt.Errorf("embedded-cluster username not found in registry-creds secret") } - // Get app namespace from release data - if m.releaseData == nil || m.releaseData.ChannelRelease == nil || m.releaseData.ChannelRelease.AppSlug == "" { - return nil, fmt.Errorf("release data with app slug is required for registry settings") - } - appNamespace := m.releaseData.ChannelRelease.AppSlug - // Construct full registry address with namespace + appNamespace := appSlug // registry namespace is the same as the app slug in linux target registryAddress := fmt.Sprintf("%s/%s", registryHost, appNamespace) // Use the full dockerconfigjson as the image pull secret value imagePullSecretValue := base64.StdEncoding.EncodeToString(dockerJson) return &types.RegistrySettings{ - HasLocalRegistry: true, - Host: registryHost, - Address: registryAddress, - Namespace: appNamespace, - Username: registryUsername, - Password: registryPassword, - ImagePullSecretName: "embedded-cluster-registry", + HasLocalRegistry: true, + LocalRegistryHost: registryHost, + LocalRegistryAddress: registryAddress, + LocalRegistryNamespace: appNamespace, + LocalRegistryUsername: registryUsername, + LocalRegistryPassword: registryPassword, + ImagePullSecretName: fmt.Sprintf("%s-registry", appSlug), + ImagePullSecretValue: imagePullSecretValue, + }, nil +} + +func (m *installationManager) getOnlineRegistrySettings() (*types.RegistrySettings, error) { + // Online mode: Use replicated proxy registry with license ID authentication + ecDomains := utils.GetDomains(m.releaseData) + + // Parse license from bytes + if len(m.license) == 0 { + return nil, fmt.Errorf("license is required for online registry settings") + } + license := &kotsv1beta1.License{} + if err := kyaml.Unmarshal(m.license, license); err != nil { + return nil, fmt.Errorf("parse license: %w", err) + } + + // Get app slug for secret name + if m.releaseData == nil || m.releaseData.ChannelRelease == nil || m.releaseData.ChannelRelease.AppSlug == "" { + return nil, fmt.Errorf("release data with app slug is required for registry settings") + } + appSlug := m.releaseData.ChannelRelease.AppSlug + + // Create auth config for both proxy and registry domains + authConfig := fmt.Sprintf(`{"auths":{"%s":{"username": "LICENSE_ID", "password": "%s"},"%s":{"username": "LICENSE_ID", "password": "%s"}}}`, + ecDomains.ProxyRegistryDomain, license.Spec.LicenseID, ecDomains.ReplicatedRegistryDomain, license.Spec.LicenseID) + imagePullSecretValue := base64.StdEncoding.EncodeToString([]byte(authConfig)) + + return &types.RegistrySettings{ + HasLocalRegistry: false, + ImagePullSecretName: fmt.Sprintf("%s-registry", appSlug), ImagePullSecretValue: imagePullSecretValue, }, nil } diff --git a/api/internal/managers/linux/installation/config_test.go b/api/internal/managers/linux/installation/config_test.go index 5648973890..fe86a3db9d 100644 --- a/api/internal/managers/linux/installation/config_test.go +++ b/api/internal/managers/linux/installation/config_test.go @@ -2,17 +2,26 @@ package installation import ( "context" + "encoding/base64" "errors" + "fmt" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "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" + "github.com/replicatedhq/embedded-cluster/pkg/addons/registry" + "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + kyaml "sigs.k8s.io/yaml" ) func TestValidateConfig(t *testing.T) { @@ -603,3 +612,400 @@ func TestConfigureHost(t *testing.T) { }) } } + +// Helper to create a test license +func createTestLicense(licenseID, appSlug string) []byte { + license := kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + LicenseID: licenseID, + AppSlug: appSlug, + }, + } + licenseBytes, _ := kyaml.Marshal(license) + return licenseBytes +} + +// Helper to create test release data +func createTestReleaseData(appSlug string, domains *ecv1beta1.Domains) *release.ReleaseData { + releaseData := &release.ReleaseData{ + ChannelRelease: &release.ChannelRelease{ + AppSlug: appSlug, + }, + } + if domains != nil { + releaseData.EmbeddedClusterConfig = &ecv1beta1.Config{ + Spec: ecv1beta1.ConfigSpec{ + Domains: *domains, + }, + } + } + return releaseData +} + +// Helper to create runtime config +func createTestRuntimeConfig() runtimeconfig.RuntimeConfig { + return runtimeconfig.New(&ecv1beta1.RuntimeConfigSpec{ + Network: ecv1beta1.NetworkSpec{ + ServiceCIDR: "10.96.0.0/12", + }, + }) +} + +func TestCalculateRegistrySettings(t *testing.T) { + + tests := []struct { + name string + license []byte + releaseData *release.ReleaseData + airgapBundle string + expectedResult *types.RegistrySettings + expectedError string + }{ + { + name: "online mode with default domains", + license: createTestLicense("test-license-123", "test-app"), + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "", // Online mode + expectedResult: &types.RegistrySettings{ + HasLocalRegistry: false, + ImagePullSecretName: "test-app-registry", + ImagePullSecretValue: base64.StdEncoding.EncodeToString([]byte(`{"auths":{"proxy.replicated.com":{"username": "LICENSE_ID", "password": "test-license-123"},"registry.replicated.com":{"username": "LICENSE_ID", "password": "test-license-123"}}}`)), + }, + }, + { + name: "online mode with custom domains", + license: createTestLicense("custom-license-456", "custom-app"), + releaseData: createTestReleaseData("custom-app", &ecv1beta1.Domains{ + ProxyRegistryDomain: "custom-proxy.example.com", + ReplicatedRegistryDomain: "custom-registry.example.com", + }), + airgapBundle: "", // Online mode + expectedResult: &types.RegistrySettings{ + HasLocalRegistry: false, + ImagePullSecretName: "custom-app-registry", + ImagePullSecretValue: base64.StdEncoding.EncodeToString([]byte(`{"auths":{"custom-proxy.example.com":{"username": "LICENSE_ID", "password": "custom-license-456"},"custom-registry.example.com":{"username": "LICENSE_ID", "password": "custom-license-456"}}}`)), + }, + }, + { + name: "online mode missing license", + license: nil, + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "", // Online mode + expectedError: "license is required for online registry settings", + }, + { + name: "online mode empty license", + license: []byte{}, + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "", // Online mode + expectedError: "license is required for online registry settings", + }, + { + name: "online mode invalid license format", + license: []byte("invalid yaml"), + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "", // Online mode + expectedError: "parse license:", + }, + { + name: "online mode missing release data", + license: createTestLicense("test-license", "test-app"), + releaseData: nil, + airgapBundle: "", // Online mode + expectedError: "release data with app slug is required for registry settings", + }, + { + name: "online mode missing app slug", + license: createTestLicense("test-license", "test-app"), + releaseData: &release.ReleaseData{ + ChannelRelease: &release.ChannelRelease{ + AppSlug: "", // Empty app slug + }, + }, + airgapBundle: "", // Online mode + expectedError: "release data with app slug is required for registry settings", + }, + { + name: "airgap mode", + license: createTestLicense("test-license", "test-app"), + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "test-bundle.tar", + expectedResult: &types.RegistrySettings{ + HasLocalRegistry: true, + LocalRegistryHost: "10.96.0.11:5000", + LocalRegistryAddress: "10.96.0.11:5000/test-app", + LocalRegistryNamespace: "test-app", + LocalRegistryUsername: "embedded-cluster", + LocalRegistryPassword: registry.GetRegistryPassword(), + ImagePullSecretName: "test-app-registry", + ImagePullSecretValue: func() string { + authString := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("embedded-cluster:%s", registry.GetRegistryPassword()))) + return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"auths":{"10.96.0.11:5000":{"username": "embedded-cluster", "password": "%s", "auth": "%s"}}}`, registry.GetRegistryPassword(), authString))) + }(), + }, + }, + { + name: "airgap mode missing release data", + license: createTestLicense("test-license", "test-app"), + releaseData: nil, + airgapBundle: "test-bundle.tar", + expectedError: "release data with app slug is required for registry settings", + }, + { + name: "airgap mode missing app slug", + license: createTestLicense("test-license", "test-app"), + releaseData: &release.ReleaseData{ + ChannelRelease: &release.ChannelRelease{ + AppSlug: "", // Empty app slug + }, + }, + airgapBundle: "test-bundle.tar", + expectedError: "release data with app slug is required for registry settings", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rc := createTestRuntimeConfig() + + manager := NewInstallationManager( + WithLicense(tt.license), + WithReleaseData(tt.releaseData), + WithAirgapBundle(tt.airgapBundle), + ) + + result, err := manager.CalculateRegistrySettings(context.Background(), rc) + + if tt.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedResult, result) + } + }) + } +} + +func TestGetRegistrySettings(t *testing.T) { + tests := []struct { + name string + license []byte + releaseData *release.ReleaseData + airgapBundle string + setupCluster func() client.Client + expectedResult *types.RegistrySettings + expectedError string + skipEnableV3Unset bool + }{ + { + name: "online mode with default domains", + license: createTestLicense("test-license-123", "test-app"), + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "", // Online mode + expectedResult: &types.RegistrySettings{ + HasLocalRegistry: false, + ImagePullSecretName: "test-app-registry", + ImagePullSecretValue: base64.StdEncoding.EncodeToString([]byte(`{"auths":{"proxy.replicated.com":{"username": "LICENSE_ID", "password": "test-license-123"},"registry.replicated.com":{"username": "LICENSE_ID", "password": "test-license-123"}}}`)), + }, + }, + { + name: "online mode with custom domains", + license: createTestLicense("custom-license-456", "custom-app"), + releaseData: createTestReleaseData("custom-app", &ecv1beta1.Domains{ + ProxyRegistryDomain: "custom-proxy.example.com", + ReplicatedRegistryDomain: "custom-registry.example.com", + }), + airgapBundle: "", // Online mode + expectedResult: &types.RegistrySettings{ + HasLocalRegistry: false, + ImagePullSecretName: "custom-app-registry", + ImagePullSecretValue: base64.StdEncoding.EncodeToString([]byte(`{"auths":{"custom-proxy.example.com":{"username": "LICENSE_ID", "password": "custom-license-456"},"custom-registry.example.com":{"username": "LICENSE_ID", "password": "custom-license-456"}}}`)), + }, + }, + { + name: "airgap mode with valid registry-creds secret", + license: createTestLicense("test-license", "test-app"), + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "test-bundle.tar", + setupCluster: func() client.Client { + // Create a fake kubernetes client with the registry-creds secret + authString := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("embedded-cluster:%s", registry.GetRegistryPassword()))) + dockerConfigJSON := fmt.Sprintf(`{"auths":{"10.96.0.11:5000":{"username": "embedded-cluster", "password": "%s", "auth": "%s"}}}`, + registry.GetRegistryPassword(), authString) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "registry-creds", + Namespace: "kotsadm", + }, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{ + ".dockerconfigjson": []byte(dockerConfigJSON), + }, + } + + return fake.NewClientBuilder().WithObjects(secret).Build() + }, + expectedResult: &types.RegistrySettings{ + HasLocalRegistry: true, + LocalRegistryHost: "10.96.0.11:5000", + LocalRegistryAddress: "10.96.0.11:5000/test-app", + LocalRegistryNamespace: "test-app", + LocalRegistryUsername: "embedded-cluster", + LocalRegistryPassword: registry.GetRegistryPassword(), + ImagePullSecretName: "test-app-registry", + ImagePullSecretValue: func() string { + authString := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("embedded-cluster:%s", registry.GetRegistryPassword()))) + dockerConfigJSON := fmt.Sprintf(`{"auths":{"10.96.0.11:5000":{"username": "embedded-cluster", "password": "%s", "auth": "%s"}}}`, + registry.GetRegistryPassword(), authString) + return base64.StdEncoding.EncodeToString([]byte(dockerConfigJSON)) + }(), + }, + }, + { + name: "airgap mode with missing registry-creds secret", + license: createTestLicense("test-license", "test-app"), + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "test-bundle.tar", + setupCluster: func() client.Client { + return fake.NewClientBuilder().Build() + }, + expectedError: "get registry-creds secret:", + }, + { + name: "airgap mode with invalid secret type", + license: createTestLicense("test-license", "test-app"), + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "test-bundle.tar", + setupCluster: func() client.Client { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "registry-creds", + Namespace: "kotsadm", + }, + Type: corev1.SecretTypeOpaque, // Wrong type + Data: map[string][]byte{ + ".dockerconfigjson": []byte(`{}`), + }, + } + return fake.NewClientBuilder().WithObjects(secret).Build() + }, + expectedError: "registry-creds secret is not of type kubernetes.io/dockerconfigjson", + }, + { + name: "airgap mode with missing dockerconfigjson", + license: createTestLicense("test-license", "test-app"), + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "test-bundle.tar", + setupCluster: func() client.Client { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "registry-creds", + Namespace: "kotsadm", + }, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{}, // Missing .dockerconfigjson + } + return fake.NewClientBuilder().WithObjects(secret).Build() + }, + expectedError: "registry-creds secret missing .dockerconfigjson data", + }, + { + name: "airgap mode with invalid json in dockerconfigjson", + license: createTestLicense("test-license", "test-app"), + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "test-bundle.tar", + setupCluster: func() client.Client { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "registry-creds", + Namespace: "kotsadm", + }, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{ + ".dockerconfigjson": []byte("invalid json"), + }, + } + return fake.NewClientBuilder().WithObjects(secret).Build() + }, + expectedError: "parse dockerconfigjson:", + }, + { + name: "airgap mode with missing embedded-cluster username", + license: createTestLicense("test-license", "test-app"), + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "test-bundle.tar", + setupCluster: func() client.Client { + dockerConfigJSON := `{"auths":{"registry.example.com":{"username": "other-user", "password": "other-pass"}}}` + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "registry-creds", + Namespace: "kotsadm", + }, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{ + ".dockerconfigjson": []byte(dockerConfigJSON), + }, + } + return fake.NewClientBuilder().WithObjects(secret).Build() + }, + expectedError: "embedded-cluster username not found in registry-creds secret", + }, + { + name: "airgap mode missing release data", + license: createTestLicense("test-license", "test-app"), + releaseData: nil, + airgapBundle: "test-bundle.tar", + // No need to setup cluster - validation happens before cluster access + expectedError: "release data with app slug is required for registry settings", + }, + { + name: "airgap mode missing app slug", + license: createTestLicense("test-license", "test-app"), + releaseData: &release.ReleaseData{ + ChannelRelease: &release.ChannelRelease{ + AppSlug: "", // Empty app slug + }, + }, + airgapBundle: "test-bundle.tar", + // No need to setup cluster - validation happens before cluster access + expectedError: "release data with app slug is required for registry settings", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Ensure ENABLE_V3 is not set so KotsadmNamespace returns "kotsadm" + if !tt.skipEnableV3Unset { + t.Setenv("ENABLE_V3", "") + } + + rc := createTestRuntimeConfig() + + var kcli client.Client + if tt.setupCluster != nil { + kcli = tt.setupCluster() + } + + manager := NewInstallationManager( + WithLicense(tt.license), + WithReleaseData(tt.releaseData), + WithAirgapBundle(tt.airgapBundle), + WithKubeClient(kcli), + ) + + result, err := manager.GetRegistrySettings(context.Background(), rc) + + if tt.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedResult, result) + } + }) + } +} diff --git a/api/internal/store/app/install/store.go b/api/internal/store/app/install/store.go index 407833e3af..fa9668fd3c 100644 --- a/api/internal/store/app/install/store.go +++ b/api/internal/store/app/install/store.go @@ -21,6 +21,8 @@ type Store interface { SetStatusDesc(desc string) error AddLogs(logs string) error GetLogs() (string, error) + SetComponentStatus(componentName string, status types.Status) error + RegisterComponents(componentNames []string) error } // memoryStore is an in-memory implementation of Store @@ -115,3 +117,42 @@ func (s *memoryStore) GetLogs() (string, error) { defer s.mu.RUnlock() return s.appInstall.Logs, nil } + +// SetComponentStatus sets the status of a specific component +func (s *memoryStore) SetComponentStatus(componentName string, status types.Status) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Find and update the component + for i := range s.appInstall.Components { + if s.appInstall.Components[i].Name == componentName { + s.appInstall.Components[i].Status = status + return nil + } + } + + return fmt.Errorf("component %s not found", componentName) +} + +// RegisterComponents initializes the components list with the given component names +func (s *memoryStore) RegisterComponents(componentNames []string) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Clear existing components + s.appInstall.Components = make([]types.AppComponent, 0, len(componentNames)) + + // Initialize each component with pending status + for _, name := range componentNames { + s.appInstall.Components = append(s.appInstall.Components, types.AppComponent{ + Name: name, + Status: types.Status{ + State: types.StatePending, + Description: "", + LastUpdated: time.Now(), + }, + }) + } + + return nil +} diff --git a/api/internal/store/app/install/store_mock.go b/api/internal/store/app/install/store_mock.go index 689373fda1..06557cc1d2 100644 --- a/api/internal/store/app/install/store_mock.go +++ b/api/internal/store/app/install/store_mock.go @@ -53,3 +53,15 @@ func (m *MockStore) GetLogs() (string, error) { args := m.Called() return args.Get(0).(string), args.Error(1) } + +// SetComponentStatus mocks the SetComponentStatus method +func (m *MockStore) SetComponentStatus(componentName string, status types.Status) error { + args := m.Called(componentName, status) + return args.Error(0) +} + +// RegisterComponents mocks the RegisterComponents method +func (m *MockStore) RegisterComponents(componentNames []string) error { + args := m.Called(componentNames) + return args.Error(0) +} diff --git a/api/internal/store/app/install/store_test.go b/api/internal/store/app/install/store_test.go index e83aae151a..9492b474ac 100644 --- a/api/internal/store/app/install/store_test.go +++ b/api/internal/store/app/install/store_test.go @@ -280,3 +280,105 @@ func TestMemoryStore_DeepCopy(t *testing.T) { assert.Equal(t, "Original description", appInstall3.Status.Description) assert.Equal(t, "Original log\n", appInstall3.Logs) } + +func TestMemoryStore_RegisterComponents(t *testing.T) { + store := newMemoryStore() + + // Test registering components + componentNames := []string{"chart1", "chart2", "chart3"} + err := store.RegisterComponents(componentNames) + require.NoError(t, err) + + // Verify components were registered + appInstall, err := store.Get() + require.NoError(t, err) + require.Len(t, appInstall.Components, 3) + + for i, component := range appInstall.Components { + assert.Equal(t, componentNames[i], component.Name) + assert.Equal(t, types.StatePending, component.Status.State) + } +} + +func TestMemoryStore_SetComponentStatus(t *testing.T) { + store := newMemoryStore() + + // Register components first + componentNames := []string{"chart1", "chart2"} + err := store.RegisterComponents(componentNames) + require.NoError(t, err) + + // Test setting component status + status := types.Status{ + State: types.StateRunning, + Description: "Installing chart1", + LastUpdated: time.Now(), + } + err = store.SetComponentStatus("chart1", status) + require.NoError(t, err) + + // Verify component status was updated + appInstall, err := store.Get() + require.NoError(t, err) + assert.Equal(t, types.StateRunning, appInstall.Components[0].Status.State) + assert.Equal(t, "Installing chart1", appInstall.Components[0].Status.Description) + // Second component should remain unchanged + assert.Equal(t, types.StatePending, appInstall.Components[1].Status.State) +} + +func TestMemoryStore_SetComponentStatus_NonExistentComponent(t *testing.T) { + store := newMemoryStore() + + // Try to set status on non-existent component + status := types.Status{ + State: types.StateRunning, + Description: "Installing", + LastUpdated: time.Now(), + } + err := store.SetComponentStatus("nonexistent", status) + assert.Error(t, err) + assert.Contains(t, err.Error(), "component nonexistent not found") +} + +func TestMemoryStore_ComponentStatusConcurrency(t *testing.T) { + store := newMemoryStore() + + // Register components + componentNames := []string{"chart1", "chart2", "chart3"} + err := store.RegisterComponents(componentNames) + require.NoError(t, err) + + var wg sync.WaitGroup + numGoroutines := 10 + numOperations := 20 + + // Concurrent component status 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++ { + componentName := componentNames[j%len(componentNames)] + status := types.Status{ + State: types.StateRunning, + Description: "Concurrent update", + LastUpdated: time.Now(), + } + err := store.SetComponentStatus(componentName, status) + assert.NoError(t, err) + } + }(i) + + // Concurrent reads + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + _, err := store.Get() + assert.NoError(t, err) + } + }(i) + } + + wg.Wait() +} diff --git a/api/internal/utils/k8s.go b/api/internal/utils/k8s.go new file mode 100644 index 0000000000..fe774305c5 --- /dev/null +++ b/api/internal/utils/k8s.go @@ -0,0 +1,11 @@ +package utils + +func GetK8sObjectMetaLabels(appSlug string, versionLabel string, component string) map[string]string { + return map[string]string{ + "app.kubernetes.io/name": appSlug, + "app.kubernetes.io/version": versionLabel, + "app.kubernetes.io/component": component, + "app.kubernetes.io/part-of": "embedded-cluster", + "app.kubernetes.io/managed-by": "embedded-cluster-installer", + } +} diff --git a/api/pkg/template/registry.go b/api/pkg/template/registry.go index 8e1dce22c0..1c52ccafb5 100644 --- a/api/pkg/template/registry.go +++ b/api/pkg/template/registry.go @@ -15,7 +15,7 @@ func (e *Engine) localRegistryHost() string { if e.registrySettings == nil { return "" } - return e.registrySettings.Host + return e.registrySettings.LocalRegistryHost } // localRegistryAddress returns full registry address with namespace (e.g., "10.128.0.11:5000/myapp") @@ -23,7 +23,7 @@ func (e *Engine) localRegistryAddress() string { if e.registrySettings == nil { return "" } - return e.registrySettings.Address + return e.registrySettings.LocalRegistryAddress } // localRegistryNamespace returns the app-specific namespace for registry isolation @@ -31,7 +31,7 @@ func (e *Engine) localRegistryNamespace() string { if e.registrySettings == nil { return "" } - return e.registrySettings.Namespace + return e.registrySettings.LocalRegistryNamespace } // imagePullSecretName returns the standardized image pull secret name diff --git a/api/pkg/template/registry_test.go b/api/pkg/template/registry_test.go index 40799242dd..bca6c33ec5 100644 --- a/api/pkg/template/registry_test.go +++ b/api/pkg/template/registry_test.go @@ -67,14 +67,14 @@ func TestEngine_LocalRegistryHost(t *testing.T) { { name: "empty host returns empty string", registrySettings: &types.RegistrySettings{ - Host: "", + LocalRegistryHost: "", }, expectedResult: "", }, { name: "host with port returns host", registrySettings: &types.RegistrySettings{ - Host: "10.128.0.11:5000", + LocalRegistryHost: "10.128.0.11:5000", }, expectedResult: "10.128.0.11:5000", }, @@ -112,14 +112,14 @@ func TestEngine_LocalRegistryAddress(t *testing.T) { { name: "empty address returns empty string", registrySettings: &types.RegistrySettings{ - Address: "", + LocalRegistryAddress: "", }, expectedResult: "", }, { name: "address with namespace returns address", registrySettings: &types.RegistrySettings{ - Address: "10.128.0.11:5000/myapp", + LocalRegistryAddress: "10.128.0.11:5000/myapp", }, expectedResult: "10.128.0.11:5000/myapp", }, @@ -157,14 +157,14 @@ func TestEngine_LocalRegistryNamespace(t *testing.T) { { name: "empty namespace returns empty string", registrySettings: &types.RegistrySettings{ - Namespace: "", + LocalRegistryNamespace: "", }, expectedResult: "", }, { name: "namespace returns namespace", registrySettings: &types.RegistrySettings{ - Namespace: "myapp", + LocalRegistryNamespace: "myapp", }, expectedResult: "myapp", }, @@ -209,9 +209,9 @@ func TestEngine_ImagePullSecretName(t *testing.T) { { name: "secret name returns secret name", registrySettings: &types.RegistrySettings{ - ImagePullSecretName: "embedded-cluster-registry", + ImagePullSecretName: "test-app-registry", }, - expectedResult: "embedded-cluster-registry", + expectedResult: "test-app-registry", }, } @@ -281,12 +281,12 @@ func TestEngine_LocalRegistryImagePullSecret(t *testing.T) { // TestEngine_RegistryFunctionsIntegrated tests multiple registry functions in a single template func TestEngine_RegistryFunctionsIntegrated(t *testing.T) { registrySettings := &types.RegistrySettings{ - HasLocalRegistry: true, - Host: "10.128.0.11:5000", - Address: "10.128.0.11:5000/myapp", - Namespace: "myapp", - ImagePullSecretName: "embedded-cluster-registry", - ImagePullSecretValue: "eyJhdXRocyI6e319", + HasLocalRegistry: true, + LocalRegistryHost: "10.128.0.11:5000", + LocalRegistryAddress: "10.128.0.11:5000/myapp", + LocalRegistryNamespace: "myapp", + ImagePullSecretName: "test-app-registry", + ImagePullSecretValue: "eyJhdXRocyI6e319", } tests := []struct { @@ -312,7 +312,7 @@ func TestEngine_RegistryFunctionsIntegrated(t *testing.T) { { name: "image pull secret name in yaml", template: "- name: '{{repl ImagePullSecretName }}'", - expectedResult: "- name: 'embedded-cluster-registry'", + expectedResult: "- name: 'test-app-registry'", }, } diff --git a/api/types/app.go b/api/types/app.go index b47c5e8ceb..bd5d00e444 100644 --- a/api/types/app.go +++ b/api/types/app.go @@ -2,6 +2,7 @@ package types import ( kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + kotsv1beta2 "github.com/replicatedhq/kotskinds/apis/kots/v1beta2" ) // AppConfig represents the configuration for an app. This is an alias for the @@ -23,8 +24,23 @@ type AppConfigValues map[string]AppConfigValue // AppInstall represents the current state of app installation type AppInstall struct { - Status Status `json:"status"` - Logs string `json:"logs"` + Components []AppComponent `json:"components"` + Status Status `json:"status"` + Logs string `json:"logs"` +} + +// AppComponent represents an individual chart component within the app +// Following the same schema pattern as types.InfraComponent +type AppComponent struct { + Name string `json:"name"` // Chart name + Status Status `json:"status"` // Uses existing Status type +} + +// InstallableHelmChart represents a Helm chart with pre-processed values ready for installation +type InstallableHelmChart struct { + Archive []byte + Values map[string]any + CR *kotsv1beta2.HelmChart } // ConvertToAppConfigValues converts kots ConfigValues to AppConfigValues format diff --git a/api/types/registry.go b/api/types/registry.go index a0a3aa0ebe..f79b56f28a 100644 --- a/api/types/registry.go +++ b/api/types/registry.go @@ -5,20 +5,20 @@ type RegistrySettings struct { // HasLocalRegistry indicates if a local registry is available (airgap installations) HasLocalRegistry bool `json:"hasLocalRegistry"` - // Host is the registry host with port (e.g., "10.128.0.11:5000") - Host string `json:"host"` + // LocalRegistryHost is the registry host with port (e.g., "10.128.0.11:5000") + LocalRegistryHost string `json:"localRegistryHost"` - // Address is the full registry address with namespace (e.g., "10.128.0.11:5000/myapp") - Address string `json:"address"` + // LocalRegistryAddress is the full registry address with namespace (e.g., "10.128.0.11:5000/myapp") + LocalRegistryAddress string `json:"localRegistryAddress"` - // Namespace is the app-specific namespace for registry isolation - Namespace string `json:"namespace"` + // LocalRegistryNamespace is the app-specific namespace for registry isolation + LocalRegistryNamespace string `json:"localRegistryNamespace"` - // Username is the registry authentication username - Username string `json:"username"` + // LocalRegistryUsername is the registry authentication username + LocalRegistryUsername string `json:"localRegistryUsername"` - // Password is the registry authentication password - Password string `json:"password"` + // LocalRegistryPassword is the registry authentication password + LocalRegistryPassword string `json:"localRegistryPassword"` // ImagePullSecretName is the standardized image pull secret name ImagePullSecretName string `json:"imagePullSecretName"` diff --git a/cmd/buildtools/utils.go b/cmd/buildtools/utils.go index 727c6c7fbb..16352abd50 100644 --- a/cmd/buildtools/utils.go +++ b/cmd/buildtools/utils.go @@ -355,7 +355,7 @@ func GetGreatestTagFromRegistry(ctx context.Context, ref string, constraints *se func LatestChartVersion(ctx context.Context, hcli helm.Client, repo *repo.Entry, name string) (string, error) { logrus.Infof("adding helm repo %s", repo.Name) - err := hcli.AddRepoBin(ctx, repo) + err := hcli.AddRepo(ctx, repo) if err != nil { return "", fmt.Errorf("add helm repo: %w", err) } @@ -479,7 +479,7 @@ func MirrorChart(ctx context.Context, hcli helm.Client, repo *repo.Entry, name, logrus.Infof("downloaded %s chart: %s", name, chpath) defer os.Remove(chpath) - err = hcli.AddRepoBin(ctx, repo) + err = hcli.AddRepo(ctx, repo) if err != nil { return fmt.Errorf("add helm repo: %w", err) } @@ -539,7 +539,6 @@ func NewHelm() (helm.Client, error) { HelmPath: "helm", // use the helm binary in PATH KubernetesEnvSettings: helmcli.New(), // use the default env settings from helm K8sVersion: sv.Original(), - Writer: logrus.New().Writer(), }) } diff --git a/e2e/kots-release-install-v3/nginx-app-helm-v1beta2.yaml b/e2e/kots-release-install-v3/nginx-app-helm-v1beta2.yaml index 6037f0d2ce..a706fc4e89 100644 --- a/e2e/kots-release-install-v3/nginx-app-helm-v1beta2.yaml +++ b/e2e/kots-release-install-v3/nginx-app-helm-v1beta2.yaml @@ -42,10 +42,10 @@ spec: configItemsConfigMapData: # Registry template functions verification has_local_registry: repl{{ HasLocalRegistry }} - local_registry_host: repl{{ HasLocalRegistry | ternary LocalRegistryHost "fallback-host" }} - local_registry_namespace: repl{{ HasLocalRegistry | ternary LocalRegistryNamespace "fallback-namespace" }} - local_registry_address: repl{{ HasLocalRegistry | ternary LocalRegistryAddress "fallback-address" }} - image_pull_secret_name: repl{{ HasLocalRegistry | ternary ImagePullSecretName "fallback-secret" }} + local_registry_host: repl{{ HasLocalRegistry | ternary LocalRegistryHost "ec-e2e-proxy.testcluster.net" }} + local_registry_namespace: repl{{ HasLocalRegistry | ternary LocalRegistryNamespace "anonymous/registry.replicated.com/library" }} + local_registry_address: repl{{ HasLocalRegistry | ternary LocalRegistryAddress "ec-e2e-proxy.testcluster.net/anonymous/registry.replicated.com/library" }} + image_pull_secret_name: repl{{ ImagePullSecretName }} image_pull_secret_value: repl{{ LocalRegistryImagePullSecret }} # Text items diff --git a/go.mod b/go.mod index c97442a750..03701fecfc 100644 --- a/go.mod +++ b/go.mod @@ -54,6 +54,7 @@ require ( golang.org/x/crypto v0.45.0 golang.org/x/term v0.37.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.19.2 k8s.io/api v0.34.2 @@ -359,7 +360,6 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiserver v0.34.2 // indirect k8s.io/cloud-provider v0.33.6 // indirect k8s.io/component-base v0.34.2 // indirect diff --git a/operator/pkg/cli/upgrade_job.go b/operator/pkg/cli/upgrade_job.go index 7be1e0edab..6625fae969 100644 --- a/operator/pkg/cli/upgrade_job.go +++ b/operator/pkg/cli/upgrade_job.go @@ -64,6 +64,7 @@ func UpgradeJobCmd() *cobra.Command { } hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH K8sVersion: versions.K0sVersion, AirgapPath: airgapChartsPath, }) diff --git a/pkg/addons/adminconsole/integration/hostcabundle_test.go b/pkg/addons/adminconsole/integration/hostcabundle_test.go index 53b1faab48..1d7b4305f7 100644 --- a/pkg/addons/adminconsole/integration/hostcabundle_test.go +++ b/pkg/addons/adminconsole/integration/hostcabundle_test.go @@ -28,7 +28,10 @@ func TestHostCABundle(t *testing.T) { err := os.WriteFile(addon.HostCABundlePath, []byte("test"), 0644) require.NoError(t, err, "Failed to write CA bundle file") - hcli, err := helm.NewClient(helm.HelmOptions{}) + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.33.0", + }) require.NoError(t, err, "NewClient should not return an error") err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) diff --git a/pkg/addons/adminconsole/integration/kubernetes_test.go b/pkg/addons/adminconsole/integration/kubernetes_test.go index 20c92f0d87..84e4163ed0 100644 --- a/pkg/addons/adminconsole/integration/kubernetes_test.go +++ b/pkg/addons/adminconsole/integration/kubernetes_test.go @@ -32,7 +32,10 @@ func TestKubernetes_Airgap(t *testing.T) { KotsadmNamespace: "my-app-namespace", } - hcli, err := helm.NewClient(helm.HelmOptions{}) + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.33.0", + }) require.NoError(t, err, "NewClient should not return an error") err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) diff --git a/pkg/addons/adminconsole/integration/linux_test.go b/pkg/addons/adminconsole/integration/linux_test.go index d6db5c2944..3545d917dc 100644 --- a/pkg/addons/adminconsole/integration/linux_test.go +++ b/pkg/addons/adminconsole/integration/linux_test.go @@ -46,7 +46,10 @@ func TestLinux_Airgap(t *testing.T) { err := os.WriteFile(addon.HostCABundlePath, []byte("test"), 0644) require.NoError(t, err, "Failed to write CA bundle file") - hcli, err := helm.NewClient(helm.HelmOptions{}) + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.33.0", + }) require.NoError(t, err, "NewClient should not return an error") err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) diff --git a/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go b/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go index 2ee9a572ef..b9690d7468 100644 --- a/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go +++ b/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go @@ -28,7 +28,10 @@ func TestHostCABundle(t *testing.T) { HostCABundlePath: "/etc/ssl/certs/ca-certificates.crt", } - hcli, err := helm.NewClient(helm.HelmOptions{}) + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.33.0", + }) require.NoError(t, err, "NewClient should not return an error") err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) diff --git a/pkg/addons/velero/integration/hostcabundle_test.go b/pkg/addons/velero/integration/hostcabundle_test.go index 3a0056472a..cf9a2d3a1b 100644 --- a/pkg/addons/velero/integration/hostcabundle_test.go +++ b/pkg/addons/velero/integration/hostcabundle_test.go @@ -22,7 +22,10 @@ func TestHostCABundle(t *testing.T) { HostCABundlePath: "/etc/ssl/certs/ca-certificates.crt", } - hcli, err := helm.NewClient(helm.HelmOptions{}) + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.33.0", + }) require.NoError(t, err, "NewClient should not return an error") err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) diff --git a/pkg/addons/velero/integration/k0ssubdir_test.go b/pkg/addons/velero/integration/k0ssubdir_test.go index 90b78b9a38..e711b888db 100644 --- a/pkg/addons/velero/integration/k0ssubdir_test.go +++ b/pkg/addons/velero/integration/k0ssubdir_test.go @@ -24,7 +24,10 @@ func TestK0sDir(t *testing.T) { K0sDataDir: k0sDir, } - hcli, err := helm.NewClient(helm.HelmOptions{}) + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.33.0", + }) require.NoError(t, err, "NewClient should not return an error") err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) diff --git a/pkg/helm/binary_executor.go b/pkg/helm/binary_executor.go index 3d39c3c8e1..5b90da1519 100644 --- a/pkg/helm/binary_executor.go +++ b/pkg/helm/binary_executor.go @@ -5,7 +5,6 @@ import ( "context" "io" "maps" - "path/filepath" "regexp" "strings" @@ -26,14 +25,8 @@ type binaryExecutor struct { } // newBinaryExecutor creates a new binaryExecutor with the specified binary path and optional default environment -func newBinaryExecutor(bin string, homeDir string) BinaryExecutor { - // Configure helm environment variables for tmpdir isolation - helmEnv := map[string]string{ - "HELM_CACHE_HOME": filepath.Join(homeDir, ".cache"), - "HELM_CONFIG_HOME": filepath.Join(homeDir, ".config"), - "HELM_DATA_HOME": filepath.Join(homeDir, ".local"), - } - return &binaryExecutor{bin: bin, defaultEnv: helmEnv} +func newBinaryExecutor(bin string, defaultEnv map[string]string) BinaryExecutor { + return &binaryExecutor{bin: bin, defaultEnv: defaultEnv} } // ExecuteCommand runs a command using helpers.RunCommandWithOptions and returns stdout, stderr, and error diff --git a/pkg/helm/binary_executor_test.go b/pkg/helm/binary_executor_test.go index 676c57cad4..dc0d0eaf33 100644 --- a/pkg/helm/binary_executor_test.go +++ b/pkg/helm/binary_executor_test.go @@ -32,7 +32,7 @@ func Test_binaryExecutor_ExecuteCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - executor := newBinaryExecutor(tt.bin, t.TempDir()) + executor := newBinaryExecutor(tt.bin, nil) stdout, stderr, err := executor.ExecuteCommand(t.Context(), nil, nil, tt.args...) if tt.wantErr { @@ -95,7 +95,7 @@ func Test_binaryExecutor_ExecuteCommand_WithLogging(t *testing.T) { logs = append(logs, fmt.Sprintf(format, v...)) } - executor := newBinaryExecutor(tt.bin, t.TempDir()) + executor := newBinaryExecutor(tt.bin, nil) stdout, stderr, err := executor.ExecuteCommand(t.Context(), nil, logFn, tt.args...) if tt.wantErr { @@ -152,13 +152,18 @@ func Test_logWriter_Write(t *testing.T) { } func Test_binaryExecutor_EnvironmentMerging(t *testing.T) { - tmpDir := t.TempDir() - executor := newBinaryExecutor("sh", tmpDir) + // Test that default environment is merged with provided environment + defaultEnv := map[string]string{ + "DEFAULT_VAR": "default_value", + "OVERRIDE_ME": "default_override", + } + + executor := newBinaryExecutor("sh", defaultEnv) // Create a command that outputs all environment variables containing our test vars providedEnv := map[string]string{ - "PROVIDED_VAR": "provided_value", - "HELM_DATA_HOME": "overridden_value", // This should override the default + "PROVIDED_VAR": "provided_value", + "OVERRIDE_ME": "overridden_value", // This should override the default } // Use a shell command to check if our environment variables are set @@ -166,20 +171,19 @@ func Test_binaryExecutor_EnvironmentMerging(t *testing.T) { t.Context(), providedEnv, nil, - "-c", "echo HELM_CACHE_HOME=$HELM_CACHE_HOME HELM_CONFIG_HOME=$HELM_CONFIG_HOME HELM_DATA_HOME=$HELM_DATA_HOME PROVIDED_VAR=$PROVIDED_VAR", + "-c", "echo DEFAULT_VAR=$DEFAULT_VAR PROVIDED_VAR=$PROVIDED_VAR OVERRIDE_ME=$OVERRIDE_ME", ) require.NoError(t, err) // Verify that: // 1. Default env var is present - assert.Contains(t, stdout, "HELM_CACHE_HOME="+tmpDir) - assert.Contains(t, stdout, "HELM_CONFIG_HOME="+tmpDir) + assert.Contains(t, stdout, "DEFAULT_VAR=default_value") // 2. Provided env var is present assert.Contains(t, stdout, "PROVIDED_VAR=provided_value") // 3. Provided env var overrides default - assert.Contains(t, stdout, "HELM_DATA_HOME=overridden_value") - assert.NotContains(t, stdout, "HELM_DATA_HOME="+tmpDir) + assert.Contains(t, stdout, "OVERRIDE_ME=overridden_value") + assert.NotContains(t, stdout, "OVERRIDE_ME=default_override") } func Test_MockBinaryExecutor_ExecuteCommand(t *testing.T) { diff --git a/pkg/helm/client.go b/pkg/helm/client.go index a4a8b82966..8bd13e0659 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -1,12 +1,9 @@ package helm import ( - "bytes" "context" "encoding/json" - "errors" "fmt" - "io" "os" "path/filepath" "strings" @@ -15,50 +12,14 @@ import ( "github.com/Masterminds/semver/v3" "github.com/sirupsen/logrus" "github.com/spf13/pflag" - "go.yaml.in/yaml/v3" - "helm.sh/helm/v3/pkg/action" + "gopkg.in/yaml.v3" "helm.sh/helm/v3/pkg/chart" - "helm.sh/helm/v3/pkg/chart/loader" - "helm.sh/helm/v3/pkg/chartutil" helmcli "helm.sh/helm/v3/pkg/cli" - "helm.sh/helm/v3/pkg/downloader" - "helm.sh/helm/v3/pkg/getter" - "helm.sh/helm/v3/pkg/pusher" - "helm.sh/helm/v3/pkg/registry" "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/repo" - "helm.sh/helm/v3/pkg/storage/driver" - "helm.sh/helm/v3/pkg/uploader" - "k8s.io/cli-runtime/pkg/genericclioptions" - restclient "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" - clientcmdapi "k8s.io/client-go/tools/clientcmd/api" k8syaml "sigs.k8s.io/yaml" ) -var ( - // getters is a list of known getters for both http and - // oci schemes. - getters = getter.Providers{ - getter.Provider{ - Schemes: []string{"http", "https"}, - New: getter.NewHTTPGetter, - }, - getter.Provider{ - Schemes: []string{"oci"}, - New: getter.NewOCIGetter, - }, - } - - // pushers holds all supported pushers (uploaders). - pushers = pusher.Providers{ - pusher.Provider{ - Schemes: []string{"oci"}, - New: pusher.NewOCIPusher, - }, - } -) - var _ Client = (*HelmClient)(nil) func newClient(opts HelmOptions) (*HelmClient, error) { @@ -76,22 +37,19 @@ func newClient(opts HelmOptions) (*HelmClient, error) { kversion = sv } - registryOpts := []registry.ClientOption{} - if opts.Writer != nil { - registryOpts = append(registryOpts, registry.ClientOptWriter(opts.Writer)) - } - regcli, err := registry.NewClient(registryOpts...) - if err != nil { - return nil, fmt.Errorf("create registry client: %w", err) + // Configure helm environment variables for tmpdir isolation + helmEnv := map[string]string{ + "HELM_CACHE_HOME": filepath.Join(tmpdir, ".cache"), + "HELM_CONFIG_HOME": filepath.Join(tmpdir, ".config"), + "HELM_DATA_HOME": filepath.Join(tmpdir, ".local"), } return &HelmClient{ helmPath: opts.HelmPath, - executor: newBinaryExecutor(opts.HelmPath, tmpdir), + executor: newBinaryExecutor(opts.HelmPath, helmEnv), tmpdir: tmpdir, kversion: kversion, kubernetesEnvSettings: opts.KubernetesEnvSettings, - regcli: regcli, airgapPath: opts.AirgapPath, repositories: []*repo.Entry{}, }, nil @@ -102,7 +60,6 @@ type HelmOptions struct { KubernetesEnvSettings *helmcli.EnvSettings K8sVersion string AirgapPath string - Writer io.Writer } type LogFn func(format string, args ...interface{}) @@ -153,45 +110,19 @@ type HelmClient struct { tmpdir string // Temporary directory for helm kversion *semver.Version // Kubernetes version for template rendering kubernetesEnvSettings *helmcli.EnvSettings // Kubernetes environment settings - regcli *registry.Client - repocfg string - repos []*repo.Entry - reposChanged bool - airgapPath string // Airgap path where charts are stored - repositories []*repo.Entry // Repository entries for helm repo commands + airgapPath string // Airgap path where charts are stored + repositories []*repo.Entry // Repository entries for helm repo commands } -func (h *HelmClient) prepare(_ context.Context) error { - // NOTE: this is a hack and should be refactored - if !h.reposChanged { - return nil - } - - data, err := k8syaml.Marshal(repo.File{Repositories: h.repos}) - if err != nil { - return fmt.Errorf("marshal repositories: %w", err) - } - - repocfg := filepath.Join(h.tmpdir, "config.yaml") - if err := os.WriteFile(repocfg, data, 0644); err != nil { - return fmt.Errorf("write repositories: %w", err) - } - - for _, repository := range h.repos { - chrepo, err := repo.NewChartRepository( - repository, getters, - ) - if err != nil { - return fmt.Errorf("create chart repo: %w", err) - } - chrepo.CachePath = h.tmpdir - _, err = chrepo.DownloadIndexFile() +func (h *HelmClient) prepare(ctx context.Context) error { + // Update all repositories to ensure we have the latest chart information + for _, repo := range h.repositories { + args := []string{"repo", "update", repo.Name} + _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return fmt.Errorf("download index file: %w", err) + return fmt.Errorf("helm repo update %s: %w", repo.Name, err) } } - h.repocfg = repocfg - h.reposChanged = false return nil } @@ -199,15 +130,7 @@ func (h *HelmClient) Close() error { return os.RemoveAll(h.tmpdir) } -func (h *HelmClient) AddRepo(_ context.Context, repo *repo.Entry) error { - h.repos = append(h.repos, repo) - h.reposChanged = true - return nil -} - -// AddRepoBin adds a repository to the helm client using the helm binary. This is necessary because -// the AddRepo method does not work with other methods using the binary executor. -func (h *HelmClient) AddRepoBin(ctx context.Context, repo *repo.Entry) error { +func (h *HelmClient) AddRepo(ctx context.Context, repo *repo.Entry) error { // Use helm repo add command to add the repository args := []string{"repo", "add", repo.Name, repo.URL} @@ -288,40 +211,62 @@ func (h *HelmClient) Pull(ctx context.Context, reponame, chart string, version s } func (h *HelmClient) PullByRef(ctx context.Context, ref string, version string) (string, error) { + // Update repositories if this is not an OCI chart if !isOCIChart(ref) { if err := h.prepare(ctx); err != nil { return "", fmt.Errorf("prepare: %w", err) } } - dl := downloader.ChartDownloader{ - Out: io.Discard, - Options: []getter.Option{}, - RepositoryConfig: h.repocfg, - RepositoryCache: h.tmpdir, - Getters: getters, + // Use helm pull to download the chart + args := []string{"pull", ref} + if version != "" { + args = append(args, "--version", version) } + args = append(args, "--destination", h.tmpdir) + + // Add debug flag to report progress and capture debug logs + args = append(args, "--debug") - dst, _, err := dl.DownloadTo(ref, version, os.TempDir()) + _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return "", fmt.Errorf("download chart %s: %w", ref, err) + return "", fmt.Errorf("helm pull: %w", err) } - return dst, nil + // Get chart metadata to determine the actual chart name and construct filename + metadata, err := h.GetChartMetadata(ctx, ref, version) + if err != nil { + return "", fmt.Errorf("get chart metadata: %w", err) + } + + // Construct expected filename (chart name + version + .tgz) + chartPath := filepath.Join(h.tmpdir, fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)) + + return chartPath, nil } -func (h *HelmClient) RegistryAuth(_ context.Context, server, user, pass string) error { - return h.regcli.Login(server, registry.LoginOptBasicAuth(user, pass)) +func (h *HelmClient) RegistryAuth(ctx context.Context, server, user, pass string) error { + // Use helm registry login for authentication + args := []string{"registry", "login", server, "--username", user, "--password", pass} + + _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) + if err != nil { + return fmt.Errorf("helm registry login: %w", err) + } + + return nil } -func (h *HelmClient) Push(_ context.Context, path, dst string) error { - up := uploader.ChartUploader{ - Out: os.Stdout, - Pushers: pushers, - Options: []pusher.Option{pusher.WithRegistryClient(h.regcli)}, +func (h *HelmClient) Push(ctx context.Context, path, dst string) error { + // Use helm push to upload the chart + args := []string{"push", path, dst} + + _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) + if err != nil { + return fmt.Errorf("helm push: %w", err) } - return up.UploadTo(path, dst) + return nil } func (h *HelmClient) GetChartMetadata(ctx context.Context, ref string, version string) (*chart.Metadata, error) { @@ -343,310 +288,419 @@ func (h *HelmClient) GetChartMetadata(ctx context.Context, ref string, version s return &metadata, nil } -// reference: https://github.com/helm/helm/blob/0d66425d9a745d8a289b1a5ebb6ccc744436da95/cmd/helm/upgrade.go#L122-L125 -func (h *HelmClient) ReleaseExists(ctx context.Context, namespace string, releaseName string) (bool, error) { - cfg, err := h.getActionCfg(namespace, nil) - if err != nil { - return false, fmt.Errorf("get action configuration: %w", err) - } +// ReleaseHistoryEntry represents a single entry in helm release history +type ReleaseHistoryEntry struct { + Revision int `json:"revision"` + Status release.Status `json:"status"` +} - client := action.NewHistory(cfg) - client.Max = 1 +// ReleaseHistory returns the release history for a given release +func (h *HelmClient) ReleaseHistory(ctx context.Context, namespace string, releaseName string, maxRevisions int) ([]ReleaseHistoryEntry, error) { + args := []string{"history", releaseName, "--namespace", namespace, "--output", "json"} - versions, err := client.Run(releaseName) - if errors.Is(err, driver.ErrReleaseNotFound) || isReleaseUninstalled(versions) { - return false, nil + if maxRevisions > 0 { + args = append(args, "--max", fmt.Sprintf("%d", maxRevisions)) } + + // Add kubeconfig and context if available + args = h.addKubernetesEnvArgs(args) + + stdout, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return false, fmt.Errorf("get release history: %w", err) + return nil, fmt.Errorf("helm history: %w", err) } - return true, nil -} + var history []ReleaseHistoryEntry + if err := json.Unmarshal([]byte(stdout), &history); err != nil { + return nil, fmt.Errorf("parse release history json: %w", err) + } -func isReleaseUninstalled(versions []*release.Release) bool { - return len(versions) > 0 && versions[len(versions)-1].Info.Status == release.StatusUninstalled + return history, nil } -func (h *HelmClient) Install(ctx context.Context, opts InstallOptions) (*release.Release, error) { - cfg, err := h.getActionCfg(opts.Namespace, opts.LogFn) +// GetLastRevision returns the revision number of the latest release entry +func (h *HelmClient) GetLastRevision(ctx context.Context, namespace string, releaseName string) (int, error) { + history, err := h.ReleaseHistory(ctx, namespace, releaseName, 1) if err != nil { - return nil, fmt.Errorf("get action configuration: %w", err) + return 0, fmt.Errorf("get release history: %w", err) } - client := action.NewInstall(cfg) - client.ReleaseName = opts.ReleaseName - client.Namespace = opts.Namespace - client.Labels = opts.Labels - client.Replace = true - client.CreateNamespace = true - client.WaitForJobs = true - client.Wait = true - // we don't set client.Atomic = true on install as it makes installation failures difficult to - // debug since it will rollback the release. - - if opts.Timeout != 0 { - client.Timeout = opts.Timeout - } else { - client.Timeout = 5 * time.Minute + if len(history) == 0 { + return 0, fmt.Errorf("no release history found for %s", releaseName) } - chartRequested, err := h.loadChart(ctx, opts.ReleaseName, opts.ChartPath, opts.ChartVersion) + return history[0].Revision, nil +} + +func (h *HelmClient) ReleaseExists(ctx context.Context, namespace string, releaseName string) (bool, error) { + history, err := h.ReleaseHistory(ctx, namespace, releaseName, 1) if err != nil { - return nil, fmt.Errorf("load chart: %w", err) + if strings.Contains(err.Error(), "release: not found") { + return false, nil + } + return false, fmt.Errorf("get release history: %w", err) } - if req := chartRequested.Metadata.Dependencies; req != nil { - if err := action.CheckDependencies(chartRequested, req); err != nil { - return nil, fmt.Errorf("check chart dependencies: %w", err) - } + // True if release has history and is not uninstalled + exists := len(history) > 0 && history[0].Status != release.StatusUninstalled + + return exists, nil +} + +// createValuesFile creates a temporary values file from the provided values map +func (h *HelmClient) createValuesFile(values map[string]interface{}) (string, error) { + if h.tmpdir == "" { + return "", fmt.Errorf("tmpdir not initialized") } - cleanVals, err := cleanUpGenericMap(opts.Values) + cleanVals, err := cleanUpGenericMap(values) if err != nil { - return nil, fmt.Errorf("clean up generic map: %w", err) + return "", fmt.Errorf("clean up generic map: %w", err) } - release, err := client.RunWithContext(ctx, chartRequested, cleanVals) + data, err := k8syaml.Marshal(cleanVals) if err != nil { - return nil, fmt.Errorf("helm install: %w", err) + return "", fmt.Errorf("marshal values: %w", err) + } + + // Use unique filename to prevent race conditions + valuesFile := filepath.Join(h.tmpdir, fmt.Sprintf("values-%d.yaml", time.Now().UnixNano())) + if err := os.WriteFile(valuesFile, data, 0644); err != nil { + return "", fmt.Errorf("write values file: %w", err) } - return release, nil + return valuesFile, nil } -func (h *HelmClient) Upgrade(ctx context.Context, opts UpgradeOptions) (*release.Release, error) { - cfg, err := h.getActionCfg(opts.Namespace, opts.LogFn) +func (h *HelmClient) Install(ctx context.Context, opts InstallOptions) (string, error) { + // Build helm install command arguments + args := []string{"install", opts.ReleaseName} + + // Handle chart source + chartPath, err := h.resolveChartPath(ctx, opts.ReleaseName, opts.ChartPath, opts.ChartVersion) if err != nil { - return nil, fmt.Errorf("get action configuration: %w", err) + return "", fmt.Errorf("resolve chart path: %w", err) } + args = append(args, chartPath) - client := action.NewUpgrade(cfg) - client.Namespace = opts.Namespace - client.Labels = opts.Labels - client.WaitForJobs = true - client.Wait = true - client.Atomic = true - client.Force = opts.Force - - if opts.Timeout != 0 { - client.Timeout = opts.Timeout - } else { - client.Timeout = 5 * time.Minute + // Add namespace + if opts.Namespace != "" { + args = append(args, "--namespace", opts.Namespace) + args = append(args, "--create-namespace") } - chartRequested, err := h.loadChart(ctx, opts.ReleaseName, opts.ChartPath, opts.ChartVersion) - if err != nil { - return nil, fmt.Errorf("load chart: %w", err) + // Add wait options + args = append(args, "--wait") + args = append(args, "--wait-for-jobs") + + // Add timeout + timeout := opts.Timeout + if timeout == 0 { + timeout = 5 * time.Minute } + args = append(args, "--timeout", timeout.String()) + + // Add replace flag + args = append(args, "--replace") - if req := chartRequested.Metadata.Dependencies; req != nil { - if err := action.CheckDependencies(chartRequested, req); err != nil { - return nil, fmt.Errorf("check chart dependencies: %w", err) + // Add values if provided + if opts.Values != nil { + valuesFile, err := h.createValuesFile(opts.Values) + if err != nil { + return "", fmt.Errorf("create values file: %w", err) } + defer os.Remove(valuesFile) + args = append(args, "--values", valuesFile) } - cleanVals, err := cleanUpGenericMap(opts.Values) - if err != nil { - return nil, fmt.Errorf("clean up generic map: %w", err) + // Add labels if provided + if opts.Labels != nil { + var labelPairs []string + for k, v := range opts.Labels { + labelPairs = append(labelPairs, fmt.Sprintf("%s=%s", k, v)) + } + args = append(args, "--labels", strings.Join(labelPairs, ",")) } - release, err := client.RunWithContext(ctx, opts.ReleaseName, chartRequested, cleanVals) + // Add kubeconfig and context if available + args = h.addKubernetesEnvArgs(args) + + // Add debug flag to report progress and capture debug logs + args = append(args, "--debug") + + // NOTE: we don't set client.Atomic = true on install as it makes installation failures difficult to debug + // since it will rollback the release. + + // Execute helm install command + stdout, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - return nil, fmt.Errorf("helm upgrade: %w", err) + return "", fmt.Errorf("execute: %w", err) } - return release, nil + return stdout, nil } -func (h *HelmClient) Uninstall(ctx context.Context, opts UninstallOptions) error { - cfg, err := h.getActionCfg(opts.Namespace, opts.LogFn) - if err != nil { - return fmt.Errorf("get action configuration: %w", err) +// resolveChartPath handles chart source resolution for install, upgrade, and render operations +func (h *HelmClient) resolveChartPath(ctx context.Context, releaseName, chartPath, chartVersion string) (string, error) { + if h.airgapPath != "" { + // Use chart from airgap path + return filepath.Join(h.airgapPath, fmt.Sprintf("%s-%s.tgz", releaseName, chartVersion)), nil } + if !strings.HasPrefix(chartPath, "/") { + // Pull chart with retries (includes oci:// prefix) + localPath, err := h.PullByRefWithRetries(ctx, chartPath, chartVersion, 3) + if err != nil { + return "", fmt.Errorf("pull chart: %w", err) + } + if localPath == "" { + return "", fmt.Errorf("pulled chart path is empty") + } + return localPath, nil + } + // Use local chart path + return chartPath, nil +} - client := action.NewUninstall(cfg) - client.Wait = opts.Wait - client.IgnoreNotFound = opts.IgnoreNotFound +func (h *HelmClient) Upgrade(ctx context.Context, opts UpgradeOptions) (string, error) { + // Build helm upgrade command arguments + args := []string{"upgrade", opts.ReleaseName} - if deadline, ok := ctx.Deadline(); ok { - client.Timeout = time.Until(deadline) + // Handle chart source + chartPath, err := h.resolveChartPath(ctx, opts.ReleaseName, opts.ChartPath, opts.ChartVersion) + if err != nil { + return "", fmt.Errorf("resolve chart path: %w", err) } + args = append(args, chartPath) - if _, err := client.Run(opts.ReleaseName); err != nil { - return fmt.Errorf("uninstall release: %w", err) + // Add namespace + if opts.Namespace != "" { + args = append(args, "--namespace", opts.Namespace) } - return nil -} + // Add wait options + args = append(args, "--wait") + args = append(args, "--wait-for-jobs") -func (h *HelmClient) Render(ctx context.Context, opts InstallOptions) ([][]byte, error) { - cfg := &action.Configuration{} - - client := action.NewInstall(cfg) - client.DryRun = true - client.ReleaseName = opts.ReleaseName - client.Replace = true - client.CreateNamespace = true - client.ClientOnly = true - client.IncludeCRDs = true - client.Namespace = opts.Namespace - client.Labels = opts.Labels + // Add timeout + timeout := opts.Timeout + if timeout == 0 { + timeout = 5 * time.Minute + } + args = append(args, "--timeout", timeout.String()) - if h.kversion != nil { - // since ClientOnly is true we need to initialize KubeVersion otherwise resorts defaults - client.KubeVersion = &chartutil.KubeVersion{ - Version: fmt.Sprintf("v%d.%d.0", h.kversion.Major(), h.kversion.Minor()), - Major: fmt.Sprintf("%d", h.kversion.Major()), - Minor: fmt.Sprintf("%d", h.kversion.Minor()), - } + // Add atomic flag + args = append(args, "--atomic") + + // Add force flag if specified + if opts.Force { + args = append(args, "--force") } - chartRequested, err := h.loadChart(ctx, opts.ReleaseName, opts.ChartPath, opts.ChartVersion) - if err != nil { - return nil, fmt.Errorf("load chart: %w", err) + // Add values if provided + if opts.Values != nil { + valuesFile, err := h.createValuesFile(opts.Values) + if err != nil { + return "", fmt.Errorf("create values file: %w", err) + } + defer os.Remove(valuesFile) + args = append(args, "--values", valuesFile) } - if req := chartRequested.Metadata.Dependencies; req != nil { - if err := action.CheckDependencies(chartRequested, req); err != nil { - return nil, fmt.Errorf("failed dependency check: %w", err) + // Add labels if provided + if opts.Labels != nil { + var labelPairs []string + for k, v := range opts.Labels { + labelPairs = append(labelPairs, fmt.Sprintf("%s=%s", k, v)) } + args = append(args, "--labels", strings.Join(labelPairs, ",")) } - cleanVals, err := cleanUpGenericMap(opts.Values) + // Add kubernetes environment arguments + args = h.addKubernetesEnvArgs(args) + + // Add debug flag to report progress and capture debug logs + args = append(args, "--debug") + + // Execute helm upgrade command + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - return nil, fmt.Errorf("clean up generic map: %w", err) + if shouldRollback(err.Error()) || shouldRollback(stderr) { + // Get the last revision + lastRevision, err := h.GetLastRevision(ctx, opts.Namespace, opts.ReleaseName) + if err != nil { + return "", fmt.Errorf("get last revision: %w", err) + } + + // Rollback to the latest revision + if _, err := h.Rollback(ctx, RollbackOptions{ + ReleaseName: opts.ReleaseName, + Namespace: opts.Namespace, + Revision: lastRevision, + Timeout: opts.Timeout, + Force: opts.Force, + LogFn: opts.LogFn, + }); err != nil { + return "", fmt.Errorf("rollback: %w", err) + } + + // Retry upgrade after successful rollback + return h.Upgrade(ctx, opts) + } + + return "", fmt.Errorf("helm upgrade failed: %w", err) } - release, err := client.Run(chartRequested, cleanVals) - if err != nil { - return nil, fmt.Errorf("run render: %w", err) + return stdout, nil +} + +func shouldRollback(err string) bool { + return strings.Contains(err, "another operation") && strings.Contains(err, "in progress") +} + +func (h *HelmClient) Rollback(ctx context.Context, opts RollbackOptions) (string, error) { + args := []string{"rollback", opts.ReleaseName} + + // If specific revision is provided, use it + if opts.Revision > 0 { + args = append(args, fmt.Sprintf("%d", opts.Revision)) } - var manifests bytes.Buffer - fmt.Fprintln(&manifests, strings.TrimSpace(release.Manifest)) - for _, m := range release.Hooks { - fmt.Fprintf(&manifests, "---\n# Source: %s\n%s\n", m.Path, m.Manifest) + // Add namespace + if opts.Namespace != "" { + args = append(args, "--namespace", opts.Namespace) } - splitManifests, err := splitManifests(manifests.String()) - if err != nil { - return nil, fmt.Errorf("split manifests: %w", err) + // Add wait options + args = append(args, "--wait") + args = append(args, "--wait-for-jobs") + + // Add timeout + timeout := opts.Timeout + if timeout == 0 { + timeout = 5 * time.Minute } - return splitManifests, nil -} + args = append(args, "--timeout", timeout.String()) -func (h *HelmClient) getActionCfg(namespace string, logFn LogFn) (*action.Configuration, error) { - cfg := &action.Configuration{} - if logFn == nil { - logFn = _logFn + // Add force flag if specified + if opts.Force { + args = append(args, "--force") } - var restClientGetter genericclioptions.RESTClientGetter - if h.kubernetesEnvSettings != nil { - restClientGetter = h.kubernetesEnvSettings.RESTClientGetter() - } else { - restClientGetter = helmcli.New().RESTClientGetter() // use the default env settings from helm + + // Add kubernetes environment arguments + args = h.addKubernetesEnvArgs(args) + + // Add debug flag to report progress and capture debug logs + args = append(args, "--debug") + + stdout, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) + if err != nil { + return "", fmt.Errorf("execute: %w", err) } - restClientGetter = &namespacedRESTClientGetter{ - RESTClientGetter: restClientGetter, - namespace: namespace, + + return stdout, nil +} + +func (h *HelmClient) Uninstall(ctx context.Context, opts UninstallOptions) error { + // Build helm uninstall command arguments + args := []string{"uninstall", opts.ReleaseName} + + // Add namespace + if opts.Namespace != "" { + args = append(args, "--namespace", opts.Namespace) } - if err := cfg.Init(restClientGetter, namespace, "secret", action.DebugLog(logFn)); err != nil { - return nil, fmt.Errorf("init helm configuration: %w", err) + + // Add wait flag + if opts.Wait { + args = append(args, "--wait") } - return cfg, nil -} -func (h *HelmClient) loadChart(ctx context.Context, releaseName, chartPath, chartVersion string) (*chart.Chart, error) { - var localPath string - if _, err := os.Stat(chartPath); err == nil { - localPath = chartPath - } else if h.airgapPath != "" { - // airgapped, use chart from airgap path - // TODO: this should just respect the chart path if it's a local path and leave it up to the caller to handle - localPath = filepath.Join(h.airgapPath, fmt.Sprintf("%s-%s.tgz", releaseName, chartVersion)) - } else if !strings.HasPrefix(chartPath, "/") { - // Assume this is a chart from a repo if it doesn't start with a / - // This includes oci:// prefix - var err error - localPath, err = h.PullByRefWithRetries(ctx, chartPath, chartVersion, 3) - if err != nil { - return nil, fmt.Errorf("pull: %w", err) - } - defer os.RemoveAll(localPath) + // Add ignore not found flag + if opts.IgnoreNotFound { + args = append(args, "--ignore-not-found") } - if localPath == "" { - return nil, fmt.Errorf("chart path not found: %s", chartPath) + // Add kubeconfig and context if available + args = h.addKubernetesEnvArgs(args) + + // Add debug flag to report progress and capture debug logs + args = append(args, "--debug") + + // Add timeout from context if available + if deadline, ok := ctx.Deadline(); ok { + timeout := time.Until(deadline) + args = append(args, "--timeout", timeout.String()) } - chartRequested, err := loader.Load(localPath) + // Execute helm uninstall command + _, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - return nil, fmt.Errorf("load: %w", err) + return fmt.Errorf("execute: %w", err) } - return chartRequested, nil + return nil } -func cleanUpGenericMap(m map[string]interface{}) (map[string]interface{}, error) { - // we must first use yaml marshal to convert the map[interface{}]interface{} to a []byte - // otherwise we will get an error "unsupported type: map[interface {}]interface {}" - b, err := yaml.Marshal(m) +func (h *HelmClient) Render(ctx context.Context, opts InstallOptions) ([][]byte, error) { + // Build helm template command arguments + args := []string{"template", opts.ReleaseName} + + // Handle chart source + chartPath, err := h.resolveChartPath(ctx, opts.ReleaseName, opts.ChartPath, opts.ChartVersion) if err != nil { - return nil, fmt.Errorf("yaml marshal: %w", err) + return nil, fmt.Errorf("resolve chart path: %w", err) } - next := map[string]interface{}{} - err = k8syaml.Unmarshal(b, &next) - if err != nil { - return nil, fmt.Errorf("yaml unmarshal: %w", err) - } - return next, nil -} + args = append(args, chartPath) -func isOCIChart(chartPath string) bool { - return strings.HasPrefix(chartPath, "oci://") -} + // Add namespace + if opts.Namespace != "" { + args = append(args, "--namespace", opts.Namespace) + args = append(args, "--create-namespace") + } -func _logFn(format string, args ...interface{}) { - log := logrus.WithField("component", "helm") - log.Debugf(format, args...) -} + // Add labels if provided + if opts.Labels != nil { + var labelPairs []string + for k, v := range opts.Labels { + labelPairs = append(labelPairs, fmt.Sprintf("%s=%s", k, v)) + } + args = append(args, "--labels", strings.Join(labelPairs, ",")) + } -type namespacedRESTClientGetter struct { - genericclioptions.RESTClientGetter - namespace string -} + // Add values if provided + if opts.Values != nil { + valuesFile, err := h.createValuesFile(opts.Values) + if err != nil { + return nil, fmt.Errorf("create values file: %w", err) + } + defer os.Remove(valuesFile) + args = append(args, "--values", valuesFile) + } -func (n *namespacedRESTClientGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig { - cfg := n.RESTClientGetter.ToRawKubeConfigLoader() - return &namespacedClientConfig{ - cfg: cfg, - namespace: n.namespace, + // Add kubernetes version if available + if h.kversion != nil { + args = append(args, "--kube-version", fmt.Sprintf("v%d.%d.0", h.kversion.Major(), h.kversion.Minor())) } -} -type namespacedClientConfig struct { - cfg clientcmd.ClientConfig - namespace string -} + // Add kubeconfig and context if available + args = h.addKubernetesEnvArgs(args) -func (n *namespacedClientConfig) RawConfig() (clientcmdapi.Config, error) { - return n.cfg.RawConfig() -} + // Add include CRDs flag + args = append(args, "--include-crds") -func (n *namespacedClientConfig) ClientConfig() (*restclient.Config, error) { - return n.cfg.ClientConfig() -} + // Add debug flag to report progress and capture debug logs + args = append(args, "--debug") -func (n *namespacedClientConfig) Namespace() (string, bool, error) { - if n.namespace == "" { - return n.cfg.Namespace() + // Execute helm template command + stdout, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) + if err != nil { + return nil, fmt.Errorf("execute: %w", err) } - return n.namespace, true, nil -} -func (n *namespacedClientConfig) ConfigAccess() clientcmd.ConfigAccess { - return n.cfg.ConfigAccess() + manifests, err := splitManifests(stdout) + if err != nil { + return nil, fmt.Errorf("parse helm template output: %w", err) + } + return manifests, nil } // addKubernetesEnvArgs adds kubernetes environment arguments to the helm command @@ -716,3 +770,22 @@ func AddKubernetesCLIFlags(flagSet *pflag.FlagSet, kubernetesEnvSettings *helmcl flagSet.IntVar(&kubernetesEnvSettings.BurstLimit, "burst-limit", kubernetesEnvSettings.BurstLimit, "Kubernetes API client-side default throttling limit") flagSet.Float32Var(&kubernetesEnvSettings.QPS, "qps", kubernetesEnvSettings.QPS, "Queries per second used when communicating with the Kubernetes API, not including bursting") } + +func cleanUpGenericMap(m map[string]interface{}) (map[string]interface{}, error) { + // we must first use yaml marshal to convert the map[interface{}]interface{} to a []byte + // otherwise we will get an error "unsupported type: map[interface {}]interface {}" + b, err := yaml.Marshal(m) + if err != nil { + return nil, fmt.Errorf("yaml marshal: %w", err) + } + next := map[string]interface{}{} + err = k8syaml.Unmarshal(b, &next) + if err != nil { + return nil, fmt.Errorf("yaml unmarshal: %w", err) + } + return next, nil +} + +func isOCIChart(chartPath string) bool { + return strings.HasPrefix(chartPath, "oci://") +} diff --git a/pkg/helm/client_test.go b/pkg/helm/client_test.go index e30266d717..7b87e2c2ff 100644 --- a/pkg/helm/client_test.go +++ b/pkg/helm/client_test.go @@ -1,78 +1,254 @@ package helm import ( + "fmt" + "os" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + helmcli "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/repo" k8syaml "sigs.k8s.io/yaml" ) -func TestHelmClient_Latest(t *testing.T) { +func TestHelmClient_PullByRef(t *testing.T) { tests := []struct { - name string - reponame string - chart string - setupMock func(*MockBinaryExecutor) - want string - wantErr bool + name string + ref string + version string + repositories []*repo.Entry + setupMock func(*MockBinaryExecutor) + want string + wantErr bool }{ { - name: "valid JSON response", - reponame: "myrepo", - chart: "mychart", + name: "successful pull with repository preparation", + ref: "myrepo/mychart", + version: "1.2.3", + repositories: []*repo.Entry{ + { + Name: "myrepo", + URL: "https://charts.example.com/myrepo", + }, + }, setupMock: func(m *MockBinaryExecutor) { - jsonOutput := `[ - { - "name": "myrepo/mychart", - "version": "1.2.3", - "app_version": "1.2.3", - "description": "A test chart" - } - ]` - m.On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, - []string{"search", "repo", "myrepo/mychart", "--version", ">0.0.0", "--versions", "--output", "json"}). - Return(jsonOutput, "", nil) + // Mock helm repo update command (called by prepare()) + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"repo", "update", "myrepo"}, + ).Return("", "", nil) + + // Mock helm pull command + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + return len(args) == 7 && + args[0] == "pull" && + args[1] == "myrepo/mychart" && + args[2] == "--version" && + args[3] == "1.2.3" && + args[4] == "--destination" && + // args[5] is the temp directory path, which varies + args[6] == "--debug" + }), + ).Return("", "", nil) + + // Mock helm show chart command for metadata + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"show", "chart", "myrepo/mychart", "--version", "1.2.3"}, + ).Return(`apiVersion: v2 +name: mychart +description: A test chart from repo +type: application +version: 1.2.3 +appVersion: "1.0.0"`, "", nil) }, - want: "1.2.3", + want: "mychart-1.2.3.tgz", wantErr: false, }, { - name: "empty results", - reponame: "myrepo", - chart: "nonexistent", + name: "successful pull from OCI registry", + ref: "oci://registry.example.com/charts/nginx", + version: "2.1.0", + repositories: nil, // OCI charts don't use repositories setupMock: func(m *MockBinaryExecutor) { - m.On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, - []string{"search", "repo", "myrepo/nonexistent", "--version", ">0.0.0", "--versions", "--output", "json"}). - Return("[]", "", nil) + // No helm repo update for OCI charts (prepare() is skipped) + + // Mock helm pull command for OCI + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + return len(args) == 7 && + args[0] == "pull" && + args[1] == "oci://registry.example.com/charts/nginx" && + args[2] == "--version" && + args[3] == "2.1.0" && + args[4] == "--destination" && + // args[5] is the temp directory path, which varies + args[6] == "--debug" + }), + ).Return("", "", nil) + + // Mock helm show chart command for metadata + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"show", "chart", "oci://registry.example.com/charts/nginx", "--version", "2.1.0"}, + ).Return(`apiVersion: v2 +name: nginx +description: A nginx chart from OCI registry +type: application +version: 2.1.0 +appVersion: "1.25.0"`, "", nil) }, - want: "", - wantErr: true, + want: "nginx-2.1.0.tgz", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + // Create temporary directory for the test + tmpdir := t.TempDir() + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + tmpdir: tmpdir, + repositories: tt.repositories, + } + + got, err := client.PullByRef(t.Context(), tt.ref, tt.version) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + // Check that the returned path ends with the expected filename + assert.True(t, strings.HasSuffix(got, tt.want)) + mockExec.AssertExpectations(t) + }) + } +} + +func TestHelmClient_Install(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + opts InstallOptions + wantErr bool + }{ + { + name: "successful install", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"install", "myrelease", "/path/to/chart", "--namespace", "default", "--create-namespace", "--wait", "--wait-for-jobs", "--timeout", "5m0s", "--replace", "--debug"}, + ).Return(`Release "myrelease" has been upgraded.`, "", nil) + }, + kubernetesEnvSettings: nil, // No kubeconfig settings + opts: InstallOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Timeout: 5 * time.Minute, + }, + wantErr: false, }, { - name: "helm command fails", - reponame: "myrepo", - chart: "mychart", + name: "install with values", setupMock: func(m *MockBinaryExecutor) { - m.On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, - []string{"search", "repo", "myrepo/mychart", "--version", ">0.0.0", "--versions", "--output", "json"}). - Return("", "repo not found", assert.AnError) + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "install") && + strings.Contains(argsStr, "--values") + }), + ).Return(`Release "myrelease" has been installed.`, "", nil) }, - want: "", - wantErr: true, + kubernetesEnvSettings: nil, // No kubeconfig settings + opts: InstallOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Timeout: 5 * time.Minute, + Values: map[string]interface{}{ + "key": "value", + }, + }, + wantErr: false, }, + { - name: "invalid JSON response", - reponame: "myrepo", - chart: "mychart", + name: "install with kubernetes env settings", setupMock: func(m *MockBinaryExecutor) { - m.On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, - []string{"search", "repo", "myrepo/mychart", "--version", ">0.0.0", "--versions", "--output", "json"}). - Return("invalid json", "", nil) + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "install") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") && + strings.Contains(argsStr, "--kube-context test-context") && + strings.Contains(argsStr, "--kube-token test-token") && + strings.Contains(argsStr, "--kube-as-user test-user") && + strings.Contains(argsStr, "--kube-as-group test-group1") && + strings.Contains(argsStr, "--kube-as-group test-group2") && + strings.Contains(argsStr, "--kube-apiserver https://test-server:6443") && + strings.Contains(argsStr, "--kube-ca-file /tmp/ca.crt") && + strings.Contains(argsStr, "--kube-tls-server-name test-server") && + strings.Contains(argsStr, "--kube-insecure-skip-tls-verify") && + strings.Contains(argsStr, "--burst-limit 100") && + strings.Contains(argsStr, "--qps 50.00") + }), + ).Return(`Release "myrelease" has been installed.`, "", nil) }, - want: "", - wantErr: true, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + KubeContext: "test-context", + KubeToken: "test-token", + KubeAsUser: "test-user", + KubeAsGroups: []string{"test-group1", "test-group2"}, + KubeAPIServer: "https://test-server:6443", + KubeCaFile: "/tmp/ca.crt", + KubeTLSServerName: "test-server", + KubeInsecureSkipTLSVerify: true, + BurstLimit: 100, + QPS: 50.0, + }, + opts: InstallOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Timeout: 5 * time.Minute, + }, + wantErr: false, }, } @@ -81,19 +257,267 @@ func TestHelmClient_Latest(t *testing.T) { mockExec := &MockBinaryExecutor{} tt.setupMock(mockExec) + // Create temporary directory for the test + tmpdir, err := os.MkdirTemp("", "helm-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpdir) + client := &HelmClient{ - helmPath: "/usr/local/bin/helm", - executor: mockExec, + helmPath: "/usr/local/bin/helm", + executor: mockExec, + tmpdir: tmpdir, + kubernetesEnvSettings: tt.kubernetesEnvSettings, } - got, err := client.Latest(t.Context(), tt.reponame, tt.chart) + stdout, err := client.Install(t.Context(), tt.opts) + if tt.wantErr { assert.Error(t, err) return } require.NoError(t, err) - assert.Equal(t, tt.want, got) + assert.NotEmpty(t, stdout) + mockExec.AssertExpectations(t) + }) + } +} + +func TestHelmClient_ReleaseExists(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + namespace string + releaseName string + want bool + wantErr bool + }{ + { + name: "release exists", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") && + strings.Contains(argsStr, "--namespace default") && + strings.Contains(argsStr, "--max 1") && + strings.Contains(argsStr, "--output json") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") + }), + ).Return(`[{ + "revision": 1, + "updated": "2023-01-01T00:00:00Z", + "status": "deployed", + "chart": "test-chart-1.0.0", + "app_version": "1.0.0", + "description": "Install complete" + }]`, "", nil) + }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + }, + namespace: "default", + releaseName: "myrelease", + want: true, + wantErr: false, + }, + { + name: "release does not exist", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"history", "myrelease", "--namespace", "default", "--output", "json", "--max", "1"}, + ).Return(`[]`, "", nil) + }, + kubernetesEnvSettings: nil, // No kubeconfig settings + namespace: "default", + releaseName: "myrelease", + want: false, + wantErr: false, + }, + { + name: "release exists but is uninstalled", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") && + strings.Contains(argsStr, "--namespace default") && + strings.Contains(argsStr, "--max 1") && + strings.Contains(argsStr, "--output json") + }), + ).Return(`[{ + "revision": 2, + "updated": "2023-01-01T01:00:00Z", + "status": "uninstalled", + "chart": "test-chart-1.0.0", + "app_version": "1.0.0", + "description": "Uninstallation complete" + }]`, "", nil) + }, + kubernetesEnvSettings: nil, + namespace: "default", + releaseName: "myrelease", + want: false, + wantErr: false, + }, + { + name: "release exists in pending-install state", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") && + strings.Contains(argsStr, "--namespace default") && + strings.Contains(argsStr, "--max 1") && + strings.Contains(argsStr, "--output json") + }), + ).Return(`[{ + "revision": 1, + "updated": "2023-01-01T00:00:00Z", + "status": "pending-install", + "chart": "test-chart-1.0.0", + "app_version": "1.0.0", + "description": "Install in progress" + }]`, "", nil) + }, + kubernetesEnvSettings: nil, + namespace: "default", + releaseName: "myrelease", + want: true, + wantErr: false, + }, + { + name: "release not found error in err message", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") + }), + ).Return("", "", fmt.Errorf("release: not found")) + }, + kubernetesEnvSettings: nil, + namespace: "default", + releaseName: "myrelease", + want: false, + wantErr: false, + }, + { + name: "other command execution error", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") + }), + ).Return("", "connection refused", fmt.Errorf("exit status 1")) + }, + kubernetesEnvSettings: nil, + namespace: "default", + releaseName: "myrelease", + want: false, + wantErr: true, + }, + { + name: "release exists with kubernetes env settings", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") && + strings.Contains(argsStr, "--namespace default") && + strings.Contains(argsStr, "--max 1") && + strings.Contains(argsStr, "--output json") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") && + strings.Contains(argsStr, "--kube-context test-context") && + strings.Contains(argsStr, "--kube-token test-token") && + strings.Contains(argsStr, "--kube-as-user test-user") && + strings.Contains(argsStr, "--kube-as-group test-group1") && + strings.Contains(argsStr, "--kube-as-group test-group2") && + strings.Contains(argsStr, "--kube-apiserver https://test-server:6443") && + strings.Contains(argsStr, "--kube-ca-file /tmp/ca.crt") && + strings.Contains(argsStr, "--kube-tls-server-name test-server") && + strings.Contains(argsStr, "--kube-insecure-skip-tls-verify") && + strings.Contains(argsStr, "--burst-limit 100") && + strings.Contains(argsStr, "--qps 50.00") + }), + ).Return(`[{ + "revision": 1, + "updated": "2023-01-01T00:00:00Z", + "status": "deployed", + "chart": "test-chart-1.0.0", + "app_version": "1.0.0", + "description": "Install complete" + }]`, "", nil) + }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + KubeContext: "test-context", + KubeToken: "test-token", + KubeAsUser: "test-user", + KubeAsGroups: []string{"test-group1", "test-group2"}, + KubeAPIServer: "https://test-server:6443", + KubeCaFile: "/tmp/ca.crt", + KubeTLSServerName: "test-server", + KubeInsecureSkipTLSVerify: true, + BurstLimit: 100, + QPS: 50.0, + }, + namespace: "default", + releaseName: "myrelease", + want: true, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + kubernetesEnvSettings: tt.kubernetesEnvSettings, + } + + exists, err := client.ReleaseExists(t.Context(), tt.namespace, tt.releaseName) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, exists) mockExec.AssertExpectations(t) }) } @@ -308,3 +732,782 @@ func Test_cleanUpGenericMap(t *testing.T) { }) } } + +func TestHelmClient_Latest(t *testing.T) { + tests := []struct { + name string + reponame string + chart string + setupMock func(*MockBinaryExecutor) + want string + wantErr bool + }{ + { + name: "valid JSON response", + reponame: "myrepo", + chart: "mychart", + setupMock: func(m *MockBinaryExecutor) { + jsonOutput := `[ + { + "name": "myrepo/mychart", + "version": "1.2.3", + "app_version": "1.2.3", + "description": "A test chart" + } + ]` + m.On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, + []string{"search", "repo", "myrepo/mychart", "--version", ">0.0.0", "--versions", "--output", "json"}). + Return(jsonOutput, "", nil) + }, + want: "1.2.3", + wantErr: false, + }, + { + name: "empty results", + reponame: "myrepo", + chart: "nonexistent", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, + []string{"search", "repo", "myrepo/nonexistent", "--version", ">0.0.0", "--versions", "--output", "json"}). + Return("[]", "", nil) + }, + want: "", + wantErr: true, + }, + { + name: "helm command fails", + reponame: "myrepo", + chart: "mychart", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, + []string{"search", "repo", "myrepo/mychart", "--version", ">0.0.0", "--versions", "--output", "json"}). + Return("", "repo not found", assert.AnError) + }, + want: "", + wantErr: true, + }, + { + name: "invalid JSON response", + reponame: "myrepo", + chart: "mychart", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, + []string{"search", "repo", "myrepo/mychart", "--version", ">0.0.0", "--versions", "--output", "json"}). + Return("invalid json", "", nil) + }, + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + } + + got, err := client.Latest(t.Context(), tt.reponame, tt.chart) + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + mockExec.AssertExpectations(t) + }) + } +} + +func TestHelmClient_Upgrade(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + opts UpgradeOptions + wantErr bool + }{ + { + name: "successful upgrade", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"upgrade", "myrelease", "/path/to/chart", "--namespace", "default", "--wait", "--wait-for-jobs", "--timeout", "5m0s", "--atomic", "--debug"}, + ).Return(`Release "myrelease" has been upgraded.`, "", nil) + }, + kubernetesEnvSettings: nil, // No kubeconfig settings + opts: UpgradeOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Timeout: 5 * time.Minute, + }, + wantErr: false, + }, + { + name: "upgrade with values", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "upgrade") && + strings.Contains(argsStr, "--values") + }), + ).Return(`Release "myrelease" has been upgraded.`, "", nil) + }, + kubernetesEnvSettings: nil, // No kubeconfig settings + opts: UpgradeOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Timeout: 5 * time.Minute, + Values: map[string]interface{}{ + "key": "value", + }, + }, + wantErr: false, + }, + { + name: "upgrade with kubernetes env settings", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "upgrade") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") && + strings.Contains(argsStr, "--kube-context test-context") && + strings.Contains(argsStr, "--kube-token test-token") && + strings.Contains(argsStr, "--kube-as-user test-user") && + strings.Contains(argsStr, "--kube-as-group test-group1") && + strings.Contains(argsStr, "--kube-as-group test-group2") && + strings.Contains(argsStr, "--kube-apiserver https://test-server:6443") && + strings.Contains(argsStr, "--kube-ca-file /tmp/ca.crt") && + strings.Contains(argsStr, "--kube-tls-server-name test-server") && + strings.Contains(argsStr, "--kube-insecure-skip-tls-verify") && + strings.Contains(argsStr, "--burst-limit 100") && + strings.Contains(argsStr, "--qps 50.00") + }), + ).Return(`Release "myrelease" has been upgraded.`, "", nil) + }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + KubeContext: "test-context", + KubeToken: "test-token", + KubeAsUser: "test-user", + KubeAsGroups: []string{"test-group1", "test-group2"}, + KubeAPIServer: "https://test-server:6443", + KubeCaFile: "/tmp/ca.crt", + KubeTLSServerName: "test-server", + KubeInsecureSkipTLSVerify: true, + BurstLimit: 100, + QPS: 50.0, + }, + opts: UpgradeOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Timeout: 5 * time.Minute, + }, + wantErr: false, + }, + { + name: "upgrade with rollback recovery on another operation in progress", + setupMock: func(m *MockBinaryExecutor) { + // First upgrade attempt fails with "another operation in progress" + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"upgrade", "myrelease", "/path/to/chart", "--namespace", "default", "--wait", "--wait-for-jobs", "--timeout", "3m0s", "--atomic", "--debug"}, + ).Return("", "Error: another operation (install/upgrade/rollback) is in progress", fmt.Errorf("exit status 1")).Once() + + // GetLastRevision call (via ReleaseHistory) + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"history", "myrelease", "--namespace", "default", "--output", "json", "--max", "1"}, + ).Return(`[{"revision": 2, "status": "deployed"}]`, "", nil).Once() + + // Rollback call + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"rollback", "myrelease", "2", "--namespace", "default", "--wait", "--wait-for-jobs", "--timeout", "3m0s", "--debug"}, + ).Return("Rollback was a success! Happy Helming!", "", nil).Once() + + // Second upgrade attempt succeeds after rollback + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"upgrade", "myrelease", "/path/to/chart", "--namespace", "default", "--wait", "--wait-for-jobs", "--timeout", "3m0s", "--atomic", "--debug"}, + ).Return(`Release "myrelease" has been upgraded.`, "", nil).Once() + }, + kubernetesEnvSettings: nil, + opts: UpgradeOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Timeout: 3 * time.Minute, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + // Create temporary directory for the test + tmpdir, err := os.MkdirTemp("", "helm-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpdir) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + tmpdir: tmpdir, + kubernetesEnvSettings: tt.kubernetesEnvSettings, + } + + _, err = client.Upgrade(t.Context(), tt.opts) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + mockExec.AssertExpectations(t) + }) + } +} + +func TestHelmClient_Uninstall(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + opts UninstallOptions + wantErr bool + }{ + { + name: "successful uninstall", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"uninstall", "myrelease", "--namespace", "default", "--debug"}, + ).Return(`release "myrelease" uninstalled`, "", nil) + }, + kubernetesEnvSettings: nil, // No kubeconfig settings + opts: UninstallOptions{ + ReleaseName: "myrelease", + Namespace: "default", + }, + wantErr: false, + }, + { + name: "uninstall with kubernetes env settings", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "uninstall") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") && + strings.Contains(argsStr, "--kube-context test-context") && + strings.Contains(argsStr, "--kube-token test-token") && + strings.Contains(argsStr, "--kube-as-user test-user") && + strings.Contains(argsStr, "--kube-as-group test-group1") && + strings.Contains(argsStr, "--kube-as-group test-group2") && + strings.Contains(argsStr, "--kube-apiserver https://test-server:6443") && + strings.Contains(argsStr, "--kube-ca-file /tmp/ca.crt") && + strings.Contains(argsStr, "--kube-tls-server-name test-server") && + strings.Contains(argsStr, "--kube-insecure-skip-tls-verify") && + strings.Contains(argsStr, "--burst-limit 100") && + strings.Contains(argsStr, "--qps 50.00") + }), + ).Return(`release "myrelease" uninstalled`, "", nil) + }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + KubeContext: "test-context", + KubeToken: "test-token", + KubeAsUser: "test-user", + KubeAsGroups: []string{"test-group1", "test-group2"}, + KubeAPIServer: "https://test-server:6443", + KubeCaFile: "/tmp/ca.crt", + KubeTLSServerName: "test-server", + KubeInsecureSkipTLSVerify: true, + BurstLimit: 100, + QPS: 50.0, + }, + opts: UninstallOptions{ + ReleaseName: "myrelease", + Namespace: "default", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + kubernetesEnvSettings: tt.kubernetesEnvSettings, + } + + err := client.Uninstall(t.Context(), tt.opts) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + mockExec.AssertExpectations(t) + }) + } +} + +func TestHelmClient_Render(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + opts InstallOptions + wantErr bool + }{ + { + name: "successful render", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"template", "myrelease", "/path/to/chart", "--namespace", "default", "--create-namespace", "--include-crds", "--debug"}, + ).Return(`--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config`, "", nil) + }, + kubernetesEnvSettings: nil, // No kubeconfig settings + opts: InstallOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + }, + wantErr: false, + }, + { + name: "render with values", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "template") && + strings.Contains(argsStr, "--values") + }), + ).Return(`--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config`, "", nil) + }, + kubernetesEnvSettings: nil, // No kubeconfig settings + opts: InstallOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Values: map[string]interface{}{ + "key": "value", + }, + }, + wantErr: false, + }, + { + name: "render with kubernetes env settings", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "template") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") && + strings.Contains(argsStr, "--kube-context test-context") && + strings.Contains(argsStr, "--kube-token test-token") && + strings.Contains(argsStr, "--kube-as-user test-user") && + strings.Contains(argsStr, "--kube-as-group test-group1") && + strings.Contains(argsStr, "--kube-as-group test-group2") && + strings.Contains(argsStr, "--kube-apiserver https://test-server:6443") && + strings.Contains(argsStr, "--kube-ca-file /tmp/ca.crt") && + strings.Contains(argsStr, "--kube-tls-server-name test-server") && + strings.Contains(argsStr, "--kube-insecure-skip-tls-verify") && + strings.Contains(argsStr, "--burst-limit 100") && + strings.Contains(argsStr, "--qps 50.00") + }), + ).Return(`--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config`, "", nil) + }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + KubeContext: "test-context", + KubeToken: "test-token", + KubeAsUser: "test-user", + KubeAsGroups: []string{"test-group1", "test-group2"}, + KubeAPIServer: "https://test-server:6443", + KubeCaFile: "/tmp/ca.crt", + KubeTLSServerName: "test-server", + KubeInsecureSkipTLSVerify: true, + BurstLimit: 100, + QPS: 50.0, + }, + opts: InstallOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + // Create temporary directory for the test + tmpdir, err := os.MkdirTemp("", "helm-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpdir) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + tmpdir: tmpdir, + kubernetesEnvSettings: tt.kubernetesEnvSettings, + } + + _, err = client.Render(t.Context(), tt.opts) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + mockExec.AssertExpectations(t) + }) + } +} +func TestHelmClient_ReleaseHistory(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + namespace string + releaseName string + maxRevisions int + wantErr bool + }{ + { + name: "successful history retrieval", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"history", "myrelease", "--namespace", "default", "--output", "json", "--max", "5"}, + ).Return(`[{"revision": 1, "status": "superseded"}, {"revision": 2, "status": "superseded"}, {"revision": 3, "status": "deployed"}]`, "", nil) + }, + kubernetesEnvSettings: nil, + namespace: "default", + releaseName: "myrelease", + maxRevisions: 5, + wantErr: false, + }, + { + name: "history with kubernetes env settings", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") && + strings.Contains(argsStr, "--namespace default") && + strings.Contains(argsStr, "--output json") && + strings.Contains(argsStr, "--max 3") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") && + strings.Contains(argsStr, "--kube-context test-context") && + strings.Contains(argsStr, "--kube-token test-token") && + strings.Contains(argsStr, "--kube-as-user test-user") && + strings.Contains(argsStr, "--kube-as-group test-group1") && + strings.Contains(argsStr, "--kube-as-group test-group2") && + strings.Contains(argsStr, "--kube-apiserver https://test-server:6443") && + strings.Contains(argsStr, "--kube-ca-file /tmp/ca.crt") && + strings.Contains(argsStr, "--kube-tls-server-name test-server") && + strings.Contains(argsStr, "--kube-insecure-skip-tls-verify") && + strings.Contains(argsStr, "--burst-limit 100") && + strings.Contains(argsStr, "--qps 50.00") + }), + ).Return(`[{"revision": 1, "status": "deployed"}]`, "", nil) + }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + KubeContext: "test-context", + KubeToken: "test-token", + KubeAsUser: "test-user", + KubeAsGroups: []string{"test-group1", "test-group2"}, + KubeAPIServer: "https://test-server:6443", + KubeCaFile: "/tmp/ca.crt", + KubeTLSServerName: "test-server", + KubeInsecureSkipTLSVerify: true, + BurstLimit: 100, + QPS: 50.0, + }, + namespace: "default", + releaseName: "myrelease", + maxRevisions: 3, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + kubernetesEnvSettings: tt.kubernetesEnvSettings, + } + + _, err := client.ReleaseHistory(t.Context(), tt.namespace, tt.releaseName, tt.maxRevisions) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + mockExec.AssertExpectations(t) + }) + } +} + +func TestHelmClient_GetLastRevision(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + namespace string + releaseName string + wantErr bool + }{ + { + name: "successful get last revision", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"history", "myrelease", "--namespace", "default", "--output", "json", "--max", "1"}, + ).Return(`[{"revision": 3, "status": "deployed"}]`, "", nil) + }, + kubernetesEnvSettings: nil, + namespace: "default", + releaseName: "myrelease", + wantErr: false, + }, + { + name: "get last revision with kubeconfig", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") && + strings.Contains(argsStr, "--namespace default") && + strings.Contains(argsStr, "--output json") && + strings.Contains(argsStr, "--max 1") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") && + strings.Contains(argsStr, "--kube-context test-context") && + strings.Contains(argsStr, "--kube-token test-token") && + strings.Contains(argsStr, "--kube-as-user test-user") && + strings.Contains(argsStr, "--kube-as-group test-group1") && + strings.Contains(argsStr, "--kube-as-group test-group2") && + strings.Contains(argsStr, "--kube-apiserver https://test-server:6443") && + strings.Contains(argsStr, "--kube-ca-file /tmp/ca.crt") && + strings.Contains(argsStr, "--kube-tls-server-name test-server") && + strings.Contains(argsStr, "--kube-insecure-skip-tls-verify") && + strings.Contains(argsStr, "--burst-limit 100") && + strings.Contains(argsStr, "--qps 50.00") + }), + ).Return(`[{"revision": 5, "status": "deployed"}]`, "", nil) + }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + KubeContext: "test-context", + KubeToken: "test-token", + KubeAsUser: "test-user", + KubeAsGroups: []string{"test-group1", "test-group2"}, + KubeAPIServer: "https://test-server:6443", + KubeCaFile: "/tmp/ca.crt", + KubeTLSServerName: "test-server", + KubeInsecureSkipTLSVerify: true, + BurstLimit: 100, + QPS: 50.0, + }, + namespace: "default", + releaseName: "myrelease", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + kubernetesEnvSettings: tt.kubernetesEnvSettings, + } + + _, err := client.GetLastRevision(t.Context(), tt.namespace, tt.releaseName) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + mockExec.AssertExpectations(t) + }) + } +} + +func TestHelmClient_Rollback(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + opts RollbackOptions + wantErr bool + }{ + { + name: "successful rollback", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"rollback", "myrelease", "2", "--namespace", "default", "--wait", "--wait-for-jobs", "--timeout", "5m0s", "--debug"}, + ).Return("Rollback was a success! Happy Helming!", "", nil) + }, + kubernetesEnvSettings: nil, + opts: RollbackOptions{ + ReleaseName: "myrelease", + Namespace: "default", + Revision: 2, + Timeout: 5 * time.Minute, + }, + wantErr: false, + }, + { + name: "rollback with kubeconfig", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "rollback") && + strings.Contains(argsStr, "myrelease") && + strings.Contains(argsStr, "3") && + strings.Contains(argsStr, "--namespace default") && + strings.Contains(argsStr, "--wait") && + strings.Contains(argsStr, "--wait-for-jobs") && + strings.Contains(argsStr, "--timeout 5m0s") && + strings.Contains(argsStr, "--debug") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") + }), + ).Return("Rollback was a success! Happy Helming!", "", nil) + }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + }, + opts: RollbackOptions{ + ReleaseName: "myrelease", + Namespace: "default", + Revision: 3, + Timeout: 5 * time.Minute, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + kubernetesEnvSettings: tt.kubernetesEnvSettings, + } + + _, err := client.Rollback(t.Context(), tt.opts) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + mockExec.AssertExpectations(t) + }) + } +} diff --git a/pkg/helm/interface.go b/pkg/helm/interface.go index 5a45d10314..e1ce8e85d8 100644 --- a/pkg/helm/interface.go +++ b/pkg/helm/interface.go @@ -4,7 +4,6 @@ import ( "context" "helm.sh/helm/v3/pkg/chart" - "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/repo" ) @@ -15,16 +14,15 @@ var ( type Client interface { Close() error AddRepo(ctx context.Context, repo *repo.Entry) error - AddRepoBin(ctx context.Context, repo *repo.Entry) error Latest(ctx context.Context, reponame, chart string) (string, error) Pull(ctx context.Context, reponame, chart string, version string) (string, error) PullByRef(ctx context.Context, ref string, version string) (string, error) RegistryAuth(ctx context.Context, server, user, pass string) error Push(ctx context.Context, path, dst string) error - GetChartMetadata(ctx context.Context, ref string, version string) (*chart.Metadata, error) + GetChartMetadata(ctx context.Context, chartPath string, version string) (*chart.Metadata, error) ReleaseExists(ctx context.Context, namespace string, releaseName string) (bool, error) - Install(ctx context.Context, opts InstallOptions) (*release.Release, error) - Upgrade(ctx context.Context, opts UpgradeOptions) (*release.Release, error) + Install(ctx context.Context, opts InstallOptions) (string, error) + Upgrade(ctx context.Context, opts UpgradeOptions) (string, error) Uninstall(ctx context.Context, opts UninstallOptions) error Render(ctx context.Context, opts InstallOptions) ([][]byte, error) } diff --git a/pkg/helm/mock_client.go b/pkg/helm/mock_client.go index f395b02a99..c9d907f705 100644 --- a/pkg/helm/mock_client.go +++ b/pkg/helm/mock_client.go @@ -5,7 +5,6 @@ import ( "github.com/stretchr/testify/mock" "helm.sh/helm/v3/pkg/chart" - "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/repo" ) @@ -25,11 +24,6 @@ func (m *MockClient) AddRepo(ctx context.Context, repo *repo.Entry) error { return args.Error(0) } -func (m *MockClient) AddRepoBin(ctx context.Context, repo *repo.Entry) error { - args := m.Called(ctx, repo) - return args.Error(0) -} - func (m *MockClient) Latest(ctx context.Context, reponame, chart string) (string, error) { args := m.Called(ctx, reponame, chart) return args.String(0), args.Error(1) @@ -55,8 +49,8 @@ func (m *MockClient) Push(ctx context.Context, path, dst string) error { return args.Error(0) } -func (m *MockClient) GetChartMetadata(ctx context.Context, ref string, version string) (*chart.Metadata, error) { - args := m.Called(ctx, ref, version) +func (m *MockClient) GetChartMetadata(ctx context.Context, chartPath string, version string) (*chart.Metadata, error) { + args := m.Called(ctx, chartPath, version) if args.Get(0) == nil { return nil, args.Error(1) } @@ -68,20 +62,20 @@ func (m *MockClient) ReleaseExists(ctx context.Context, namespace string, releas return args.Bool(0), args.Error(1) } -func (m *MockClient) Install(ctx context.Context, opts InstallOptions) (*release.Release, error) { +func (m *MockClient) Install(ctx context.Context, opts InstallOptions) (string, error) { args := m.Called(ctx, opts) if args.Get(0) == nil { - return nil, args.Error(1) + return "", args.Error(1) } - return args.Get(0).(*release.Release), args.Error(1) + return args.Get(0).(string), args.Error(1) } -func (m *MockClient) Upgrade(ctx context.Context, opts UpgradeOptions) (*release.Release, error) { +func (m *MockClient) Upgrade(ctx context.Context, opts UpgradeOptions) (string, error) { args := m.Called(ctx, opts) if args.Get(0) == nil { - return nil, args.Error(1) + return "", args.Error(1) } - return args.Get(0).(*release.Release), args.Error(1) + return args.Get(0).(string), args.Error(1) } func (m *MockClient) Uninstall(ctx context.Context, opts UninstallOptions) error { diff --git a/tests/dryrun/util.go b/tests/dryrun/util.go index aef6d85f82..0eaa977838 100644 --- a/tests/dryrun/util.go +++ b/tests/dryrun/util.go @@ -26,7 +26,6 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -329,7 +328,7 @@ func assertSecretNotExists(t *testing.T, kcli client.Client, name string, namesp } // assertConfigValuesSecret validates that a config values secret exists with the expected values -func assertConfigValuesSecret(t *testing.T, kcli client.Client, name string, namespace string, expectedValues map[string]kotsv1beta1.ConfigValue) { +func assertConfigValuesSecret(t *testing.T, kcli client.Client, name string, namespace string, expectedValues map[string]apitypes.AppConfigValue) { t.Helper() // Get the secret @@ -353,17 +352,16 @@ func assertConfigValuesSecret(t *testing.T, kcli client.Client, name string, nam require.NotEmpty(t, data, "config-values.yaml should not be empty") // Unmarshal config values - var configValues kotsv1beta1.ConfigValues + var configValues apitypes.AppConfigValues err = yaml.Unmarshal(data, &configValues) require.NoError(t, err, "should be able to unmarshal config values from secret") // Validate each expected value for key, expectedValue := range expectedValues { - actualValue, exists := configValues.Spec.Values[key] + actualValue, exists := configValues[key] require.True(t, exists, "config value %s should exist", key) assert.Equal(t, expectedValue.Value, actualValue.Value, "config value %s should match", key) - assert.Equal(t, expectedValue.ValuePlaintext, actualValue.ValuePlaintext, "config value plaintext %s should match", key) if expectedValue.Filename != "" { assert.Equal(t, expectedValue.Filename, actualValue.Filename, "config value filename %s should match", key) diff --git a/tests/dryrun/v3_install_test.go b/tests/dryrun/v3_install_test.go index e99b8f83e8..9d1269b8e1 100644 --- a/tests/dryrun/v3_install_test.go +++ b/tests/dryrun/v3_install_test.go @@ -19,7 +19,6 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" @@ -195,11 +194,14 @@ func validateHappyPathOnline(t *testing.T, hcli *helm.MockClient) { // Validate that registry-creds secret is NOT created for online installations assertSecretNotExists(t, kcli, "registry-creds", adminConsoleNamespace) + // Validate that image pull secret IS created for online installations + assertSecretExists(t, kcli, "fake-app-slug-registry", adminConsoleNamespace) + // Validate config values secret exists and contains correct values - assertConfigValuesSecret(t, kcli, "fake-app-slug-config-values", adminConsoleNamespace, map[string]kotsv1beta1.ConfigValue{ + assertConfigValuesSecret(t, kcli, "fake-app-slug-config-values", adminConsoleNamespace, map[string]apitypes.AppConfigValue{ "text_required": {Value: "text required value"}, "text_required_with_regex": {Value: "ethan@replicated.com"}, - "password_required": {ValuePlaintext: "password required value"}, + "password_required": {Value: "password required value"}, "file_required": { Value: "ZmlsZSByZXF1aXJlZCB2YWx1ZQo=", Filename: "file_required.txt", @@ -235,13 +237,11 @@ func validateHappyPathOnline(t *testing.T, hcli *helm.MockClient) { }, }) - // Validate that KOTS CLI install command is present - assertCommands(t, dr.Commands, - []any{ - regexp.MustCompile(`kubectl-kots.* install fake-app-slug/fake-channel-slug .*`), - }, - false, - ) + // Validate that app charts are installed via Helm + _, found = isHelmReleaseInstalled(hcli, "nginx-app") + require.True(t, found, "nginx-app helm release should be installed") + _, found = isHelmReleaseInstalled(hcli, "redis-app") + require.True(t, found, "redis-app helm release should be installed") } func TestV3InstallHeadless_HappyPathAirgap(t *testing.T) { @@ -266,7 +266,7 @@ func TestV3InstallHeadless_HappyPathAirgap(t *testing.T) { require.NoError(t, err, "headless installation should succeed") - validateHappyPathAirgap(t, hcli, airgapBundleFile) + validateHappyPathAirgap(t, hcli) if !t.Failed() { t.Logf("V3 headless airgap installation test passed") @@ -306,14 +306,14 @@ func TestV3Install_HappyPathAirgap(t *testing.T) { ignoreAppPreflights: false, }) - validateHappyPathAirgap(t, hcli, airgapBundleFile) + validateHappyPathAirgap(t, hcli) if !t.Failed() { t.Logf("V3 airgap installation test passed") } } -func validateHappyPathAirgap(t *testing.T, hcli *helm.MockClient, airgapBundleFile string) { +func validateHappyPathAirgap(t *testing.T, hcli *helm.MockClient) { t.Helper() adminConsoleNamespace := "fake-app-slug" @@ -395,28 +395,25 @@ func validateHappyPathAirgap(t *testing.T, hcli *helm.MockClient, airgapBundleFi // Validate that registry-creds secret IS created for airgap installations assertSecretExists(t, kcli, "registry-creds", adminConsoleNamespace) + // Validate that image pull secret IS created for airgap installations + assertSecretExists(t, kcli, "fake-app-slug-registry", adminConsoleNamespace) + + // Validate that app charts are installed via Helm for airgap installations + _, found = isHelmReleaseInstalled(hcli, "nginx-app") + require.True(t, found, "nginx-app helm release should be installed") + _, found = isHelmReleaseInstalled(hcli, "redis-app") + require.True(t, found, "redis-app helm release should be installed") + // Validate config values secret exists and contains correct values - assertConfigValuesSecret(t, kcli, "fake-app-slug-config-values", adminConsoleNamespace, map[string]kotsv1beta1.ConfigValue{ + assertConfigValuesSecret(t, kcli, "fake-app-slug-config-values", adminConsoleNamespace, map[string]apitypes.AppConfigValue{ "text_required": {Value: "text required value"}, "text_required_with_regex": {Value: "ethan@replicated.com"}, - "password_required": {ValuePlaintext: "password required value"}, + "password_required": {Value: "password required value"}, "file_required": { Value: "ZmlsZSByZXF1aXJlZCB2YWx1ZQo=", Filename: "file_required.txt", }, }) - - // Validate that KOTS CLI install command includes --airgap-bundle flag for airgap installations - // The --airgap-bundle flag flows through: Installer → Install Controller → App Install Manager - // The App Install Manager uses it to set kotscli.InstallOptions.AirgapBundle (install.go:68) - // This ensures the KOTS installer receives the airgap bundle path - assertCommands(t, dr.Commands, - []any{ - // KOTS install command should contain --airgap-bundle with the correct path - regexp.MustCompile(fmt.Sprintf(`kubectl-kots.* install fake-app-slug/fake-channel-slug .* --airgap-bundle %s`, regexp.QuoteMeta(airgapBundleFile))), - }, - false, - ) } func TestV3InstallHeadless_Metrics(t *testing.T) { diff --git a/web/package-lock.json b/web/package-lock.json index eddee5b26b..8eecb8a1a1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -183,7 +183,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -543,7 +542,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -587,7 +585,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2029,6 +2026,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "20 || >=22" } @@ -2040,6 +2038,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@isaacs/balanced-match": "^4.0.1" }, @@ -2242,18 +2241,18 @@ } }, "node_modules/@netlify/ai": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@netlify/ai/-/ai-0.3.4.tgz", - "integrity": "sha512-mV0RtkO5dOwbuqRn/Sn0aHIV4j6sw8B4F16WCx0GYBRcJ9IbBkzvuEzW0IDUbNE6hxu9FFs5WRDASDJpgDY1ZQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@netlify/ai/-/ai-0.3.5.tgz", + "integrity": "sha512-7suwHOBy9s14yeWRxt+w3Zh6Rrx8gX7zP/xmsxqxLyJlcBykWm6siBJs2mMtJgbWvcrgI5BEgNLh5qfXlTCsRQ==", "dev": true, "dependencies": { - "@netlify/api": "^14.0.11" + "@netlify/api": "^14.0.12" }, "engines": { "node": ">=20.6.1" }, "peerDependencies": { - "@netlify/api": ">=14.0.11" + "@netlify/api": ">=14.0.12" } }, "node_modules/@netlify/api": { @@ -2280,14 +2279,14 @@ "license": "Apache 2" }, "node_modules/@netlify/blobs": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.4.2.tgz", - "integrity": "sha512-IySDzwNTtI7jehxkbc1bnODiIcmIaamG8YBf1YtXmxsBlQGcAKdkN6QSulHYQ+quSEK+W5qDecwV6BjUs5094A==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.4.3.tgz", + "integrity": "sha512-a5Wh4Mc6OwR4vUPnp4DAySDugvrrUmA16NEW0atUnP9R2kOr7vAmV986MXzQGpb57w3B8aq3XXuG/jquTvciiQ==", "dev": true, "license": "MIT", "dependencies": { - "@netlify/dev-utils": "4.3.2", - "@netlify/otel": "^5.0.1", + "@netlify/dev-utils": "4.3.3", + "@netlify/otel": "^5.1.0", "@netlify/runtime-utils": "2.2.1" }, "engines": { @@ -2295,9 +2294,9 @@ } }, "node_modules/@netlify/cache": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@netlify/cache/-/cache-3.3.3.tgz", - "integrity": "sha512-xWiKDtGSqIUYev8LVtdeBsDFLjczlvn8tp4nMs+3QqPk7FJ94j7qwFjB+QRWg7g4Fbin9UvgIrMlmAEXE+gjKA==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@netlify/cache/-/cache-3.3.4.tgz", + "integrity": "sha512-Fl4/KxP8NS7+skjfRixgekuqBNvLPay/J6qC2mxvHjkkZNu1oUs8QOc+T3Nvt4n+UMrltnt9ggg0q/q4hmBIVw==", "dev": true, "license": "MIT", "dependencies": { @@ -2308,9 +2307,9 @@ } }, "node_modules/@netlify/config": { - "version": "24.1.2", - "resolved": "https://registry.npmjs.org/@netlify/config/-/config-24.1.2.tgz", - "integrity": "sha512-ZnJTi/BZRONWQ9JcDIv2RYUIlEoxHAvLodtp9DzI3EwWoalh/3XJlA7kDuC/JsKqlZWfm2xddAl7W8ZKus7gpQ==", + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@netlify/config/-/config-24.2.0.tgz", + "integrity": "sha512-idc1D6kdQOFjG70aZC06crqElTyaSulVlnOEDZX2+5/vcmfFCBu8CJSEd5YzC6VCCXBgOW3Hw0cVxDTl5X6+CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2463,23 +2462,23 @@ } }, "node_modules/@netlify/dev": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/@netlify/dev/-/dev-4.8.3.tgz", - "integrity": "sha512-x3N6tflo9daQH6gxZrvUwfxe1/Z7cdmPQuyOmw8NHKPDu4+JQHK41BcJdIUDBzeKxKjfKyhGautYjXVML3//wg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@netlify/ai": "^0.3.4", - "@netlify/blobs": "10.4.2", - "@netlify/config": "^24.1.1", - "@netlify/dev-utils": "4.3.2", - "@netlify/edge-functions-dev": "1.0.6", - "@netlify/functions-dev": "1.1.3", - "@netlify/headers": "2.1.2", - "@netlify/images": "1.3.2", - "@netlify/redirects": "3.1.3", - "@netlify/runtime": "4.1.10", - "@netlify/static": "3.1.2", + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/@netlify/dev/-/dev-4.8.4.tgz", + "integrity": "sha512-YGmsIvv0JeL8ojbyr9XWXWj3FNAIImxhppFN9WhRZyK0w5fDC6bVlNRQsqApPxd1QVDMfTQFY4aYQ0ratE0nUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@netlify/ai": "^0.3.5", + "@netlify/blobs": "10.4.3", + "@netlify/config": "^24.1.2", + "@netlify/dev-utils": "4.3.3", + "@netlify/edge-functions-dev": "1.0.7", + "@netlify/functions-dev": "1.1.4", + "@netlify/headers": "2.1.3", + "@netlify/images": "1.3.3", + "@netlify/redirects": "3.1.4", + "@netlify/runtime": "4.1.11", + "@netlify/static": "3.1.3", "ulid": "^3.0.0" }, "engines": { @@ -2487,9 +2486,9 @@ } }, "node_modules/@netlify/dev-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.3.2.tgz", - "integrity": "sha512-Nl6c5UVLbpOwvzVaT6fJycdkc3EswqFoI9c2hZ3WUUX+kQ2ojdrkFMuKcPERaGXYxrhy/uGk1CURAflG8YC2RA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.3.3.tgz", + "integrity": "sha512-qziF8R9kf7mRNgSpmUH96O0aV1ZiwK4c9ZecFQbDSQuYhgy9GY1WTjiQF0oQnohjTjWNtXhrU39LAeXWNLaBJg==", "dev": true, "license": "MIT", "dependencies": { @@ -3036,7 +3035,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3237,13 +3235,13 @@ } }, "node_modules/@netlify/edge-functions": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@netlify/edge-functions/-/edge-functions-3.0.2.tgz", - "integrity": "sha512-1vW3R+Rc2JxL6qITndlT87N94GPjJ6gH2ntXW3IDdLzSABoU9XCHw4lRzDw+bhgSLTm0oyOwQA2+hhFvstznNQ==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@netlify/edge-functions/-/edge-functions-3.0.3.tgz", + "integrity": "sha512-grElRK+rTBdYrPsULPKrhcHhrW+fwpDRLPbGByqa6Xrz0fhzcFJ2D9ijxEQ/onFcSVPYHT1u1mI48GhS5bZ/Ag==", "dev": true, "license": "MIT", "dependencies": { - "@netlify/types": "2.2.0" + "@netlify/types": "2.3.0" }, "engines": { "node": ">=18.0.0" @@ -3257,15 +3255,15 @@ "license": "MIT" }, "node_modules/@netlify/edge-functions-dev": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@netlify/edge-functions-dev/-/edge-functions-dev-1.0.6.tgz", - "integrity": "sha512-3T9OQVEHP/i/248K2bklrQ995dEfs0FhIiYAfzeCkeAKAB2ipbnXHqUDXMBXiQPM0Rl12U82yh+RM+LG5UFQZw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@netlify/edge-functions-dev/-/edge-functions-dev-1.0.7.tgz", + "integrity": "sha512-PlkG3PxULQ7z/CSzx5LthGsVtJPOo8E+sA67cOwNq/eHxtwpCUfCPOmxq3AGKqMR1pzUGC6k5yewhgXoG8Zm7w==", "dev": true, "license": "MIT", "dependencies": { - "@netlify/dev-utils": "4.3.2", - "@netlify/edge-bundler": "^14.9.0", - "@netlify/edge-functions": "3.0.2", + "@netlify/dev-utils": "4.3.3", + "@netlify/edge-bundler": "^14.9.1", + "@netlify/edge-functions": "3.0.3", "@netlify/edge-functions-bootstrap": "2.16.0", "@netlify/runtime-utils": "2.2.1", "get-port": "^7.1.0" @@ -3275,29 +3273,29 @@ } }, "node_modules/@netlify/functions": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-5.1.0.tgz", - "integrity": "sha512-LZtiQtf/QzPHIeNDZuIBxx04kmU7lCipWqZ26ejX7mYSB3yj2wvpZfF49kD8B8FoKTydSvgFmBpIcCO5FvpEXA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-5.1.1.tgz", + "integrity": "sha512-64TvwQkAFpYb3QqYemPYDqWi1xMbYOBfg70bhy23iahWf+F9TJgOOnAVUOk5fMWGN/fk9bZG5ROc+cm32whh+g==", "dev": true, "license": "MIT", "dependencies": { - "@netlify/types": "2.2.0" + "@netlify/types": "2.3.0" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@netlify/functions-dev": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@netlify/functions-dev/-/functions-dev-1.1.3.tgz", - "integrity": "sha512-IXSmXvdBhykOMb1sOWYKAZYqAWU0WbfPfch5y+5RXe0vq6ubxzM2WiHJlyl0e14L3N2cra7dvDBh6JhbF045dQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@netlify/functions-dev/-/functions-dev-1.1.4.tgz", + "integrity": "sha512-L7+yFgDrG+pTAsLo4wZZAEV+2m3BiEocjdINEMdXvjVikW9PH3anDXtDJqtDd6kXl9G/o4fd9crBaeN97V+k2w==", "dev": true, "license": "MIT", "dependencies": { - "@netlify/blobs": "10.4.2", - "@netlify/dev-utils": "4.3.2", - "@netlify/functions": "5.1.0", - "@netlify/zip-it-and-ship-it": "^14.1.14", + "@netlify/blobs": "10.4.3", + "@netlify/dev-utils": "4.3.3", + "@netlify/functions": "5.1.1", + "@netlify/zip-it-and-ship-it": "^14.1.15", "cron-parser": "^4.9.0", "decache": "^4.6.2", "extract-zip": "^2.0.1", @@ -3326,9 +3324,9 @@ } }, "node_modules/@netlify/headers": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@netlify/headers/-/headers-2.1.2.tgz", - "integrity": "sha512-3dkP1LU9U3ynKHuLUP0HCzJhf0bLs/ESuz1QjnUTOwx6oxc3hkIFa8B8wAa00uOrx3lVgcN76Zuydvkjulk7wA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@netlify/headers/-/headers-2.1.3.tgz", + "integrity": "sha512-jVjhHokAQLGI5SJA2nj8OWeNQ7ASV4m0n4aiR4PHrhM8ot385V2BbUGkSpC28M92uqP0l1cbAQaSoSOU4re8iQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3367,9 +3365,9 @@ } }, "node_modules/@netlify/images": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@netlify/images/-/images-1.3.2.tgz", - "integrity": "sha512-/YEhpm0KbiDtbYZbfOv6tbJyk+/j10tndeJAvUc94mSyv3EB7X4DREvhftZcxLuE6A2IceaKIbh5DpUNgJHjxA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@netlify/images/-/images-1.3.3.tgz", + "integrity": "sha512-1X3fUmacCLMlPIqyeV5tdo6Wbf9aBSWobgr4DyRvg9zDV9jbKqgdN3BNbcUXmVaqfN+0iiv0k9p02mcRV3OyOw==", "dev": true, "license": "MIT", "dependencies": { @@ -3390,9 +3388,9 @@ } }, "node_modules/@netlify/otel": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@netlify/otel/-/otel-5.0.1.tgz", - "integrity": "sha512-h3Em98qEUQ+WGYbTxsulr5TUrNcIjP2+SpkbBfToyFLIBH3DYofvhsWajlySQoBijuRKB35OUHxt/QuX/fuo5Q==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@netlify/otel/-/otel-5.1.0.tgz", + "integrity": "sha512-bXZuWXXq3IZQ8i3/b5YD7ud5fQq/fiiCR51ClUx3WPcDbH/Rck0F6gc08yyrB5+8Dn8IYmvj+xlWL23B39YDHA==", "dev": true, "license": "MIT", "dependencies": { @@ -3433,13 +3431,13 @@ } }, "node_modules/@netlify/redirects": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@netlify/redirects/-/redirects-3.1.3.tgz", - "integrity": "sha512-gIk/h4nzg9n/LWV7odfqMP00MBRSfujnW50DRYkWqh4ApW5NdfZxOcla7ISl6hZcIDMUruOPcFOD7PFXSALVvQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@netlify/redirects/-/redirects-3.1.4.tgz", + "integrity": "sha512-2FcF/0Q24JA+VmpWlVRp835UvhBHQe3XGVaxAQfHiDd5aXztaz2U5Y4VEZyrZJOubY5xnxr2yqumDfClAiCKxw==", "dev": true, "license": "MIT", "dependencies": { - "@netlify/dev-utils": "4.3.2", + "@netlify/dev-utils": "4.3.3", "@netlify/redirect-parser": "^15.0.3", "cookie": "^1.0.2", "jsonwebtoken": "9.0.2", @@ -3450,16 +3448,16 @@ } }, "node_modules/@netlify/runtime": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@netlify/runtime/-/runtime-4.1.10.tgz", - "integrity": "sha512-7Ilxa+7zljPkZkY7a7HHiC16LEf6rYKFnBX6QPDJ43QHmr6uxzo3m1pBj/qpnYeUZ6h0qi08/QF97I0FwKTvVg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@netlify/runtime/-/runtime-4.1.11.tgz", + "integrity": "sha512-tfqZmNH3pm4E9KoWKP/H5d4+Acl/25m/PXTh5EDIR1iRQ8ZyJkAJlnjtjCaSg2u6leAFkbOrdeTMLnvFenF75g==", "dev": true, "license": "MIT", "dependencies": { - "@netlify/blobs": "^10.4.2", - "@netlify/cache": "3.3.3", + "@netlify/blobs": "^10.4.3", + "@netlify/cache": "3.3.4", "@netlify/runtime-utils": "2.2.1", - "@netlify/types": "2.2.0" + "@netlify/types": "2.3.0" }, "engines": { "node": ">=20.6.1" @@ -3476,9 +3474,9 @@ } }, "node_modules/@netlify/serverless-functions-api": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@netlify/serverless-functions-api/-/serverless-functions-api-2.8.0.tgz", - "integrity": "sha512-5ZhDAZaumUAzs5hmX0NGLEsAo3XYsQKbzfOENrHf+kwGv0MdiBlIiS5rdnn51rkLVmlYd5d+6T+7J2L0bNuZxg==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/@netlify/serverless-functions-api/-/serverless-functions-api-2.8.1.tgz", + "integrity": "sha512-UZVlpSCgBS/6gdamRlAlOkRV3Atd6BUgpU6n59JdKsfAj67z2XbiGn7dWZuOaCrdv3h4FX3Z5zHZsSDRDi5V6g==", "dev": true, "license": "MIT", "engines": { @@ -3486,9 +3484,9 @@ } }, "node_modules/@netlify/static": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@netlify/static/-/static-3.1.2.tgz", - "integrity": "sha512-1kxT/xTro9+zzdbyxdNSZpzwzOwlxBTbLPpVsuvF+77euman6Fxhwyt9KoEBMSwO3dyKC3LW9W4Wa/k+zeDqpg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@netlify/static/-/static-3.1.3.tgz", + "integrity": "sha512-88VG2jwWY1eOT/IiMbkrak7qyo+t7om0v731i63JiCDfXjCEp+yFPNr9L4v8S6wcCmgnkGQ6Sr5roF1sEtp6+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3499,9 +3497,9 @@ } }, "node_modules/@netlify/types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.2.0.tgz", - "integrity": "sha512-XOWlZ2wPpdRKkAOcQbjIf/Qz7L4RjcSVINVNQ9p3F6U8V6KSEOsB3fPrc6Ly8EOeJioHUepRPuzHzJE/7V5EsA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.3.0.tgz", + "integrity": "sha512-5gxMWh/S7wr0uHKSTbMv4bjWmWSpwpeLYvErWeVNAPll5/QNFo9aWimMAUuh8ReLY3/fg92XAroVVu7+z27Snw==", "dev": true, "license": "MIT", "engines": { @@ -3509,14 +3507,14 @@ } }, "node_modules/@netlify/vite-plugin": { - "version": "2.7.15", - "resolved": "https://registry.npmjs.org/@netlify/vite-plugin/-/vite-plugin-2.7.15.tgz", - "integrity": "sha512-Vx4F+C31C63o3Z38bux7dUTFMfJA4ts9kkaScEn82WUDAQe/OsQgDqFYQA5WU9IKRv7LABMjzYk38Xc2/xV44g==", + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@netlify/vite-plugin/-/vite-plugin-2.7.16.tgz", + "integrity": "sha512-PdpKYobM0oH7Qh87Aao4z5drNcBwq9ahkom5ep6vEdh5koPDKCBY+334gB9nIihy3pmruc3ur6+3jRyKjAkm1w==", "dev": true, "license": "MIT", "dependencies": { - "@netlify/dev": "4.8.3", - "@netlify/dev-utils": "^4.3.2", + "@netlify/dev": "4.8.4", + "@netlify/dev-utils": "^4.3.3", "dedent": "^1.7.0" }, "engines": { @@ -3527,16 +3525,16 @@ } }, "node_modules/@netlify/zip-it-and-ship-it": { - "version": "14.1.15", - "resolved": "https://registry.npmjs.org/@netlify/zip-it-and-ship-it/-/zip-it-and-ship-it-14.1.15.tgz", - "integrity": "sha512-n6ZNnNOZKVFWSGx8WR4YfF7UEnUT6mSMOoyUd1oioIWasWQ3gVLFpWHSqieA1r/k0ULnxiJpTQcqiI0VLMo7Bw==", + "version": "14.1.16", + "resolved": "https://registry.npmjs.org/@netlify/zip-it-and-ship-it/-/zip-it-and-ship-it-14.1.16.tgz", + "integrity": "sha512-oUliNza9Tab3hHpIaHDAuT6ApN/c3SPJDUUenFGujpJm6IKZPdmnAGuKM2N2zb+XxHzApLdOogqv0kr7nY0avQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.22.5", "@babel/types": "7.28.5", "@netlify/binary-info": "^1.0.0", - "@netlify/serverless-functions-api": "^2.8.0", + "@netlify/serverless-functions-api": "^2.8.1", "@vercel/nft": "0.29.4", "archiver": "^7.0.0", "common-path-prefix": "^3.0.0", @@ -4282,7 +4280,6 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -5737,7 +5734,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5881,7 +5879,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5892,7 +5889,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5936,18 +5932,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", - "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", + "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/type-utils": "8.48.1", - "@typescript-eslint/utils": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", - "graphemer": "^1.4.0", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/type-utils": "8.49.0", + "@typescript-eslint/utils": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" @@ -5960,7 +5955,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.48.1", + "@typescript-eslint/parser": "^8.49.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -5976,17 +5971,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", - "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", + "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4" }, "engines": { @@ -6002,14 +5996,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", - "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", + "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.1", - "@typescript-eslint/types": "^8.48.1", + "@typescript-eslint/tsconfig-utils": "^8.49.0", + "@typescript-eslint/types": "^8.49.0", "debug": "^4.3.4" }, "engines": { @@ -6024,14 +6018,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", - "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", + "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1" + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6042,9 +6036,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", - "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", + "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", "dev": true, "license": "MIT", "engines": { @@ -6059,15 +6053,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", - "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", + "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/utils": "8.48.1", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -6084,9 +6078,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", - "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", + "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", "dev": true, "license": "MIT", "engines": { @@ -6098,16 +6092,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", - "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", + "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.48.1", - "@typescript-eslint/tsconfig-utils": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", + "@typescript-eslint/project-service": "8.49.0", + "@typescript-eslint/tsconfig-utils": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", @@ -6165,17 +6159,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", - "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", + "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1" + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6190,13 +6183,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", - "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", + "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/types": "8.49.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -6873,7 +6866,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6917,7 +6909,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -7538,7 +7529,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -8088,6 +8078,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 12.0.0" } @@ -8812,7 +8803,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -9274,7 +9266,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9360,7 +9351,6 @@ "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", @@ -9446,7 +9436,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9482,6 +9471,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@typescript-eslint/types": "^8.35.0", "comment-parser": "^1.4.1", @@ -9520,6 +9510,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "@isaacs/brace-expansion": "^5.0.0" }, @@ -9537,6 +9528,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -10473,13 +10465,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/graphql": { "version": "16.12.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", @@ -11664,7 +11649,6 @@ "integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.19", "@asamuzakjp/dom-selector": "^6.7.3", @@ -12373,6 +12357,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -14521,7 +14506,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14610,6 +14594,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -14625,6 +14610,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -14635,6 +14621,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -14745,7 +14732,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14755,7 +14741,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14768,7 +14753,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-markdown": { "version": "10.1.0", @@ -15194,7 +15180,6 @@ "integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.7" }, @@ -16157,8 +16142,7 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.2.3", @@ -16284,7 +16268,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16599,7 +16582,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16609,16 +16591,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.1.tgz", - "integrity": "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz", + "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.48.1", - "@typescript-eslint/parser": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/utils": "8.48.1" + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -17141,12 +17123,11 @@ } }, "node_modules/vite": { - "version": "7.2.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", - "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -17282,7 +17263,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17575,9 +17555,9 @@ } }, "node_modules/winston": { - "version": "3.18.3", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", - "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "dev": true, "license": "MIT", "dependencies": { @@ -18021,7 +18001,6 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/types/api.ts b/web/src/types/api.ts index babcfdd34b..5ac93cbe06 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -998,6 +998,11 @@ export interface components { logs: string; status: components["schemas"]["types.Status"]; }; + "types.AppComponent": { + /** @description Chart name */ + name: string; + status: components["schemas"]["types.Status"]; + }; "types.AppConfig": { groups: components["schemas"]["v1beta1.ConfigGroup"][]; }; @@ -1016,6 +1021,7 @@ export interface components { values: components["schemas"]["types.AppConfigValues"]; }; "types.AppInstall": { + components: components["schemas"]["types.AppComponent"][]; logs: string; status: components["schemas"]["types.Status"]; }; @@ -1159,6 +1165,7 @@ export interface components { * @enum {string} */ "types.State": "Pending" | "Running" | "Succeeded" | "Failed"; + /** @description Uses existing Status type */ "types.Status": { description: string; lastUpdated: string;