diff --git a/internal/cmd/install/dataplane.go b/internal/cmd/install/dataplane.go index 2aeb1a6..e71c108 100644 --- a/internal/cmd/install/dataplane.go +++ b/internal/cmd/install/dataplane.go @@ -17,7 +17,8 @@ import ( // DataplaneCmd handles dataplane installation type DataplaneCmd struct { - Namespace string `short:"n" help:"Target namespace (default: 'default' for Kind clusters, current kubeconfig context for existing clusters)."` + Namespace string `short:"n" help:"Target namespace (default: 'default' for Kind clusters, current kubeconfig context for existing clusters)."` + ChartVersion string `help:"Version of the airbyte-data-plane chart to install (default: latest chart V2 release)."` // Hide flag for now. WithKindCluster bool `default:"true" help:"Create a Kind cluster for the dataplane and set it as current context." hidden:""` } @@ -62,6 +63,12 @@ func (c *DataplaneCmd) Run( return err } + chartURL, chartVersion, repoURL, err := helm.ResolveDataplaneChartReference(c.ChartVersion) + if err != nil { + return fmt.Errorf("failed to resolve dataplane chart: %w", err) + } + c.warnOnChartV1(ui) + // Setup Kind cluster if needed var kubeconfig, kubeContext, clusterName string if c.WithKindCluster { @@ -84,7 +91,7 @@ func (c *DataplaneCmd) Run( } // Deploy to Kubernetes - if err := deployChart(ctx, ui, helmClient, c.Namespace, name, creds, abContext); err != nil { + if err := deployChart(ctx, ui, helmClient, c.Namespace, name, chartURL, chartVersion, repoURL, creds, abContext); err != nil { return err } @@ -93,6 +100,18 @@ func (c *DataplaneCmd) Run( return nil } +// warnOnChartV1 notifies the user when an explicitly requested chart version +// comes from the retired V1 Helm repository. +func (c *DataplaneCmd) warnOnChartV1(ui ui.Provider) { + if c.ChartVersion == "" || helm.ChartIsV2PlusBaseVersion(c.ChartVersion) { + return + } + ui.ShowInfo(fmt.Sprintf( + "Warning: chart V1 (%s) is retired and no longer receives updates. Migrate to chart V2: https://docs.airbyte.com/platform/deploying-airbyte/chart-v2-community", + c.ChartVersion, + )) +} + // resolveNamespace sets the namespace based on cluster type func (c *DataplaneCmd) resolveNamespace() error { if c.Namespace != "" { @@ -176,9 +195,9 @@ func registerDataplane(ctx context.Context, ui ui.Provider, apiClient api.Servic } // deployChart installs the Helm chart -func deployChart(ctx context.Context, ui ui.Provider, client goHelm.Client, namespace, name string, creds *api.CreateDataplaneResponse, context *airbox.Context) error { +func deployChart(ctx context.Context, ui ui.Provider, client goHelm.Client, namespace, name, chartURL, chartVersion, repoURL string, creds *api.CreateDataplaneResponse, context *airbox.Context) error { return ui.RunWithSpinner("Installing dataplane chart", func() error { - return helm.InstallDataplaneChart(ctx, client, namespace, name, creds, context) + return helm.InstallDataplaneChart(ctx, client, namespace, name, chartURL, chartVersion, repoURL, creds, context) }) } diff --git a/internal/cmd/install/dataplane_test.go b/internal/cmd/install/dataplane_test.go index 3dc9fc4..97fab35 100644 --- a/internal/cmd/install/dataplane_test.go +++ b/internal/cmd/install/dataplane_test.go @@ -27,6 +27,7 @@ func TestDataplaneCmd_Run(t *testing.T) { name string namespace string withKind bool + chartVersion string expectedError string setupMocks func(ctrl *gomock.Controller) (airbox.ConfigStore, http.HTTPDoer, airbox.APIServiceFactory, helm.Factory, k8s.ClusterFactory, *uimock.MockProvider) }{ @@ -241,8 +242,9 @@ func TestDataplaneCmd_Run(t *testing.T) { }, }, { - name: "success flow with Kind cluster", - withKind: true, + name: "success flow with Kind cluster", + withKind: true, + chartVersion: "1.9.2", setupMocks: func(ctrl *gomock.Controller) (airbox.ConfigStore, http.HTTPDoer, airbox.APIServiceFactory, helm.Factory, k8s.ClusterFactory, *uimock.MockProvider) { mockStore := airboxmock.NewMockConfigStore(ctrl) mockHTTP := httpmock.NewMockHTTPDoer(ctrl) @@ -291,6 +293,7 @@ func TestDataplaneCmd_Run(t *testing.T) { }, nil), mockUI.EXPECT().FilterableSelect("Select existing region:", []string{"Test Region - US (AWS)"}).Return(0, "Test Region - US (AWS)", nil), mockUI.EXPECT().TextInput("Enter dataplane name:", "my-dataplane", gomock.Any()).Return("test-dataplane", nil), + mockUI.EXPECT().ShowInfo(gomock.Any()), mockUI.EXPECT().RunWithSpinner("Creating Kind cluster", gomock.Any()).DoAndReturn(func(msg string, fn func() error) error { return fn() }), @@ -407,8 +410,14 @@ func TestDataplaneCmd_Run(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() + chartVersion := tt.chartVersion + if chartVersion == "" { + chartVersion = "2.1.1" + } + cmd := &DataplaneCmd{ Namespace: tt.namespace, + ChartVersion: chartVersion, WithKindCluster: tt.withKind, } @@ -425,3 +434,30 @@ func TestDataplaneCmd_Run(t *testing.T) { }) } } + +func TestDataplaneCmdWarnOnChartV1(t *testing.T) { + tests := []struct { + name string + chartVersion string + warn bool + }{ + {name: "v1 chart", chartVersion: "1.9.2", warn: true}, + {name: "v2 prerelease chart", chartVersion: "2.0.0-rc1", warn: false}, + {name: "empty chart version", chartVersion: "", warn: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ui := uimock.NewMockProvider(ctrl) + if tt.warn { + ui.EXPECT().ShowInfo(gomock.Any()) + } + + cmd := &DataplaneCmd{ChartVersion: tt.chartVersion} + cmd.warnOnChartV1(ui) + }) + } +} diff --git a/internal/cmd/local/install.go b/internal/cmd/local/install.go index 5f90984..d82c297 100644 --- a/internal/cmd/local/install.go +++ b/internal/cmd/local/install.go @@ -241,6 +241,11 @@ func (i *InstallCmd) setDefaultChartFlags(helmClient goHelm.Client) error { i.Chart = resolvedChart i.ChartVersion = resolvedVersion + if resolvedVersion != "" && !helm.ChartIsV2PlusBaseVersion(resolvedVersion) { + pterm.Warning.Printfln( + "Chart V1 is deprecated. Upgrade to the V2 chart: https://docs.airbyte.com/platform/deploying-airbyte/chart-v2-community", + ) + } return nil } diff --git a/internal/helm/chart.go b/internal/helm/chart.go index ad57b98..1c855ba 100644 --- a/internal/helm/chart.go +++ b/internal/helm/chart.go @@ -43,6 +43,13 @@ func ChartIsV2Plus(v string) bool { return ChartEqualsOrHigherVersion(v, "v2.0.0") } +// ChartIsV2PlusBaseVersion returns true if the base version before any +// pre-release suffix is v2.0.0 or higher. +func ChartIsV2PlusBaseVersion(v string) bool { + baseVersion, _, _ := strings.Cut(v, "-") + return ChartIsV2Plus(baseVersion) +} + // ChartIsV1Dot8Plus returns true if the chart version is v1.8.0 or higher func ChartIsV1Dot8Plus(v string) bool { return ChartEqualsOrHigherVersion(v, "v1.8.0") @@ -72,10 +79,8 @@ func (r *ChartResolver) ResolveChartReference(chart, version string) (string, st } return chartURL, chartVersion, nil } else { - // Extract base version without suffix (e.g., "1.8.4-rc5" -> "1.8.4") - // to determine which repo to use, but keep full version for URL - baseVersion, _, _ := strings.Cut(version, "-") - if ChartIsV2Plus(baseVersion) { + // Extract the base version to determine which repo to use, but keep the full version for URL. + if ChartIsV2PlusBaseVersion(version) { // Construct the v2 chart URL. return fmt.Sprintf("%s/airbyte-%s.tgz", r.v2RepoURL, version), version, nil } else { diff --git a/internal/helm/chart_test.go b/internal/helm/chart_test.go index fc8341e..973d362 100644 --- a/internal/helm/chart_test.go +++ b/internal/helm/chart_test.go @@ -65,6 +65,25 @@ func TestChartIsV2Plus(t *testing.T) { } } +func TestChartIsV2PlusBaseVersion(t *testing.T) { + tests := []struct { + name string + ver string + want bool + }{ + {name: "empty version", ver: "", want: false}, + {name: "v1 version", ver: "1.9.2", want: false}, + {name: "v2 release candidate", ver: "2.0.0-rc1", want: true}, + {name: "v2 version", ver: "2.0.0", want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ChartIsV2PlusBaseVersion(tt.ver)) + }) + } +} + func TestChartIsV1Dot8Plus(t *testing.T) { tests := []struct { name string @@ -314,3 +333,25 @@ entries: }) } } + +func TestGetLatestChartURLFromRepoIndex(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `apiVersion: v1 +entries: + airbyte-data-plane: + - name: "airbyte-data-plane" + version: "2.1.1" + urls: ["airbyte-data-plane-2.1.1.tgz"] + - name: "airbyte-data-plane" + version: "2.1.2-beta.1" + urls: ["airbyte-data-plane-2.1.2-beta.1.tgz"] +`) + })) + defer server.Close() + + url, version, err := GetLatestChartURLFromRepoIndex("airbyte", server.URL, "airbyte-data-plane") + + assert.NoError(t, err) + assert.Equal(t, server.URL+"/airbyte-data-plane-2.1.1.tgz", url) + assert.Equal(t, "2.1.1", version) +} diff --git a/internal/helm/dataplane.go b/internal/helm/dataplane.go index d6bb579..488963c 100644 --- a/internal/helm/dataplane.go +++ b/internal/helm/dataplane.go @@ -13,18 +13,41 @@ import ( ) const ( - dataplaneChartName = "airbyte/airbyte-data-plane" - dataplaneChartVersion = "1.8.1" - dataplaneRepoName = "airbyte" - dataplaneRepoURL = common.AirbyteRepoURLv1 // v1 repo has the dataplane chart + dataplaneRepoName = "airbyte" + dataplaneChartName = "airbyte-data-plane" ) -// InstallDataplaneChart installs the official Airbyte dataplane Helm chart -func InstallDataplaneChart(ctx context.Context, client goHelm.Client, namespace, releaseName string, credentials *api.CreateDataplaneResponse, config *airbox.Context) error { +var getLatestDataplaneChartURL = func(repoURL string) (string, string, error) { + return GetLatestChartURLFromRepoIndex(dataplaneRepoName, repoURL, dataplaneChartName) +} + +// ResolveDataplaneChartReference resolves the dataplane chart URL, version and +// source repository for the requested version. An empty version resolves the +// latest stable chart from the v2 repository; a version below 2.0.0 resolves +// against the deprecated v1 repository. +func ResolveDataplaneChartReference(version string) (chartURL, chartVersion, repoURL string, err error) { + if version == "" { + chartURL, chartVersion, err = getLatestDataplaneChartURL(common.AirbyteRepoURLv2) + if err != nil { + return "", "", "", fmt.Errorf("failed to resolve latest dataplane chart: %w", err) + } + return chartURL, chartVersion, common.AirbyteRepoURLv2, nil + } + + repoURL = common.AirbyteRepoURLv1 + if ChartIsV2PlusBaseVersion(version) { + repoURL = common.AirbyteRepoURLv2 + } + + return fmt.Sprintf("%s/%s-%s.tgz", repoURL, dataplaneChartName, version), version, repoURL, nil +} + +// InstallDataplaneChart installs the official Airbyte dataplane Helm chart. +func InstallDataplaneChart(ctx context.Context, client goHelm.Client, namespace, releaseName, chartURL, chartVersion, repoURL string, credentials *api.CreateDataplaneResponse, config *airbox.Context) error { // Add the Airbyte Helm repository if err := client.AddOrUpdateChartRepo(repo.Entry{ Name: dataplaneRepoName, - URL: dataplaneRepoURL, + URL: repoURL, }); err != nil { return fmt.Errorf("failed to add Airbyte chart repository: %w", err) } @@ -35,8 +58,8 @@ func InstallDataplaneChart(ctx context.Context, client goHelm.Client, namespace, // Install the chart with atomic flag to ensure cleanup on failure/interrupt _, err := client.InstallOrUpgradeChart(ctx, &goHelm.ChartSpec{ ReleaseName: releaseName, - ChartName: dataplaneChartName, - Version: dataplaneChartVersion, + ChartName: chartURL, + Version: chartVersion, CreateNamespace: true, Namespace: namespace, Wait: true, diff --git a/internal/helm/dataplane_test.go b/internal/helm/dataplane_test.go index 9d86cb4..cd74d3f 100644 --- a/internal/helm/dataplane_test.go +++ b/internal/helm/dataplane_test.go @@ -8,6 +8,7 @@ import ( "github.com/airbytehq/abctl/internal/airbox" "github.com/airbytehq/abctl/internal/api" + "github.com/airbytehq/abctl/internal/common" "github.com/airbytehq/abctl/internal/helm/mock" goHelm "github.com/mittwald/go-helm-client" "github.com/stretchr/testify/assert" @@ -113,11 +114,68 @@ func TestBuildDataplaneValues(t *testing.T) { } } +func TestResolveDataplaneChartReference(t *testing.T) { + originalResolver := getLatestDataplaneChartURL + t.Cleanup(func() { getLatestDataplaneChartURL = originalResolver }) + getLatestDataplaneChartURL = func(string) (string, string, error) { + return "https://example.com/airbyte-data-plane-2.1.1.tgz", "2.1.1", nil + } + + tests := []struct { + name string + version string + wantChartURL string + wantVersion string + wantRepoURL string + }{ + { + name: "empty version resolves latest v2", + wantChartURL: "https://example.com/airbyte-data-plane-2.1.1.tgz", + wantVersion: "2.1.1", + wantRepoURL: common.AirbyteRepoURLv2, + }, + { + name: "v2 version uses the v2 repo", + version: "2.1.0", + wantChartURL: common.AirbyteRepoURLv2 + "/airbyte-data-plane-2.1.0.tgz", + wantVersion: "2.1.0", + wantRepoURL: common.AirbyteRepoURLv2, + }, + { + name: "v1 version uses the v1 repo", + version: "1.8.1", + wantChartURL: common.AirbyteRepoURLv1 + "/airbyte-data-plane-1.8.1.tgz", + wantVersion: "1.8.1", + wantRepoURL: common.AirbyteRepoURLv1, + }, + { + name: "prerelease suffix does not change repo selection", + version: "2.2.0-rc1", + wantChartURL: common.AirbyteRepoURLv2 + "/airbyte-data-plane-2.2.0-rc1.tgz", + wantVersion: "2.2.0-rc1", + wantRepoURL: common.AirbyteRepoURLv2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chartURL, version, repoURL, err := ResolveDataplaneChartReference(tt.version) + assert.NoError(t, err) + assert.Equal(t, tt.wantChartURL, chartURL) + assert.Equal(t, tt.wantVersion, version) + assert.Equal(t, tt.wantRepoURL, repoURL) + }) + } +} + func TestInstallDataplaneChart(t *testing.T) { tests := []struct { name string namespace string releaseName string + chartVersion string + wantChartURL string + wantChartVersion string credentials *api.CreateDataplaneResponse config *airbox.Context repoError error @@ -141,8 +199,32 @@ func TestInstallDataplaneChart(t *testing.T) { }, expectedRepoEntry: repo.Entry{ Name: dataplaneRepoName, - URL: dataplaneRepoURL, + URL: common.AirbyteRepoURLv2, + }, + wantChartURL: "https://example.com/airbyte-data-plane-2.1.1.tgz", + wantChartVersion: "2.1.1", + }, + { + name: "explicit v1 version installs from the v1 repo", + namespace: "test-namespace", + releaseName: "test-dataplane", + chartVersion: "1.8.1", + credentials: &api.CreateDataplaneResponse{ + DataplaneID: "test-dataplane-id", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + }, + config: &airbox.Context{ + AirbyteURL: "https://test.example.com", + AirbyteAPIURL: "https://test.example.com/api/v1", + Edition: "enterprise", + }, + expectedRepoEntry: repo.Entry{ + Name: dataplaneRepoName, + URL: common.AirbyteRepoURLv1, }, + wantChartURL: common.AirbyteRepoURLv1 + "/airbyte-data-plane-1.8.1.tgz", + wantChartVersion: "1.8.1", }, { name: "repository addition fails", @@ -160,10 +242,12 @@ func TestInstallDataplaneChart(t *testing.T) { }, expectedRepoEntry: repo.Entry{ Name: dataplaneRepoName, - URL: dataplaneRepoURL, + URL: common.AirbyteRepoURLv2, }, - repoError: assert.AnError, - expectedError: "failed to add Airbyte chart repository", + wantChartURL: "https://example.com/airbyte-data-plane-2.1.1.tgz", + wantChartVersion: "2.1.1", + repoError: assert.AnError, + expectedError: "failed to add Airbyte chart repository", }, { name: "chart installation fails", @@ -181,10 +265,12 @@ func TestInstallDataplaneChart(t *testing.T) { }, expectedRepoEntry: repo.Entry{ Name: dataplaneRepoName, - URL: dataplaneRepoURL, + URL: common.AirbyteRepoURLv2, }, - installError: assert.AnError, - expectedError: "failed to install dataplane chart", + wantChartURL: "https://example.com/airbyte-data-plane-2.1.1.tgz", + wantChartVersion: "2.1.1", + installError: assert.AnError, + expectedError: "failed to install dataplane chart", }, } @@ -204,8 +290,8 @@ func TestInstallDataplaneChart(t *testing.T) { DoAndReturn(func(ctx context.Context, spec *goHelm.ChartSpec, opts *goHelm.GenericHelmOptions) (*release.Release, error) { // Verify chart spec parameters assert.Equal(t, tt.releaseName, spec.ReleaseName) - assert.Equal(t, dataplaneChartName, spec.ChartName) - assert.Equal(t, dataplaneChartVersion, spec.Version) + assert.Equal(t, tt.wantChartURL, spec.ChartName) + assert.Equal(t, tt.wantChartVersion, spec.Version) assert.Equal(t, tt.namespace, spec.Namespace) assert.True(t, spec.CreateNamespace) assert.True(t, spec.Wait) @@ -222,7 +308,7 @@ func TestInstallDataplaneChart(t *testing.T) { }) } - err := InstallDataplaneChart(context.Background(), mockClient, tt.namespace, tt.releaseName, tt.credentials, tt.config) + err := InstallDataplaneChart(context.Background(), mockClient, tt.namespace, tt.releaseName, tt.wantChartURL, tt.wantChartVersion, tt.expectedRepoEntry.URL, tt.credentials, tt.config) if tt.expectedError != "" { assert.Error(t, err) diff --git a/internal/helm/locate.go b/internal/helm/locate.go index 7959012..a01f96e 100644 --- a/internal/helm/locate.go +++ b/internal/helm/locate.go @@ -39,13 +39,19 @@ var defaultNewChartRepo newChartRepo = func(cfg *repo.Entry, getters getter.Prov // This variable should only be modified for testing purposes. var defaultLoadIndexFile loadIndexFile = repo.LoadIndexFile -// GetLatestAirbyteChartUrlFromRepoIndex fetches the latest stable Airbyte Helm chart URL and version -// from the given Helm repository index. Returns the chart download URL, the chart version, and an error if any. -// Only stable (non-prerelease) versions are considered. -func GetLatestAirbyteChartUrlFromRepoIndex(repoName, repoUrl string) (string, string, error) { +// GetLatestAirbyteChartUrlFromRepoIndex fetches the latest stable Airbyte chart +// URL and version from the given Helm repository index. +func GetLatestAirbyteChartUrlFromRepoIndex(repoName, repoURL string) (string, string, error) { + return GetLatestChartURLFromRepoIndex(repoName, repoURL, "airbyte") +} + +// GetLatestChartURLFromRepoIndex fetches the latest stable chart URL and version +// for chartName from the given Helm repository index. Only stable (non-prerelease) +// versions are considered. +func GetLatestChartURLFromRepoIndex(repoName, repoURL, chartName string) (string, string, error) { chartRepository, err := defaultNewChartRepo(&repo.Entry{ Name: repoName, - URL: repoUrl, + URL: repoURL, }, getter.All(cli.New())) if err != nil { return "", "", fmt.Errorf("unable to access repo index: %w", err) @@ -61,9 +67,9 @@ func GetLatestAirbyteChartUrlFromRepoIndex(repoName, repoUrl string) (string, st return "", "", fmt.Errorf("unable to load index file (%s): %w", idxPath, err) } - entries, ok := idx.Entries["airbyte"] + entries, ok := idx.Entries[chartName] if !ok { - return "", "", fmt.Errorf("no entry for airbyte in repo index") + return "", "", fmt.Errorf("no entry for %s in repo index", chartName) } if len(entries) == 0 { @@ -92,5 +98,5 @@ func GetLatestAirbyteChartUrlFromRepoIndex(repoName, repoUrl string) (string, st return "", "", fmt.Errorf("unexpected number of URLs - %d", len(latest.URLs)) } - return repoUrl + "/" + latest.URLs[0], latest.Version, nil + return repoURL + "/" + latest.URLs[0], latest.Version, nil } diff --git a/internal/service/install.go b/internal/service/install.go index 8b6cf23..f6c1086 100644 --- a/internal/service/install.go +++ b/internal/service/install.go @@ -255,7 +255,7 @@ func (m *Manager) Install(ctx context.Context, opts *InstallOpts) error { if err := m.handleChart(ctx, chartRequest{ name: "airbyte", repoName: common.AirbyteRepoName, - repoURL: common.AirbyteRepoURLv1, + repoURL: common.AirbyteRepoURLv2, chartName: common.AirbyteChartName, chartRelease: common.AirbyteChartRelease, chartVersion: opts.HelmChartVersion, diff --git a/internal/service/install_test.go b/internal/service/install_test.go index 449f0b9..6ff8d12 100644 --- a/internal/service/install_test.go +++ b/internal/service/install_test.go @@ -42,7 +42,7 @@ func TestCommand_Install_HappyPath(t *testing.T) { helm.EXPECT().AddOrUpdateChartRepo(gomock.Any()).DoAndReturn(func(entry repo.Entry) error { switch entry.Name { case common.AirbyteRepoName: - if d := cmp.Diff(common.AirbyteRepoURLv1, entry.URL); d != "" { + if d := cmp.Diff(common.AirbyteRepoURLv2, entry.URL); d != "" { t.Error("chart url mismatch", d) } case common.NginxRepoName: