Skip to content

Commit cfefc0c

Browse files
committed
feat(api): update app install status endpoint to return list of charts being installed (#2750)
* feat(api): update app install status endpoint to return list of charts being installed * f * f
1 parent 7d1e1a3 commit cfefc0c

File tree

12 files changed

+426
-14
lines changed

12 files changed

+426
-14
lines changed

api/docs/docs.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/docs/swagger.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

api/docs/swagger.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ components:
2121
status_code:
2222
type: integer
2323
type: object
24+
types.AppComponent:
25+
properties:
26+
name:
27+
description: Chart name
28+
type: string
29+
status:
30+
$ref: '#/components/schemas/types.Status'
31+
type: object
2432
types.AppConfig:
2533
properties:
2634
groups:
@@ -47,6 +55,11 @@ components:
4755
type: object
4856
types.AppInstall:
4957
properties:
58+
components:
59+
items:
60+
$ref: '#/components/schemas/types.AppComponent'
61+
type: array
62+
uniqueItems: false
5063
logs:
5164
type: string
5265
status:
@@ -210,6 +223,7 @@ components:
210223
- StateSucceeded
211224
- StateFailed
212225
types.Status:
226+
description: Uses existing Status type
213227
properties:
214228
description:
215229
type: string

api/integration/kubernetes/install/appinstall_test.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,30 @@ import (
3636
// TestGetAppInstallStatus tests the GET /kubernetes/install/app/status endpoint
3737
func TestGetAppInstallStatus(t *testing.T) {
3838
t.Run("Success", func(t *testing.T) {
39-
// Create app install status
39+
// Create app install status with components
4040
appInstallStatus := types.AppInstall{
41+
Components: []types.AppComponent{
42+
{
43+
Name: "nginx-chart",
44+
Status: types.Status{
45+
State: types.StateSucceeded,
46+
Description: "Installation complete",
47+
LastUpdated: time.Now(),
48+
},
49+
},
50+
{
51+
Name: "postgres-chart",
52+
Status: types.Status{
53+
State: types.StateRunning,
54+
Description: "Installing chart",
55+
LastUpdated: time.Now(),
56+
},
57+
},
58+
},
4159
Status: types.Status{
4260
State: types.StateRunning,
4361
Description: "Installing application",
62+
LastUpdated: time.Now(),
4463
},
4564
Logs: "Installation in progress...",
4665
}
@@ -110,10 +129,25 @@ func TestGetAppInstallStatus(t *testing.T) {
110129
err = json.NewDecoder(rec.Body).Decode(&response)
111130
require.NoError(t, err)
112131

113-
// Verify the response
132+
// Verify the response structure includes components
114133
assert.Equal(t, appInstallStatus.Status.State, response.Status.State)
115134
assert.Equal(t, appInstallStatus.Status.Description, response.Status.Description)
116135
assert.Equal(t, appInstallStatus.Logs, response.Logs)
136+
137+
// Verify components array is present and has expected data
138+
assert.Len(t, response.Components, 2, "Should have 2 components")
139+
140+
// Verify first component (nginx-chart)
141+
nginxComponent := response.Components[0]
142+
assert.Equal(t, "nginx-chart", nginxComponent.Name)
143+
assert.Equal(t, types.StateSucceeded, nginxComponent.Status.State)
144+
assert.Equal(t, "Installation complete", nginxComponent.Status.Description)
145+
146+
// Verify second component (postgres-chart)
147+
postgresComponent := response.Components[1]
148+
assert.Equal(t, "postgres-chart", postgresComponent.Name)
149+
assert.Equal(t, types.StateRunning, postgresComponent.Status.State)
150+
assert.Equal(t, "Installing chart", postgresComponent.Status.Description)
117151
})
118152

119153
t.Run("Authorization error", func(t *testing.T) {

api/integration/linux/install/appinstall_test.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,30 @@ func TestGetAppInstallStatus(t *testing.T) {
5353
}
5454

5555
t.Run("Success", func(t *testing.T) {
56-
// Create app install status
56+
// Create app install status with components
5757
appInstallStatus := types.AppInstall{
58+
Components: []types.AppComponent{
59+
{
60+
Name: "nginx-chart",
61+
Status: types.Status{
62+
State: types.StateSucceeded,
63+
Description: "Installation complete",
64+
LastUpdated: time.Now(),
65+
},
66+
},
67+
{
68+
Name: "postgres-chart",
69+
Status: types.Status{
70+
State: types.StateRunning,
71+
Description: "Installing chart",
72+
LastUpdated: time.Now(),
73+
},
74+
},
75+
},
5876
Status: types.Status{
5977
State: types.StateRunning,
6078
Description: "Installing application",
79+
LastUpdated: time.Now(),
6180
},
6281
Logs: "Installation in progress...",
6382
}
@@ -119,10 +138,25 @@ func TestGetAppInstallStatus(t *testing.T) {
119138
err = json.NewDecoder(rec.Body).Decode(&response)
120139
require.NoError(t, err)
121140

122-
// Verify the response
141+
// Verify the response structure includes components
123142
assert.Equal(t, appInstallStatus.Status.State, response.Status.State)
124143
assert.Equal(t, appInstallStatus.Status.Description, response.Status.Description)
125144
assert.Equal(t, appInstallStatus.Logs, response.Logs)
145+
146+
// Verify components array is present and has expected data
147+
assert.Len(t, response.Components, 2, "Should have 2 components")
148+
149+
// Verify first component (nginx-chart)
150+
nginxComponent := response.Components[0]
151+
assert.Equal(t, "nginx-chart", nginxComponent.Name)
152+
assert.Equal(t, types.StateSucceeded, nginxComponent.Status.State)
153+
assert.Equal(t, "Installation complete", nginxComponent.Status.Description)
154+
155+
// Verify second component (postgres-chart)
156+
postgresComponent := response.Components[1]
157+
assert.Equal(t, "postgres-chart", postgresComponent.Name)
158+
assert.Equal(t, types.StateRunning, postgresComponent.Status.State)
159+
assert.Equal(t, "Installing chart", postgresComponent.Status.Description)
126160
})
127161

128162
t.Run("Authorization error", func(t *testing.T) {

api/internal/managers/app/install/install.go

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import (
1818

1919
// Install installs the app with the provided installable Helm charts and config values
2020
func (m *appInstallManager) Install(ctx context.Context, installableCharts []types.InstallableHelmChart, kotsConfigValues kotsv1beta1.ConfigValues) (finalErr error) {
21+
if err := m.initializeComponents(installableCharts); err != nil {
22+
return fmt.Errorf("initialize components: %w", err)
23+
}
24+
2125
if err := m.setStatus(types.StateRunning, "Installing application"); err != nil {
2226
return fmt.Errorf("set status: %w", err)
2327
}
@@ -109,7 +113,7 @@ func (m *appInstallManager) createConfigValuesFile(kotsConfigValues kotsv1beta1.
109113
}
110114

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

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

120124
for _, installableChart := range installableCharts {
121-
logFn("installing %s helm chart", installableChart.CR.GetChartName())
125+
chartName := installableChart.CR.GetChartName()
126+
logFn("installing %s chart", chartName)
122127

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

127-
logFn("successfully installed %s helm chart", installableChart.CR.GetChartName())
132+
logFn("successfully installed %s chart", chartName)
128133
}
129134

130135
return nil
131136
}
132137

133-
func (m *appInstallManager) installHelmChart(ctx context.Context, installableChart types.InstallableHelmChart) error {
138+
func (m *appInstallManager) installHelmChart(ctx context.Context, installableChart types.InstallableHelmChart) (finalErr error) {
139+
chartName := installableChart.CR.GetChartName()
140+
141+
if err := m.setComponentStatus(chartName, types.StateRunning, "Installing"); err != nil {
142+
return fmt.Errorf("set component status: %w", err)
143+
}
144+
145+
defer func() {
146+
if r := recover(); r != nil {
147+
finalErr = fmt.Errorf("recovered from panic: %v: %s", r, string(debug.Stack()))
148+
}
149+
150+
if finalErr != nil {
151+
if err := m.setComponentStatus(chartName, types.StateFailed, finalErr.Error()); err != nil {
152+
m.logger.WithError(err).Errorf("failed to set %s chart failed status", chartName)
153+
}
154+
} else {
155+
if err := m.setComponentStatus(chartName, types.StateSucceeded, ""); err != nil {
156+
m.logger.WithError(err).Errorf("failed to set %s chart succeeded status", chartName)
157+
}
158+
}
159+
}()
160+
134161
// Write chart archive to temp file
135162
chartPath, err := m.writeChartArchiveToTemp(installableChart.Archive)
136163
if err != nil {
@@ -157,3 +184,13 @@ func (m *appInstallManager) installHelmChart(ctx context.Context, installableCha
157184

158185
return nil
159186
}
187+
188+
// initializeComponents initializes the component tracking with chart names
189+
func (m *appInstallManager) initializeComponents(charts []types.InstallableHelmChart) error {
190+
chartNames := make([]string, 0, len(charts))
191+
for _, chart := range charts {
192+
chartNames = append(chartNames, chart.CR.GetChartName())
193+
}
194+
195+
return m.appInstallStore.RegisterComponents(chartNames)
196+
}

api/internal/managers/app/install/install_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"bytes"
66
"compress/gzip"
77
"context"
8+
"errors"
89
"fmt"
910
"os"
1011
"testing"
@@ -388,6 +389,10 @@ func createTestHelmChartCR(name, releaseName, namespace string, weight int64) *k
388389
Name: name,
389390
},
390391
Spec: kotsv1beta2.HelmChartSpec{
392+
Chart: kotsv1beta2.ChartIdentifier{
393+
Name: name,
394+
ChartVersion: "1.0.0",
395+
},
391396
ReleaseName: releaseName,
392397
Namespace: namespace,
393398
Weight: weight,
@@ -402,3 +407,118 @@ func createTestInstallableHelmChart(t *testing.T, chartName, chartVersion, relea
402407
CR: createTestHelmChartCR(chartName, releaseName, namespace, weight),
403408
}
404409
}
410+
411+
// TestComponentStatusTracking tests that components are properly initialized and tracked
412+
func TestComponentStatusTracking(t *testing.T) {
413+
t.Run("Components are registered and status is tracked", func(t *testing.T) {
414+
// Create test charts with different weights
415+
installableCharts := []types.InstallableHelmChart{
416+
createTestInstallableHelmChart(t, "database-chart", "1.0.0", "postgres", "data", 10, map[string]any{"key": "value1"}),
417+
createTestInstallableHelmChart(t, "web-chart", "2.0.0", "nginx", "web", 20, map[string]any{"key": "value2"}),
418+
}
419+
420+
// Create mock helm client
421+
mockHelmClient := &helm.MockClient{}
422+
423+
// Database chart installation (should be first due to lower weight)
424+
mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool {
425+
return opts.ReleaseName == "postgres" && opts.Namespace == "data"
426+
})).Return(&helmrelease.Release{Name: "postgres"}, nil).Once()
427+
428+
// Web chart installation (should be second due to higher weight)
429+
mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool {
430+
return opts.ReleaseName == "nginx" && opts.Namespace == "web"
431+
})).Return(&helmrelease.Release{Name: "nginx"}, nil).Once()
432+
433+
// Create mock KOTS installer
434+
mockInstaller := &MockKotsCLIInstaller{}
435+
mockInstaller.On("Install", mock.Anything).Return(nil)
436+
437+
// Create manager with in-memory store
438+
appInstallStore := appinstallstore.NewMemoryStore(appinstallstore.WithAppInstall(types.AppInstall{
439+
Status: types.Status{State: types.StatePending},
440+
}))
441+
manager, err := NewAppInstallManager(
442+
WithAppInstallStore(appInstallStore),
443+
WithReleaseData(&release.ReleaseData{}),
444+
WithLicense([]byte(`{"spec":{"appSlug":"test-app"}}`)),
445+
WithClusterID("test-cluster"),
446+
WithKotsCLI(mockInstaller),
447+
WithHelmClient(mockHelmClient),
448+
)
449+
require.NoError(t, err)
450+
451+
// Install the charts
452+
err = manager.Install(context.Background(), installableCharts, kotsv1beta1.ConfigValues{})
453+
require.NoError(t, err)
454+
455+
// Verify that components were registered and have correct status
456+
appInstall, err := manager.GetStatus()
457+
require.NoError(t, err)
458+
459+
// Should have 2 components
460+
assert.Len(t, appInstall.Components, 2, "Should have 2 components")
461+
462+
// Components should be sorted by weight (database first with weight 10, web second with weight 20)
463+
assert.Equal(t, "database-chart", appInstall.Components[0].Name)
464+
assert.Equal(t, types.StateSucceeded, appInstall.Components[0].Status.State)
465+
466+
assert.Equal(t, "web-chart", appInstall.Components[1].Name)
467+
assert.Equal(t, types.StateSucceeded, appInstall.Components[1].Status.State)
468+
469+
// Overall status should be succeeded
470+
assert.Equal(t, types.StateSucceeded, appInstall.Status.State)
471+
assert.Equal(t, "Installation complete", appInstall.Status.Description)
472+
473+
mockInstaller.AssertExpectations(t)
474+
mockHelmClient.AssertExpectations(t)
475+
})
476+
477+
t.Run("Component failure is tracked correctly", func(t *testing.T) {
478+
// Create test chart
479+
installableCharts := []types.InstallableHelmChart{
480+
createTestInstallableHelmChart(t, "failing-chart", "1.0.0", "failing-app", "default", 0, map[string]any{"key": "value"}),
481+
}
482+
483+
// Create mock helm client that fails
484+
mockHelmClient := &helm.MockClient{}
485+
mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool {
486+
return opts.ReleaseName == "failing-app"
487+
})).Return((*helmrelease.Release)(nil), errors.New("helm install failed"))
488+
489+
// Create manager with in-memory store
490+
appInstallStore := appinstallstore.NewMemoryStore(appinstallstore.WithAppInstall(types.AppInstall{
491+
Status: types.Status{State: types.StatePending},
492+
}))
493+
manager, err := NewAppInstallManager(
494+
WithAppInstallStore(appInstallStore),
495+
WithReleaseData(&release.ReleaseData{}),
496+
WithLicense([]byte(`{"spec":{"appSlug":"test-app"}}`)),
497+
WithClusterID("test-cluster"),
498+
WithHelmClient(mockHelmClient),
499+
)
500+
require.NoError(t, err)
501+
502+
// Install the charts (should fail)
503+
err = manager.Install(context.Background(), installableCharts, kotsv1beta1.ConfigValues{})
504+
require.Error(t, err)
505+
506+
// Verify that component failure is tracked
507+
appInstall, err := manager.GetStatus()
508+
require.NoError(t, err)
509+
510+
// Should have 1 component
511+
assert.Len(t, appInstall.Components, 1, "Should have 1 component")
512+
513+
// Component should be marked as failed
514+
failedComponent := appInstall.Components[0]
515+
assert.Equal(t, "failing-chart", failedComponent.Name)
516+
assert.Equal(t, types.StateFailed, failedComponent.Status.State)
517+
assert.Contains(t, failedComponent.Status.Description, "helm install failed")
518+
519+
// Overall status should be failed
520+
assert.Equal(t, types.StateFailed, appInstall.Status.State)
521+
522+
mockHelmClient.AssertExpectations(t)
523+
})
524+
}

api/internal/managers/app/install/status.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,11 @@ func (m *appInstallManager) setStatus(state types.State, description string) err
1717
LastUpdated: time.Now(),
1818
})
1919
}
20+
21+
func (m *appInstallManager) setComponentStatus(componentName string, state types.State, description string) error {
22+
return m.appInstallStore.SetComponentStatus(componentName, types.Status{
23+
State: state,
24+
Description: description,
25+
LastUpdated: time.Now(),
26+
})
27+
}

0 commit comments

Comments
 (0)