Skip to content

Commit a42cceb

Browse files
authored
feat(ec-updates): don't allow updates of >1 minor kube versions for airgap (#5473)
1 parent ce89e05 commit a42cceb

8 files changed

Lines changed: 180 additions & 8 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ require (
5151
github.com/pkg/errors v0.9.1
5252
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
5353
github.com/replicatedhq/embedded-cluster/kinds v1.15.1-0.20250729184643-f055e67a064d
54-
github.com/replicatedhq/kotskinds v0.0.0-20250411153224-089dbeb7ba2a
54+
github.com/replicatedhq/kotskinds v0.0.0-20250813145521-a47bae9097bc
5555
github.com/replicatedhq/kurlkinds v1.5.0
5656
github.com/replicatedhq/troubleshoot v0.121.2
5757
github.com/replicatedhq/yaml/v3 v3.0.0-beta5-replicatedhq

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1862,8 +1862,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uY
18621862
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
18631863
github.com/replicatedhq/embedded-cluster/kinds v1.15.1-0.20250729184643-f055e67a064d h1:Wl0fHoWTcrveqbWmDVEPFoa/K3EzyROC8tjHZxzwg3Q=
18641864
github.com/replicatedhq/embedded-cluster/kinds v1.15.1-0.20250729184643-f055e67a064d/go.mod h1:gZdtXCAVJt0a5Ry64Jlaeph7qh+IGsAkq4P0PE6XiZI=
1865-
github.com/replicatedhq/kotskinds v0.0.0-20250411153224-089dbeb7ba2a h1:aNZ7qcuEmPGIUIIfxF7c0sdKR2+zL2vc5r2V8j8a49I=
1866-
github.com/replicatedhq/kotskinds v0.0.0-20250411153224-089dbeb7ba2a/go.mod h1:QjhIUu3+OmHZ09u09j3FCoTt8F3BYtQglS+OLmftu9I=
1865+
github.com/replicatedhq/kotskinds v0.0.0-20250813145521-a47bae9097bc h1:ytcOyselshUTCaUcWakxn0ltt1HFM+6LWez02Uik+J8=
1866+
github.com/replicatedhq/kotskinds v0.0.0-20250813145521-a47bae9097bc/go.mod h1:oR0Gnu3MwBFVM75uwmLFmhTtEsKQlvECHQ27oHUb9TM=
18671867
github.com/replicatedhq/kurlkinds v1.5.0 h1:zZ0PKNeh4kXvSzVGkn62DKTo314GxhXg1TSB3azURMc=
18681868
github.com/replicatedhq/kurlkinds v1.5.0/go.mod h1:rUpBMdC81IhmJNCWMU/uRsMETv9P0xFoMvdSP/TAr5A=
18691869
github.com/replicatedhq/termui/v3 v3.1.1-0.20200811145416-f40076d26851 h1:eRlNDHxGfVkPCRXbA4BfQJvt5DHjFiTtWy3R/t4djyY=

pkg/airgap/update.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ func UpdateAppFromPath(a *apptypes.App, airgapRoot string, airgapBundlePath stri
9595
return errors.Wrap(err, "failed to find airgap meta")
9696
}
9797

98-
deployable, nonDeployableCause, err := update.IsAirgapUpdateDeployable(a, airgap)
98+
currentECVersion := util.EmbeddedClusterVersion()
99+
deployable, nonDeployableCause, err := update.IsAirgapUpdateDeployable(a, airgap, currentECVersion)
99100
if err != nil {
100101
return errors.Wrapf(err, "failed to check if airgap update is deployable")
101102
}

pkg/handlers/app.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,8 @@ func (h *Handler) CanInstallAppVersion(w http.ResponseWriter, r *http.Request) {
569569
return
570570
}
571571

572-
deployable, nonDeployableCause, err := update.IsAirgapUpdateDeployable(a, decoded.(*kotsv1beta1.Airgap))
572+
currentECVersion := util.EmbeddedClusterVersion()
573+
deployable, nonDeployableCause, err := update.IsAirgapUpdateDeployable(a, decoded.(*kotsv1beta1.Airgap), currentECVersion)
573574
if err != nil {
574575
response.Error = "failed to check if airgap update is deployable"
575576
logger.Error(errors.Wrap(err, response.Error))

pkg/handlers/upgrade_service.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,8 @@ func canStartUpgradeService(a *apptypes.App, r StartUpgradeServiceRequest) (bool
124124
if r.ChannelID != airgap.Spec.ChannelID {
125125
return false, "channel mismatch", nil
126126
}
127-
isDeployable, nonDeployableCause, err := update.IsAirgapUpdateDeployable(a, airgap)
127+
currentECVersion := util.EmbeddedClusterVersion()
128+
isDeployable, nonDeployableCause, err := update.IsAirgapUpdateDeployable(a, airgap, currentECVersion)
128129
if err != nil {
129130
return false, "", errors.Wrap(err, "failed to check if airgap update is deployable")
130131
}

pkg/update/required.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func getAvailableUpdates(updates []upstreamtypes.Update, currentECVersion string
5757
return availableUpdates
5858
}
5959

60-
func IsAirgapUpdateDeployable(app *apptypes.App, airgap *kotsv1beta1.Airgap) (bool, string, error) {
60+
func IsAirgapUpdateDeployable(app *apptypes.App, airgap *kotsv1beta1.Airgap, currentECVersion string) (bool, string, error) {
6161
appVersions, err := store.GetStore().FindDownstreamVersions(app.ID, true)
6262
if err != nil {
6363
return false, "", errors.Wrap(err, "failed to get downstream versions")
@@ -73,6 +73,12 @@ func IsAirgapUpdateDeployable(app *apptypes.App, airgap *kotsv1beta1.Airgap) (bo
7373
if len(requiredUpdates) > 0 {
7474
return false, getRequiredNonDeployableCause(requiredUpdates), nil
7575
}
76+
// If this is an EC install, check if the update kubernetes version is within the range of what we can upgrade to
77+
if currentECVersion != "" && airgap.Spec.EmbeddedClusterVersion != "" {
78+
if err := util.UpdateWithinKubeRange(currentECVersion, airgap.Spec.EmbeddedClusterVersion); err != nil {
79+
return false, getKubeVersionNonDeployableCause(err), nil
80+
}
81+
}
7682
return true, "", nil
7783
}
7884

pkg/update/required_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ import (
44
"testing"
55

66
"github.com/blang/semver"
7+
"github.com/golang/mock/gomock"
78
downstreamtypes "github.com/replicatedhq/kots/pkg/api/downstream/types"
9+
apptypes "github.com/replicatedhq/kots/pkg/app/types"
810
"github.com/replicatedhq/kots/pkg/cursor"
11+
"github.com/replicatedhq/kots/pkg/store"
12+
mock_store "github.com/replicatedhq/kots/pkg/store/mock"
913
kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1"
1014
"github.com/stretchr/testify/require"
1115
)
@@ -287,3 +291,161 @@ func Test_getRequiredAirgapUpdates(t *testing.T) {
287291
})
288292
}
289293
}
294+
295+
func TestIsAirgapUpdateDeployable(t *testing.T) {
296+
tests := []struct {
297+
name string
298+
airgap *kotsv1beta1.Airgap
299+
currentECVersion string
300+
wantDeployable bool
301+
wantCause string
302+
setupStore func(mockStore *mock_store.MockStore)
303+
}{
304+
{
305+
name: "not deployable due to required releases",
306+
airgap: &kotsv1beta1.Airgap{
307+
Spec: kotsv1beta1.AirgapSpec{
308+
ChannelID: "test-channel",
309+
RequiredReleases: []kotsv1beta1.AirgapReleaseMeta{
310+
{
311+
VersionLabel: "1.0.0",
312+
UpdateCursor: "100",
313+
},
314+
},
315+
AirgapReleaseMeta: kotsv1beta1.AirgapReleaseMeta{
316+
VersionLabel: "1.1.0",
317+
UpdateCursor: "110",
318+
EmbeddedClusterVersion: "2.7.4+k8s-1.30",
319+
},
320+
},
321+
},
322+
currentECVersion: "2.7.3+k8s-1.29",
323+
wantDeployable: false,
324+
wantCause: "This version cannot be deployed because version 1.0.0 is required and must be deployed first.",
325+
setupStore: func(mockStore *mock_store.MockStore) {
326+
s := semver.MustParse("0.9.0")
327+
c := cursor.MustParse("90")
328+
mockStore.EXPECT().FindDownstreamVersions("test-app", true).Return(&downstreamtypes.DownstreamVersions{
329+
AllVersions: []*downstreamtypes.DownstreamVersion{
330+
{
331+
ChannelID: "test-channel",
332+
VersionLabel: "0.9.0",
333+
UpdateCursor: "90",
334+
Semver: &s,
335+
Cursor: &c,
336+
},
337+
},
338+
}, nil)
339+
},
340+
},
341+
{
342+
name: "deployable when no required updates and compatible EC version",
343+
airgap: &kotsv1beta1.Airgap{
344+
Spec: kotsv1beta1.AirgapSpec{
345+
ChannelID: "test-channel",
346+
AirgapReleaseMeta: kotsv1beta1.AirgapReleaseMeta{
347+
EmbeddedClusterVersion: "2.7.4+k8s-1.30",
348+
},
349+
},
350+
},
351+
currentECVersion: "2.7.3+k8s-1.29",
352+
wantDeployable: true,
353+
wantCause: "",
354+
setupStore: func(mockStore *mock_store.MockStore) {
355+
mockStore.EXPECT().FindDownstreamVersions("test-app", true).Return(&downstreamtypes.DownstreamVersions{
356+
AllVersions: []*downstreamtypes.DownstreamVersion{},
357+
}, nil)
358+
},
359+
},
360+
{
361+
name: "not deployable due to EC version incompatibility",
362+
airgap: &kotsv1beta1.Airgap{
363+
Spec: kotsv1beta1.AirgapSpec{
364+
ChannelID: "test-channel",
365+
AirgapReleaseMeta: kotsv1beta1.AirgapReleaseMeta{
366+
EmbeddedClusterVersion: "2.8.0+k8s-1.31",
367+
},
368+
},
369+
},
370+
currentECVersion: "2.7.4+k8s-1.29",
371+
wantDeployable: false,
372+
wantCause: "Before you can update to this version, you need to update to an earlier version that includes the required infrastructure update.",
373+
setupStore: func(mockStore *mock_store.MockStore) {
374+
mockStore.EXPECT().FindDownstreamVersions("test-app", true).Return(&downstreamtypes.DownstreamVersions{
375+
AllVersions: []*downstreamtypes.DownstreamVersion{},
376+
}, nil)
377+
},
378+
},
379+
{
380+
name: "deployable when airgap EC version is empty",
381+
airgap: &kotsv1beta1.Airgap{
382+
Spec: kotsv1beta1.AirgapSpec{
383+
ChannelID: "test-channel",
384+
AirgapReleaseMeta: kotsv1beta1.AirgapReleaseMeta{
385+
EmbeddedClusterVersion: "",
386+
},
387+
},
388+
},
389+
currentECVersion: "2.7.3+k8s-1.29",
390+
wantDeployable: true,
391+
wantCause: "",
392+
setupStore: func(mockStore *mock_store.MockStore) {
393+
mockStore.EXPECT().FindDownstreamVersions("test-app", true).Return(&downstreamtypes.DownstreamVersions{
394+
AllVersions: []*downstreamtypes.DownstreamVersion{},
395+
}, nil)
396+
},
397+
},
398+
{
399+
name: "deployable when current EC version is empty (non-ec installation) even if airgap EC version is set",
400+
airgap: &kotsv1beta1.Airgap{
401+
Spec: kotsv1beta1.AirgapSpec{
402+
ChannelID: "test-channel",
403+
AirgapReleaseMeta: kotsv1beta1.AirgapReleaseMeta{
404+
EmbeddedClusterVersion: "2.7.5+k8s-1.30",
405+
},
406+
},
407+
},
408+
currentECVersion: "",
409+
wantDeployable: true,
410+
wantCause: "",
411+
setupStore: func(mockStore *mock_store.MockStore) {
412+
mockStore.EXPECT().FindDownstreamVersions("test-app", true).Return(&downstreamtypes.DownstreamVersions{
413+
AllVersions: []*downstreamtypes.DownstreamVersion{},
414+
}, nil)
415+
},
416+
},
417+
}
418+
419+
for _, tt := range tests {
420+
t.Run(tt.name, func(t *testing.T) {
421+
ctrl := gomock.NewController(t)
422+
defer ctrl.Finish()
423+
424+
mockStore := mock_store.NewMockStore(ctrl)
425+
tt.setupStore(mockStore)
426+
427+
store.SetStore(mockStore)
428+
defer store.SetStore(nil)
429+
430+
app := &apptypes.App{
431+
ID: "test-app",
432+
SelectedChannelID: "test-channel",
433+
License: `apiVersion: kots.io/v1beta1
434+
kind: License
435+
spec:
436+
channelID: test-channel
437+
channels:
438+
- channelID: test-channel
439+
channelName: Test
440+
isDefault: true
441+
isSemverRequired: false`,
442+
}
443+
444+
deployable, cause, err := IsAirgapUpdateDeployable(app, tt.airgap, tt.currentECVersion)
445+
446+
require.NoError(t, err)
447+
require.Equal(t, tt.wantDeployable, deployable)
448+
require.Equal(t, tt.wantCause, cause)
449+
})
450+
}
451+
}

pkg/update/update.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ func GetAvailableAirgapUpdates(app *apptypes.App, license *kotsv1beta1.License)
9393
return nil // skip airgap updates that are not for the current channel, preserving previous behavior
9494
}
9595

96-
deployable, nonDeployableCause, err := IsAirgapUpdateDeployable(app, airgap)
96+
currentECVersion := util.EmbeddedClusterVersion()
97+
deployable, nonDeployableCause, err := IsAirgapUpdateDeployable(app, airgap, currentECVersion)
9798
if err != nil {
9899
return errors.Wrap(err, "failed to check if airgap update is deployable")
99100
}

0 commit comments

Comments
 (0)