diff --git a/.github/actions/kots-e2e/action.yml b/.github/actions/kots-e2e/action.yml index 465fc7ee92..4dc16afeca 100644 --- a/.github/actions/kots-e2e/action.yml +++ b/.github/actions/kots-e2e/action.yml @@ -95,6 +95,7 @@ runs: - name: execute suite "${{ inputs.test-focus }}" env: TESTIM_ACCESS_TOKEN: ${{ inputs.testim-access-token }} + REPLICATED_API_TOKEN: ${{ inputs.replicated-api-token }} KOTS_NAMESPACE: ${{ inputs.kots-namespace }} run: | make -C e2e test \ diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 40469c3027..269db08ca7 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -1085,6 +1085,40 @@ jobs: kots-dockerhub-username: '${{ secrets.E2E_DOCKERHUB_USERNAME }}' kots-dockerhub-password: '${{ secrets.E2E_DOCKERHUB_PASSWORD }}' + validate-change-channel: + runs-on: ubuntu-20.04 + needs: [ enable-tests, can-run-ci, build-kots, build-kotsadm, build-e2e, build-kurl-proxy, build-migrations, push-minio, push-rqlite ] + strategy: + fail-fast: false + matrix: + cluster: [ + {distribution: kind, version: v1.28.0} + ] + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: download e2e deps + uses: actions/download-artifact@v4 + with: + name: e2e + path: e2e/bin/ + - run: docker load -i e2e/bin/e2e-deps.tar + - run: chmod +x e2e/bin/* + - name: download kots binary + uses: actions/download-artifact@v4 + with: + name: kots + path: bin/ + - run: chmod +x bin/* + - uses: ./.github/actions/kots-e2e + with: + test-focus: 'Change Channel' + kots-namespace: 'change-channel' + k8s-distribution: ${{ matrix.cluster.distribution }} + k8s-version: ${{ matrix.cluster.version }} + replicated-api-token: '${{ secrets.C11Y_MATRIX_TOKEN }}' + kots-dockerhub-username: '${{ secrets.E2E_DOCKERHUB_USERNAME }}' + kots-dockerhub-password: '${{ secrets.E2E_DOCKERHUB_PASSWORD }}' validate-minimal-rbac-override: runs-on: ubuntu-20.04 @@ -4130,6 +4164,7 @@ jobs: - validate-backup-and-restore - validate-no-required-config - validate-config + - validate-change-channel # non-testim tests - validate-minimal-rbac - validate-minimal-rbac-override diff --git a/cmd/kots/cli/install.go b/cmd/kots/cli/install.go index fb9d367fcf..5b8f7a1e5b 100644 --- a/cmd/kots/cli/install.go +++ b/cmd/kots/cli/install.go @@ -31,6 +31,7 @@ import ( kotsadmtypes "github.com/replicatedhq/kots/pkg/kotsadm/types" "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/kurl" + kotslicense "github.com/replicatedhq/kots/pkg/license" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/metrics" preflighttypes "github.com/replicatedhq/kots/pkg/preflight/types" @@ -162,6 +163,14 @@ func InstallCmd() *cobra.Command { }() upstream := pull.RewriteUpstream(args[0]) + preferredChannelSlug, err := extractPreferredChannelSlug(upstream) + if err != nil { + return errors.Wrap(err, "failed to extract preferred channel slug") + } + license, err = kotslicense.VerifyAndUpdateLicense(log, license, preferredChannelSlug, isAirgap) + if err != nil { + return errors.Wrap(err, "failed to verify and update license") + } namespace := v.GetString("namespace") @@ -278,6 +287,7 @@ func InstallCmd() *cobra.Command { IncludeMinio: v.GetBool("with-minio"), IncludeMinioSnapshots: v.GetBool("with-minio"), StrictSecurityContext: v.GetBool("strict-security-context"), + RequestedChannelSlug: preferredChannelSlug, RegistryConfig: *registryConfig, diff --git a/cmd/kots/cli/pull.go b/cmd/kots/cli/pull.go index a2df9b1f3d..d4dbdd0ba5 100644 --- a/cmd/kots/cli/pull.go +++ b/cmd/kots/cli/pull.go @@ -7,11 +7,12 @@ import ( "github.com/pkg/errors" "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/kotsutil" + kotslicense "github.com/replicatedhq/kots/pkg/license" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/pull" registrytypes "github.com/replicatedhq/kots/pkg/registry/types" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" - "github.com/replicatedhq/kotskinds/client/kotsclientset/scheme" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -43,11 +44,13 @@ func PullCmd() *cobra.Command { } } - appSlug, err := getAppSlugForPull(args[0], v.GetString("license-file")) + license, err := getLicense(v) if err != nil { - return errors.Wrap(err, "failed to determine app slug") + return errors.Wrap(err, "failed to get license") } + appSlug := getAppSlugForPull(args[0], license) + namespace, err := getNamespaceOrDefault(v.GetString("namespace")) if err != nil { return errors.Wrap(err, "failed to get namespace") @@ -98,13 +101,30 @@ func PullCmd() *cobra.Command { } upstream := pull.RewriteUpstream(args[0]) - renderDir, err := pull.Pull(upstream, pullOptions) + preferredChannelSlug, err := extractPreferredChannelSlug(upstream) if err != nil { - return errors.Wrap(err, "failed to pull") + return errors.Wrap(err, "failed to extract preferred channel slug") } log := logger.NewCLILogger(cmd.OutOrStdout()) log.Initialize() + + // If we are passed a multi-channel license, verify that the requested channel is in the license + // so that we can warn the user immediately if it is not. + license, err = kotslicense.VerifyAndUpdateLicense(log, license, preferredChannelSlug, false) + if err != nil { + return errors.Wrap(err, "failed to verify and update license") + } + pullOptions.AppSelectedChannelID, err = kotsutil.FindChannelIDInLicense(preferredChannelSlug, license) + if err != nil { // should never happen since we just verified the channel + return errors.Wrap(err, "failed to find channel ID in license") + } + + renderDir, err := pull.Pull(upstream, pullOptions) + if err != nil { + return errors.Wrap(err, "failed to pull") + } + log.Info("Kubernetes application files created in %s", renderDir) if len(v.GetStringSlice("downstream")) == 0 { log.Info("To deploy, run kubectl apply -k %s", path.Join(renderDir, "overlays", "midstream")) @@ -146,28 +166,10 @@ func PullCmd() *cobra.Command { return cmd } -func getAppSlugForPull(uri string, licenseFile string) (string, error) { +func getAppSlugForPull(uri string, license *kotsv1beta1.License) string { appSlug := strings.Split(uri, "/")[0] - if licenseFile == "" { - return appSlug, nil - } - - licenseData, err := os.ReadFile(licenseFile) - if err != nil { - return "", errors.Wrap(err, "failed to read license file") - } - - decode := scheme.Codecs.UniversalDeserializer().Decode - decoded, gvk, err := decode(licenseData, nil, nil) - if err != nil { - return "", errors.Wrap(err, "unable to decode license file") - } - - if gvk.Group != "kots.io" || gvk.Version != "v1beta1" || gvk.Kind != "License" { - return "", errors.New("not an application license") + if license == nil { + return appSlug } - - license := decoded.(*kotsv1beta1.License) - - return license.Spec.AppSlug, nil + return license.Spec.AppSlug } diff --git a/cmd/kots/cli/util.go b/cmd/kots/cli/util.go index 4203831fcf..4d4ea33f56 100644 --- a/cmd/kots/cli/util.go +++ b/cmd/kots/cli/util.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/replicatedapp" "github.com/replicatedhq/kots/pkg/util" ) @@ -51,3 +52,20 @@ func splitEndpointAndNamespace(endpoint string) (string, string) { } return registryEndpoint, registryNamespace } + +func extractPreferredChannelSlug(upstreamURI string) (string, error) { + u, err := url.ParseRequestURI(upstreamURI) + if err != nil { + return "", errors.Wrap(err, "failed to parse uri") + } + + replicatedUpstream, err := replicatedapp.ParseReplicatedURL(u) + if err != nil { + return "", errors.Wrap(err, "failed to parse replicated url") + } + + if replicatedUpstream.Channel != nil { + return *replicatedUpstream.Channel, nil + } + return "stable", nil +} diff --git a/cmd/kots/cli/util_test.go b/cmd/kots/cli/util_test.go index 821490a80a..a270dbaecf 100644 --- a/cmd/kots/cli/util_test.go +++ b/cmd/kots/cli/util_test.go @@ -95,3 +95,52 @@ func Test_getHostFromEndpoint(t *testing.T) { }) } } + +func Test_extractPreferredChannelSlug(t *testing.T) { + type args struct { + upstreamURI string + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + "no channel", + args{ + upstreamURI: "replicated://app-slug", + }, + "stable", // default channel + false, + }, + { + "with channel", + args{ + upstreamURI: "replicated://app-slug/channel", + }, + "channel", + false, + }, + { + "invalid uri", + args{ + upstreamURI: "junk", + }, + "", + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := extractPreferredChannelSlug(tt.args.upstreamURI) + if (err != nil) != tt.wantErr { + t.Errorf("extractPreferredChannelSlug() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("extractPreferredChannelSlug() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/e2e/Makefile b/e2e/Makefile index 846bcdb923..3fb0dae36e 100644 --- a/e2e/Makefile +++ b/e2e/Makefile @@ -32,6 +32,7 @@ test: docker run --rm -i --net host \ -e TESTIM_ACCESS_TOKEN \ -v $(BIN_DIR)/e2e.test:/usr/local/bin/e2e.test \ + -e REPLICATED_API_TOKEN \ -v $(KOTS_BIN_DIR)/kots:/usr/local/bin/kots \ -v $(KOTS_BIN_DIR)/kots:/usr/local/bin/kubectl-kots \ -v $(PLAYWRIGHT_DIR)/playwright-report:/playwright/playwright-report \ diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index ea47699c88..0abfca8436 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -221,6 +221,7 @@ var _ = Describe("E2E", func() { Entry(nil, inventory.MultiAppTest()), Entry(nil, inventory.NewSupportBundle()), Entry(nil, inventory.NewGitOps()), + Entry(nil, inventory.NewChangeChannel()), ) }) diff --git a/e2e/inventory/inventory.go b/e2e/inventory/inventory.go index 7b2affdff8..cb8d20f1fb 100644 --- a/e2e/inventory/inventory.go +++ b/e2e/inventory/inventory.go @@ -169,6 +169,16 @@ func NewGitOps() Test { } } +func NewChangeChannel() Test { + return Test{ + ID: "change-channel", + Name: "Change Channel", + Namespace: "change-channel", + AppSlug: "change-channel", + UpstreamURI: "change-channel/automated", + } +} + func SetupRegressionTest(kubectlCLI *kubectl.CLI) TestimParams { cmd := kubectlCLI.Command( context.Background(), diff --git a/e2e/playwright/tests/change-channel/license.yaml b/e2e/playwright/tests/change-channel/license.yaml new file mode 100644 index 0000000000..a050aa766b --- /dev/null +++ b/e2e/playwright/tests/change-channel/license.yaml @@ -0,0 +1,29 @@ +apiVersion: kots.io/v1beta1 +kind: License +metadata: + name: github-action +spec: + appSlug: change-channel + channelID: 2k6j62KPRQLjO0tF9zZB6zJJukg + channelName: Automated + channels: + - channelID: 2k6j62KPRQLjO0tF9zZB6zJJukg + channelName: Automated + channelSlug: automated + endpoint: https://replicated.app + isDefault: true + customerName: github-action + endpoint: https://replicated.app + entitlements: + expires_at: + description: License Expiration + signature: {} + title: Expiration + value: "" + valueType: String + isKotsInstallEnabled: true + isNewKotsUiEnabled: true + licenseID: 2k6jempcB6aQyddcIEBSTj0Bvvv + licenseSequence: 16 + licenseType: trial + signature: eyJsaWNlbnNlRGF0YSI6ImV5SmhjR2xXWlhKemFXOXVJam9pYTI5MGN5NXBieTkyTVdKbGRHRXhJaXdpYTJsdVpDSTZJa3hwWTJWdWMyVWlMQ0p0WlhSaFpHRjBZU0k2ZXlKdVlXMWxJam9pWjJsMGFIVmlMV0ZqZEdsdmJpSjlMQ0p6Y0dWaklqcDdJbXhwWTJWdWMyVkpSQ0k2SWpKck5tcGxiWEJqUWpaaFVYbGtaR05KUlVKVFZHb3dRbloyZGlJc0lteHBZMlZ1YzJWVWVYQmxJam9pZEhKcFlXd2lMQ0pqZFhOMGIyMWxjazVoYldVaU9pSm5hWFJvZFdJdFlXTjBhVzl1SWl3aVlYQndVMngxWnlJNkltTm9ZVzVuWlMxamFHRnVibVZzSWl3aVkyaGhibTVsYkVsRUlqb2lNbXMyYWpZeVMxQlNVVXhxVHpCMFJqbDZXa0kyZWtwS2RXdG5JaXdpWTJoaGJtNWxiRTVoYldVaU9pSkJkWFJ2YldGMFpXUWlMQ0pqYUdGdWJtVnNjeUk2VzNzaVkyaGhibTVsYkVsRUlqb2lNbXMyYWpZeVMxQlNVVXhxVHpCMFJqbDZXa0kyZWtwS2RXdG5JaXdpWTJoaGJtNWxiRk5zZFdjaU9pSmhkWFJ2YldGMFpXUWlMQ0pqYUdGdWJtVnNUbUZ0WlNJNklrRjFkRzl0WVhSbFpDSXNJbWx6UkdWbVlYVnNkQ0k2ZEhKMVpTd2laVzVrY0c5cGJuUWlPaUpvZEhSd2N6b3ZMM0psY0d4cFkyRjBaV1F1WVhCd0luMWRMQ0pzYVdObGJuTmxVMlZ4ZFdWdVkyVWlPakUyTENKbGJtUndiMmx1ZENJNkltaDBkSEJ6T2k4dmNtVndiR2xqWVhSbFpDNWhjSEFpTENKbGJuUnBkR3hsYldWdWRITWlPbnNpWlhod2FYSmxjMTloZENJNmV5SjBhWFJzWlNJNklrVjRjR2x5WVhScGIyNGlMQ0prWlhOamNtbHdkR2x2YmlJNklreHBZMlZ1YzJVZ1JYaHdhWEpoZEdsdmJpSXNJblpoYkhWbElqb2lJaXdpZG1Gc2RXVlVlWEJsSWpvaVUzUnlhVzVuSWl3aWMybG5ibUYwZFhKbElqcDdmWDE5TENKcGMwNWxkMHR2ZEhOVmFVVnVZV0pzWldRaU9uUnlkV1VzSW1selMyOTBjMGx1YzNSaGJHeEZibUZpYkdWa0lqcDBjblZsZlgwPSIsImlubmVyU2lnbmF0dXJlIjoiZXlKc2FXTmxibk5sVTJsbmJtRjBkWEpsSWpvaWRsRmpjWGt6T1hRellYQnRabWhTVFdoR01HdE5jblp2YlhST1pGZDBaR3Q1UW1zNVZWbHBaM2MzT0ZKMU56Z3Jaa0V5ZW1aVVRGTXJNV1JMUkRCcVNWcG5SbkZhYkRCRlJVaHJWbmRWUld0bE5IWk9RV2QyY1VsdWJrcDJXVGxNWWpKNGVUTk1NMDFQY1VjMk1GZERZV1l3VGxkUVpWTm9jbTVHYldSSmVXcG1SRFpOTUhsWVVUWndZVkpaZVU0MWFsaEdPVUZVY0dWVU5VSXpWVnBYVkdwb1R6QTJPVk5GYVdkUGEyeExjRlYzU3pZclRGbENPV2xEVWk5bWMyNXdkbU5vV0ROYU1HSjVSSGhtZEZnMFF6Rk1XWEF4VVc5MWRITjZNMkl2TVZZMWRXOUdMemd6YlZGUVRsQXplVEZrT1hadVFYazFlV1VyWVd4cE1HTm1RalZuYmpoTFYybERkRTFsVHk5aGNHRnFZbTlVTmpoTVdGUXZRbWhTUnpaQlJVTTBaWFp4Vkd4aVNYTXhZVTFOWW0wM2VIcGFlVFo2Y2twNVFWRXJjVzA1TmxWVVpGUjRiekJ4VUdKRFJrVndWSGhyYzJKQlBUMGlMQ0p3ZFdKc2FXTkxaWGtpT2lJdExTMHRMVUpGUjBsT0lGQlZRa3hKUXlCTFJWa3RMUzB0TFZ4dVRVbEpRa2xxUVU1Q1oydHhhR3RwUnpsM01FSkJVVVZHUVVGUFEwRlJPRUZOU1VsQ1EyZExRMEZSUlVFeFFWRmFRa2xEZW5wemRXTnVXV1JNVlM5YU1GeHVNSGsxTVc5U1EzaGxNVzFRWTBvNVUxbzRORlJqZUVScFVVZFNTRzluV2pZMVQyWklWV3g1V21Sb09FeEpOSFpYZFRKRGJVVnpkbVppV1VOS05VWlRTRnh1UlhSeVkzWnhURTlUWTJVemNsaHZhR2RNVmpkNVRtaFZNa3cxUm1vMWFuRXhkelU0WjBWbGVHTXJZbTUxVVdvME16TXJOR2s0VkZWTk1tNUZVVzFKY1Z4dU1tZHNOa1JxUWk5Sk1YWnNVMjQxTmtvdk1IVmhSWHBaVjFBck4ySkhlbTEzT0dNeWQycEJhRTFyUkZJck5USlBRM2hHY1dGaFExbDBaemROTDBWbE5seHVaWFIzWVRGNFREUlNSMlJCSzNkTFYzaDRWa1VyYjAxMlluQjROMXA1WWxJdldERjBRazlvZWtaSVdISjBaa2RCYm5CWE1sQkRjbVJ4Um1oMWVsWlZMMXh1TUhWSVRuVk1URkV5ZVVaemNFVjJUa1V4YlVKRFpqZHpUR2hNY205M1RVRlBWRnAxWkZJNGFtMXJTMnhJWlROT2MzUnlUekl2ZVVadWN6bHZWRnBoTWx4dWJYZEpSRUZSUVVKY2JpMHRMUzB0UlU1RUlGQlZRa3hKUXlCTFJWa3RMUzB0TFZ4dUlpd2lhMlY1VTJsbmJtRjBkWEpsSWpvaVpYbEtlbUZYWkhWWldGSXhZMjFWYVU5cFNsQmhhbVJ4VWtaT2MwMHljSEJhVjFKMVlsZDRiV05GTVhsVFJXUnRUVzVCTldJd1VUUmFSMFY0WTBka2VrOVhUbFpoVjJSRllXdHNTbEpXYUhwbGF6VlBWakZPVkU1SVJrMWhhWFJMVlhwc2JXSXhWa1pTTUhoNFUzazROV0l6VGxKV2JtUXhVbTVXTldNeFVsSmllWFJEWWpGQk0wMUhVbHBqYkc4d1lUSTViRk13UlhoaFdHUmFZbnBhV0dGVWJFcFphMk15U3pCUmQxTkhjRWRrYlhnelVtMVZNbUpUZEV0a2JsSlBZak5yTldSR1ZuRlRXRkpUVmpGSmVtRnJNVmRWUkZwUllsVTRkMWRYVWt4UFNFMTZWMWRhVldOdVRrOWlibWgxV2pGUk1WWlZUVEZhVkVKM1UxVTVWMXBYVGpOVlZrSkhWa2hqZGxSc1dsQmlSVnBxWVRCS05rOUlRbHBsVjBaTVpESlZNbE5YZEhWT1EzUndVbXBDVTJSVVVsQk9NalZDWVRBNGNsRnNhR3BUYlZZeldYcGtRMVZVUms5bFJXUmhVV3hGTkU1VmR6VmtSM2N4WTBaa2NsTkZUbmRMTWxadFVqSkpNV1J1UmpKU1NFcHRWVlZhZEdNelJrWlhVemxIV1dwYVVWZFliSE5OYlU1RVZucHNSVmx1YUhWWFIzUnJZbFpqTVU5VVdsWlhiVkV6VDFadmNsZFlVbmRNTVdSdFpWZEtlVlpHYnpOaVIxSnpaRzVzYjJNd05XaGpTRlpYWTFST1VrNXNiSFZhTVdoMFRXNXdOVnBIWXpsUVUwbHpTVzFrYzJJeVNtaGlSWFJzWlZWc2EwbHFiMmxaYlZKc1dsUlZNazVVV1hkWk1scHBUa1JPYWs5WFNYbFBSMHB0VDFSb2JGbFhUbWhhYlVVeVRrUlphV1pSUFQwaWZRPT0ifQ== \ No newline at end of file diff --git a/e2e/playwright/tests/change-channel/test.spec.ts b/e2e/playwright/tests/change-channel/test.spec.ts new file mode 100644 index 0000000000..3ab19014aa --- /dev/null +++ b/e2e/playwright/tests/change-channel/test.spec.ts @@ -0,0 +1,58 @@ +import { test, expect } from '@playwright/test'; +import { login, uploadLicense } from '../shared'; + +const CUSTOMER_ID = '2k6jemHbYgZFqtwgyjqiVfjRQqi'; +const APP_ID = '2k6j65t0STtrZ1emyP5PUqBIQ23'; +const AUTOMATED_CHANNEL_ID = '2k6j62KPRQLjO0tF9zZB6zJJukg'; +const ALTERNATE_CHANNEL_ID = '2k6j61j49IPyDyQlbmRZJsxy3TP'; + +test('change channel', async ({ page }) => { + test.slow(); + await changeChannel(AUTOMATED_CHANNEL_ID); + await login(page); + await uploadLicense(page, expect); + await page.getByRole('button', { name: 'Deploy' }).click(); + await expect(page.locator('#app')).toContainText('Automated'); + await changeChannel(ALTERNATE_CHANNEL_ID); + + await page.getByText('Sync license').click(); + + await expect(page.getByLabel('Next step')).toContainText('License synced', { timeout: 10000 }); + await page.getByRole('button', { name: 'Ok, got it!' }).click(); + + await expect(page.locator('#app')).toContainText('Alternate'); + await expect(page.locator('#app')).toContainText('1.0.3', { timeout: 10000 }); + await expect(page.locator('#app')).toContainText('Upstream Update', { timeout: 10000 }); + + await page.getByRole('button', { name: 'Deploy', exact: true }).click(); + await page.getByRole('button', { name: 'Yes, Deploy' }).click(); + + await expect(page.locator('#app')).toContainText('Currently deployed version', { timeout: 15000 }); + await expect(page.getByText('v1.0.0')).not.toBeVisible(); + await expect(page.getByText('1.0.3')).toBeVisible(); +}); + +async function changeChannel(channelId: string) { + await fetch(`https://api.replicated.com/vendor/v3/customer/${CUSTOMER_ID}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': process.env.REPLICATED_API_TOKEN, + }, + body: JSON.stringify({ + "app_id": APP_ID, + "name": "github-action", // customer name + "channels": [ + { + "channel_id": channelId, + "pinned_channel_sequence": null, + "is_default_for_customer": true + } + ] + }) + }).then(response => { + if (!response.ok) { + throw new Error(`Unexpected status code: ${response.status}`); + } + }); +} \ No newline at end of file diff --git a/go.mod b/go.mod index 8b3809fc1e..07a7e88b47 100644 --- a/go.mod +++ b/go.mod @@ -49,7 +49,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/replicatedhq/embedded-cluster-kinds v1.4.7 - github.com/replicatedhq/kotskinds v0.0.0-20240621084729-1eb1e3eac6f2 + github.com/replicatedhq/kotskinds v0.0.0-20240718194123-1018dd404e95 github.com/replicatedhq/kurlkinds v1.5.0 github.com/replicatedhq/troubleshoot v0.95.1 github.com/replicatedhq/yaml/v3 v3.0.0-beta5-replicatedhq diff --git a/go.sum b/go.sum index ba629b5d88..652c867255 100644 --- a/go.sum +++ b/go.sum @@ -1317,6 +1317,10 @@ github.com/replicatedhq/embedded-cluster-kinds v1.4.7 h1:CR55z8Q2ek90BwwwOEz+P3q github.com/replicatedhq/embedded-cluster-kinds v1.4.7/go.mod h1:AwopUvvGcaWO4mn9DkbPj5RnLuOy756CNLrcaAlmjMo= github.com/replicatedhq/kotskinds v0.0.0-20240621084729-1eb1e3eac6f2 h1:xL4u2RHhMaGDgz7Lol5MhVYLnWahV3sCJZbfebpPao0= github.com/replicatedhq/kotskinds v0.0.0-20240621084729-1eb1e3eac6f2/go.mod h1:QjhIUu3+OmHZ09u09j3FCoTt8F3BYtQglS+OLmftu9I= +github.com/replicatedhq/kotskinds v0.0.0-20240717145110-8eb39e8b3a41 h1:xvvRq5EZ7wBlsrDZIAUpJ318cEMzTpt8zVAO2atFQlM= +github.com/replicatedhq/kotskinds v0.0.0-20240717145110-8eb39e8b3a41/go.mod h1:QjhIUu3+OmHZ09u09j3FCoTt8F3BYtQglS+OLmftu9I= +github.com/replicatedhq/kotskinds v0.0.0-20240718194123-1018dd404e95 h1:JhwPz4Bgbz5iYl3UV2EB+HnF9oW/eCRi+hASAz+J6XI= +github.com/replicatedhq/kotskinds v0.0.0-20240718194123-1018dd404e95/go.mod h1:QjhIUu3+OmHZ09u09j3FCoTt8F3BYtQglS+OLmftu9I= github.com/replicatedhq/kurlkinds v1.5.0 h1:zZ0PKNeh4kXvSzVGkn62DKTo314GxhXg1TSB3azURMc= github.com/replicatedhq/kurlkinds v1.5.0/go.mod h1:rUpBMdC81IhmJNCWMU/uRsMETv9P0xFoMvdSP/TAr5A= github.com/replicatedhq/termui/v3 v3.1.1-0.20200811145416-f40076d26851 h1:eRlNDHxGfVkPCRXbA4BfQJvt5DHjFiTtWy3R/t4djyY= diff --git a/integration/replicated/pull_test.go b/integration/replicated/pull_test.go index 5fc1638b7e..37e94d3881 100644 --- a/integration/replicated/pull_test.go +++ b/integration/replicated/pull_test.go @@ -64,6 +64,7 @@ func Test_PullReplicated(t *testing.T) { ExcludeAdminConsole: true, ExcludeKotsKinds: true, Silent: true, + AppSelectedChannelID: "1vusIYZLAVxMG6q760OJmRKj5i5", } _, err = pull.Pull("replicated://integration", pullOptions) req.NoError(err) diff --git a/integration/replicated/tests/kitchen-sink/license.yaml b/integration/replicated/tests/kitchen-sink/license.yaml index 70b7866fd1..0d50122ad3 100644 --- a/integration/replicated/tests/kitchen-sink/license.yaml +++ b/integration/replicated/tests/kitchen-sink/license.yaml @@ -1,22 +1,49 @@ apiVersion: kots.io/v1beta1 kind: License metadata: - name: kots + creationTimestamp: null + name: testcustomer spec: - licenseID: WQ1srPj9HDmfBG-iHgwniEeUCaF2Ic-y - licenseType: dev - customerName: kots - appSlug: kitchen-sink - channelName: Unstable - licenseSequence: 1 + appSlug: my-app + channelID: 1vusIYZLAVxMG6q760OJmRKj5i5 + channelName: My Channel + customerName: Test Customer endpoint: https://replicated.app entitlements: + bool_field: + title: Bool Field + value: true + valueType: Boolean expires_at: - title: Expiration description: License Expiration - value: '' + title: Expiration + value: "2030-07-27T00:00:00Z" + valueType: String + hidden_field: + isHidden: true + title: Hidden Field + value: this is secret + valueType: String + int_field: + title: Int Field + value: 123 + valueType: Integer + string_field: + title: StringField + value: single line text valueType: String + text_field: + title: Text Field + value: |- + multi + line + text + valueType: Text isAirgapSupported: true isGitOpsSupported: true - signature: >- - eyJsaWNlbnNlRGF0YSI6ImV5SmhjR2xXWlhKemFXOXVJam9pYTI5MGN5NXBieTkyTVdKbGRHRXhJaXdpYTJsdVpDSTZJa3hwWTJWdWMyVWlMQ0p0WlhSaFpHRjBZU0k2ZXlKdVlXMWxJam9pYTI5MGN5SjlMQ0p6Y0dWaklqcDdJbXhwWTJWdWMyVkpSQ0k2SWxkUk1YTnlVR281U0VSdFprSkhMV2xJWjNkdWFVVmxWVU5oUmpKSll5MTVJaXdpYkdsalpXNXpaVlI1Y0dVaU9pSmtaWFlpTENKamRYTjBiMjFsY2s1aGJXVWlPaUpyYjNSeklpd2lZWEJ3VTJ4MVp5STZJbXRwZEdOb1pXNHRjMmx1YXlJc0ltTm9ZVzV1Wld4T1lXMWxJam9pVlc1emRHRmliR1VpTENKc2FXTmxibk5sVTJWeGRXVnVZMlVpT2pFc0ltVnVaSEJ2YVc1MElqb2lhSFIwY0hNNkx5OXlaWEJzYVdOaGRHVmtMbUZ3Y0NJc0ltVnVkR2wwYkdWdFpXNTBjeUk2ZXlKbGVIQnBjbVZ6WDJGMElqcDdJblJwZEd4bElqb2lSWGh3YVhKaGRHbHZiaUlzSW1SbGMyTnlhWEIwYVc5dUlqb2lUR2xqWlc1elpTQkZlSEJwY21GMGFXOXVJaXdpZG1Gc2RXVWlPaUlpTENKMllXeDFaVlI1Y0dVaU9pSlRkSEpwYm1jaWZYMHNJbWx6UVdseVoyRndVM1Z3Y0c5eWRHVmtJanAwY25WbExDSnBjMGRwZEU5d2MxTjFjSEJ2Y25SbFpDSTZkSEoxWlgxOSIsImlubmVyU2lnbmF0dXJlIjoiZXlKc2FXTmxibk5sVTJsbmJtRjBkWEpsSWpvaVJFRkVVVTFXTTJkRFprdG5iMkZKYVRoSmNUVnJUV051WTJWb2FpOUJNVkIyYUZkb2QxWkhRVEpYT1ZoUVdYVjVPWE5TZHpCaU4wZ3lPRlJ5WlRadE1XUjBOazh5UlZoak1ISlBVbTFyT0dkRVZEWjJTRTF6Y2xWMFpWSXplQ3N5WWtWVU1XRnRhVWQxZDBWRFIwRnJOMnBWUkhVMFozRjNLemhvYlZOTmMxSm1iMVZLVjFGaWNVVkJOMW96UVRSVGNYVm1Va3MyZEVKUVMwcHVRek12TnpsTFJrc3JNaTlSVUVaSk0yTkRUbEZ5U1VnM04xQXhRa1ZSUVRGVU5XbFJiWEoxYURoRVVYVkRORVpCU1ZWSlpqTk9jSEZLY1dsa2IyWlpjVE52VERGaGIyNW5jWGsxUzNWQ2IwdDNhVFY2SzFkeFNDOTVLM1IwYWpOTGVYbDBPR0ZHV1ZSVWN6RnVOemRQVEZaRWVsSTBUR3R3TURGSlpUZ3dhM1JMYmtKVFoyRm5VWEJtWkhwM1VrVnpha2xKU1hGdU9IWXlXR05WU0ZaVVQxUTRha1J5UkZVd2FGQkpjaTg0WjJ4dlRrczBRMlJrY1ZoblBUMGlMQ0p3ZFdKc2FXTkxaWGtpT2lJdExTMHRMVUpGUjBsT0lGQlZRa3hKUXlCTFJWa3RMUzB0TFZ4dVRVbEpRa2xxUVU1Q1oydHhhR3RwUnpsM01FSkJVVVZHUVVGUFEwRlJPRUZOU1VsQ1EyZExRMEZSUlVGeVUzQmtkMDVpYkhSUUswODRMMFI0Y25FNFZseHVhR1pyTDNOVlZHeEJWMHhyWm5Wak1WY3pOM1p5ZFdVeGFGTlRjMHB4TldSelNIcDNiVzVtT0dORVNtdGxjSFZGWm5aUFZDOXNibTlMUjBNcmRFaFZibHh1UXpsbVRIYzViMEZPV21Wc1JIVkphemcyT1ZsSmVHdDFiVGh2Y0ZaSk0wWXdZMmh2Wm1vM0t5dENTVVIyTVhVcldITmFiR3cwVVZabmFraEZSM3BGVDF4dWQwdEtha3RYWlUxWWFYUldTSFZTZVdkNlZsSXZUemR3VUVnM09WbEVVMUJoWkV4SmFYZFFNM2gwVFhwU2QwMVJTMnRHTm1WS2RUbDJaSFExSzJSaVpWeHVVWFpUZVUxVVJHODJSV3gwYVdkMWJFTmFkemx3VldaU1MwWXlkVTUzTDJOd2MxaFhSWFpZU1ZCRU5EZzRVMlEwWWt3ellrWXZNMkY1Ym04NFlrbFlWbHh1YzB3NVV6UlBaREZSV25KNFJVUndabXhtU2pGbVN5OU9VV1JKVm1GVkwxZHRiREZyZWtGM2NESkhWMlJZUVhwS2N6VjJSVTF6YW5CcVR6VmpZVTlQTVZ4dU5uZEpSRUZSUVVKY2JpMHRMUzB0UlU1RUlGQlZRa3hKUXlCTFJWa3RMUzB0TFZ4dUlpd2lhMlY1VTJsbmJtRjBkWEpsSWpvaVpYbEtlbUZYWkhWWldGSXhZMjFWYVU5cFNsUmlia3BYWWtaT2RGZEVXbEpoVlVZMFRrVk9XVlZZV25sbGJFSkxWRVJGZGs1V1VtNWhNSGh6Vkd4cmVsZEZlR3RrTVd4WFQxWkJNMUpUZEc5V1JVcDBZVEJHYW1WWFVrZGFNSEJTVGtad2QxcFhhRlZpUkZKM1pFUlNURXN6WkZkT2VscEpZMms1ZWxaWVdsRlJNRXAwVlVjNVdHUlZWalpaTTA0MVlrWnNVRmRxWkZwaU1qVnhXVEJHZUZOcmNHcE9Semx2Wkc1YU1rMUZiRlpTYlRGaFdtcENUMVJGVG0xVWJWbzBUMFZrVTFveVpEVlNSRll3VjBSa1RWb3phRXRXZW1SM1QwaGtWV1F6VG1sbFJHUTBXVlJOTUdGR2FFSlRSbWg2WTJwT2JVOUZOV2xYUlZJeVZFUkdNMW94VWs5aGJFWjZZVlZqY2xKRE9ETk5WelZxVkRKV05sZFdiRFJVV0Vwd1QxYzRkbFV5VmpaU1JGSkdUbGhSZGxkSVpISlNNM0J4WWpCdmNrOVliM0pYYkZKVVRYcFNlbUZ0VFhkU1JGSm9VbGRXVEU5WGFFaE9WVko0WkVWNFRGbHRPSHBYYTA1WVdsY3hTR05JUm5kaWJrWjVWa2hzYjFkVVZscGtWVko0WTFSS01GZFRPVXRhTUdNelRsWkNhRTE1ZEZWak1tTXlWMWRTVjFWRll6UlNNRFI2WWxob1MwNUlhelJoTVVrMVYwWnNjV0ZGYjNkUmJVNXdZbXBTVTFJd09WbGlNakZvVVRKb2FWUnJaRFJWYkVvelQxZGFkbGRGUlRsUVUwbHpTVzFrYzJJeVNtaGlSWFJzWlZWc2EwbHFiMmxaYlZKc1dsUlZNazVVV1hkWk1scHBUa1JPYWs5WFNYbFBSMHB0VDFSb2JGbFhUbWhhYlVVeVRrUlphV1pSUFQwaWZRPT0ifQ== \ No newline at end of file + isSnapshotSupported: true + licenseID: 1vusOokxAVp1tkRGuyxnF23PJcq + licenseSequence: 7 + licenseType: prod + signature: eyJsaWNlbnNlRGF0YSI6ImV5SmhjR2xXWlhKemFXOXVJam9pYTI5MGN5NXBieTkyTVdKbGRHRXhJaXdpYTJsdVpDSTZJa3hwWTJWdWMyVWlMQ0p0WlhSaFpHRjBZU0k2ZXlKdVlXMWxJam9pZEdWemRHTjFjM1J2YldWeUluMHNJbk53WldNaU9uc2liR2xqWlc1elpVbEVJam9pTVhaMWMwOXZhM2hCVm5BeGRHdFNSM1Y1ZUc1R01qTlFTbU54SWl3aWJHbGpaVzV6WlZSNWNHVWlPaUp3Y205a0lpd2lZM1Z6ZEc5dFpYSk9ZVzFsSWpvaVZHVnpkQ0JEZFhOMGIyMWxjaUlzSW1Gd2NGTnNkV2NpT2lKdGVTMWhjSEFpTENKamFHRnVibVZzU1VRaU9pSXhkblZ6U1ZsYVRFRldlRTFITm5FM05qQlBTbTFTUzJvMWFUVWlMQ0pqYUdGdWJtVnNUbUZ0WlNJNklrMTVJRU5vWVc1dVpXd2lMQ0pzYVdObGJuTmxVMlZ4ZFdWdVkyVWlPamNzSW1WdVpIQnZhVzUwSWpvaWFIUjBjSE02THk5eVpYQnNhV05oZEdWa0xtRndjQ0lzSW1WdWRHbDBiR1Z0Wlc1MGN5STZleUppYjI5c1gyWnBaV3hrSWpwN0luUnBkR3hsSWpvaVFtOXZiQ0JHYVdWc1pDSXNJblpoYkhWbElqcDBjblZsTENKMllXeDFaVlI1Y0dVaU9pSkNiMjlzWldGdUluMHNJbVY0Y0dseVpYTmZZWFFpT25zaWRHbDBiR1VpT2lKRmVIQnBjbUYwYVc5dUlpd2laR1Z6WTNKcGNIUnBiMjRpT2lKTWFXTmxibk5sSUVWNGNHbHlZWFJwYjI0aUxDSjJZV3gxWlNJNklqSXdNekF0TURjdE1qZFVNREE2TURBNk1EQmFJaXdpZG1Gc2RXVlVlWEJsSWpvaVUzUnlhVzVuSW4wc0ltaHBaR1JsYmw5bWFXVnNaQ0k2ZXlKMGFYUnNaU0k2SWtocFpHUmxiaUJHYVdWc1pDSXNJblpoYkhWbElqb2lkR2hwY3lCcGN5QnpaV055WlhRaUxDSjJZV3gxWlZSNWNHVWlPaUpUZEhKcGJtY2lMQ0pwYzBocFpHUmxiaUk2ZEhKMVpYMHNJbWx1ZEY5bWFXVnNaQ0k2ZXlKMGFYUnNaU0k2SWtsdWRDQkdhV1ZzWkNJc0luWmhiSFZsSWpveE1qTXNJblpoYkhWbFZIbHdaU0k2SWtsdWRHVm5aWElpZlN3aWMzUnlhVzVuWDJacFpXeGtJanA3SW5ScGRHeGxJam9pVTNSeWFXNW5SbWxsYkdRaUxDSjJZV3gxWlNJNkluTnBibWRzWlNCc2FXNWxJSFJsZUhRaUxDSjJZV3gxWlZSNWNHVWlPaUpUZEhKcGJtY2lmU3dpZEdWNGRGOW1hV1ZzWkNJNmV5SjBhWFJzWlNJNklsUmxlSFFnUm1sbGJHUWlMQ0oyWVd4MVpTSTZJbTExYkhScFhHNXNhVzVsWEc1MFpYaDBJaXdpZG1Gc2RXVlVlWEJsSWpvaVZHVjRkQ0o5ZlN3aWFYTkJhWEpuWVhCVGRYQndiM0owWldRaU9uUnlkV1VzSW1selIybDBUM0J6VTNWd2NHOXlkR1ZrSWpwMGNuVmxMQ0pwYzFOdVlYQnphRzkwVTNWd2NHOXlkR1ZrSWpwMGNuVmxmWDA9IiwiaW5uZXJTaWduYXR1cmUiOiJleUpzYVdObGJuTmxVMmxuYm1GMGRYSmxJam9pYUhneE1XTXZUR1ozUTNoVE5YRmtRWEJGU1hGdVRrMU9NMHBLYTJzNFZHZFhSVVpzVDFKVlJ6UjJjR1YzZEZoV1YzbG1lamRZY0hBd1ExazJZamRyUVRSS2N6TklhR3d3YkZJMFdUQTFMemN2UVVkQ2FEZFZNSGczUkhaTVozUXpVM00wYm5GTFZTdFhXRXBTVHpKWVFVRnZSME4xZFRWR1RGcHJRVWhYY1RSUVFtMXphSFY2Y1ZsdmNucHhlbGhGWVZWVlpFUlVkVXhDTW1nNWFIZ3dXRWhQUmxwUk16bHVkbTlPUjJaT2R5OTRTVmRaZEhSUGRYZHZhMncyTVZsb1JVeFZlRmQxU1ZSRmMwTlVhM2xtTVRNd09IazVSbFJzWlRKeVYyZEVlSEZNYTBSUFNXVXlPRWwzUzJSQkwySXdWVUl5VEZGbVRWcHdWemwyUTNCSkwybHlWek5uYmpaeU5WWjNWMjB2U1dweWJtNDNSelJrVmpadVYzcFRkMGhQUTJSdWEwMTRNRXQ1VVVOa0wxQjFaWEpUYjNSdVEwOXRTMDEzWlRSTGJqaERkMU5YVVRRNGRURkRNbTFpV1VzeGRYTlpOM1YzUFQwaUxDSndkV0pzYVdOTFpYa2lPaUl0TFMwdExVSkZSMGxPSUZCVlFreEpReUJMUlZrdExTMHRMVnh1VFVsSlFrbHFRVTVDWjJ0eGFHdHBSemwzTUVKQlVVVkdRVUZQUTBGUk9FRk5TVWxDUTJkTFEwRlJSVUZ6TkhKdlVIcDFhV1JNZVhOMmIxWTJkemxhTkZ4dVdHRmliME5tWTJNeGFHZFZhQ3N3V1VkS2NFNURSVXhyTjBaTFF5OTJhemR6ZERsR05tY3dUMjlrU0VSbGVYZFJXa2hLZFU1TVpsUnNRbEJHUTJOaU5seHVObTlzVEZOeWNGQTRjbFUzU0d4SGJsRkVSMFJNYVhkS1EyaGtSRGRVVUdSM2FXdHBkMHRGY201aldqaEdaalZsU25vd2RETmlUWFpyVDJaVVluSkJiRnh1WWtGQ1kwbzVNVmxVT1hKdVVXOXFkVWN4UldKUVRqaEZWblI2TWxZNE5IZHViR2Q0TUhCd2JEVjRPSFpOYlhwcE1ISnVibEZVV1VGamJ6WnFhMnBJTTF4dVRuTlVkWE4xUzFkdlJGUjVNWE5yZGtSUk9IbEJZV0ptWTNNME4zWnNRazAwU0RGT1JFNHZSSFJhWWxZdllubDJia0o2YkM4eFZrVnpURmRqWlZWcFRGeHVSWEYxT0VkeWF5dFFVRGQyUkdSd2JFUjNjWFpQV2t4RmRYazNkamhuUm01U09WUlVSV3ByTlVvNWRuWlVTR2RtU25VemVubEVPR2xLWTBSRE5YcHFPVnh1YjFGSlJFRlJRVUpjYmkwdExTMHRSVTVFSUZCVlFreEpReUJMUlZrdExTMHRMVnh1SWl3aWEyVjVVMmxuYm1GMGRYSmxJam9pWlhsS2VtRlhaSFZaV0ZJeFkyMVZhVTlwU2pCUldIQjJXVE5LVms1NmFGaFNSMlJzVVRKb2NtTklXa1ZVVlRsRldqQktXVTFGUmtaVFJFNUZVMGhLYkUxclRUTkxNSEJFVkROR2VGTnROVVJVVlRWVlltMDFiVnBGUm5sWldIQjZaRVJqTVZaSGFFeFBXRUpVVWtacmRrd3diek5aTUZaSlVteFdWRXd5T1VoV1JXeHNWa1ZPTUZSSE1WWlJNR04zVkd4R2JGa3pTblJUUm1zMFZVWk9hMVpWU2pCVU1WbDNZbXQwY0ZSclZuQmpia0poVFZjNWFtSldiSEZaYTNob1UyeHNWV0pGUmtWWGJVWnZWakZLVUZkcWJGSmhXRVp1V2xkb1EyRnVRak5TUjNNd1lWWkpOVTVXVmxkV1ZUVnlUMGhLYjFsVlRYbGhiVGcwVjBkYWVGbHFWbFppYlhoeFpFWkZkMDU1Y3pCaFZsSkpWRVpPTm1WRk1IcGxWWFJ2VFVaR1ZtRXdWVFJSVnpsSFVsaEtVRTFZUmxCU01WcFJVMVJDTmxsV2FIcFdWWEJ0WTBSU2JFMVVRazlPVjNSU1ZucFdUMU5XWTNaU1ZYUkZVMGhzYlU5VmJGaGtNMUl3WTFWc1lXTlhSakJTYTA1RVlVWmtjbUo2VmtSU00wSllUREkxUmsxWVl6SmxWM1JKVlZoQk1sVXhTbEppU0Zwd1VrVXdNRlpFVWt0VU1rWnNVVmQwYzFSV1VrMVVWV055V1RCYVRHSXpaRTlUVm05NVlraE9SR1JzVG5aUmFrWmFaVmRPVGxOVlNteGFiRXB1Wld0U2RVMHhSVGxRVTBselNXMWtjMkl5U21oaVJYUnNaVlZzYTBscWIybFpiVkpzV2xSVk1rNVVXWGRaTWxwcFRrUk9hazlYU1hsUFIwcHRUMVJvYkZsWFRtaGFiVVV5VGtSWmFXWlJQVDBpZlE9PSJ9 +status: {} \ No newline at end of file diff --git a/migrations/tables/app.yaml b/migrations/tables/app.yaml index af9aec57eb..84caf03014 100644 --- a/migrations/tables/app.yaml +++ b/migrations/tables/app.yaml @@ -85,3 +85,5 @@ spec: default: 0 constraints: notNull: true + - name: selected_channel_id + type: text diff --git a/pkg/airgap/airgap.go b/pkg/airgap/airgap.go index d0aa6f9135..5600e0c9e5 100644 --- a/pkg/airgap/airgap.go +++ b/pkg/airgap/airgap.go @@ -229,6 +229,7 @@ func CreateAppFromAirgap(opts CreateAirgapAppOpts) (finalError error) { AppSlug: opts.PendingApp.Slug, AppSequence: 0, AppVersionLabel: instParams.AppVersionLabel, + AppSelectedChannelID: opts.PendingApp.SelectedChannelID, SkipCompatibilityCheck: opts.SkipCompatibilityCheck, } diff --git a/pkg/airgap/types/types.go b/pkg/airgap/types/types.go index b6b8234808..6927d1e8ed 100644 --- a/pkg/airgap/types/types.go +++ b/pkg/airgap/types/types.go @@ -1,10 +1,11 @@ package types type PendingApp struct { - ID string - Slug string - Name string - LicenseData string + ID string + Slug string + Name string + LicenseData string + SelectedChannelID string } type InstallStatus struct { diff --git a/pkg/airgap/update.go b/pkg/airgap/update.go index 35986a9863..4f4d208dc6 100644 --- a/pkg/airgap/update.go +++ b/pkg/airgap/update.go @@ -203,6 +203,7 @@ func UpdateAppFromPath(a *apptypes.App, airgapRoot string, airgapBundlePath stri AppID: a.ID, AppSlug: a.Slug, AppSequence: appSequence, + AppSelectedChannelID: a.SelectedChannelID, SkipCompatibilityCheck: skipCompatibilityCheck, KotsKinds: beforeKotsKinds, } diff --git a/pkg/app/types/app.go b/pkg/app/types/app.go index f2682ae6cd..a933ee64d2 100644 --- a/pkg/app/types/app.go +++ b/pkg/app/types/app.go @@ -30,6 +30,7 @@ type App struct { InstallState string `json:"installState"` LastLicenseSync string `json:"lastLicenseSync"` ChannelChanged bool `json:"channelChanged"` + SelectedChannelID string `json:"selected_channel_id"` } func (a *App) GetID() string { @@ -44,6 +45,10 @@ func (a *App) GetCurrentSequence() int64 { return a.CurrentSequence } +func (a *App) GetSelectedChannelID() string { + return a.SelectedChannelID +} + func (a *App) GetIsAirgap() bool { return a.IsAirgap } diff --git a/pkg/automation/automation.go b/pkg/automation/automation.go index 3ce8d237d5..04b1e9f30b 100644 --- a/pkg/automation/automation.go +++ b/pkg/automation/automation.go @@ -203,8 +203,21 @@ func installLicenseSecret(clientset *kubernetes.Clientset, licenseSecret corev1. return errors.Wrap(err, "failed to verify license signature") } + instParams, err := kotsutil.GetInstallationParams(kotsadmtypes.KotsadmConfigMap) + if err != nil { + return errors.Wrap(err, "failed to get existing kotsadm config map") + } + + desiredAppName := strings.Replace(appSlug, "-", " ", 0) + upstreamURI := fmt.Sprintf("replicated://%s", appSlug) + + matchedChannelID, err := kotsutil.FindChannelIDInLicense(instParams.RequestedChannelSlug, verifiedLicense) + if err != nil { + return errors.Wrap(err, "failed to find requested channel in license") + } + if !kotsadm.IsAirgap() { - licenseData, err := replicatedapp.GetLatestLicense(verifiedLicense) + licenseData, err := replicatedapp.GetLatestLicense(verifiedLicense, matchedChannelID) if err != nil { return errors.Wrap(err, "failed to get latest license") } @@ -236,15 +249,7 @@ func installLicenseSecret(clientset *kubernetes.Clientset, licenseSecret corev1. } } - instParams, err := kotsutil.GetInstallationParams(kotsadmtypes.KotsadmConfigMap) - if err != nil { - return errors.Wrap(err, "failed to get existing kotsadm config map") - } - - desiredAppName := strings.Replace(appSlug, "-", " ", 0) - upstreamURI := fmt.Sprintf("replicated://%s", appSlug) - - a, err := store.GetStore().CreateApp(desiredAppName, upstreamURI, string(license), verifiedLicense.Spec.IsAirgapSupported, instParams.SkipImagePush, instParams.RegistryIsReadOnly) + a, err := store.GetStore().CreateApp(desiredAppName, matchedChannelID, upstreamURI, string(license), verifiedLicense.Spec.IsAirgapSupported, instParams.SkipImagePush, instParams.RegistryIsReadOnly) if err != nil { return errors.Wrap(err, "failed to create app record") } @@ -311,11 +316,12 @@ func installLicenseSecret(clientset *kubernetes.Clientset, licenseSecret corev1. } else if annotations["kots.io/airgap"] != "true" { createAppOpts := online.CreateOnlineAppOpts{ PendingApp: &onlinetypes.PendingApp{ - ID: a.ID, - Slug: a.Slug, - Name: a.Name, - LicenseData: string(license), - VersionLabel: instParams.AppVersionLabel, + ID: a.ID, + Slug: a.Slug, + Name: a.Name, + LicenseData: string(license), + VersionLabel: instParams.AppVersionLabel, + SelectedChannelID: a.SelectedChannelID, }, UpstreamURI: upstreamURI, IsAutomated: true, diff --git a/pkg/handlers/license.go b/pkg/handlers/license.go index 017369f643..a3658d6e44 100644 --- a/pkg/handlers/license.go +++ b/pkg/handlers/license.go @@ -272,10 +272,30 @@ func (h *Handler) UploadNewLicense(w http.ResponseWriter, r *http.Request) { return } + installationParams, err := kotsutil.GetInstallationParams(kotsadmtypes.KotsadmConfigMap) + if err != nil { + logger.Error(err) + uploadLicenseResponse.Error = err.Error() + JSON(w, http.StatusInternalServerError, uploadLicenseResponse) + return + } + + desiredAppName := strings.Replace(verifiedLicense.Spec.AppSlug, "-", " ", 0) + upstreamURI := fmt.Sprintf("replicated://%s", verifiedLicense.Spec.AppSlug) + + // verify that requested channel slug exists in the license + matchedChannelID, err := kotsutil.FindChannelIDInLicense(installationParams.RequestedChannelSlug, verifiedLicense) + if err != nil { + logger.Error(err) + uploadLicenseResponse.Error = "Your current license does not grant access to the channel you requested. Please generate a support bundle and contact support for assistance." + JSON(w, http.StatusBadRequest, uploadLicenseResponse) + return + } + if !kotsadm.IsAirgap() { // sync license logger.Info("syncing license with server to retrieve latest version") - licenseData, err := replicatedapp.GetLatestLicense(verifiedLicense) + licenseData, err := replicatedapp.GetLatestLicense(verifiedLicense, matchedChannelID) if err != nil { logger.Error(errors.Wrap(err, "failed to get latest license")) uploadLicenseResponse.Error = err.Error() @@ -323,18 +343,7 @@ func (h *Handler) UploadNewLicense(w http.ResponseWriter, r *http.Request) { } } - installationParams, err := kotsutil.GetInstallationParams(kotsadmtypes.KotsadmConfigMap) - if err != nil { - logger.Error(err) - uploadLicenseResponse.Error = err.Error() - JSON(w, http.StatusInternalServerError, uploadLicenseResponse) - return - } - - desiredAppName := strings.Replace(verifiedLicense.Spec.AppSlug, "-", " ", 0) - upstreamURI := fmt.Sprintf("replicated://%s", verifiedLicense.Spec.AppSlug) - - a, err := store.GetStore().CreateApp(desiredAppName, upstreamURI, licenseString, verifiedLicense.Spec.IsAirgapSupported, installationParams.SkipImagePush, installationParams.RegistryIsReadOnly) + a, err := store.GetStore().CreateApp(desiredAppName, matchedChannelID, upstreamURI, licenseString, verifiedLicense.Spec.IsAirgapSupported, installationParams.SkipImagePush, installationParams.RegistryIsReadOnly) if err != nil { logger.Error(err) uploadLicenseResponse.Error = err.Error() @@ -346,14 +355,16 @@ func (h *Handler) UploadNewLicense(w http.ResponseWriter, r *http.Request) { // complete the install online createAppOpts := online.CreateOnlineAppOpts{ PendingApp: &installationtypes.PendingApp{ - ID: a.ID, - Slug: a.Slug, - Name: a.Name, - LicenseData: uploadLicenseRequest.LicenseData, - VersionLabel: installationParams.AppVersionLabel, + ID: a.ID, + Slug: a.Slug, + Name: a.Name, + SelectedChannelID: a.SelectedChannelID, + LicenseData: uploadLicenseRequest.LicenseData, + VersionLabel: installationParams.AppVersionLabel, }, UpstreamURI: upstreamURI, } + kotsKinds, err := online.CreateAppFromOnline(createAppOpts) if err != nil { logger.Error(err) @@ -427,10 +438,11 @@ func (h *Handler) ResumeInstallOnline(w http.ResponseWriter, r *http.Request) { } pendingApp := installationtypes.PendingApp{ - ID: a.ID, - Slug: a.Slug, - Name: a.Name, - VersionLabel: installationParams.AppVersionLabel, + ID: a.ID, + Slug: a.Slug, + Name: a.Name, + VersionLabel: installationParams.AppVersionLabel, + SelectedChannelID: a.SelectedChannelID, } // the license data is left in the table @@ -457,6 +469,7 @@ func (h *Handler) ResumeInstallOnline(w http.ResponseWriter, r *http.Request) { PendingApp: &pendingApp, UpstreamURI: fmt.Sprintf("replicated://%s", kotsLicense.Spec.AppSlug), } + kotsKinds, err := online.CreateAppFromOnline(createAppOpts) if err != nil { logger.Error(err) diff --git a/pkg/handlers/update_checker_spec.go b/pkg/handlers/update_checker_spec.go index 5dbc2b163c..d281fbed7d 100644 --- a/pkg/handlers/update_checker_spec.go +++ b/pkg/handlers/update_checker_spec.go @@ -56,8 +56,16 @@ func (h *Handler) SetAutomaticUpdatesConfig(w http.ResponseWriter, r *http.Reque return } + licenseChan, err := kotsutil.FindChannelInLicense(foundApp.SelectedChannelID, license) + if err != nil { + updateCheckerSpecResponse.Error = "failed to find app channel id from license" + logger.Error(errors.Wrap(err, updateCheckerSpecResponse.Error)) + JSON(w, http.StatusInternalServerError, updateCheckerSpecResponse) + return + } + // Check if the deploy update configuration is valid based on app channel - if license.Spec.IsSemverRequired { + if licenseChan.IsSemverRequired { if configureAutomaticUpdatesRequest.AutoDeploy == apptypes.AutoDeploySequence { updateCheckerSpecResponse.Error = "automatic updates based on sequence type are not supported for semantic versioning apps" JSON(w, http.StatusUnprocessableEntity, updateCheckerSpecResponse) diff --git a/pkg/handlers/upgrade_service.go b/pkg/handlers/upgrade_service.go index 2fdf54228a..1dab0b6908 100644 --- a/pkg/handlers/upgrade_service.go +++ b/pkg/handlers/upgrade_service.go @@ -118,7 +118,10 @@ func canStartUpgradeService(a *apptypes.App, r StartUpgradeServiceRequest) (bool if err != nil { return false, "", errors.Wrap(err, "failed to find airgap metadata") } - if currLicense.Spec.ChannelID != airgap.Spec.ChannelID || r.ChannelID != airgap.Spec.ChannelID { + if _, err := kotsutil.FindChannelInLicense(airgap.Spec.ChannelID, currLicense); err != nil { + return false, "channel mismatch, channel not in license", nil + } + if r.ChannelID != airgap.Spec.ChannelID { return false, "channel mismatch", nil } isDeployable, nonDeployableCause, err := update.IsAirgapUpdateDeployable(a, airgap) @@ -131,7 +134,7 @@ func canStartUpgradeService(a *apptypes.App, r StartUpgradeServiceRequest) (bool return true, "", nil } - ll, err := replicatedapp.GetLatestLicense(currLicense) + ll, err := replicatedapp.GetLatestLicense(currLicense, a.SelectedChannelID) if err != nil { return false, "", errors.Wrap(err, "failed to get latest license") } diff --git a/pkg/handlers/upgrade_service_test.go b/pkg/handlers/upgrade_service_test.go index 97de2268b4..59aacf031c 100644 --- a/pkg/handlers/upgrade_service_test.go +++ b/pkg/handlers/upgrade_service_test.go @@ -86,6 +86,37 @@ spec: signature: eyJsaWNlbnNlRGF0YSI6ImV5SmhjR2xXWlhKemFXOXVJam9pYTI5MGN5NXBieTkyTVdKbGRHRXhJaXdpYTJsdVpDSTZJa3hwWTJWdWMyVWlMQ0p0WlhSaFpHRjBZU0k2ZXlKdVlXMWxJam9pZEdWemRHTjFjM1J2YldWeUluMHNJbk53WldNaU9uc2liR2xqWlc1elpVbEVJam9pTVhaMWMwOXZhM2hCVm5BeGRHdFNSM1Y1ZUc1R01qTlFTbU54SWl3aWJHbGpaVzV6WlZSNWNHVWlPaUp3Y205a0lpd2lZM1Z6ZEc5dFpYSk9ZVzFsSWpvaVZHVnpkQ0JEZFhOMGIyMWxjaUlzSW1Gd2NGTnNkV2NpT2lKdGVTMWhjSEFpTENKamFHRnVibVZzU1VRaU9pSXhkblZ6U1ZsYVRFRldlRTFITm5FM05qQlBTbTFTUzJvMWFUVWlMQ0pqYUdGdWJtVnNUbUZ0WlNJNklrMTVJRU5vWVc1dVpXd2lMQ0pzYVdObGJuTmxVMlZ4ZFdWdVkyVWlPamNzSW1WdVpIQnZhVzUwSWpvaWFIUjBjSE02THk5eVpYQnNhV05oZEdWa0xtRndjQ0lzSW1WdWRHbDBiR1Z0Wlc1MGN5STZleUppYjI5c1gyWnBaV3hrSWpwN0luUnBkR3hsSWpvaVFtOXZiQ0JHYVdWc1pDSXNJblpoYkhWbElqcDBjblZsTENKMllXeDFaVlI1Y0dVaU9pSkNiMjlzWldGdUluMHNJbVY0Y0dseVpYTmZZWFFpT25zaWRHbDBiR1VpT2lKRmVIQnBjbUYwYVc5dUlpd2laR1Z6WTNKcGNIUnBiMjRpT2lKTWFXTmxibk5sSUVWNGNHbHlZWFJwYjI0aUxDSjJZV3gxWlNJNklqSXdNekF0TURjdE1qZFVNREE2TURBNk1EQmFJaXdpZG1Gc2RXVlVlWEJsSWpvaVUzUnlhVzVuSW4wc0ltaHBaR1JsYmw5bWFXVnNaQ0k2ZXlKMGFYUnNaU0k2SWtocFpHUmxiaUJHYVdWc1pDSXNJblpoYkhWbElqb2lkR2hwY3lCcGN5QnpaV055WlhRaUxDSjJZV3gxWlZSNWNHVWlPaUpUZEhKcGJtY2lMQ0pwYzBocFpHUmxiaUk2ZEhKMVpYMHNJbWx1ZEY5bWFXVnNaQ0k2ZXlKMGFYUnNaU0k2SWtsdWRDQkdhV1ZzWkNJc0luWmhiSFZsSWpveE1qTXNJblpoYkhWbFZIbHdaU0k2SWtsdWRHVm5aWElpZlN3aWMzUnlhVzVuWDJacFpXeGtJanA3SW5ScGRHeGxJam9pVTNSeWFXNW5SbWxsYkdRaUxDSjJZV3gxWlNJNkluTnBibWRzWlNCc2FXNWxJSFJsZUhRaUxDSjJZV3gxWlZSNWNHVWlPaUpUZEhKcGJtY2lmU3dpZEdWNGRGOW1hV1ZzWkNJNmV5SjBhWFJzWlNJNklsUmxlSFFnUm1sbGJHUWlMQ0oyWVd4MVpTSTZJbTExYkhScFhHNXNhVzVsWEc1MFpYaDBJaXdpZG1Gc2RXVlVlWEJsSWpvaVZHVjRkQ0o5ZlN3aWFYTkJhWEpuWVhCVGRYQndiM0owWldRaU9uUnlkV1VzSW1selIybDBUM0J6VTNWd2NHOXlkR1ZrSWpwMGNuVmxMQ0pwYzFOdVlYQnphRzkwVTNWd2NHOXlkR1ZrSWpwMGNuVmxmWDA9IiwiaW5uZXJTaWduYXR1cmUiOiJleUpzYVdObGJuTmxVMmxuYm1GMGRYSmxJam9pYUhneE1XTXZUR1ozUTNoVE5YRmtRWEJGU1hGdVRrMU9NMHBLYTJzNFZHZFhSVVpzVDFKVlJ6UjJjR1YzZEZoV1YzbG1lamRZY0hBd1ExazJZamRyUVRSS2N6TklhR3d3YkZJMFdUQTFMemN2UVVkQ2FEZFZNSGczUkhaTVozUXpVM00wYm5GTFZTdFhXRXBTVHpKWVFVRnZSME4xZFRWR1RGcHJRVWhYY1RSUVFtMXphSFY2Y1ZsdmNucHhlbGhGWVZWVlpFUlVkVXhDTW1nNWFIZ3dXRWhQUmxwUk16bHVkbTlPUjJaT2R5OTRTVmRaZEhSUGRYZHZhMncyTVZsb1JVeFZlRmQxU1ZSRmMwTlVhM2xtTVRNd09IazVSbFJzWlRKeVYyZEVlSEZNYTBSUFNXVXlPRWwzUzJSQkwySXdWVUl5VEZGbVRWcHdWemwyUTNCSkwybHlWek5uYmpaeU5WWjNWMjB2U1dweWJtNDNSelJrVmpadVYzcFRkMGhQUTJSdWEwMTRNRXQ1VVVOa0wxQjFaWEpUYjNSdVEwOXRTMDEzWlRSTGJqaERkMU5YVVRRNGRURkRNbTFpV1VzeGRYTlpOM1YzUFQwaUxDSndkV0pzYVdOTFpYa2lPaUl0TFMwdExVSkZSMGxPSUZCVlFreEpReUJMUlZrdExTMHRMVnh1VFVsSlFrbHFRVTVDWjJ0eGFHdHBSemwzTUVKQlVVVkdRVUZQUTBGUk9FRk5TVWxDUTJkTFEwRlJSVUZ6TkhKdlVIcDFhV1JNZVhOMmIxWTJkemxhTkZ4dVdHRmliME5tWTJNeGFHZFZhQ3N3V1VkS2NFNURSVXhyTjBaTFF5OTJhemR6ZERsR05tY3dUMjlrU0VSbGVYZFJXa2hLZFU1TVpsUnNRbEJHUTJOaU5seHVObTlzVEZOeWNGQTRjbFUzU0d4SGJsRkVSMFJNYVhkS1EyaGtSRGRVVUdSM2FXdHBkMHRGY201aldqaEdaalZsU25vd2RETmlUWFpyVDJaVVluSkJiRnh1WWtGQ1kwbzVNVmxVT1hKdVVXOXFkVWN4UldKUVRqaEZWblI2TWxZNE5IZHViR2Q0TUhCd2JEVjRPSFpOYlhwcE1ISnVibEZVV1VGamJ6WnFhMnBJTTF4dVRuTlVkWE4xUzFkdlJGUjVNWE5yZGtSUk9IbEJZV0ptWTNNME4zWnNRazAwU0RGT1JFNHZSSFJhWWxZdllubDJia0o2YkM4eFZrVnpURmRqWlZWcFRGeHVSWEYxT0VkeWF5dFFVRGQyUkdSd2JFUjNjWFpQV2t4RmRYazNkamhuUm01U09WUlVSV3ByTlVvNWRuWlVTR2RtU25VemVubEVPR2xLWTBSRE5YcHFPVnh1YjFGSlJFRlJRVUpjYmkwdExTMHRSVTVFSUZCVlFreEpReUJMUlZrdExTMHRMVnh1SWl3aWEyVjVVMmxuYm1GMGRYSmxJam9pWlhsS2VtRlhaSFZaV0ZJeFkyMVZhVTlwU2pCUldIQjJXVE5LVms1NmFGaFNSMlJzVVRKb2NtTklXa1ZVVlRsRldqQktXVTFGUmtaVFJFNUZVMGhLYkUxclRUTkxNSEJFVkROR2VGTnROVVJVVlRWVlltMDFiVnBGUm5sWldIQjZaRVJqTVZaSGFFeFBXRUpVVWtacmRrd3diek5aTUZaSlVteFdWRXd5T1VoV1JXeHNWa1ZPTUZSSE1WWlJNR04zVkd4R2JGa3pTblJUUm1zMFZVWk9hMVpWU2pCVU1WbDNZbXQwY0ZSclZuQmpia0poVFZjNWFtSldiSEZaYTNob1UyeHNWV0pGUmtWWGJVWnZWakZLVUZkcWJGSmhXRVp1V2xkb1EyRnVRak5TUjNNd1lWWkpOVTVXVmxkV1ZUVnlUMGhLYjFsVlRYbGhiVGcwVjBkYWVGbHFWbFppYlhoeFpFWkZkMDU1Y3pCaFZsSkpWRVpPTm1WRk1IcGxWWFJ2VFVaR1ZtRXdWVFJSVnpsSFVsaEtVRTFZUmxCU01WcFJVMVJDTmxsV2FIcFdWWEJ0WTBSU2JFMVVRazlPVjNSU1ZucFdUMU5XWTNaU1ZYUkZVMGhzYlU5VmJGaGtNMUl3WTFWc1lXTlhSakJTYTA1RVlVWmtjbUo2VmtSU00wSllUREkxUmsxWVl6SmxWM1JKVlZoQk1sVXhTbEppU0Zwd1VrVXdNRlpFVWt0VU1rWnNVVmQwYzFSV1VrMVVWV055V1RCYVRHSXpaRTlUVm05NVlraE9SR1JzVG5aUmFrWmFaVmRPVGxOVlNteGFiRXB1Wld0U2RVMHhSVGxRVTBselNXMWtjMkl5U21oaVJYUnNaVlZzYTBscWIybFpiVkpzV2xSVk1rNVVXWGRaTWxwcFRrUk9hazlYU1hsUFIwcHRUMVJvYkZsWFRtaGFiVVV5VGtSWmFXWlJQVDBpZlE9PSJ9 `, mockServer.URL) + testMultiChannelLicense := fmt.Sprintf(`apiVersion: kots.io/v1beta1 +kind: License +metadata: + name: testcustomer +spec: + appSlug: my-app + channelID: 1vusIYZLAVxMG6q760OJmRKj5i5 + channelName: My Channel + customerName: Test Customer + endpoint: %s + channels: + - channelId: 1vusIYZLAVxMG6q760OJmRKj5i5 + channelName: My Channel + channelSlug: my-channel + isDefault: true + isSemverRequired: false + entitlements: + expires_at: + description: License Expiration + title: Expiration + value: "2030-07-27T00:00:00Z" + valueType: String + isAirgapSupported: true + isGitOpsSupported: true + isSnapshotSupported: true + licenseID: 1vusOokxAVp1tkRGuyxnF23PJcq + licenseSequence: 7 + licenseType: prod + signature: eyJsaWNlbnNlRGF0YSI6ImV5SmhjR2xXWlhKemFXOXVJam9pYTI5MGN5NXBieTkyTVdKbGRHRXhJaXdpYTJsdVpDSTZJa3hwWTJWdWMyVWlMQ0p0WlhSaFpHRjBZU0k2ZXlKdVlXMWxJam9pZEdWemRHTjFjM1J2YldWeUluMHNJbk53WldNaU9uc2liR2xqWlc1elpVbEVJam9pTVhaMWMwOXZhM2hCVm5BeGRHdFNSM1Y1ZUc1R01qTlFTbU54SWl3aWJHbGpaVzV6WlZSNWNHVWlPaUp3Y205a0lpd2lZM1Z6ZEc5dFpYSk9ZVzFsSWpvaVZHVnpkQ0JEZFhOMGIyMWxjaUlzSW1Gd2NGTnNkV2NpT2lKdGVTMWhjSEFpTENKamFHRnVibVZzU1VRaU9pSXhkblZ6U1ZsYVRFRldlRTFITm5FM05qQlBTbTFTUzJvMWFUVWlMQ0pqYUdGdWJtVnNUbUZ0WlNJNklrMTVJRU5vWVc1dVpXd2lMQ0pzYVdObGJuTmxVMlZ4ZFdWdVkyVWlPamNzSW1WdVpIQnZhVzUwSWpvaWFIUjBjSE02THk5eVpYQnNhV05oZEdWa0xtRndjQ0lzSW1WdWRHbDBiR1Z0Wlc1MGN5STZleUppYjI5c1gyWnBaV3hrSWpwN0luUnBkR3hsSWpvaVFtOXZiQ0JHYVdWc1pDSXNJblpoYkhWbElqcDBjblZsTENKMllXeDFaVlI1Y0dVaU9pSkNiMjlzWldGdUluMHNJbVY0Y0dseVpYTmZZWFFpT25zaWRHbDBiR1VpT2lKRmVIQnBjbUYwYVc5dUlpd2laR1Z6WTNKcGNIUnBiMjRpT2lKTWFXTmxibk5sSUVWNGNHbHlZWFJwYjI0aUxDSjJZV3gxWlNJNklqSXdNekF0TURjdE1qZFVNREE2TURBNk1EQmFJaXdpZG1Gc2RXVlVlWEJsSWpvaVUzUnlhVzVuSW4wc0ltaHBaR1JsYmw5bWFXVnNaQ0k2ZXlKMGFYUnNaU0k2SWtocFpHUmxiaUJHYVdWc1pDSXNJblpoYkhWbElqb2lkR2hwY3lCcGN5QnpaV055WlhRaUxDSjJZV3gxWlZSNWNHVWlPaUpUZEhKcGJtY2lMQ0pwYzBocFpHUmxiaUk2ZEhKMVpYMHNJbWx1ZEY5bWFXVnNaQ0k2ZXlKMGFYUnNaU0k2SWtsdWRDQkdhV1ZzWkNJc0luWmhiSFZsSWpveE1qTXNJblpoYkhWbFZIbHdaU0k2SWtsdWRHVm5aWElpZlN3aWMzUnlhVzVuWDJacFpXeGtJanA3SW5ScGRHeGxJam9pVTNSeWFXNW5SbWxsYkdRaUxDSjJZV3gxWlNJNkluTnBibWRzWlNCc2FXNWxJSFJsZUhRaUxDSjJZV3gxWlZSNWNHVWlPaUpUZEhKcGJtY2lmU3dpZEdWNGRGOW1hV1ZzWkNJNmV5SjBhWFJzWlNJNklsUmxlSFFnUm1sbGJHUWlMQ0oyWVd4MVpTSTZJbTExYkhScFhHNXNhVzVsWEc1MFpYaDBJaXdpZG1Gc2RXVlVlWEJsSWpvaVZHVjRkQ0o5ZlN3aWFYTkJhWEpuWVhCVGRYQndiM0owWldRaU9uUnlkV1VzSW1selIybDBUM0J6VTNWd2NHOXlkR1ZrSWpwMGNuVmxMQ0pwYzFOdVlYQnphRzkwVTNWd2NHOXlkR1ZrSWpwMGNuVmxmWDA9IiwiaW5uZXJTaWduYXR1cmUiOiJleUpzYVdObGJuTmxVMmxuYm1GMGRYSmxJam9pYUhneE1XTXZUR1ozUTNoVE5YRmtRWEJGU1hGdVRrMU9NMHBLYTJzNFZHZFhSVVpzVDFKVlJ6UjJjR1YzZEZoV1YzbG1lamRZY0hBd1ExazJZamRyUVRSS2N6TklhR3d3YkZJMFdUQTFMemN2UVVkQ2FEZFZNSGczUkhaTVozUXpVM00wYm5GTFZTdFhXRXBTVHpKWVFVRnZSME4xZFRWR1RGcHJRVWhYY1RSUVFtMXphSFY2Y1ZsdmNucHhlbGhGWVZWVlpFUlVkVXhDTW1nNWFIZ3dXRWhQUmxwUk16bHVkbTlPUjJaT2R5OTRTVmRaZEhSUGRYZHZhMncyTVZsb1JVeFZlRmQxU1ZSRmMwTlVhM2xtTVRNd09IazVSbFJzWlRKeVYyZEVlSEZNYTBSUFNXVXlPRWwzUzJSQkwySXdWVUl5VEZGbVRWcHdWemwyUTNCSkwybHlWek5uYmpaeU5WWjNWMjB2U1dweWJtNDNSelJrVmpadVYzcFRkMGhQUTJSdWEwMTRNRXQ1VVVOa0wxQjFaWEpUYjNSdVEwOXRTMDEzWlRSTGJqaERkMU5YVVRRNGRURkRNbTFpV1VzeGRYTlpOM1YzUFQwaUxDSndkV0pzYVdOTFpYa2lPaUl0TFMwdExVSkZSMGxPSUZCVlFreEpReUJMUlZrdExTMHRMVnh1VFVsSlFrbHFRVTVDWjJ0eGFHdHBSemwzTUVKQlVVVkdRVUZQUTBGUk9FRk5TVWxDUTJkTFEwRlJSVUZ6TkhKdlVIcDFhV1JNZVhOMmIxWTJkemxhTkZ4dVdHRmliME5tWTJNeGFHZFZhQ3N3V1VkS2NFNURSVXhyTjBaTFF5OTJhemR6ZERsR05tY3dUMjlrU0VSbGVYZFJXa2hLZFU1TVpsUnNRbEJHUTJOaU5seHVObTlzVEZOeWNGQTRjbFUzU0d4SGJsRkVSMFJNYVhkS1EyaGtSRGRVVUdSM2FXdHBkMHRGY201aldqaEdaalZsU25vd2RETmlUWFpyVDJaVVluSkJiRnh1WWtGQ1kwbzVNVmxVT1hKdVVXOXFkVWN4UldKUVRqaEZWblI2TWxZNE5IZHViR2Q0TUhCd2JEVjRPSFpOYlhwcE1ISnVibEZVV1VGamJ6WnFhMnBJTTF4dVRuTlVkWE4xUzFkdlJGUjVNWE5yZGtSUk9IbEJZV0ptWTNNME4zWnNRazAwU0RGT1JFNHZSSFJhWWxZdllubDJia0o2YkM4eFZrVnpURmRqWlZWcFRGeHVSWEYxT0VkeWF5dFFVRGQyUkdSd2JFUjNjWFpQV2t4RmRYazNkamhuUm01U09WUlVSV3ByTlVvNWRuWlVTR2RtU25VemVubEVPR2xLWTBSRE5YcHFPVnh1YjFGSlJFRlJRVUpjYmkwdExTMHRSVTVFSUZCVlFreEpReUJMUlZrdExTMHRMVnh1SWl3aWEyVjVVMmxuYm1GMGRYSmxJam9pWlhsS2VtRlhaSFZaV0ZJeFkyMVZhVTlwU2pCUldIQjJXVE5LVms1NmFGaFNSMlJzVVRKb2NtTklXa1ZVVlRsRldqQktXVTFGUmtaVFJFNUZVMGhLYkUxclRUTkxNSEJFVkROR2VGTnROVVJVVlRWVlltMDFiVnBGUm5sWldIQjZaRVJqTVZaSGFFeFBXRUpVVWtacmRrd3diek5aTUZaSlVteFdWRXd5T1VoV1JXeHNWa1ZPTUZSSE1WWlJNR04zVkd4R2JGa3pTblJUUm1zMFZVWk9hMVpWU2pCVU1WbDNZbXQwY0ZSclZuQmpia0poVFZjNWFtSldiSEZaYTNob1UyeHNWV0pGUmtWWGJVWnZWakZLVUZkcWJGSmhXRVp1V2xkb1EyRnVRak5TUjNNd1lWWkpOVTVXVmxkV1ZUVnlUMGhLYjFsVlRYbGhiVGcwVjBkYWVGbHFWbFppYlhoeFpFWkZkMDU1Y3pCaFZsSkpWRVpPTm1WRk1IcGxWWFJ2VFVaR1ZtRXdWVFJSVnpsSFVsaEtVRTFZUmxCU01WcFJVMVJDTmxsV2FIcFdWWEJ0WTBSU2JFMVVRazlPVjNSU1ZucFdUMU5XWTNaU1ZYUkZVMGhzYlU5VmJGaGtNMUl3WTFWc1lXTlhSakJTYTA1RVlVWmtjbUo2VmtSU00wSllUREkxUmsxWVl6SmxWM1JKVlZoQk1sVXhTbEppU0Zwd1VrVXdNRlpFVWt0VU1rWnNVVmQwYzFSV1VrMVVWV055V1RCYVRHSXpaRTlUVm05NVlraE9SR1JzVG5aUmFrWmFaVmRPVGxOVlNteGFiRXB1Wld0U2RVMHhSVGxRVTBselNXMWtjMkl5U21oaVJYUnNaVlZzYTBscWIybFpiVkpzV2xSVk1rNVVXWGRaTWxwcFRrUk9hazlYU1hsUFIwcHRUMVJvYkZsWFRtaGFiVVV5VGtSWmFXWlJQVDBpZlE9PSJ9 +`, mockServer.URL) + onlineApp := &apptypes.App{ ID: "app-id", Slug: "app-slug", @@ -104,6 +135,15 @@ spec: License: testLicense, } + airgapAppMultiChannel := &apptypes.App{ + ID: "app-id", + Slug: "app-slug", + Name: "app-name", + IsAirgap: true, + IsGitOps: false, + License: testMultiChannelLicense, + } + type args struct { app *apptypes.App request handlers.StartUpgradeServiceRequest @@ -213,6 +253,60 @@ spec: RegistryNamespace: "namespace", RegistryIsReadOnly: false, + ReportingInfo: reporting.GetReportingInfo(airgapApp.ID), + }, + }, + { + name: "airgap with multi-channel license", + args: args{ + app: airgapAppMultiChannel, + request: handlers.StartUpgradeServiceRequest{ + VersionLabel: "1.0.0", + UpdateCursor: "1", + ChannelID: "channel-id", + }, + }, + mockStoreExpectations: func() { + mockStore.EXPECT().GetRegistryDetailsForApp(airgapAppMultiChannel.ID).Return(registrytypes.RegistrySettings{ + Hostname: "hostname", + Username: "username", + Password: "password", + Namespace: "namespace", + IsReadOnly: false, + }, nil) + mockStore.EXPECT().GetAppVersionBaseArchive(airgapAppMultiChannel.ID, "1.0.0").Return("base-archive", int64(1), nil) + mockStore.EXPECT().GetNextAppSequence(airgapAppMultiChannel.ID).Return(int64(2), nil) + }, + wantParams: &upgradeservicetypes.UpgradeServiceParams{ + Port: "", // port is random, we just check it's not empty + + AppID: airgapAppMultiChannel.ID, + AppSlug: airgapAppMultiChannel.Slug, + AppName: airgapAppMultiChannel.Name, + AppIsAirgap: airgapAppMultiChannel.IsAirgap, + AppIsGitOps: airgapAppMultiChannel.IsGitOps, + AppLicense: airgapAppMultiChannel.License, + AppArchive: "base-archive", + + Source: "Airgap Update", + BaseSequence: 1, + NextSequence: 2, + + UpdateVersionLabel: "1.0.0", + UpdateCursor: "1", + UpdateChannelID: "channel-id", + UpdateECVersion: "airgap-update-ec-version", + UpdateKOTSBin: "", // tmp file name is random, we just check it's not empty + UpdateAirgapBundle: updateAirgapBundle, + + CurrentECVersion: "current-ec-version", + + RegistryEndpoint: "hostname", + RegistryUsername: "username", + RegistryPassword: "password", + RegistryNamespace: "namespace", + RegistryIsReadOnly: false, + ReportingInfo: reporting.GetReportingInfo(airgapApp.ID), }, }, diff --git a/pkg/kotsadm/objects/configmaps_objects.go b/pkg/kotsadm/objects/configmaps_objects.go index 70f28ccabd..2082570f6b 100644 --- a/pkg/kotsadm/objects/configmaps_objects.go +++ b/pkg/kotsadm/objects/configmaps_objects.go @@ -25,7 +25,9 @@ func KotsadmConfigMap(deployOptions types.DeployOptions) *corev1.ConfigMap { "wait-duration": fmt.Sprintf("%v", deployOptions.Timeout), "with-minio": fmt.Sprintf("%v", deployOptions.IncludeMinio), "app-version-label": deployOptions.AppVersionLabel, + "requested-channel-slug": deployOptions.RequestedChannelSlug, } + if kotsadmversion.KotsadmPullSecret(deployOptions.Namespace, deployOptions.RegistryConfig) != nil { data["kotsadm-registry"] = kotsadmversion.KotsadmRegistry(deployOptions.RegistryConfig) } diff --git a/pkg/kotsadm/types/deployoptions.go b/pkg/kotsadm/types/deployoptions.go index 5db3215c06..14d5be2404 100644 --- a/pkg/kotsadm/types/deployoptions.go +++ b/pkg/kotsadm/types/deployoptions.go @@ -56,6 +56,7 @@ type DeployOptions struct { IsMinimalRBAC bool AdditionalNamespaces []string IsGKEAutopilot bool + RequestedChannelSlug string IdentityConfig kotsv1beta1.IdentityConfig IngressConfig kotsv1beta1.IngressConfig diff --git a/pkg/kotsadmlicense/license.go b/pkg/kotsadmlicense/license.go index 78a2ec83b8..74fb9532f4 100644 --- a/pkg/kotsadmlicense/license.go +++ b/pkg/kotsadmlicense/license.go @@ -46,7 +46,7 @@ func Sync(a *apptypes.App, licenseString string, failOnVersionCreate bool) (*kot updatedLicense = verifiedLicense } else { // get from the api - licenseData, err := replicatedapp.GetLatestLicense(currentLicense) + licenseData, err := replicatedapp.GetLatestLicense(currentLicense, a.SelectedChannelID) if err != nil { return nil, false, errors.Wrap(err, "failed to get latest license") } @@ -120,7 +120,7 @@ func Change(a *apptypes.App, newLicenseString string) (*kotsv1beta1.License, err } if !a.IsAirgap { - licenseData, err := replicatedapp.GetLatestLicense(newLicense) + licenseData, err := replicatedapp.GetLatestLicense(newLicense, a.SelectedChannelID) if err != nil { return nil, errors.Wrap(err, "failed to get latest license") } diff --git a/pkg/kotsadmupstream/upstream.go b/pkg/kotsadmupstream/upstream.go index 525dd8f7a5..1d59d246d0 100644 --- a/pkg/kotsadmupstream/upstream.go +++ b/pkg/kotsadmupstream/upstream.go @@ -231,6 +231,7 @@ func DownloadUpdate(appID string, update types.Update, skipPreflights bool, skip RewriteImageOptions: registrySettings, SkipCompatibilityCheck: skipCompatibilityCheck, KotsKinds: beforeKotsKinds, + AppSelectedChannelID: a.SelectedChannelID, } _, err = pull.Pull(fmt.Sprintf("replicated://%s", beforeKotsKinds.License.Spec.AppSlug), pullOptions) diff --git a/pkg/kotsutil/kots.go b/pkg/kotsutil/kots.go index 242bc0e54f..1252f62b57 100644 --- a/pkg/kotsutil/kots.go +++ b/pkg/kotsutil/kots.go @@ -1139,6 +1139,7 @@ type InstallationParams struct { WaitDuration time.Duration WithMinio bool AppVersionLabel string + RequestedChannelSlug string } func GetInstallationParams(configMapName string) (InstallationParams, error) { @@ -1174,6 +1175,7 @@ func GetInstallationParams(configMapName string) (InstallationParams, error) { autoConfig.WaitDuration, _ = time.ParseDuration(kotsadmConfigMap.Data["wait-duration"]) autoConfig.WithMinio, _ = strconv.ParseBool(kotsadmConfigMap.Data["with-minio"]) autoConfig.AppVersionLabel = kotsadmConfigMap.Data["app-version-label"] + autoConfig.RequestedChannelSlug = kotsadmConfigMap.Data["requested-channel-slug"] if enableImageDeletion, ok := kotsadmConfigMap.Data["enable-image-deletion"]; ok { autoConfig.EnableImageDeletion, _ = strconv.ParseBool(enableImageDeletion) @@ -1598,3 +1600,66 @@ func GetECVersionFromAirgapBundle(airgapBundle string) (string, error) { } return ecVersion, nil } + +func FindChannelIDInLicense(requestedSlug string, license *kotsv1beta1.License) (string, error) { + matchedChannelID := "" + if requestedSlug != "" { + // if we do not have a Channels array or its empty, default to using the top level fields for backwards compatibility + if len(license.Spec.Channels) == 0 { + logger.Debug("not a multi-channel license, using top level license channel id") + matchedChannelID = license.Spec.ChannelID + } else { + for _, channel := range license.Spec.Channels { + if channel.ChannelSlug == requestedSlug { + matchedChannelID = channel.ChannelID + break + } + } + if matchedChannelID == "" { + return "", errors.New("requested install channel slug not found in license channels") + } + } + } else { // this is an install from before the channel slug was added to the configmap + logger.Debug("requested channel slug not found in configmap, using top level channel id from license") + matchedChannelID = license.Spec.ChannelID + } + return matchedChannelID, nil +} + +func FindChannelInLicense(channelID string, license *kotsv1beta1.License) (*kotsv1beta1.Channel, error) { + if channelID == "" { + return nil, errors.New("channelID is required") + } + if len(license.Spec.Channels) == 0 { + if license.Spec.ChannelID != channelID { + return nil, errors.New("channel not found in non-multi channel license") + } + // this is an install from before multi channel support, so emulate it using the top level info + return &kotsv1beta1.Channel{ + ChannelID: license.Spec.ChannelID, + ChannelName: license.Spec.ChannelName, + IsDefault: true, + IsSemverRequired: license.Spec.IsSemverRequired, + }, nil + } + + for _, channel := range license.Spec.Channels { + if channel.ChannelID == channelID { + return &channel, nil + } + } + + logger.Warnf("channel id '%s' not found in multi channel license with sequence", channelID, license.Spec.LicenseSequence) + return nil, errors.New("channel not found in multi channel format license") +} + +func GetDefaultChannelIDFromLicense(license *kotsv1beta1.License) string { + for _, channel := range license.Spec.Channels { + if channel.IsDefault { + return channel.ChannelID + } + } + // either this isn't a multi channel license or the default channel is not set + // either way we should fall back to the top level channel id for backwards compatibility + return license.Spec.ChannelID +} diff --git a/pkg/kotsutil/kots_test.go b/pkg/kotsutil/kots_test.go index 419565549d..5b90ec047c 100644 --- a/pkg/kotsutil/kots_test.go +++ b/pkg/kotsutil/kots_test.go @@ -1057,3 +1057,201 @@ status: {} }) } } + +func TestFindChannelIDInLicense(t *testing.T) { + tests := []struct { + name string + license *kotsv1beta1.License + requestedSlug string + expectedChannelID string + expectError bool + }{ + { + name: "Found slug", + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "channel-id-1", + ChannelSlug: "slug-1", + IsDefault: true, + }, + { + ChannelID: "channel-id-2", + ChannelSlug: "slug-2", + IsDefault: false, + }, + }, + }, + }, + requestedSlug: "slug-2", + expectedChannelID: "channel-id-2", + expectError: false, + }, + { + name: "Empty requested slug", + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + ChannelID: "top-level-channel-id", + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "channel-id-1", + ChannelSlug: "channel-slug-1", + }, + { + ChannelID: "channel-id-2", + ChannelSlug: "channel-slug-2", + }, + }, + }, + }, + requestedSlug: "", + expectedChannelID: "top-level-channel-id", + expectError: false, + }, + { + name: "Legacy license with no / empty channels", + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + ChannelID: "test-channel-id", + }, + }, + requestedSlug: "test-slug", + expectedChannelID: "test-channel-id", + expectError: false, + }, + { + name: "No matching slug should error", + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + ChannelID: "top-level-channel-id", + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "channel-id-1", + ChannelSlug: "channel-slug-1", + }, + { + ChannelID: "channel-id-2", + ChannelSlug: "channel-slug-2", + }, + }, + }, + }, + requestedSlug: "non-existent-slug", + expectedChannelID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + channelID, err := kotsutil.FindChannelIDInLicense(tt.requestedSlug, tt.license) + + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedChannelID, channelID) + } + }) + } +} + +func TestFindChannelInLicense(t *testing.T) { + tests := []struct { + name string + license *kotsv1beta1.License + requestedID string + expectedChannel *kotsv1beta1.Channel + expectError bool + }{ + { + name: "Find multi channel license", + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "channel-id-1", + ChannelName: "name-1", + IsDefault: true, + IsSemverRequired: true, + }, + { + ChannelID: "channel-id-2", + ChannelName: "name-2", + IsDefault: false, + IsSemverRequired: false, + }, + }, + }, + }, + requestedID: "channel-id-2", + expectedChannel: &kotsv1beta1.Channel{ + ChannelID: "channel-id-2", + ChannelName: "name-2", + IsDefault: false, + IsSemverRequired: false, + }, + expectError: false, + }, + { + name: "Legacy license with no / empty channels", + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + ChannelID: "test-channel-id", + ChannelName: "test-channel-name", + IsSemverRequired: true, + }, + }, + requestedID: "test-channel-id", + expectedChannel: &kotsv1beta1.Channel{ + ChannelID: "test-channel-id", + ChannelName: "test-channel-name", + IsSemverRequired: true, + IsDefault: true, + }, + expectError: false, + }, + { + name: "No matching ID should error", + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + ChannelID: "channel-id-1", + ChannelName: "name-1", + IsSemverRequired: true, + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "channel-id-1", + ChannelName: "name-1", + IsDefault: true, + IsSemverRequired: true, + }, + { + ChannelID: "channel-id-2", + ChannelName: "name-2", + IsDefault: false, + IsSemverRequired: false, + }, + }, + }, + }, + requestedID: "non-existent-id", + expectedChannel: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + channel, err := kotsutil.FindChannelInLicense(tt.requestedID, tt.license) + + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, channel) + require.Equal(t, tt.expectedChannel, channel) + } + }) + } +} diff --git a/pkg/license/multichannel.go b/pkg/license/multichannel.go new file mode 100644 index 0000000000..db7f58d444 --- /dev/null +++ b/pkg/license/multichannel.go @@ -0,0 +1,60 @@ +package license + +import ( + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/logger" + "github.com/replicatedhq/kots/pkg/replicatedapp" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" +) + +func isSlugInLicenseChannels(slug string, license *kotsv1beta1.License) bool { + for _, channel := range license.Spec.Channels { + if channel.ChannelSlug == slug { + return true + } + } + return false +} + +func isMultiChannelLicense(license *kotsv1beta1.License) bool { + if license == nil { + return false + } + // whether a license is multi-channel is determined by the presence of channels in the license + // if there are no channels, it is not multi-channel - and was generated before channels + // were introduced. + return len(license.Spec.Channels) > 0 +} + +func canInstallFromChannel(slug string, license *kotsv1beta1.License) bool { + if !isMultiChannelLicense(license) { + return true + } + return isSlugInLicenseChannels(slug, license) +} + +// VerifyAndUpdateLicense will update (if not airgapped), verify that the request channel slug is present, and return the possibly updated license. +// Note that this is a noop if the license passed in is nil. +func VerifyAndUpdateLicense(log *logger.CLILogger, license *kotsv1beta1.License, preferredChannelSlug string, isAirgap bool) (*kotsv1beta1.License, error) { + if license == nil { + return nil, nil + } + if isAirgap { + if !canInstallFromChannel(preferredChannelSlug, license) { + return nil, errors.New("requested channel not found in supplied license") + } + return license, nil + } + log.ActionWithSpinner("Checking for license update") + // we fetch the latest license to ensure that the license is up to date, before proceeding + updatedLicense, err := replicatedapp.GetLatestLicense(license, "") + if err != nil { + log.FinishSpinnerWithError() + return nil, errors.Wrap(err, "failed to get latest license") + } + log.FinishSpinner() + if canInstallFromChannel(preferredChannelSlug, updatedLicense.License) { + return updatedLicense.License, nil + } + return nil, errors.New("requested channel not found in latest license") +} diff --git a/pkg/online/online.go b/pkg/online/online.go index 8ba1ae7ba4..74a8ce6bbf 100644 --- a/pkg/online/online.go +++ b/pkg/online/online.go @@ -155,6 +155,7 @@ func CreateAppFromOnline(opts CreateOnlineAppOpts) (_ *kotsutil.KotsKinds, final AppSlug: opts.PendingApp.Slug, AppSequence: 0, AppVersionLabel: opts.PendingApp.VersionLabel, + AppSelectedChannelID: opts.PendingApp.SelectedChannelID, ReportingInfo: reporting.GetReportingInfo(opts.PendingApp.ID), SkipCompatibilityCheck: opts.SkipCompatibilityCheck, } diff --git a/pkg/online/types/types.go b/pkg/online/types/types.go index 992a0cab12..3858abd4dc 100644 --- a/pkg/online/types/types.go +++ b/pkg/online/types/types.go @@ -1,11 +1,12 @@ package types type PendingApp struct { - ID string - Slug string - Name string - LicenseData string - VersionLabel string + ID string + Slug string + Name string + LicenseData string + VersionLabel string + SelectedChannelID string } type InstallStatus struct { diff --git a/pkg/pull/pull.go b/pkg/pull/pull.go index 65020d7140..d35133e034 100644 --- a/pkg/pull/pull.go +++ b/pkg/pull/pull.go @@ -68,6 +68,7 @@ type PullOptions struct { AppSlug string AppSequence int64 AppVersionLabel string + AppSelectedChannelID string IsGitOps bool StorageClassName string HTTPProxyEnvValue string @@ -131,6 +132,7 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) { AppSlug: pullOptions.AppSlug, AppSequence: pullOptions.AppSequence, AppVersionLabel: pullOptions.AppVersionLabel, + AppSelectedChannelID: pullOptions.AppSelectedChannelID, LocalRegistry: pullOptions.RewriteImageOptions, ReportingInfo: pullOptions.ReportingInfo, SkipCompatibilityCheck: pullOptions.SkipCompatibilityCheck, @@ -236,10 +238,10 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) { logger.Infof("Expecting to install version %s but airgap bundle version is %s.", fetchOptions.AppVersionLabel, airgap.Spec.VersionLabel) } - if fetchOptions.License.Spec.ChannelID != airgap.Spec.ChannelID { + if _, err = kotsutil.FindChannelInLicense(airgap.Spec.ChannelID, fetchOptions.License); err != nil { return "", util.ActionableError{ NoRetry: true, // if this is airgap upload, make sure to free up tmp space - Message: fmt.Sprintf("License (%s) and airgap bundle (%s) channels do not match.", fetchOptions.License.Spec.ChannelName, airgap.Spec.ChannelName), + Message: fmt.Sprintf("Requested channel (%s) not found in license.", airgap.Spec.ChannelName), } } diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index c7f28c14b6..1a1f76519f 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -145,11 +145,12 @@ func RewriteImages(appID string, sequence int64, hostname string, username strin Password: password, IsReadOnly: isReadOnly, }, - AppID: a.ID, - AppSlug: a.Slug, - IsGitOps: a.IsGitOps, - AppSequence: nextAppSequence, - ReportingInfo: reporting.GetReportingInfo(a.ID), + AppID: a.ID, + AppSlug: a.Slug, + AppSelectedChannelID: a.SelectedChannelID, + IsGitOps: a.IsGitOps, + AppSequence: nextAppSequence, + ReportingInfo: reporting.GetReportingInfo(a.ID), // TODO: pass in as arguments if this is ever called from CLI HTTPProxyEnvValue: os.Getenv("HTTP_PROXY"), diff --git a/pkg/render/render.go b/pkg/render/render.go index a5bb8cc3d4..7551dbea00 100644 --- a/pkg/render/render.go +++ b/pkg/render/render.go @@ -121,31 +121,39 @@ func RenderDir(opts types.RenderDirOptions) error { downstreamNames = append(downstreamNames, d.Name) } + // typically we want the selected channel id on the App obj + // but in the case of a license sync, like when called from UpdateAppLicense + // the app object is not updated yet - but the license being passed in is the latest + selectedChannelID := opts.AppSelectedChannelID + if selectedChannelID == "" { + selectedChannelID = opts.App.SelectedChannelID + } + appNamespace := util.PodNamespace if os.Getenv("KOTSADM_TARGET_NAMESPACE") != "" { appNamespace = os.Getenv("KOTSADM_TARGET_NAMESPACE") } - reOptions := rewrite.RewriteOptions{ - RootDir: opts.ArchiveDir, - UpstreamURI: fmt.Sprintf("replicated://%s", license.Spec.AppSlug), - UpstreamPath: filepath.Join(opts.ArchiveDir, "upstream"), - Installation: installation, - Downstreams: downstreamNames, - Silent: true, - CreateAppDir: false, - ExcludeKotsKinds: true, - License: license, - ConfigValues: configValues, - K8sNamespace: appNamespace, - CopyImages: false, - IsAirgap: opts.App.IsAirgap, - AppID: opts.App.ID, - AppSlug: opts.App.Slug, - IsGitOps: opts.App.IsGitOps, - AppSequence: opts.Sequence, - ReportingInfo: opts.ReportingInfo, - RegistrySettings: opts.RegistrySettings, + RootDir: opts.ArchiveDir, + UpstreamURI: fmt.Sprintf("replicated://%s", license.Spec.AppSlug), + UpstreamPath: filepath.Join(opts.ArchiveDir, "upstream"), + Installation: installation, + Downstreams: downstreamNames, + Silent: true, + CreateAppDir: false, + ExcludeKotsKinds: true, + License: license, + ConfigValues: configValues, + K8sNamespace: appNamespace, + CopyImages: false, + IsAirgap: opts.App.IsAirgap, + AppID: opts.App.ID, + AppSlug: opts.App.Slug, + AppSelectedChannelID: selectedChannelID, + IsGitOps: opts.App.IsGitOps, + AppSequence: opts.Sequence, + ReportingInfo: opts.ReportingInfo, + RegistrySettings: opts.RegistrySettings, // TODO: pass in as arguments if this is ever called from CLI HTTPProxyEnvValue: os.Getenv("HTTP_PROXY"), diff --git a/pkg/render/types/interface.go b/pkg/render/types/interface.go index a56ce9fb99..525edf27c2 100644 --- a/pkg/render/types/interface.go +++ b/pkg/render/types/interface.go @@ -19,12 +19,13 @@ type RenderFileOptions struct { } type RenderDirOptions struct { - ArchiveDir string - App *apptypes.App - Downstreams []downstreamtypes.Downstream - RegistrySettings registrytypes.RegistrySettings - Sequence int64 - ReportingInfo *reportingtypes.ReportingInfo + ArchiveDir string + App *apptypes.App + Downstreams []downstreamtypes.Downstream + RegistrySettings registrytypes.RegistrySettings + Sequence int64 + ReportingInfo *reportingtypes.ReportingInfo + AppSelectedChannelID string } type Renderer interface { diff --git a/pkg/replicatedapp/api.go b/pkg/replicatedapp/api.go index fa7662f3c6..eebcad4878 100644 --- a/pkg/replicatedapp/api.go +++ b/pkg/replicatedapp/api.go @@ -40,9 +40,15 @@ type LicenseData struct { License *kotsv1beta1.License } -func GetLatestLicense(license *kotsv1beta1.License) (*LicenseData, error) { +// GetLatestLicense will return the latest license from the replicated api, if selectedChannelID is provided +// it will be passed along to the api. +func GetLatestLicense(license *kotsv1beta1.License, selectedChannelID string) (*LicenseData, error) { url := fmt.Sprintf("%s/license/%s", license.Spec.Endpoint, license.Spec.AppSlug) + if selectedChannelID != "" { + url = fmt.Sprintf("%s?selectedChannelId=%s", url, selectedChannelID) + } + licenseData, err := getLicenseFromAPI(url, license.Spec.LicenseID) if err != nil { return nil, errors.Wrap(err, "failed to get license from api") diff --git a/pkg/replicatedapp/api_test.go b/pkg/replicatedapp/api_test.go index 4d81d0f891..07103e0bee 100644 --- a/pkg/replicatedapp/api_test.go +++ b/pkg/replicatedapp/api_test.go @@ -30,7 +30,7 @@ func Test_getRequest(t *testing.T) { channel: nil, channelSequence: "", versionLabel: nil, - expectedURL: "https://replicated-app/release/sluggy1?channelSequence=&isSemverSupported=true&licenseSequence=23", + expectedURL: "https://replicated-app/release/sluggy1?channelSequence=&isSemverSupported=true&licenseSequence=23&selectedChannelId=channel", }, { endpoint: "http://localhost:30016", @@ -38,7 +38,7 @@ func Test_getRequest(t *testing.T) { channel: &beta, channelSequence: "", versionLabel: nil, - expectedURL: "http://localhost:30016/release/sluggy2/beta?channelSequence=&isSemverSupported=true&licenseSequence=23", + expectedURL: "http://localhost:30016/release/sluggy2/beta?channelSequence=&isSemverSupported=true&licenseSequence=23&selectedChannelId=channel", }, { endpoint: "https://replicated-app", @@ -46,7 +46,7 @@ func Test_getRequest(t *testing.T) { channel: &unstable, channelSequence: "10", versionLabel: nil, - expectedURL: "https://replicated-app/release/sluggy3/unstable?channelSequence=10&isSemverSupported=true&licenseSequence=23", + expectedURL: "https://replicated-app/release/sluggy3/unstable?channelSequence=10&isSemverSupported=true&licenseSequence=23&selectedChannelId=channel", }, { endpoint: "https://replicated-app", @@ -54,7 +54,7 @@ func Test_getRequest(t *testing.T) { channel: &unstable, channelSequence: "", versionLabel: &version, - expectedURL: "https://replicated-app/release/sluggy3/unstable?channelSequence=&isSemverSupported=true&licenseSequence=23&versionLabel=1.1.0", + expectedURL: "https://replicated-app/release/sluggy3/unstable?channelSequence=&isSemverSupported=true&licenseSequence=23&selectedChannelId=channel&versionLabel=1.1.0", }, } @@ -65,6 +65,7 @@ func Test_getRequest(t *testing.T) { Endpoint: test.endpoint, AppSlug: test.appSlug, LicenseSequence: 23, + ChannelID: "channel", }, } r := &ReplicatedUpstream{ @@ -77,7 +78,7 @@ func Test_getRequest(t *testing.T) { if test.channel != nil { cursor.ChannelName = *test.channel } - request, err := r.GetRequest("GET", license, cursor) + request, err := r.GetRequest("GET", license, cursor, channel) req.NoError(err) assert.Equal(t, test.expectedURL, request.URL.String()) } diff --git a/pkg/replicatedapp/upstream.go b/pkg/replicatedapp/upstream.go index c96aa418b1..4f52d6ca25 100644 --- a/pkg/replicatedapp/upstream.go +++ b/pkg/replicatedapp/upstream.go @@ -41,7 +41,7 @@ func ParseReplicatedURL(u *url.URL) (*ReplicatedUpstream, error) { return &replicatedUpstream, nil } -func (r *ReplicatedUpstream) GetRequest(method string, license *kotsv1beta1.License, cursor ReplicatedCursor) (*http.Request, error) { +func (r *ReplicatedUpstream) GetRequest(method string, license *kotsv1beta1.License, cursor ReplicatedCursor, selectedChannelID string) (*http.Request, error) { u, err := url.Parse(license.Spec.Endpoint) if err != nil { return nil, errors.Wrap(err, "failed to parse endpoint from license") @@ -64,6 +64,7 @@ func (r *ReplicatedUpstream) GetRequest(method string, license *kotsv1beta1.Lice } urlValues.Add("licenseSequence", fmt.Sprintf("%d", license.Spec.LicenseSequence)) urlValues.Add("isSemverSupported", "true") + urlValues.Add("selectedChannelId", selectedChannelID) url := fmt.Sprintf("%s://%s?%s", u.Scheme, urlPath, urlValues.Encode()) diff --git a/pkg/rewrite/rewrite.go b/pkg/rewrite/rewrite.go index edfaceeb27..c8390cd8e3 100644 --- a/pkg/rewrite/rewrite.go +++ b/pkg/rewrite/rewrite.go @@ -29,29 +29,30 @@ import ( ) type RewriteOptions struct { - RootDir string - UpstreamURI string - UpstreamPath string - Downstreams []string - K8sNamespace string - Silent bool - CreateAppDir bool - ExcludeKotsKinds bool - Installation *kotsv1beta1.Installation - License *kotsv1beta1.License - ConfigValues *kotsv1beta1.ConfigValues - ReportWriter io.Writer - CopyImages bool // can be false even if registry is not read-only - IsAirgap bool - RegistrySettings registrytypes.RegistrySettings - AppID string - AppSlug string - IsGitOps bool - AppSequence int64 - ReportingInfo *reportingtypes.ReportingInfo - HTTPProxyEnvValue string - HTTPSProxyEnvValue string - NoProxyEnvValue string + RootDir string + UpstreamURI string + UpstreamPath string + Downstreams []string + K8sNamespace string + Silent bool + CreateAppDir bool + ExcludeKotsKinds bool + Installation *kotsv1beta1.Installation + License *kotsv1beta1.License + ConfigValues *kotsv1beta1.ConfigValues + ReportWriter io.Writer + CopyImages bool // can be false even if registry is not read-only + IsAirgap bool + RegistrySettings registrytypes.RegistrySettings + AppID string + AppSlug string + AppSelectedChannelID string + IsGitOps bool + AppSequence int64 + ReportingInfo *reportingtypes.ReportingInfo + HTTPProxyEnvValue string + HTTPSProxyEnvValue string + NoProxyEnvValue string } func Rewrite(rewriteOptions RewriteOptions) error { @@ -81,6 +82,7 @@ func Rewrite(rewriteOptions RewriteOptions) error { License: rewriteOptions.License, AppSequence: rewriteOptions.AppSequence, AppSlug: rewriteOptions.AppSlug, + AppSelectedChannelID: rewriteOptions.AppSelectedChannelID, LocalRegistry: rewriteOptions.RegistrySettings, ReportingInfo: rewriteOptions.ReportingInfo, SkipCompatibilityCheck: true, // we're rewriting an existing version, no need to check for compatibility diff --git a/pkg/store/kotsstore/airgap_store.go b/pkg/store/kotsstore/airgap_store.go index 3e6a013c16..54e38c1c80 100644 --- a/pkg/store/kotsstore/airgap_store.go +++ b/pkg/store/kotsstore/airgap_store.go @@ -28,7 +28,7 @@ func (s *KOTSStore) GetPendingAirgapUploadApp() (*airgaptypes.PendingApp, error) return nil, errors.Wrap(err, "failed to scan pending app id") } - query = `select id, slug, name, license from app where id = ?` + query = `select id, slug, name, license, selected_channel_id from app where id = ?` rows, err = db.QueryOneParameterized(gorqlite.ParameterizedStatement{ Query: query, Arguments: []interface{}{id}, @@ -41,7 +41,7 @@ func (s *KOTSStore) GetPendingAirgapUploadApp() (*airgaptypes.PendingApp, error) } pendingApp := airgaptypes.PendingApp{} - if err := rows.Scan(&pendingApp.ID, &pendingApp.Slug, &pendingApp.Name, &pendingApp.LicenseData); err != nil { + if err := rows.Scan(&pendingApp.ID, &pendingApp.Slug, &pendingApp.Name, &pendingApp.LicenseData, &pendingApp.SelectedChannelID); err != nil { return nil, errors.Wrap(err, "failed to scan pending app") } diff --git a/pkg/store/kotsstore/app_store.go b/pkg/store/kotsstore/app_store.go index 847f8b7df9..e3e6dd8dd7 100644 --- a/pkg/store/kotsstore/app_store.go +++ b/pkg/store/kotsstore/app_store.go @@ -13,10 +13,12 @@ import ( "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/persistence" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" troubleshootanalyze "github.com/replicatedhq/troubleshoot/pkg/analyze" "github.com/rqlite/gorqlite" "github.com/segmentio/ksuid" "go.uber.org/zap" + "k8s.io/client-go/kubernetes/scheme" ) func (s *KOTSStore) AddAppToAllDownstreams(appID string) error { @@ -146,7 +148,7 @@ func (s *KOTSStore) GetAppIDFromSlug(slug string) (string, error) { func (s *KOTSStore) GetApp(id string) (*apptypes.App, error) { db := persistence.MustGetDBSession() - query := `select id, name, license, upstream_uri, icon_uri, created_at, updated_at, slug, current_sequence, last_update_check_at, last_license_sync, is_airgap, snapshot_ttl_new, snapshot_schedule, restore_in_progress_name, restore_undeploy_status, update_checker_spec, semver_auto_deploy, install_state, channel_changed from app where id = ?` + query := `select id, name, license, upstream_uri, icon_uri, created_at, updated_at, slug, current_sequence, last_update_check_at, last_license_sync, is_airgap, snapshot_ttl_new, snapshot_schedule, restore_in_progress_name, restore_undeploy_status, update_checker_spec, semver_auto_deploy, install_state, channel_changed, selected_channel_id from app where id = ?` rows, err := db.QueryOneParameterized(gorqlite.ParameterizedStatement{ Query: query, Arguments: []interface{}{id}, @@ -173,8 +175,9 @@ func (s *KOTSStore) GetApp(id string) (*apptypes.App, error) { var restoreUndeployStatus gorqlite.NullString var updateCheckerSpec gorqlite.NullString var autoDeploy gorqlite.NullString + var selectedChannelId gorqlite.NullString - if err := rows.Scan(&app.ID, &app.Name, &licenseStr, &upstreamURI, &iconURI, &app.CreatedAt, &updatedAt, &app.Slug, ¤tSequence, &lastUpdateCheckAt, &lastLicenseSync, &app.IsAirgap, &snapshotTTLNew, &snapshotSchedule, &restoreInProgressName, &restoreUndeployStatus, &updateCheckerSpec, &autoDeploy, &app.InstallState, &app.ChannelChanged); err != nil { + if err := rows.Scan(&app.ID, &app.Name, &licenseStr, &upstreamURI, &iconURI, &app.CreatedAt, &updatedAt, &app.Slug, ¤tSequence, &lastUpdateCheckAt, &lastLicenseSync, &app.IsAirgap, &snapshotTTLNew, &snapshotSchedule, &restoreInProgressName, &restoreUndeployStatus, &updateCheckerSpec, &autoDeploy, &app.InstallState, &app.ChannelChanged, &selectedChannelId); err != nil { return nil, errors.Wrap(err, "failed to scan app") } @@ -187,6 +190,7 @@ func (s *KOTSStore) GetApp(id string) (*apptypes.App, error) { app.RestoreUndeployStatus = apptypes.UndeployStatus(restoreUndeployStatus.String) app.UpdateCheckerSpec = updateCheckerSpec.String app.AutoDeploy = apptypes.AutoDeploy(autoDeploy.String) + app.SelectedChannelID = selectedChannelId.String if lastLicenseSync.Valid { app.LastLicenseSync = lastLicenseSync.Time.Format(time.RFC3339) @@ -267,6 +271,20 @@ func (s *KOTSStore) GetApp(id string) (*apptypes.App, error) { } app.IsGitOps = isGitOps + if app.SelectedChannelID == "" { + decode := scheme.Codecs.UniversalDeserializer().Decode + obj, _, err := decode([]byte(licenseStr.String), nil, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to decode license yaml") + } + license := obj.(*kotsv1beta1.License) + licenseChan, err := s.backfillChannelIDFromLicense(app.ID, license) + if err != nil { + return nil, errors.Wrap(err, "failed to backfill channel id") + } + app.SelectedChannelID = licenseChan.ChannelID + } + return &app, nil } @@ -279,10 +297,12 @@ func (s *KOTSStore) GetAppFromSlug(slug string) (*apptypes.App, error) { return s.GetApp(id) } -func (s *KOTSStore) CreateApp(name string, upstreamURI string, licenseData string, isAirgapEnabled bool, skipImagePush bool, registryIsReadOnly bool) (*apptypes.App, error) { +func (s *KOTSStore) CreateApp(name string, selectedChannelID string, upstreamURI string, licenseData string, isAirgapEnabled bool, skipImagePush bool, registryIsReadOnly bool) (*apptypes.App, error) { logger.Debug("creating app", zap.String("name", name), - zap.String("upstreamURI", upstreamURI)) + zap.String("upstreamURI", upstreamURI), + zap.String("selectedChannelID", selectedChannelID), + ) db := persistence.MustGetDBSession() @@ -337,10 +357,10 @@ func (s *KOTSStore) CreateApp(name string, upstreamURI string, licenseData strin id := ksuid.New().String() - query := `insert into app (id, name, icon_uri, created_at, slug, upstream_uri, license, is_all_users, install_state, registry_is_readonly) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + query := `insert into app (id, name, icon_uri, created_at, slug, upstream_uri, license, is_all_users, install_state, registry_is_readonly, selected_channel_id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` wr, err := db.WriteOneParameterized(gorqlite.ParameterizedStatement{ Query: query, - Arguments: []interface{}{id, name, "", time.Now().Unix(), slugProposal, upstreamURI, licenseData, true, installState, registryIsReadOnly}, + Arguments: []interface{}{id, name, "", time.Now().Unix(), slugProposal, upstreamURI, licenseData, true, installState, registryIsReadOnly, selectedChannelID}, }) if err != nil { return nil, fmt.Errorf("failed to insert app: %v: %v", err, wr.Err) @@ -594,3 +614,50 @@ func (s *KOTSStore) SetAppChannelChanged(appID string, channelChanged bool) erro return nil } + +func (s *KOTSStore) GetAppSelectedChannelID(appID string) (string, error) { + db := persistence.MustGetDBSession() + query := `select selected_channel_id from app where id = ?` + rows, err := db.QueryOneParameterized(gorqlite.ParameterizedStatement{ + Query: query, + Arguments: []interface{}{appID}, + }) + if err != nil { + return "", fmt.Errorf("failed to query: %v: %v", err, rows.Err) + } + if !rows.Next() { + return "", ErrNotFound + } + + var channelID gorqlite.NullString + if err := rows.Scan(&channelID); err != nil { + return "", errors.Wrap(err, "failed to scan channel id") + } + + return channelID.String, nil +} + +func (s *KOTSStore) SetAppSelectedChannelID(appID string, channelID string) error { + logger.Debug("setting app channel id", + zap.String("appID", appID), zap.String("channelID", channelID)) + db := persistence.MustGetDBSession() + + query := `update app set selected_channel_id = ? where id = ?` + wr, err := db.WriteOneParameterized(gorqlite.ParameterizedStatement{ + Query: query, + Arguments: []interface{}{channelID, appID}, + }) + if err != nil { + return fmt.Errorf("failed to update app channel id: %v: %v", err, wr.Err) + } + + return nil +} + +func (s *KOTSStore) backfillChannelIDFromLicense(appID string, license *kotsv1beta1.License) (*kotsv1beta1.Channel, error) { + backfillID := kotsutil.GetDefaultChannelIDFromLicense(license) + if err := s.SetAppSelectedChannelID(appID, backfillID); err != nil { + return nil, errors.Wrap(err, "failed to backfill app channel id from license") + } + return kotsutil.FindChannelInLicense(backfillID, license) +} diff --git a/pkg/store/kotsstore/downstream_store.go b/pkg/store/kotsstore/downstream_store.go index 1a87320104..b69db3cf9a 100644 --- a/pkg/store/kotsstore/downstream_store.go +++ b/pkg/store/kotsstore/downstream_store.go @@ -409,6 +409,7 @@ func (s *KOTSStore) GetDownstreamVersions(appID string, clusterID string, downlo if err != nil { return nil, errors.Wrap(err, "failed to get app license") } + downstreamtypes.SortDownstreamVersions(result.AllVersions, license.Spec.IsSemverRequired) // retrieve additional details about the latest downloaded version, @@ -673,6 +674,7 @@ func (s *KOTSStore) AddDownstreamVersionsDetails(appID string, clusterID string, if err != nil { return errors.Wrap(err, "failed to get app license") } + for _, v := range versions { v.IsDeployable, v.NonDeployableCause = isAppVersionDeployable(v, allVersions, license.Spec.IsSemverRequired) } diff --git a/pkg/store/kotsstore/license_store.go b/pkg/store/kotsstore/license_store.go index 3c2477c1d1..4cc4da6e0e 100644 --- a/pkg/store/kotsstore/license_store.go +++ b/pkg/store/kotsstore/license_store.go @@ -121,13 +121,32 @@ func (s *KOTSStore) UpdateAppLicense(appID string, baseSequence int64, archiveDi return int64(0), errors.Wrap(err, "failed to write new license") } - // app has the original license data received from the server - statements = append(statements, gorqlite.ParameterizedStatement{ - Query: `update app set license = ?, last_license_sync = ?, channel_changed = ? where id = ?`, - Arguments: []interface{}{originalLicenseData, time.Now().Unix(), channelChanged, appID}, - }) + selectedChannelId, err := s.GetAppSelectedChannelID(appID) + if err != nil { + return int64(0), errors.Wrap(err, "failed to get existing app selected channel id") + } + + // If the license channels array has more than one entry, then the license is a true multi-channel license, + // and we should skip updating selected_channel_id in the app table. If there's only a single entry, + // we should update the selected_channel_id in the app table to ensure it stays consistent across channel + // changes. This is a temporary solution until channel changes on true multi-channel licenses are supported. + if len(newLicense.Spec.Channels) > 1 { + logger.Debug("Skipping selected_channel_id update for multi-channel license") + // app has the original license data received from the server + statements = append(statements, gorqlite.ParameterizedStatement{ + Query: `update app set license = ?, last_license_sync = ?, channel_changed = ? where id = ?`, + Arguments: []interface{}{originalLicenseData, time.Now().Unix(), channelChanged, appID}, + }) + } else { + // app has the original license data received from the server + statements = append(statements, gorqlite.ParameterizedStatement{ + Query: `update app set license = ?, last_license_sync = ?, channel_changed = ?, selected_channel_id = ? where id = ?`, + Arguments: []interface{}{originalLicenseData, time.Now().Unix(), channelChanged, newLicense.Spec.ChannelID, appID}, + }) + selectedChannelId = newLicense.Spec.ChannelID + } - appVersionStatements, newSeq, err := s.createNewVersionForLicenseChangeStatements(appID, baseSequence, archiveDir, renderer, reportingInfo) + appVersionStatements, newSeq, err := s.createNewVersionForLicenseChangeStatements(appID, baseSequence, archiveDir, renderer, reportingInfo, selectedChannelId) if err != nil { // ignore error here to prevent a failure to render the current version // preventing the end-user from updating the application @@ -164,7 +183,7 @@ func (s *KOTSStore) UpdateAppLicenseSyncNow(appID string) error { return nil } -func (s *KOTSStore) createNewVersionForLicenseChangeStatements(appID string, baseSequence int64, archiveDir string, renderer rendertypes.Renderer, reportingInfo *reportingtypes.ReportingInfo) ([]gorqlite.ParameterizedStatement, int64, error) { +func (s *KOTSStore) createNewVersionForLicenseChangeStatements(appID string, baseSequence int64, archiveDir string, renderer rendertypes.Renderer, reportingInfo *reportingtypes.ReportingInfo, selectedChannelID string) ([]gorqlite.ParameterizedStatement, int64, error) { registrySettings, err := s.GetRegistryDetailsForApp(appID) if err != nil { return nil, int64(0), errors.Wrap(err, "failed to get registry settings for app") @@ -186,12 +205,13 @@ func (s *KOTSStore) createNewVersionForLicenseChangeStatements(appID string, bas } if err := renderer.RenderDir(rendertypes.RenderDirOptions{ - ArchiveDir: archiveDir, - App: app, - Downstreams: downstreams, - RegistrySettings: registrySettings, - Sequence: nextAppSequence, - ReportingInfo: reportingInfo, + ArchiveDir: archiveDir, + App: app, + Downstreams: downstreams, + RegistrySettings: registrySettings, + Sequence: nextAppSequence, + ReportingInfo: reportingInfo, + AppSelectedChannelID: selectedChannelID, }); err != nil { return nil, int64(0), errors.Wrap(err, "failed to render new version") } diff --git a/pkg/store/kotsstore/version_store.go b/pkg/store/kotsstore/version_store.go index 92492cc581..98b4da9160 100644 --- a/pkg/store/kotsstore/version_store.go +++ b/pkg/store/kotsstore/version_store.go @@ -439,7 +439,6 @@ func (s *KOTSStore) UpdateAppVersion(appID string, sequence int64, baseSequence func (s *KOTSStore) CreateAppVersion(appID string, baseSequence *int64, filesInDir string, source string, skipPreflights bool, renderer rendertypes.Renderer) (int64, error) { db := persistence.MustGetDBSession() - appVersionStatements, newSequence, err := s.createAppVersionStatements(appID, baseSequence, filesInDir, source, skipPreflights, renderer) if err != nil { return 0, errors.Wrap(err, "failed to construct app version statements") diff --git a/pkg/store/mock/mock.go b/pkg/store/mock/mock.go index 0f65b2fea2..9d696b3afd 100644 --- a/pkg/store/mock/mock.go +++ b/pkg/store/mock/mock.go @@ -97,18 +97,18 @@ func (mr *MockStoreMockRecorder) AddDownstreamVersionsDetails(appID, clusterID, } // CreateApp mocks base method. -func (m *MockStore) CreateApp(name, upstreamURI, licenseData string, isAirgapEnabled, skipImagePush, registryIsReadOnly bool) (*types3.App, error) { +func (m *MockStore) CreateApp(name, channelID, upstreamURI, licenseData string, isAirgapEnabled, skipImagePush, registryIsReadOnly bool) (*types3.App, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateApp", name, upstreamURI, licenseData, isAirgapEnabled, skipImagePush, registryIsReadOnly) + ret := m.ctrl.Call(m, "CreateApp", name, channelID, upstreamURI, licenseData, isAirgapEnabled, skipImagePush, registryIsReadOnly) ret0, _ := ret[0].(*types3.App) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateApp indicates an expected call of CreateApp. -func (mr *MockStoreMockRecorder) CreateApp(name, upstreamURI, licenseData, isAirgapEnabled, skipImagePush, registryIsReadOnly interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CreateApp(name, channelID, upstreamURI, licenseData, isAirgapEnabled, skipImagePush, registryIsReadOnly interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateApp", reflect.TypeOf((*MockStore)(nil).CreateApp), name, upstreamURI, licenseData, isAirgapEnabled, skipImagePush, registryIsReadOnly) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateApp", reflect.TypeOf((*MockStore)(nil).CreateApp), name, channelID, upstreamURI, licenseData, isAirgapEnabled, skipImagePush, registryIsReadOnly) } // CreateAppVersion mocks base method. @@ -1546,6 +1546,20 @@ func (mr *MockStoreMockRecorder) SetAppIsAirgap(appID, isAirgap interface{}) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAppIsAirgap", reflect.TypeOf((*MockStore)(nil).SetAppIsAirgap), appID, isAirgap) } +// SetAppSelectedChannelID mocks base method. +func (m *MockStore) SetAppSelectedChannelID(appID, channelID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetAppSelectedChannelID", appID, channelID) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetAppSelectedChannelID indicates an expected call of SetAppSelectedChannelID. +func (mr *MockStoreMockRecorder) SetAppSelectedChannelID(appID, channelID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAppSelectedChannelID", reflect.TypeOf((*MockStore)(nil).SetAppSelectedChannelID), appID, channelID) +} + // SetAppStatus mocks base method. func (m *MockStore) SetAppStatus(appID string, resourceStates types4.ResourceStates, updatedAt time.Time, sequence int64) error { m.ctrl.T.Helper() @@ -2680,18 +2694,18 @@ func (mr *MockAppStoreMockRecorder) AddAppToAllDownstreams(appID interface{}) *g } // CreateApp mocks base method. -func (m *MockAppStore) CreateApp(name, upstreamURI, licenseData string, isAirgapEnabled, skipImagePush, registryIsReadOnly bool) (*types3.App, error) { +func (m *MockAppStore) CreateApp(name, channelID, upstreamURI, licenseData string, isAirgapEnabled, skipImagePush, registryIsReadOnly bool) (*types3.App, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateApp", name, upstreamURI, licenseData, isAirgapEnabled, skipImagePush, registryIsReadOnly) + ret := m.ctrl.Call(m, "CreateApp", name, channelID, upstreamURI, licenseData, isAirgapEnabled, skipImagePush, registryIsReadOnly) ret0, _ := ret[0].(*types3.App) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateApp indicates an expected call of CreateApp. -func (mr *MockAppStoreMockRecorder) CreateApp(name, upstreamURI, licenseData, isAirgapEnabled, skipImagePush, registryIsReadOnly interface{}) *gomock.Call { +func (mr *MockAppStoreMockRecorder) CreateApp(name, channelID, upstreamURI, licenseData, isAirgapEnabled, skipImagePush, registryIsReadOnly interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateApp", reflect.TypeOf((*MockAppStore)(nil).CreateApp), name, upstreamURI, licenseData, isAirgapEnabled, skipImagePush, registryIsReadOnly) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateApp", reflect.TypeOf((*MockAppStore)(nil).CreateApp), name, channelID, upstreamURI, licenseData, isAirgapEnabled, skipImagePush, registryIsReadOnly) } // GetApp mocks base method. @@ -2886,6 +2900,20 @@ func (mr *MockAppStoreMockRecorder) SetAppInstallState(appID, state interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAppInstallState", reflect.TypeOf((*MockAppStore)(nil).SetAppInstallState), appID, state) } +// SetAppSelectedChannelID mocks base method. +func (m *MockAppStore) SetAppSelectedChannelID(appID, channelID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetAppSelectedChannelID", appID, channelID) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetAppSelectedChannelID indicates an expected call of SetAppSelectedChannelID. +func (mr *MockAppStoreMockRecorder) SetAppSelectedChannelID(appID, channelID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAppSelectedChannelID", reflect.TypeOf((*MockAppStore)(nil).SetAppSelectedChannelID), appID, channelID) +} + // SetAutoDeploy mocks base method. func (m *MockAppStore) SetAutoDeploy(appID string, autoDeploy types3.AutoDeploy) error { m.ctrl.T.Helper() diff --git a/pkg/store/store_interface.go b/pkg/store/store_interface.go index 049fc2de18..c487980c64 100644 --- a/pkg/store/store_interface.go +++ b/pkg/store/store_interface.go @@ -120,7 +120,7 @@ type AppStore interface { GetAppIDFromSlug(slug string) (appID string, err error) GetApp(appID string) (*apptypes.App, error) GetAppFromSlug(slug string) (*apptypes.App, error) - CreateApp(name string, upstreamURI string, licenseData string, isAirgapEnabled bool, skipImagePush bool, registryIsReadOnly bool) (*apptypes.App, error) + CreateApp(name string, channelID string, upstreamURI string, licenseData string, isAirgapEnabled bool, skipImagePush bool, registryIsReadOnly bool) (*apptypes.App, error) ListDownstreamsForApp(appID string) ([]downstreamtypes.Downstream, error) ListAppsForDownstream(clusterID string) ([]*apptypes.App, error) GetDownstream(clusterID string) (*downstreamtypes.Downstream, error) @@ -131,6 +131,7 @@ type AppStore interface { SetSnapshotSchedule(appID string, snapshotSchedule string) error RemoveApp(appID string) error SetAppChannelChanged(appID string, channelChanged bool) error + SetAppSelectedChannelID(appID string, channelID string) error } type DownstreamStore interface { diff --git a/pkg/tests/pull/cases/airgap/testcase.yaml b/pkg/tests/pull/cases/airgap/testcase.yaml index 406773976c..b597227ffa 100644 --- a/pkg/tests/pull/cases/airgap/testcase.yaml +++ b/pkg/tests/pull/cases/airgap/testcase.yaml @@ -15,4 +15,5 @@ PullOptions: Password: fake-pass IsReadOnly: true Downstreams: - - this-cluster \ No newline at end of file + - this-cluster + AppSelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 \ No newline at end of file diff --git a/pkg/tests/pull/cases/configcontext/testcase.yaml b/pkg/tests/pull/cases/configcontext/testcase.yaml index a2dc01e2cb..3d5440c8f1 100644 --- a/pkg/tests/pull/cases/configcontext/testcase.yaml +++ b/pkg/tests/pull/cases/configcontext/testcase.yaml @@ -13,4 +13,5 @@ PullOptions: Password: fake-pass IsReadOnly: true Downstreams: - - this-cluster \ No newline at end of file + - this-cluster + AppSelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 \ No newline at end of file diff --git a/pkg/tests/pull/cases/customhostnames/testcase.yaml b/pkg/tests/pull/cases/customhostnames/testcase.yaml index 05385c5044..1db21dc494 100644 --- a/pkg/tests/pull/cases/customhostnames/testcase.yaml +++ b/pkg/tests/pull/cases/customhostnames/testcase.yaml @@ -8,4 +8,5 @@ PullOptions: SharedPassword: dummy-pass RewriteImages: false Downstreams: - - this-cluster \ No newline at end of file + - this-cluster + AppSelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 \ No newline at end of file diff --git a/pkg/tests/pull/cases/kotskinds/testcase.yaml b/pkg/tests/pull/cases/kotskinds/testcase.yaml index 080188080e..82e589a62f 100644 --- a/pkg/tests/pull/cases/kotskinds/testcase.yaml +++ b/pkg/tests/pull/cases/kotskinds/testcase.yaml @@ -9,4 +9,5 @@ PullOptions: RewriteImages: false Downstreams: - this-cluster - SkipHelmChartCheck: true \ No newline at end of file + SkipHelmChartCheck: true + AppSelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 \ No newline at end of file diff --git a/pkg/tests/pull/cases/multidoc/testcase.yaml b/pkg/tests/pull/cases/multidoc/testcase.yaml index fae3553d4c..02d3eff940 100644 --- a/pkg/tests/pull/cases/multidoc/testcase.yaml +++ b/pkg/tests/pull/cases/multidoc/testcase.yaml @@ -8,4 +8,5 @@ PullOptions: SharedPassword: dummy-pass RewriteImages: false Downstreams: - - this-cluster \ No newline at end of file + - this-cluster + AppSelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 \ No newline at end of file diff --git a/pkg/tests/pull/cases/needsconfig/testcase.yaml b/pkg/tests/pull/cases/needsconfig/testcase.yaml index 82693ef8fe..90d8083e0a 100644 --- a/pkg/tests/pull/cases/needsconfig/testcase.yaml +++ b/pkg/tests/pull/cases/needsconfig/testcase.yaml @@ -9,4 +9,5 @@ PullOptions: RewriteImages: false Downstreams: - this-cluster - SkipHelmChartCheck: true \ No newline at end of file + SkipHelmChartCheck: true + AppSelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 \ No newline at end of file diff --git a/pkg/tests/pull/cases/replicatedhelm/testcase.yaml b/pkg/tests/pull/cases/replicatedhelm/testcase.yaml index 8ba924c060..bc256e88c8 100644 --- a/pkg/tests/pull/cases/replicatedhelm/testcase.yaml +++ b/pkg/tests/pull/cases/replicatedhelm/testcase.yaml @@ -8,4 +8,5 @@ PullOptions: SharedPassword: dummy-pass RewriteImages: false Downstreams: - - this-cluster \ No newline at end of file + - this-cluster + AppSelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 \ No newline at end of file diff --git a/pkg/tests/pull/cases/required-helm-values/testcase.yaml b/pkg/tests/pull/cases/required-helm-values/testcase.yaml index 32e8a5ee3e..64fb4f5238 100644 --- a/pkg/tests/pull/cases/required-helm-values/testcase.yaml +++ b/pkg/tests/pull/cases/required-helm-values/testcase.yaml @@ -8,4 +8,5 @@ PullOptions: SharedPassword: dummy-pass RewriteImages: false Downstreams: - - this-cluster \ No newline at end of file + - this-cluster + AppSelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 \ No newline at end of file diff --git a/pkg/tests/pull/cases/samechartvariations/testcase.yaml b/pkg/tests/pull/cases/samechartvariations/testcase.yaml index 0bce10bb5a..35210f96e5 100644 --- a/pkg/tests/pull/cases/samechartvariations/testcase.yaml +++ b/pkg/tests/pull/cases/samechartvariations/testcase.yaml @@ -15,4 +15,5 @@ PullOptions: Password: fake-pass IsReadOnly: true Downstreams: - - this-cluster \ No newline at end of file + - this-cluster + AppSelectedChannelID: 1YHCrcZzBxY2nJF5kcTCN9PHpk0 \ No newline at end of file diff --git a/pkg/tests/pull/cases/simple/testcase.yaml b/pkg/tests/pull/cases/simple/testcase.yaml index ad55b19a33..3d4dd74c6e 100644 --- a/pkg/tests/pull/cases/simple/testcase.yaml +++ b/pkg/tests/pull/cases/simple/testcase.yaml @@ -8,4 +8,5 @@ PullOptions: SharedPassword: dummy-pass RewriteImages: false Downstreams: - - this-cluster \ No newline at end of file + - this-cluster + AppSelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 \ No newline at end of file diff --git a/pkg/tests/pull/cases/subchart-alias/testcase.yaml b/pkg/tests/pull/cases/subchart-alias/testcase.yaml index 8e34a604d2..848952c4bb 100644 --- a/pkg/tests/pull/cases/subchart-alias/testcase.yaml +++ b/pkg/tests/pull/cases/subchart-alias/testcase.yaml @@ -13,4 +13,5 @@ PullOptions: Password: fake-pass IsReadOnly: true Downstreams: - - this-cluster \ No newline at end of file + - this-cluster + AppSelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 \ No newline at end of file diff --git a/pkg/tests/pull/cases/subchart-crds/testcase.yaml b/pkg/tests/pull/cases/subchart-crds/testcase.yaml index 348f723ac5..811851da1e 100644 --- a/pkg/tests/pull/cases/subchart-crds/testcase.yaml +++ b/pkg/tests/pull/cases/subchart-crds/testcase.yaml @@ -8,4 +8,5 @@ PullOptions: SharedPassword: dummy-pass RewriteImages: false Downstreams: - - this-cluster \ No newline at end of file + - this-cluster + AppSelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 \ No newline at end of file diff --git a/pkg/tests/pull/cases/subcharts/testcase.yaml b/pkg/tests/pull/cases/subcharts/testcase.yaml index 34e8fb76ed..e679f3b89b 100644 --- a/pkg/tests/pull/cases/subcharts/testcase.yaml +++ b/pkg/tests/pull/cases/subcharts/testcase.yaml @@ -13,4 +13,5 @@ PullOptions: Password: fake-pass IsReadOnly: true Downstreams: - - this-cluster \ No newline at end of file + - this-cluster + AppSelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 \ No newline at end of file diff --git a/pkg/tests/pull/cases/taganddigest-norewrite/testcase.yaml b/pkg/tests/pull/cases/taganddigest-norewrite/testcase.yaml index 96cad188f6..e9ae529d29 100644 --- a/pkg/tests/pull/cases/taganddigest-norewrite/testcase.yaml +++ b/pkg/tests/pull/cases/taganddigest-norewrite/testcase.yaml @@ -8,4 +8,5 @@ PullOptions: SharedPassword: dummy-pass RewriteImages: false Downstreams: - - this-cluster \ No newline at end of file + - this-cluster + AppSelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 \ No newline at end of file diff --git a/pkg/tests/pull/cases/taganddigest-rewrite/testcase.yaml b/pkg/tests/pull/cases/taganddigest-rewrite/testcase.yaml index ca965930fa..2fdd9059e1 100644 --- a/pkg/tests/pull/cases/taganddigest-rewrite/testcase.yaml +++ b/pkg/tests/pull/cases/taganddigest-rewrite/testcase.yaml @@ -14,4 +14,5 @@ PullOptions: Password: fake-pass IsReadOnly: true Downstreams: - - this-cluster \ No newline at end of file + - this-cluster + AppSelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 \ No newline at end of file diff --git a/pkg/tests/pull/cases/v1beta2-charts/testcase.yaml b/pkg/tests/pull/cases/v1beta2-charts/testcase.yaml index d0bc3fb357..9d67049213 100644 --- a/pkg/tests/pull/cases/v1beta2-charts/testcase.yaml +++ b/pkg/tests/pull/cases/v1beta2-charts/testcase.yaml @@ -14,4 +14,5 @@ PullOptions: Password: fake-pass IsReadOnly: true Downstreams: - - this-cluster \ No newline at end of file + - this-cluster + AppSelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 \ No newline at end of file diff --git a/pkg/tests/renderdir/cases/outdated-kotskinds/testcase.yaml b/pkg/tests/renderdir/cases/outdated-kotskinds/testcase.yaml index 3d61cfa465..db0ea96ffd 100644 --- a/pkg/tests/renderdir/cases/outdated-kotskinds/testcase.yaml +++ b/pkg/tests/renderdir/cases/outdated-kotskinds/testcase.yaml @@ -4,6 +4,7 @@ RenderDirOptions: App: ID: app-id Slug: my-app + SelectedChannelID: 1vusIYZLAVxMG6q760OJmRKj5i5 Downstreams: - Name: this-cluster Sequence: 1 \ No newline at end of file diff --git a/pkg/tests/renderdir/renderdir_test.go b/pkg/tests/renderdir/renderdir_test.go index 3eb9d5ccb1..30f9cebed0 100644 --- a/pkg/tests/renderdir/renderdir_test.go +++ b/pkg/tests/renderdir/renderdir_test.go @@ -73,7 +73,6 @@ func TestKotsRenderDir(t *testing.T) { Name: spec.Name, RenderDirOptions: spec.RenderDirOptions, } - tests = append(tests, test) } require.NoError(t, err) diff --git a/pkg/update/required.go b/pkg/update/required.go index 3ed40dca22..36e3ef6e37 100644 --- a/pkg/update/required.go +++ b/pkg/update/required.go @@ -41,7 +41,7 @@ func IsAirgapUpdateDeployable(app *apptypes.App, airgap *kotsv1beta1.Airgap) (bo if err != nil { return false, "", errors.Wrap(err, "failed to load license") } - requiredUpdates, err := getRequiredAirgapUpdates(airgap, license, appVersions.AllVersions, app.ChannelChanged) + requiredUpdates, err := getRequiredAirgapUpdates(airgap, license, appVersions.AllVersions, app.ChannelChanged, app.SelectedChannelID) if err != nil { return false, "", errors.Wrap(err, "failed to get missing required versions") } @@ -51,7 +51,7 @@ func IsAirgapUpdateDeployable(app *apptypes.App, airgap *kotsv1beta1.Airgap) (bo return true, "", nil } -func getRequiredAirgapUpdates(airgap *kotsv1beta1.Airgap, license *kotsv1beta1.License, installedVersions []*downstreamtypes.DownstreamVersion, channelChanged bool) ([]string, error) { +func getRequiredAirgapUpdates(airgap *kotsv1beta1.Airgap, license *kotsv1beta1.License, installedVersions []*downstreamtypes.DownstreamVersion, channelChanged bool, selectedChannelID string) ([]string, error) { requiredUpdates := make([]string, 0) // If no versions are installed, we can consider this an initial install. // If the channel changed, we can consider this an initial install. @@ -64,9 +64,14 @@ func getRequiredAirgapUpdates(airgap *kotsv1beta1.Airgap, license *kotsv1beta1.L for _, appVersion := range installedVersions { requiredSemver, requiredSemverErr := semver.ParseTolerant(requiredRelease.VersionLabel) + licenseChan, err := kotsutil.FindChannelInLicense(selectedChannelID, license) + if err != nil { + return nil, errors.Wrap(err, "failed to find channel in license during") + } + // semvers can be compared across channels // if a semmver is missing, fallback to comparing the cursor but only if channel is the same - if license.Spec.IsSemverRequired && appVersion.Semver != nil && requiredSemverErr == nil { + if licenseChan.IsSemverRequired && appVersion.Semver != nil && requiredSemverErr == nil { if (*appVersion.Semver).GTE(requiredSemver) { laterReleaseInstalled = true break diff --git a/pkg/update/required_test.go b/pkg/update/required_test.go index 973be2d57b..d53f589a2f 100644 --- a/pkg/update/required_test.go +++ b/pkg/update/required_test.go @@ -12,6 +12,29 @@ import ( func Test_getRequiredAirgapUpdates(t *testing.T) { channelID := "channel-id" + channelName := "channel-name" + + testLicense := &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + ChannelID: "default-channel-id", + ChannelName: "Default Channel", + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "default-channel-id", + ChannelName: "Default Channel", + IsDefault: true, + IsSemverRequired: true, + }, + { + ChannelID: channelID, + ChannelName: channelName, + IsDefault: false, + IsSemverRequired: true, + }, + }, + }, + } + tests := []struct { name string airgap *kotsv1beta1.Airgap @@ -20,6 +43,7 @@ func Test_getRequiredAirgapUpdates(t *testing.T) { channelChanged bool wantSemver []string wantNoSemver []string + selectedChannelID string }{ { name: "nothing is installed yet", @@ -40,6 +64,7 @@ func Test_getRequiredAirgapUpdates(t *testing.T) { installedVersions: []*downstreamtypes.DownstreamVersion{}, wantNoSemver: []string{}, wantSemver: []string{}, + selectedChannelID: channelID, }, { name: "latest satisfies all prerequsites", @@ -62,9 +87,7 @@ func Test_getRequiredAirgapUpdates(t *testing.T) { }, }, }, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{}, - }, + license: testLicense, installedVersions: []*downstreamtypes.DownstreamVersion{ { ChannelID: channelID, @@ -72,8 +95,9 @@ func Test_getRequiredAirgapUpdates(t *testing.T) { UpdateCursor: "124", }, }, - wantNoSemver: []string{}, - wantSemver: []string{}, + wantNoSemver: []string{}, + wantSemver: []string{}, + selectedChannelID: channelID, }, { name: "need some prerequsites", @@ -96,9 +120,7 @@ func Test_getRequiredAirgapUpdates(t *testing.T) { }, }, }, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{}, - }, + license: testLicense, installedVersions: []*downstreamtypes.DownstreamVersion{ { ChannelID: channelID, @@ -106,8 +128,9 @@ func Test_getRequiredAirgapUpdates(t *testing.T) { UpdateCursor: "117", }, }, - wantNoSemver: []string{"0.1.120", "0.1.123"}, - wantSemver: []string{"0.1.120", "0.1.123"}, + wantNoSemver: []string{"0.1.120", "0.1.123"}, + wantSemver: []string{"0.1.120", "0.1.123"}, + selectedChannelID: channelID, }, { name: "need all prerequsites", @@ -130,9 +153,7 @@ func Test_getRequiredAirgapUpdates(t *testing.T) { }, }, }, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{}, - }, + license: testLicense, installedVersions: []*downstreamtypes.DownstreamVersion{ { ChannelID: channelID, @@ -140,8 +161,9 @@ func Test_getRequiredAirgapUpdates(t *testing.T) { UpdateCursor: "113", }, }, - wantNoSemver: []string{"0.1.115", "0.1.120", "0.1.123"}, - wantSemver: []string{"0.1.115", "0.1.120", "0.1.123"}, + wantNoSemver: []string{"0.1.115", "0.1.120", "0.1.123"}, + wantSemver: []string{"0.1.115", "0.1.120", "0.1.123"}, + selectedChannelID: channelID, }, { name: "check across multiple channels", @@ -164,8 +186,68 @@ func Test_getRequiredAirgapUpdates(t *testing.T) { }, }, }, + license: testLicense, + channelChanged: true, + installedVersions: []*downstreamtypes.DownstreamVersion{ + { + ChannelID: "different-channel", + VersionLabel: "0.1.117", + UpdateCursor: "117", + }, + }, + wantNoSemver: []string{}, + wantSemver: []string{}, + selectedChannelID: channelID, + }, + { + name: "check across multiple channels with multi chan license", + airgap: &kotsv1beta1.Airgap{ + Spec: kotsv1beta1.AirgapSpec{ + ChannelID: channelID, + RequiredReleases: []kotsv1beta1.AirgapReleaseMeta{ + { + VersionLabel: "0.1.123", + UpdateCursor: "123", + }, + { + VersionLabel: "0.1.120", + UpdateCursor: "120", + }, + { + VersionLabel: "0.1.115", + UpdateCursor: "115", + }, + }, + }, + }, license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{}, + Spec: kotsv1beta1.LicenseSpec{ + ChannelID: "stable-channel", // intentionally fully avoiding the default channel + ChannelName: "Stable Channel", + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "stable-channel", + ChannelName: "Stable Channel", + ChannelSlug: "stable-channel", + IsDefault: false, + IsSemverRequired: true, + }, + { + ChannelID: "different-channel", + ChannelName: "Different Channel", + ChannelSlug: "different-channel", + IsDefault: true, + IsSemverRequired: false, + }, + { + ChannelID: channelID, + ChannelName: channelName, + ChannelSlug: channelID, + IsDefault: false, + IsSemverRequired: true, + }, + }, + }, }, channelChanged: true, installedVersions: []*downstreamtypes.DownstreamVersion{ @@ -175,14 +257,14 @@ func Test_getRequiredAirgapUpdates(t *testing.T) { UpdateCursor: "117", }, }, - wantNoSemver: []string{}, - wantSemver: []string{}, + wantNoSemver: []string{}, + wantSemver: []string{}, + selectedChannelID: channelID, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := require.New(t) - for _, v := range tt.installedVersions { s := semver.MustParse(v.VersionLabel) v.Semver = &s @@ -193,13 +275,13 @@ func Test_getRequiredAirgapUpdates(t *testing.T) { // cursor based tt.license.Spec.IsSemverRequired = false - got, err := getRequiredAirgapUpdates(tt.airgap, tt.license, tt.installedVersions, tt.channelChanged) + got, err := getRequiredAirgapUpdates(tt.airgap, tt.license, tt.installedVersions, tt.channelChanged, tt.selectedChannelID) req.NoError(err) req.Equal(tt.wantNoSemver, got) // semver based tt.license.Spec.IsSemverRequired = true - got, err = getRequiredAirgapUpdates(tt.airgap, tt.license, tt.installedVersions, tt.channelChanged) + got, err = getRequiredAirgapUpdates(tt.airgap, tt.license, tt.installedVersions, tt.channelChanged, tt.selectedChannelID) req.NoError(err) req.Equal(tt.wantSemver, got) }) diff --git a/pkg/update/update.go b/pkg/update/update.go index badc7aefa8..3d79e61ffb 100644 --- a/pkg/update/update.go +++ b/pkg/update/update.go @@ -8,12 +8,14 @@ import ( "github.com/pkg/errors" apptypes "github.com/replicatedhq/kots/pkg/app/types" "github.com/replicatedhq/kots/pkg/kotsutil" + "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/reporting" storepkg "github.com/replicatedhq/kots/pkg/store" "github.com/replicatedhq/kots/pkg/update/types" upstreampkg "github.com/replicatedhq/kots/pkg/upstream" upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "go.uber.org/zap" ) // a ephemeral directory to store available updates @@ -29,7 +31,12 @@ func InitAvailableUpdatesDir() error { } func GetAvailableUpdates(kotsStore storepkg.Store, app *apptypes.App, license *kotsv1beta1.License) ([]types.AvailableUpdate, error) { - updateCursor, err := kotsStore.GetCurrentUpdateCursor(app.ID, license.Spec.ChannelID) + licenseChan, err := kotsutil.FindChannelInLicense(app.SelectedChannelID, license) + if err != nil { + return nil, errors.Wrap(err, "failed to find channel in license") + } + + updateCursor, err := kotsStore.GetCurrentUpdateCursor(app.ID, licenseChan.ChannelID) if err != nil { return nil, errors.Wrap(err, "failed to get current update cursor") } @@ -39,8 +46,8 @@ func GetAvailableUpdates(kotsStore storepkg.Store, app *apptypes.App, license *k License: license, LastUpdateCheckAt: app.LastUpdateCheckAt, CurrentCursor: updateCursor, - CurrentChannelID: license.Spec.ChannelID, - CurrentChannelName: license.Spec.ChannelName, + CurrentChannelID: licenseChan.ChannelID, + CurrentChannelName: licenseChan.ChannelName, ChannelChanged: app.ChannelChanged, SortOrder: "desc", // get the latest updates first ReportingInfo: reporting.GetReportingInfo(app.ID), @@ -88,8 +95,12 @@ func GetAvailableAirgapUpdates(app *apptypes.App, license *kotsv1beta1.License) if airgap.Spec.AppSlug != license.Spec.AppSlug { return nil } - if airgap.Spec.ChannelID != license.Spec.ChannelID { - return nil + if _, err = kotsutil.FindChannelInLicense(airgap.Spec.ChannelID, license); err != nil { + logger.Info("skipping airgap update check for channel not found in current license", + zap.String("airgap_channelName", airgap.Spec.ChannelName), + zap.String("airgap_channelID", airgap.Spec.ChannelID), + ) + return nil // skip airgap updates that are not for the current channel, preserving previous behavior } deployable, nonDeployableCause, err := IsAirgapUpdateDeployable(app, airgap) diff --git a/pkg/update/update_test.go b/pkg/update/update_test.go index 091dd8c725..1e880fdb0f 100644 --- a/pkg/update/update_test.go +++ b/pkg/update/update_test.go @@ -31,19 +31,21 @@ func TestGetAvailableUpdates(t *testing.T) { license *kotsv1beta1.License } tests := []struct { - name string - args args - channelReleases []upstream.ChannelRelease - setup func(t *testing.T, args args, mockServerEndpoint string) - want []types.AvailableUpdate - wantErr bool + name string + args args + perChannelReleases map[string][]upstream.ChannelRelease + setup func(t *testing.T, args args, mockServerEndpoint string) + want []types.AvailableUpdate + wantErr bool + expectedSelectedChannelId string }{ { name: "no updates", args: args{ kotsStore: mockStore, app: &apptypes.App{ - ID: "app-id", + ID: "app-id", + SelectedChannelID: "channel-id", }, license: &kotsv1beta1.License{ Spec: kotsv1beta1.LicenseSpec{ @@ -54,21 +56,23 @@ func TestGetAvailableUpdates(t *testing.T) { }, }, }, - channelReleases: []upstream.ChannelRelease{}, + perChannelReleases: map[string][]upstream.ChannelRelease{}, setup: func(t *testing.T, args args, licenseEndpoint string) { t.Setenv("USE_MOCK_REPORTING", "1") args.license.Spec.Endpoint = licenseEndpoint mockStore.EXPECT().GetCurrentUpdateCursor(args.app.ID, args.license.Spec.ChannelID).Return("1", nil) }, - want: []types.AvailableUpdate{}, - wantErr: false, + want: []types.AvailableUpdate{}, + wantErr: false, + expectedSelectedChannelId: "channel-id", }, { name: "has updates", args: args{ kotsStore: mockStore, app: &apptypes.App{ - ID: "app-id", + ID: "app-id", + SelectedChannelID: "channel-id", }, license: &kotsv1beta1.License{ Spec: kotsv1beta1.LicenseSpec{ @@ -79,22 +83,24 @@ func TestGetAvailableUpdates(t *testing.T) { }, }, }, - channelReleases: []upstream.ChannelRelease{ - { - ChannelSequence: 2, - ReleaseSequence: 2, - VersionLabel: "0.0.2", - IsRequired: false, - CreatedAt: testTime.Format(time.RFC3339), - ReleaseNotes: "release notes", - }, - { - ChannelSequence: 1, - ReleaseSequence: 1, - VersionLabel: "0.0.1", - IsRequired: true, - CreatedAt: testTime.Format(time.RFC3339), - ReleaseNotes: "release notes", + perChannelReleases: map[string][]upstream.ChannelRelease{ + "channel-id": { + { + ChannelSequence: 2, + ReleaseSequence: 2, + VersionLabel: "0.0.2", + IsRequired: false, + CreatedAt: testTime.Format(time.RFC3339), + ReleaseNotes: "release notes", + }, + { + ChannelSequence: 1, + ReleaseSequence: 1, + VersionLabel: "0.0.1", + IsRequired: true, + CreatedAt: testTime.Format(time.RFC3339), + ReleaseNotes: "release notes", + }, }, }, setup: func(t *testing.T, args args, licenseEndpoint string) { @@ -123,14 +129,16 @@ func TestGetAvailableUpdates(t *testing.T) { IsDeployable: true, }, }, - wantErr: false, + wantErr: false, + expectedSelectedChannelId: "channel-id", }, { name: "fails to fetch updates", args: args{ kotsStore: mockStore, app: &apptypes.App{ - ID: "app-id", + ID: "app-id", + SelectedChannelID: "channel-id", }, license: &kotsv1beta1.License{ Spec: kotsv1beta1.LicenseSpec{ @@ -141,20 +149,99 @@ func TestGetAvailableUpdates(t *testing.T) { }, }, }, - channelReleases: []upstream.ChannelRelease{}, + perChannelReleases: map[string][]upstream.ChannelRelease{}, setup: func(t *testing.T, args args, licenseEndpoint string) { t.Setenv("USE_MOCK_REPORTING", "1") args.license.Spec.Endpoint = licenseEndpoint mockStore.EXPECT().GetCurrentUpdateCursor(args.app.ID, args.license.Spec.ChannelID).Return("1", nil) }, - want: []types.AvailableUpdate{}, - wantErr: true, + want: []types.AvailableUpdate{}, + wantErr: true, + expectedSelectedChannelId: "channel-id", + }, + { + name: "uses installed channel id when multi-channel present", + args: args{ + kotsStore: mockStore, + app: &apptypes.App{ + ID: "app-id", + SelectedChannelID: "channel-id2", // explicitly using the non-default channel + }, + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + ChannelID: "channel-id", + ChannelName: "channel-name", + AppSlug: "app-slug", + LicenseID: "license-id", + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "channel-id", + ChannelName: "channel-name", + IsDefault: true, + }, + { + ChannelID: "channel-id2", + ChannelName: "channel-name2", + IsDefault: false, + }, + }, + }, + }, + }, + perChannelReleases: map[string][]upstream.ChannelRelease{ + "channel-id": { + { + ChannelSequence: 2, + ReleaseSequence: 2, + VersionLabel: "0.0.2", + IsRequired: false, + CreatedAt: testTime.Format(time.RFC3339), + ReleaseNotes: "release notes", + }, + { + ChannelSequence: 1, + ReleaseSequence: 1, + VersionLabel: "0.0.1", + IsRequired: true, + CreatedAt: testTime.Format(time.RFC3339), + ReleaseNotes: "release notes", + }, + }, + "channel-id2": { + { + ChannelSequence: 3, + ReleaseSequence: 3, + VersionLabel: "3.0.0", + IsRequired: false, + CreatedAt: testTime.Format(time.RFC3339), + ReleaseNotes: "release notes", + }, + }, + }, + setup: func(t *testing.T, args args, licenseEndpoint string) { + t.Setenv("USE_MOCK_REPORTING", "1") + args.license.Spec.Endpoint = licenseEndpoint + mockStore.EXPECT().GetCurrentUpdateCursor(args.app.ID, args.license.Spec.Channels[1].ChannelID).Return("1", nil) + }, + want: []types.AvailableUpdate{ + { + VersionLabel: "3.0.0", + UpdateCursor: "3", + ChannelID: "channel-id2", + IsRequired: false, + UpstreamReleasedAt: &testTime, + ReleaseNotes: "release notes", + IsDeployable: true, + }, + }, + wantErr: false, + expectedSelectedChannelId: "channel-id2", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := require.New(t) - mockServer := newMockServerWithReleases(tt.channelReleases, tt.wantErr) + mockServer := newMockServerWithReleases(tt.perChannelReleases, tt.expectedSelectedChannelId, tt.wantErr) defer mockServer.Close() tt.setup(t, tt.args, mockServer.URL) got, err := GetAvailableUpdates(tt.args.kotsStore, tt.args.app, tt.args.license) @@ -168,16 +255,29 @@ func TestGetAvailableUpdates(t *testing.T) { } } -func newMockServerWithReleases(channelReleases []upstream.ChannelRelease, wantErr bool) *httptest.Server { +func newMockServerWithReleases(preChannelReleases map[string][]upstream.ChannelRelease, expectedSelectedChannelId string, wantErr bool) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if wantErr { http.Error(w, "error", http.StatusInternalServerError) return } + var response struct { ChannelReleases []upstream.ChannelRelease `json:"channelReleases"` } - response.ChannelReleases = channelReleases + + selectedChannelID := r.URL.Query().Get("selectedChannelId") + if selectedChannelID != expectedSelectedChannelId { + http.Error(w, "invalid selectedChannelId", http.StatusBadRequest) + return + } + + if releases, ok := preChannelReleases[selectedChannelID]; ok { + response.ChannelReleases = releases + } else { + response.ChannelReleases = []upstream.ChannelRelease{} + } + w.Header().Set("X-Replicated-UpdateCheckAt", time.Now().Format(time.RFC3339)) if err := json.NewEncoder(w).Encode(response); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/pkg/updatechecker/updatechecker.go b/pkg/updatechecker/updatechecker.go index 44438d7134..05a87c675f 100644 --- a/pkg/updatechecker/updatechecker.go +++ b/pkg/updatechecker/updatechecker.go @@ -13,6 +13,7 @@ import ( apptypes "github.com/replicatedhq/kots/pkg/app/types" license "github.com/replicatedhq/kots/pkg/kotsadmlicense" upstream "github.com/replicatedhq/kots/pkg/kotsadmupstream" + "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/preflight" preflighttypes "github.com/replicatedhq/kots/pkg/preflight/types" @@ -226,7 +227,12 @@ func checkForKotsAppUpdates(opts types.CheckForUpdatesOpts, finishedChan chan<- return nil, errors.Wrap(err, "failed to get app") } - updateCursor, err := store.GetCurrentUpdateCursor(a.ID, latestLicense.Spec.ChannelID) + licenseChan, err := kotsutil.FindChannelInLicense(a.SelectedChannelID, latestLicense) + if err != nil { + return nil, errors.Wrap(err, "failed to find channel in license after sync") + } + + updateCursor, err := store.GetCurrentUpdateCursor(a.ID, licenseChan.ChannelID) if err != nil { return nil, errors.Wrap(err, "failed to get current update cursor") } @@ -235,8 +241,8 @@ func checkForKotsAppUpdates(opts types.CheckForUpdatesOpts, finishedChan chan<- License: latestLicense, LastUpdateCheckAt: a.LastUpdateCheckAt, CurrentCursor: updateCursor, - CurrentChannelID: latestLicense.Spec.ChannelID, - CurrentChannelName: latestLicense.Spec.ChannelName, + CurrentChannelID: licenseChan.ChannelID, + CurrentChannelName: licenseChan.ChannelName, ChannelChanged: a.ChannelChanged, Silent: false, ReportingInfo: reporting.GetReportingInfo(a.ID), @@ -266,7 +272,7 @@ func checkForKotsAppUpdates(opts types.CheckForUpdatesOpts, finishedChan chan<- return nil, errors.Errorf("no app versions found for app %s in downstream %s", opts.AppID, d.ClusterID) } - filteredUpdates := removeOldUpdates(updates.Updates, appVersions, latestLicense.Spec.IsSemverRequired) + filteredUpdates := removeOldUpdates(updates.Updates, appVersions, licenseChan.IsSemverRequired) var availableReleases []types.UpdateCheckRelease availableSequence := appVersions.AllVersions[0].Sequence + 1 diff --git a/pkg/upstream/fetch.go b/pkg/upstream/fetch.go index 7e634294c6..2fe0b075fb 100644 --- a/pkg/upstream/fetch.go +++ b/pkg/upstream/fetch.go @@ -60,6 +60,7 @@ func downloadUpstream(upstreamURI string, fetchOptions *types.FetchOptions) (*ty fetchOptions.LocalRegistry, fetchOptions.ReportingInfo, fetchOptions.SkipCompatibilityCheck, + fetchOptions.AppSelectedChannelID, ) } diff --git a/pkg/upstream/fetch_test.go b/pkg/upstream/fetch_test.go index bda113b936..9e54aa565d 100644 --- a/pkg/upstream/fetch_test.go +++ b/pkg/upstream/fetch_test.go @@ -37,16 +37,22 @@ ACgAAA==`, airgapVersionLabel string currentVersionLabel string expectedLabel string + expectedChannelID string + expectedChannelName string }{ { airgapVersionLabel: "10.9.8", currentVersionLabel: "1.2.0", expectedLabel: "10.9.8", + expectedChannelID: "channel-2", + expectedChannelName: "ChannelTwo", }, { airgapVersionLabel: "", currentVersionLabel: "1.2.0", expectedLabel: "1.2.0", + expectedChannelID: "channel-2", + expectedChannelName: "ChannelTwo", }, } @@ -59,6 +65,19 @@ ACgAAA==`, Spec: kotsv1beta1.LicenseSpec{ Endpoint: "http://localhost", AppSlug: "app-slug", + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "channel-1", + ChannelName: "ChannelOne", + ChannelSlug: "channel-one", + IsDefault: true, + }, + { + ChannelID: "channel-2", + ChannelName: "ChannelTwo", + ChannelSlug: "channel-two", + }, + }, }, }, Airgap: &kotsv1beta1.Airgap{ @@ -72,9 +91,12 @@ ACgAAA==`, }, }, }, + AppSelectedChannelID: "channel-2", } u, err := FetchUpstream("replicated://app-slug", fetchOptions) req.NoError(err) assert.Equal(t, test.expectedLabel, u.VersionLabel) + assert.Equal(t, test.expectedChannelID, u.ChannelID) + assert.Equal(t, test.expectedChannelName, u.ChannelName) } } diff --git a/pkg/upstream/replicated.go b/pkg/upstream/replicated.go index 8569db4841..a566342bb2 100644 --- a/pkg/upstream/replicated.go +++ b/pkg/upstream/replicated.go @@ -81,7 +81,7 @@ func getUpdatesReplicated(fetchOptions *types.FetchOptions) (*types.UpdateCheckR return nil, errors.New("No license was provided") } - pendingReleases, updateCheckTime, err := listPendingChannelReleases(fetchOptions.License, fetchOptions.LastUpdateCheckAt, currentCursor, fetchOptions.ChannelChanged, fetchOptions.SortOrder, fetchOptions.ReportingInfo) + pendingReleases, updateCheckTime, err := listPendingChannelReleases(fetchOptions.License, fetchOptions.LastUpdateCheckAt, currentCursor, fetchOptions.ChannelChanged, fetchOptions.SortOrder, fetchOptions.ReportingInfo, fetchOptions.CurrentChannelID) if err != nil { return nil, errors.Wrap(err, "failed to list replicated app releases") } @@ -131,6 +131,7 @@ func downloadReplicated( registry registrytypes.RegistrySettings, reportingInfo *reportingtypes.ReportingInfo, skipCompatibilityCheck bool, + appSelectedChannelID string, ) (*types.Upstream, error) { var release *Release @@ -168,12 +169,12 @@ func downloadReplicated( } } - downloadedRelease, err := downloadReplicatedApp(replicatedUpstream, license, updateCursor, reportingInfo) + downloadedRelease, err := downloadReplicatedApp(replicatedUpstream, license, updateCursor, reportingInfo, appSelectedChannelID) if err != nil { return nil, errors.Wrap(err, "failed to download replicated app") } - licenseData, err := replicatedapp.GetLatestLicense(license) + licenseData, err := replicatedapp.GetLatestLicense(license, appSelectedChannelID) if err != nil { return nil, errors.Wrap(err, "failed to get latest license") } @@ -204,8 +205,17 @@ func downloadReplicated( // get channel name from license, if one was provided channelID, channelName := "", "" if license != nil { - channelID = license.Spec.ChannelID - channelName = license.Spec.ChannelName + if appSelectedChannelID != "" { + channel, err := kotsutil.FindChannelInLicense(appSelectedChannelID, license) + if err != nil { + return nil, errors.Wrap(err, "failed to find channel in license") + } + channelID = channel.ChannelID + channelName = channel.ChannelName + } else { + channelID = license.Spec.ChannelID + channelName = license.Spec.ChannelName + } } if existingIdentityConfig == nil { @@ -344,8 +354,8 @@ func readReplicatedAppFromLocalPath(localPath string, localCursor replicatedapp. return &release, nil } -func downloadReplicatedApp(replicatedUpstream *replicatedapp.ReplicatedUpstream, license *kotsv1beta1.License, cursor replicatedapp.ReplicatedCursor, reportingInfo *reportingtypes.ReportingInfo) (*Release, error) { - getReq, err := replicatedUpstream.GetRequest("GET", license, cursor) +func downloadReplicatedApp(replicatedUpstream *replicatedapp.ReplicatedUpstream, license *kotsv1beta1.License, cursor replicatedapp.ReplicatedCursor, reportingInfo *reportingtypes.ReportingInfo, selectedAppChannel string) (*Release, error) { + getReq, err := replicatedUpstream.GetRequest("GET", license, cursor, selectedAppChannel) if err != nil { return nil, errors.Wrap(err, "failed to create http request") } @@ -441,7 +451,7 @@ func downloadReplicatedApp(replicatedUpstream *replicatedapp.ReplicatedUpstream, return &release, nil } -func listPendingChannelReleases(license *kotsv1beta1.License, lastUpdateCheckAt *time.Time, currentCursor replicatedapp.ReplicatedCursor, channelChanged bool, sortOrder string, reportingInfo *reportingtypes.ReportingInfo) ([]ChannelRelease, *time.Time, error) { +func listPendingChannelReleases(license *kotsv1beta1.License, lastUpdateCheckAt *time.Time, currentCursor replicatedapp.ReplicatedCursor, channelChanged bool, sortOrder string, reportingInfo *reportingtypes.ReportingInfo, selectedChannelID string) ([]ChannelRelease, *time.Time, error) { u, err := url.Parse(license.Spec.Endpoint) if err != nil { return nil, nil, errors.Wrap(err, "failed to parse endpoint from license") @@ -461,6 +471,7 @@ func listPendingChannelReleases(license *kotsv1beta1.License, lastUpdateCheckAt urlValues.Set("channelSequence", sequence) urlValues.Add("licenseSequence", fmt.Sprintf("%d", license.Spec.LicenseSequence)) urlValues.Add("isSemverSupported", "true") + urlValues.Add("selectedChannelId", selectedChannelID) if lastUpdateCheckAt != nil { urlValues.Add("lastUpdateCheckAt", lastUpdateCheckAt.UTC().Format(time.RFC3339)) diff --git a/pkg/upstream/types/types.go b/pkg/upstream/types/types.go index 126792a551..169d10a0fc 100644 --- a/pkg/upstream/types/types.go +++ b/pkg/upstream/types/types.go @@ -115,6 +115,7 @@ type FetchOptions struct { LocalRegistry registrytypes.RegistrySettings ReportingInfo *reportingtypes.ReportingInfo SkipCompatibilityCheck bool + AppSelectedChannelID string } func (u *Upstream) GetUpstreamDir(options WriteOptions) string {