Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/docs/docs.go

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion api/docs/swagger.json

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions api/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ components:
status_code:
type: integer
type: object
types.AppComponent:
properties:
name:
description: Chart name
type: string
status:
$ref: '#/components/schemas/types.Status'
type: object
types.AppConfig:
properties:
groups:
Expand All @@ -47,6 +55,11 @@ components:
type: object
types.AppInstall:
properties:
components:
items:
$ref: '#/components/schemas/types.AppComponent'
type: array
uniqueItems: false
logs:
type: string
status:
Expand Down Expand Up @@ -210,6 +223,7 @@ components:
- StateSucceeded
- StateFailed
types.Status:
description: Uses existing Status type
properties:
description:
type: string
Expand Down
38 changes: 36 additions & 2 deletions api/integration/kubernetes/install/appinstall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,30 @@ import (
// TestGetAppInstallStatus tests the GET /kubernetes/install/app/status endpoint
func TestGetAppInstallStatus(t *testing.T) {
t.Run("Success", func(t *testing.T) {
// Create app install status
// Create app install status with components
appInstallStatus := types.AppInstall{
Components: []types.AppComponent{
{
Name: "nginx-chart",
Status: types.Status{
State: types.StateSucceeded,
Description: "Installation complete",
LastUpdated: time.Now(),
},
},
{
Name: "postgres-chart",
Status: types.Status{
State: types.StateRunning,
Description: "Installing chart",
LastUpdated: time.Now(),
},
},
},
Status: types.Status{
State: types.StateRunning,
Description: "Installing application",
LastUpdated: time.Now(),
},
Logs: "Installation in progress...",
}
Expand Down Expand Up @@ -110,10 +129,25 @@ func TestGetAppInstallStatus(t *testing.T) {
err = json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)

// Verify the response
// Verify the response structure includes components
assert.Equal(t, appInstallStatus.Status.State, response.Status.State)
assert.Equal(t, appInstallStatus.Status.Description, response.Status.Description)
assert.Equal(t, appInstallStatus.Logs, response.Logs)

// Verify components array is present and has expected data
assert.Len(t, response.Components, 2, "Should have 2 components")

// Verify first component (nginx-chart)
nginxComponent := response.Components[0]
assert.Equal(t, "nginx-chart", nginxComponent.Name)
assert.Equal(t, types.StateSucceeded, nginxComponent.Status.State)
assert.Equal(t, "Installation complete", nginxComponent.Status.Description)

// Verify second component (postgres-chart)
postgresComponent := response.Components[1]
assert.Equal(t, "postgres-chart", postgresComponent.Name)
assert.Equal(t, types.StateRunning, postgresComponent.Status.State)
assert.Equal(t, "Installing chart", postgresComponent.Status.Description)
})

t.Run("Authorization error", func(t *testing.T) {
Expand Down
38 changes: 36 additions & 2 deletions api/integration/linux/install/appinstall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,30 @@ func TestGetAppInstallStatus(t *testing.T) {
}

t.Run("Success", func(t *testing.T) {
// Create app install status
// Create app install status with components
appInstallStatus := types.AppInstall{
Components: []types.AppComponent{
{
Name: "nginx-chart",
Status: types.Status{
State: types.StateSucceeded,
Description: "Installation complete",
LastUpdated: time.Now(),
},
},
{
Name: "postgres-chart",
Status: types.Status{
State: types.StateRunning,
Description: "Installing chart",
LastUpdated: time.Now(),
},
},
},
Status: types.Status{
State: types.StateRunning,
Description: "Installing application",
LastUpdated: time.Now(),
},
Logs: "Installation in progress...",
}
Expand Down Expand Up @@ -119,10 +138,25 @@ func TestGetAppInstallStatus(t *testing.T) {
err = json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)

// Verify the response
// Verify the response structure includes components
assert.Equal(t, appInstallStatus.Status.State, response.Status.State)
assert.Equal(t, appInstallStatus.Status.Description, response.Status.Description)
assert.Equal(t, appInstallStatus.Logs, response.Logs)

// Verify components array is present and has expected data
assert.Len(t, response.Components, 2, "Should have 2 components")

// Verify first component (nginx-chart)
nginxComponent := response.Components[0]
assert.Equal(t, "nginx-chart", nginxComponent.Name)
assert.Equal(t, types.StateSucceeded, nginxComponent.Status.State)
assert.Equal(t, "Installation complete", nginxComponent.Status.Description)

// Verify second component (postgres-chart)
postgresComponent := response.Components[1]
assert.Equal(t, "postgres-chart", postgresComponent.Name)
assert.Equal(t, types.StateRunning, postgresComponent.Status.State)
assert.Equal(t, "Installing chart", postgresComponent.Status.Description)
})

t.Run("Authorization error", func(t *testing.T) {
Expand Down
47 changes: 42 additions & 5 deletions api/internal/managers/app/install/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import (

// Install installs the app with the provided installable Helm charts and config values
func (m *appInstallManager) Install(ctx context.Context, installableCharts []types.InstallableHelmChart, kotsConfigValues kotsv1beta1.ConfigValues) (finalErr error) {
if err := m.initializeComponents(installableCharts); err != nil {
return fmt.Errorf("initialize components: %w", err)
}

if err := m.setStatus(types.StateRunning, "Installing application"); err != nil {
return fmt.Errorf("set status: %w", err)
}
Expand Down Expand Up @@ -109,7 +113,7 @@ func (m *appInstallManager) createConfigValuesFile(kotsConfigValues kotsv1beta1.
}

func (m *appInstallManager) installHelmCharts(ctx context.Context, installableCharts []types.InstallableHelmChart) error {
logFn := m.logFn("app-helm")
logFn := m.logFn("app")

if len(installableCharts) == 0 {
return fmt.Errorf("no helm charts found")
Expand All @@ -118,19 +122,42 @@ func (m *appInstallManager) installHelmCharts(ctx context.Context, installableCh
logFn("installing %d helm charts", len(installableCharts))

for _, installableChart := range installableCharts {
logFn("installing %s helm chart", installableChart.CR.GetChartName())
chartName := installableChart.CR.GetChartName()
logFn("installing %s chart", chartName)

if err := m.installHelmChart(ctx, installableChart); err != nil {
return fmt.Errorf("install %s helm chart: %w", installableChart.CR.GetChartName(), err)
return fmt.Errorf("install %s helm chart: %w", chartName, err)
}

logFn("successfully installed %s helm chart", installableChart.CR.GetChartName())
logFn("successfully installed %s chart", chartName)
}

return nil
}

func (m *appInstallManager) installHelmChart(ctx context.Context, installableChart types.InstallableHelmChart) error {
func (m *appInstallManager) installHelmChart(ctx context.Context, installableChart types.InstallableHelmChart) (finalErr error) {
chartName := installableChart.CR.GetChartName()

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 {
Expand All @@ -157,3 +184,13 @@ func (m *appInstallManager) installHelmChart(ctx context.Context, installableCha

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, chart.CR.GetChartName())
}

return m.appInstallStore.RegisterComponents(chartNames)
}
120 changes: 120 additions & 0 deletions api/internal/managers/app/install/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"bytes"
"compress/gzip"
"context"
"errors"
"fmt"
"os"
"testing"
Expand Down Expand Up @@ -388,6 +389,10 @@ func createTestHelmChartCR(name, releaseName, namespace string, weight int64) *k
Name: name,
},
Spec: kotsv1beta2.HelmChartSpec{
Chart: kotsv1beta2.ChartIdentifier{
Name: name,
ChartVersion: "1.0.0",
},
ReleaseName: releaseName,
Namespace: namespace,
Weight: weight,
Expand All @@ -402,3 +407,118 @@ func createTestInstallableHelmChart(t *testing.T, chartName, chartVersion, relea
CR: createTestHelmChartCR(chartName, releaseName, namespace, weight),
}
}

// TestComponentStatusTracking tests that components are properly initialized and tracked
func TestComponentStatusTracking(t *testing.T) {
t.Run("Components are registered and status is tracked", func(t *testing.T) {
// 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(&helmrelease.Release{Name: "postgres"}, 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(&helmrelease.Release{Name: "nginx"}, nil).Once()

// Create mock KOTS installer
mockInstaller := &MockKotsCLIInstaller{}
mockInstaller.On("Install", mock.Anything).Return(nil)

// 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{}),
WithLicense([]byte(`{"spec":{"appSlug":"test-app"}}`)),
WithClusterID("test-cluster"),
WithKotsCLI(mockInstaller),
WithHelmClient(mockHelmClient),
)
require.NoError(t, err)

// Install the charts
err = manager.Install(context.Background(), installableCharts, kotsv1beta1.ConfigValues{})
require.NoError(t, err)

// 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)

// Overall status should be succeeded
assert.Equal(t, types.StateSucceeded, appInstall.Status.State)
assert.Equal(t, "Installation complete", appInstall.Status.Description)

mockInstaller.AssertExpectations(t)
mockHelmClient.AssertExpectations(t)
})

t.Run("Component failure is tracked correctly", func(t *testing.T) {
// 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((*helmrelease.Release)(nil), 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{}),
WithLicense([]byte(`{"spec":{"appSlug":"test-app"}}`)),
WithClusterID("test-cluster"),
WithHelmClient(mockHelmClient),
)
require.NoError(t, err)

// Install the charts (should fail)
err = manager.Install(context.Background(), installableCharts, kotsv1beta1.ConfigValues{})
require.Error(t, err)

// Verify that component failure is tracked
appInstall, err := manager.GetStatus()
require.NoError(t, err)

// Should have 1 component
assert.Len(t, appInstall.Components, 1, "Should have 1 component")

// 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")

// Overall status should be failed
assert.Equal(t, types.StateFailed, appInstall.Status.State)

mockHelmClient.AssertExpectations(t)
})
}
8 changes: 8 additions & 0 deletions api/internal/managers/app/install/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,11 @@ func (m *appInstallManager) setStatus(state types.State, description string) err
LastUpdated: time.Now(),
})
}

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(),
})
}
Loading
Loading