diff --git a/api/controllers/app/controller.go b/api/controllers/app/controller.go index 258b43be76..3e9de3280d 100644 --- a/api/controllers/app/controller.go +++ b/api/controllers/app/controller.go @@ -16,12 +16,12 @@ import ( "github.com/replicatedhq/embedded-cluster/api/types" "github.com/replicatedhq/embedded-cluster/pkg-new/preflights" "github.com/replicatedhq/embedded-cluster/pkg/helm" + "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/pkg/release" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/sirupsen/logrus" helmcli "helm.sh/helm/v3/pkg/cli" "sigs.k8s.io/controller-runtime/pkg/client" - kyaml "sigs.k8s.io/yaml" ) type Controller interface { @@ -184,10 +184,11 @@ func NewAppController(opts ...AppControllerOption) (*AppController, error) { return nil, err } - var license *kotsv1beta1.License + var license *licensewrapper.LicenseWrapper if len(controller.license) > 0 { - license = &kotsv1beta1.License{} - if err := kyaml.Unmarshal(controller.license, license); err != nil { + var err error + license, err = helpers.ParseLicenseFromBytes(controller.license) + if err != nil { return nil, fmt.Errorf("parse license: %w", err) } } diff --git a/api/controllers/linux/install/controller_test.go b/api/controllers/linux/install/controller_test.go index 0c070c4d84..00c978f744 100644 --- a/api/controllers/linux/install/controller_test.go +++ b/api/controllers/linux/install/controller_test.go @@ -1004,7 +1004,7 @@ func TestSetupInfra(t *testing.T) { appcontroller.WithStateMachine(sm), appcontroller.WithStore(mockStore), appcontroller.WithReleaseData(getTestReleaseData(&appConfig)), - appcontroller.WithLicense([]byte("spec:\n licenseID: test-license\n")), + appcontroller.WithLicense([]byte("apiVersion: kots.io/v1beta1\nkind: License\nspec:\n licenseID: test-license\n")), appcontroller.WithAppConfigManager(mockAppConfigManager), appcontroller.WithAppPreflightManager(mockAppPreflightManager), appcontroller.WithAppReleaseManager(mockAppReleaseManager), @@ -1022,7 +1022,7 @@ func TestSetupInfra(t *testing.T) { WithAllowIgnoreHostPreflights(tt.serverAllowIgnoreHostPreflights), WithMetricsReporter(mockMetricsReporter), WithReleaseData(getTestReleaseData(&appConfig)), - WithLicense([]byte("spec:\n licenseID: test-license\n")), + WithLicense([]byte("apiVersion: kots.io/v1beta1\nkind: License\nspec:\n licenseID: test-license\n")), WithStore(mockStore), WithHelmClient(&helm.MockClient{}), ) diff --git a/api/internal/managers/app/config/manager.go b/api/internal/managers/app/config/manager.go index 2a5eaae4f3..f2e395dc7d 100644 --- a/api/internal/managers/app/config/manager.go +++ b/api/internal/managers/app/config/manager.go @@ -9,6 +9,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/types" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/sirupsen/logrus" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -35,7 +36,7 @@ type appConfigManager struct { rawConfig kotsv1beta1.Config appConfigStore configstore.Store releaseData *release.ReleaseData - license *kotsv1beta1.License + license *licensewrapper.LicenseWrapper isAirgap bool privateCACertConfigMapName string kcli client.Client @@ -64,7 +65,7 @@ func WithReleaseData(releaseData *release.ReleaseData) AppConfigManagerOption { } } -func WithLicense(license *kotsv1beta1.License) AppConfigManagerOption { +func WithLicense(license *licensewrapper.LicenseWrapper) AppConfigManagerOption { return func(c *appConfigManager) { c.license = license } diff --git a/api/internal/managers/app/install/install.go b/api/internal/managers/app/install/install.go index ef59ec677f..e981eb77a2 100644 --- a/api/internal/managers/app/install/install.go +++ b/api/internal/managers/app/install/install.go @@ -10,6 +10,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/netutils" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "sigs.k8s.io/controller-runtime/pkg/client" @@ -18,8 +19,8 @@ import ( // Install installs the app with the provided config values func (m *appInstallManager) Install(ctx context.Context, configValues kotsv1beta1.ConfigValues) error { - license := &kotsv1beta1.License{} - if err := kyaml.Unmarshal(m.license, license); err != nil { + licenseWrapper, err := licensewrapper.LoadLicenseFromBytes(m.license) + if err != nil { return fmt.Errorf("parse license: %w", err) } @@ -40,7 +41,7 @@ func (m *appInstallManager) Install(ctx context.Context, configValues kotsv1beta ecDomains := utils.GetDomains(m.releaseData) installOpts := kotscli.InstallOptions{ - AppSlug: license.Spec.AppSlug, + AppSlug: licenseWrapper.GetAppSlug(), License: m.license, Namespace: kotsadmNamespace, ClusterID: m.clusterID, diff --git a/api/internal/managers/app/install/install_test.go b/api/internal/managers/app/install/install_test.go index 19b291ca9d..106f35438a 100644 --- a/api/internal/managers/app/install/install_test.go +++ b/api/internal/managers/app/install/install_test.go @@ -27,14 +27,13 @@ func TestAppInstallManager_Install(t *testing.T) { // Setup environment variable for V3 t.Setenv("ENABLE_V3", "1") - // Create test license - license := &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ - AppSlug: "test-app", - }, - } - licenseBytes, err := kyaml.Marshal(license) - require.NoError(t, err) + // Create test license with proper Kubernetes resource format + licenseYAML := `apiVersion: kots.io/v1beta1 +kind: License +spec: + appSlug: test-app +` + licenseBytes := []byte(licenseYAML) // Create test release data releaseData := &release.ReleaseData{ @@ -46,7 +45,7 @@ func TestAppInstallManager_Install(t *testing.T) { } // Set up release data globally so AppSlug() returns the correct value for v3 - err = release.SetReleaseDataForTests(map[string][]byte{ + err := release.SetReleaseDataForTests(map[string][]byte{ "channelrelease.yaml": []byte("# channel release object\nappSlug: test-app"), }) require.NoError(t, err) diff --git a/api/internal/managers/app/release/manager.go b/api/internal/managers/app/release/manager.go index 8622f862ac..ee68fb85aa 100644 --- a/api/internal/managers/app/release/manager.go +++ b/api/internal/managers/app/release/manager.go @@ -11,6 +11,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" "github.com/sirupsen/logrus" "sigs.k8s.io/controller-runtime/pkg/client" @@ -24,7 +25,7 @@ type AppReleaseManager interface { type appReleaseManager struct { rawConfig kotsv1beta1.Config releaseData *release.ReleaseData - license *kotsv1beta1.License + license *licensewrapper.LicenseWrapper isAirgap bool privateCACertConfigMapName string kcli client.Client @@ -60,7 +61,7 @@ func WithHelmClient(hcli helm.Client) AppReleaseManagerOption { } } -func WithLicense(license *kotsv1beta1.License) AppReleaseManagerOption { +func WithLicense(license *licensewrapper.LicenseWrapper) AppReleaseManagerOption { return func(m *appReleaseManager) { m.license = license } diff --git a/api/internal/managers/kubernetes/infra/install.go b/api/internal/managers/kubernetes/infra/install.go index 360b65a0f3..f903fa8016 100644 --- a/api/internal/managers/kubernetes/infra/install.go +++ b/api/internal/managers/kubernetes/infra/install.go @@ -11,13 +11,13 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/addons" addontypes "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" + "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/pkg/support" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/sirupsen/logrus" "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" - kyaml "sigs.k8s.io/yaml" ) func (m *infraManager) Install(ctx context.Context, ki kubernetesinstallation.Installation) error { @@ -51,12 +51,12 @@ func (m *infraManager) initInstallComponentsList() error { } func (m *infraManager) install(ctx context.Context, ki kubernetesinstallation.Installation) error { - license := &kotsv1beta1.License{} - if err := kyaml.Unmarshal(m.license, license); err != nil { + license, err := helpers.ParseLicenseFromBytes(m.license) + if err != nil { return fmt.Errorf("parse license: %w", err) } - _, err := m.recordInstallation(ctx, m.kcli, license, ki) + _, err = m.recordInstallation(ctx, m.kcli, license, ki) if err != nil { return fmt.Errorf("record installation: %w", err) } @@ -77,13 +77,13 @@ func (m *infraManager) install(ctx context.Context, ki kubernetesinstallation.In return nil } -func (m *infraManager) recordInstallation(ctx context.Context, kcli client.Client, license *kotsv1beta1.License, ki kubernetesinstallation.Installation) (*ecv1beta1.Installation, error) { +func (m *infraManager) recordInstallation(ctx context.Context, kcli client.Client, license *licensewrapper.LicenseWrapper, ki kubernetesinstallation.Installation) (*ecv1beta1.Installation, error) { // TODO: we may need this later return nil, nil } -func (m *infraManager) installAddOns(ctx context.Context, kcli client.Client, mcli metadata.Interface, hcli helm.Client, license *kotsv1beta1.License, ki kubernetesinstallation.Installation) error { +func (m *infraManager) installAddOns(ctx context.Context, kcli client.Client, mcli metadata.Interface, hcli helm.Client, license *licensewrapper.LicenseWrapper, ki kubernetesinstallation.Installation) error { progressChan := make(chan addontypes.AddOnProgress) defer close(progressChan) @@ -128,7 +128,7 @@ func (m *infraManager) installAddOns(ctx context.Context, kcli client.Client, mc return nil } -func (m *infraManager) getAddonInstallOpts(ctx context.Context, license *kotsv1beta1.License, ki kubernetesinstallation.Installation) (addons.KubernetesInstallOptions, error) { +func (m *infraManager) getAddonInstallOpts(ctx context.Context, license *licensewrapper.LicenseWrapper, ki kubernetesinstallation.Installation) (addons.KubernetesInstallOptions, error) { // TODO: We should not use the runtimeconfig package for kubernetes target installs. Since runtimeconfig.KotsadmNamespace is // target agnostic, we should move it to a package that can be used by both linux/kubernetes targets. kotsadmNamespace, err := runtimeconfig.KotsadmNamespace(ctx, m.kcli) @@ -143,7 +143,7 @@ func (m *infraManager) getAddonInstallOpts(ctx context.Context, license *kotsv1b TLSCertBytes: m.tlsConfig.CertBytes, TLSKeyBytes: m.tlsConfig.KeyBytes, Hostname: m.tlsConfig.Hostname, - IsMultiNodeEnabled: license.Spec.IsEmbeddedClusterMultiNodeEnabled, + IsMultiNodeEnabled: license.IsEmbeddedClusterMultiNodeEnabled(), EmbeddedConfigSpec: m.getECConfigSpec(), EndUserConfigSpec: m.getEndUserConfigSpec(), KotsadmNamespace: kotsadmNamespace, diff --git a/api/internal/managers/linux/infra/install.go b/api/internal/managers/linux/infra/install.go index a0ea1c7217..79b23f0033 100644 --- a/api/internal/managers/linux/infra/install.go +++ b/api/internal/managers/linux/infra/install.go @@ -15,16 +15,16 @@ import ( addontypes "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/extensions" "github.com/replicatedhq/embedded-cluster/pkg/helm" + "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" "github.com/replicatedhq/embedded-cluster/pkg/netutils" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/pkg/support" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/sirupsen/logrus" "k8s.io/client-go/metadata" nodeutil "k8s.io/component-helpers/node/util" "sigs.k8s.io/controller-runtime/pkg/client" - kyaml "sigs.k8s.io/yaml" ) const K0sComponentName = "Runtime" @@ -53,10 +53,14 @@ func (m *infraManager) Install(ctx context.Context, rc runtimeconfig.RuntimeConf return nil } -func (m *infraManager) initInstallComponentsList(license *kotsv1beta1.License) error { +func (m *infraManager) initInstallComponentsList(license *licensewrapper.LicenseWrapper) error { + if license.IsEmpty() { + return fmt.Errorf("license is required for component initialization") + } + components := []types.InfraComponent{{Name: K0sComponentName}} - addOnsNames := addons.GetAddOnsNamesForInstall(m.airgapBundle != "", license.Spec.IsDisasterRecoverySupported) + addOnsNames := addons.GetAddOnsNamesForInstall(m.airgapBundle != "", license.IsDisasterRecoverySupported()) for _, addOnName := range addOnsNames { components = append(components, types.InfraComponent{Name: addOnName}) } @@ -72,8 +76,8 @@ func (m *infraManager) initInstallComponentsList(license *kotsv1beta1.License) e } func (m *infraManager) install(ctx context.Context, rc runtimeconfig.RuntimeConfig) error { - license := &kotsv1beta1.License{} - if err := kyaml.Unmarshal(m.license, license); err != nil { + license, err := helpers.ParseLicenseFromBytes(m.license) + if err != nil { return fmt.Errorf("parse license: %w", err) } @@ -81,7 +85,7 @@ func (m *infraManager) install(ctx context.Context, rc runtimeconfig.RuntimeConf return fmt.Errorf("init components: %w", err) } - _, err := m.installK0s(ctx, rc) + _, err = m.installK0s(ctx, rc) if err != nil { return fmt.Errorf("install k0s: %w", err) } @@ -191,7 +195,7 @@ func (m *infraManager) installK0s(ctx context.Context, rc runtimeconfig.RuntimeC return k0sCfg, nil } -func (m *infraManager) recordInstallation(ctx context.Context, kcli client.Client, license *kotsv1beta1.License, rc runtimeconfig.RuntimeConfig) (*ecv1beta1.Installation, error) { +func (m *infraManager) recordInstallation(ctx context.Context, kcli client.Client, license *licensewrapper.LicenseWrapper, rc runtimeconfig.RuntimeConfig) (*ecv1beta1.Installation, error) { logFn := m.logFn("metadata") // get the configured custom domains @@ -227,7 +231,7 @@ func (m *infraManager) recordInstallation(ctx context.Context, kcli client.Clien return in, nil } -func (m *infraManager) installAddOns(ctx context.Context, kcli client.Client, mcli metadata.Interface, hcli helm.Client, license *kotsv1beta1.License, rc runtimeconfig.RuntimeConfig) error { +func (m *infraManager) installAddOns(ctx context.Context, kcli client.Client, mcli metadata.Interface, hcli helm.Client, license *licensewrapper.LicenseWrapper, rc runtimeconfig.RuntimeConfig) error { progressChan := make(chan addontypes.AddOnProgress) defer close(progressChan) @@ -272,7 +276,11 @@ func (m *infraManager) installAddOns(ctx context.Context, kcli client.Client, mc return nil } -func (m *infraManager) getAddonInstallOpts(ctx context.Context, license *kotsv1beta1.License, rc runtimeconfig.RuntimeConfig) (addons.InstallOptions, error) { +func (m *infraManager) getAddonInstallOpts(ctx context.Context, license *licensewrapper.LicenseWrapper, rc runtimeconfig.RuntimeConfig) (addons.InstallOptions, error) { + if license.IsEmpty() { + return addons.InstallOptions{}, fmt.Errorf("license is required for addon installation") + } + kotsadmNamespace, err := runtimeconfig.KotsadmNamespace(ctx, m.kcli) if err != nil { return addons.InstallOptions{}, fmt.Errorf("get kotsadm namespace: %w", err) @@ -285,8 +293,8 @@ func (m *infraManager) getAddonInstallOpts(ctx context.Context, license *kotsv1b TLSCertBytes: m.tlsConfig.CertBytes, TLSKeyBytes: m.tlsConfig.KeyBytes, Hostname: m.tlsConfig.Hostname, - DisasterRecoveryEnabled: license.Spec.IsDisasterRecoverySupported, - IsMultiNodeEnabled: license.Spec.IsEmbeddedClusterMultiNodeEnabled, + DisasterRecoveryEnabled: license.IsDisasterRecoverySupported(), + IsMultiNodeEnabled: license.IsEmbeddedClusterMultiNodeEnabled(), EmbeddedConfigSpec: m.getECConfigSpec(), EndUserConfigSpec: m.getEndUserConfigSpec(), ProxySpec: rc.ProxySpec(), diff --git a/api/internal/managers/linux/infra/install_test.go b/api/internal/managers/linux/infra/install_test.go index 0da6574663..4e90cc1f20 100644 --- a/api/internal/managers/linux/infra/install_test.go +++ b/api/internal/managers/linux/infra/install_test.go @@ -7,6 +7,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/addons" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/stretchr/testify/assert" ) @@ -63,6 +64,11 @@ func TestInfraManager_getAddonInstallOpts(t *testing.T) { }, } + // Wrap the license + wrappedLicense := &licensewrapper.LicenseWrapper{ + V1: license, + } + // Create infra manager manager := NewInfraManager( WithClusterID("test-cluster"), @@ -70,7 +76,7 @@ func TestInfraManager_getAddonInstallOpts(t *testing.T) { ) // Test the getAddonInstallOpts method with configValues passed as parameter - opts, err := manager.getAddonInstallOpts(t.Context(), license, rc) + opts, err := manager.getAddonInstallOpts(t.Context(), wrappedLicense, rc) assert.NoError(t, err) // Verify the install options diff --git a/api/internal/managers/linux/infra/upgrade.go b/api/internal/managers/linux/infra/upgrade.go index 348eb98221..f4a2a814ca 100644 --- a/api/internal/managers/linux/infra/upgrade.go +++ b/api/internal/managers/linux/infra/upgrade.go @@ -17,10 +17,9 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/pkg/versions" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/sirupsen/logrus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - kyaml "sigs.k8s.io/yaml" ) // Upgrade performs the infrastructure upgrade by orchestrating the upgrade steps @@ -86,8 +85,8 @@ func (m *infraManager) newInstallationObj(ctx context.Context, registrySettings return nil, fmt.Errorf("get current installation: %w", err) } - license := &kotsv1beta1.License{} - if err := kyaml.Unmarshal(m.license, license); err != nil { + license, err := licensewrapper.LoadLicenseFromBytes(m.license) + if err != nil { return nil, fmt.Errorf("parse license: %w", err) } @@ -112,8 +111,8 @@ func (m *infraManager) newInstallationObj(ctx context.Context, registrySettings in.Spec.Artifacts = artifacts in.Spec.Config = m.getECConfigSpec() in.Spec.LicenseInfo = &ecv1beta1.LicenseInfo{ - IsDisasterRecoverySupported: license.Spec.IsDisasterRecoverySupported, - IsMultiNodeEnabled: license.Spec.IsEmbeddedClusterMultiNodeEnabled, + IsDisasterRecoverySupported: license.IsDisasterRecoverySupported(), + IsMultiNodeEnabled: license.IsEmbeddedClusterMultiNodeEnabled(), } return in, nil @@ -235,8 +234,8 @@ func (m *infraManager) upgradeK0s(ctx context.Context, in *ecv1beta1.Installatio } func (m *infraManager) distributeArtifacts(ctx context.Context, in *ecv1beta1.Installation, registrySettings *types.RegistrySettings) error { - license := &kotsv1beta1.License{} - if err := kyaml.Unmarshal(m.license, license); err != nil { + license, err := licensewrapper.LoadLicenseFromBytes(m.license) + if err != nil { return fmt.Errorf("parse license: %w", err) } @@ -260,7 +259,7 @@ func (m *infraManager) distributeArtifacts(ctx context.Context, in *ecv1beta1.In localArtifactMirrorImage = destImage } - return m.upgrader.DistributeArtifacts(ctx, in, localArtifactMirrorImage, license.Spec.LicenseID, appSlug, channelID, appVersion) + return m.upgrader.DistributeArtifacts(ctx, in, localArtifactMirrorImage, license.GetLicenseID(), appSlug, channelID, appVersion) } // destECImage returns the location to an EC image in the registry diff --git a/api/pkg/template/engine.go b/api/pkg/template/engine.go index f1edbc7a88..f523275ca4 100644 --- a/api/pkg/template/engine.go +++ b/api/pkg/template/engine.go @@ -11,6 +11,7 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/sirupsen/logrus" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -28,7 +29,7 @@ const ( type Engine struct { mode Mode config *kotsv1beta1.Config - license *kotsv1beta1.License + license *licensewrapper.LicenseWrapper releaseData *release.ReleaseData privateCACertConfigMapName string // ConfigMap name for private CA certificates, empty string if not available isAirgapInstallation bool // Whether the installation is an airgap installation @@ -58,7 +59,7 @@ func WithMode(mode Mode) EngineOption { } } -func WithLicense(license *kotsv1beta1.License) EngineOption { +func WithLicense(license *licensewrapper.LicenseWrapper) EngineOption { return func(e *Engine) { e.license = license } diff --git a/api/pkg/template/execute_test.go b/api/pkg/template/execute_test.go index 85f58ca215..5d84bef921 100644 --- a/api/pkg/template/execute_test.go +++ b/api/pkg/template/execute_test.go @@ -9,12 +9,20 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/helpers" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/replicatedhq/kotskinds/multitype" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kyaml "sigs.k8s.io/yaml" ) +// Helper function to wrap old-style license in LicenseWrapper for testing +func wrapLicenseForExecuteTests(license *kotsv1beta1.License) *licensewrapper.LicenseWrapper { + return &licensewrapper.LicenseWrapper{ + V1: license, + } +} + func TestEngine_BasicTemplating(t *testing.T) { config := &kotsv1beta1.Config{ Spec: kotsv1beta1.ConfigSpec{ @@ -614,7 +622,7 @@ func TestEngine_ComplexTemplate(t *testing.T) { }, } - engine := NewEngine(config, WithLicense(license)) + engine := NewEngine(config, WithLicense(wrapLicenseForExecuteTests(license))) // Test with user values overriding config values configValues := types.AppConfigValues{ diff --git a/api/pkg/template/license.go b/api/pkg/template/license.go index 16d229fd94..6e0fdc182d 100644 --- a/api/pkg/template/license.go +++ b/api/pkg/template/license.go @@ -11,7 +11,7 @@ import ( ) func (e *Engine) licenseFieldValue(name string) (string, error) { - if e.license == nil { + if e.license.IsEmpty() { return "", fmt.Errorf("license is nil") } @@ -19,39 +19,39 @@ func (e *Engine) licenseFieldValue(name string) (string, error) { // when adding new values switch name { case "isSnapshotSupported": - return fmt.Sprintf("%t", e.license.Spec.IsSnapshotSupported), nil + return fmt.Sprintf("%t", e.license.IsSnapshotSupported()), nil case "IsDisasterRecoverySupported": - return fmt.Sprintf("%t", e.license.Spec.IsDisasterRecoverySupported), nil + return fmt.Sprintf("%t", e.license.IsDisasterRecoverySupported()), nil case "isGitOpsSupported": - return fmt.Sprintf("%t", e.license.Spec.IsGitOpsSupported), nil + return fmt.Sprintf("%t", e.license.IsGitOpsSupported()), nil case "isSupportBundleUploadSupported": - return fmt.Sprintf("%t", e.license.Spec.IsSupportBundleUploadSupported), nil + return fmt.Sprintf("%t", e.license.IsSupportBundleUploadSupported()), nil case "isEmbeddedClusterMultiNodeEnabled": - return fmt.Sprintf("%t", e.license.Spec.IsEmbeddedClusterMultiNodeEnabled), nil + return fmt.Sprintf("%t", e.license.IsEmbeddedClusterMultiNodeEnabled()), nil case "isIdentityServiceSupported": - return fmt.Sprintf("%t", e.license.Spec.IsIdentityServiceSupported), nil + return fmt.Sprintf("%t", e.license.IsIdentityServiceSupported()), nil case "isGeoaxisSupported": - return fmt.Sprintf("%t", e.license.Spec.IsGeoaxisSupported), nil + return fmt.Sprintf("%t", e.license.IsGeoaxisSupported()), nil case "isAirgapSupported": - return fmt.Sprintf("%t", e.license.Spec.IsAirgapSupported), nil + return fmt.Sprintf("%t", e.license.IsAirgapSupported()), nil case "licenseType": - return e.license.Spec.LicenseType, nil + return e.license.GetLicenseType(), nil case "licenseSequence": - return fmt.Sprintf("%d", e.license.Spec.LicenseSequence), nil + return fmt.Sprintf("%d", e.license.GetLicenseSequence()), nil case "signature": - return string(e.license.Spec.Signature), nil + return string(e.license.GetSignature()), nil case "appSlug": - return e.license.Spec.AppSlug, nil + return e.license.GetAppSlug(), nil case "channelID": - return e.license.Spec.ChannelID, nil + return e.license.GetChannelID(), nil case "channelName": - return e.license.Spec.ChannelName, nil + return e.license.GetChannelName(), nil case "isSemverRequired": - return fmt.Sprintf("%t", e.license.Spec.IsSemverRequired), nil + return fmt.Sprintf("%t", e.license.IsSemverRequired()), nil case "customerName": - return e.license.Spec.CustomerName, nil + return e.license.GetCustomerName(), nil case "licenseID", "licenseId": - return e.license.Spec.LicenseID, nil + return e.license.GetLicenseID(), nil case "endpoint": if e.releaseData == nil { return "", fmt.Errorf("release data is nil") @@ -59,16 +59,18 @@ func (e *Engine) licenseFieldValue(name string) (string, error) { ecDomains := utils.GetDomains(e.releaseData) return netutils.MaybeAddHTTPS(ecDomains.ReplicatedAppDomain), nil default: - entitlement, ok := e.license.Spec.Entitlements[name] + entitlements := e.license.GetEntitlements() + entitlement, ok := entitlements[name] if ok { - return fmt.Sprintf("%v", entitlement.Value.Value()), nil + val := entitlement.GetValue() + return fmt.Sprintf("%v", val), nil } return "", nil } } func (e *Engine) licenseDockerCfg() (string, error) { - if e.license == nil { + if e.license.IsEmpty() { return "", fmt.Errorf("license is nil") } if e.releaseData == nil { @@ -78,7 +80,8 @@ func (e *Engine) licenseDockerCfg() (string, error) { return "", fmt.Errorf("channel release is nil") } - auth := fmt.Sprintf("%s:%s", e.license.Spec.LicenseID, e.license.Spec.LicenseID) + licenseID := e.license.GetLicenseID() + auth := fmt.Sprintf("%s:%s", licenseID, licenseID) encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) registryProxyInfo := getRegistryProxyInfo(e.releaseData) @@ -116,7 +119,7 @@ func getRegistryProxyInfo(releaseData *release.ReleaseData) *registryProxyInfo { } func (e *Engine) channelName() (string, error) { - if e.license == nil { + if e.license.IsEmpty() { return "", fmt.Errorf("license is nil") } if e.releaseData == nil { @@ -126,13 +129,13 @@ func (e *Engine) channelName() (string, error) { return "", fmt.Errorf("channel release is nil") } - for _, channel := range e.license.Spec.Channels { + for _, channel := range e.license.GetChannels() { if channel.ChannelID == e.releaseData.ChannelRelease.ChannelID { return channel.ChannelName, nil } } - if e.license.Spec.ChannelID == e.releaseData.ChannelRelease.ChannelID { - return e.license.Spec.ChannelName, nil + if e.license.GetChannelID() == e.releaseData.ChannelRelease.ChannelID { + return e.license.GetChannelName(), nil } return "", fmt.Errorf("channel %s not found in license", e.releaseData.ChannelRelease.ChannelID) } diff --git a/api/pkg/template/license_test.go b/api/pkg/template/license_test.go index 9f7c0aa1ed..ea273642df 100644 --- a/api/pkg/template/license_test.go +++ b/api/pkg/template/license_test.go @@ -9,10 +9,26 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + kotsv1beta2 "github.com/replicatedhq/kotskinds/apis/kots/v1beta2" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// Helper function to wrap v1beta1 license in LicenseWrapper for testing +func wrapLicense(license *kotsv1beta1.License) *licensewrapper.LicenseWrapper { + return &licensewrapper.LicenseWrapper{ + V1: license, + } +} + +// Helper function to wrap v1beta2 license in LicenseWrapper for testing +func wrapLicenseV2(license *kotsv1beta2.License) *licensewrapper.LicenseWrapper { + return &licensewrapper.LicenseWrapper{ + V2: license, + } +} + func TestEngine_LicenseFieldValue(t *testing.T) { license := &kotsv1beta1.License{ Spec: kotsv1beta1.LicenseSpec{ @@ -67,7 +83,7 @@ func TestEngine_LicenseFieldValue(t *testing.T) { }, } - engine := NewEngine(config, WithLicense(license)) + engine := NewEngine(config, WithLicense(wrapLicense(license))) // Test basic license fields testCases := []struct { @@ -157,7 +173,7 @@ func TestEngine_LicenseFieldValue_Endpoint(t *testing.T) { }, } - engine := NewEngine(config, WithLicense(license), WithReleaseData(releaseData)) + engine := NewEngine(config, WithLicense(wrapLicense(license)), WithReleaseData(releaseData)) err := engine.Parse("{{repl LicenseFieldValue \"endpoint\" }}") require.NoError(t, err) @@ -167,8 +183,8 @@ func TestEngine_LicenseFieldValue_Endpoint(t *testing.T) { } func TestEngine_LicenseFieldValue_EndpointWithoutReleaseData(t *testing.T) { - license := &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ + license := &kotsv1beta2.License{ + Spec: kotsv1beta2.LicenseSpec{ CustomerName: "Acme Corp", LicenseID: "license-123", }, @@ -180,7 +196,7 @@ func TestEngine_LicenseFieldValue_EndpointWithoutReleaseData(t *testing.T) { }, } - engine := NewEngine(config, WithLicense(license)) + engine := NewEngine(config, WithLicense(wrapLicenseV2(license))) err := engine.Parse("{{repl LicenseFieldValue \"endpoint\" }}") require.NoError(t, err) @@ -216,7 +232,7 @@ func TestEngine_LicenseDockerCfg(t *testing.T) { }, } - engine := NewEngine(config, WithLicense(license), WithReleaseData(releaseData)) + engine := NewEngine(config, WithLicense(wrapLicense(license)), WithReleaseData(releaseData)) err := engine.Parse("{{repl LicenseDockerCfg }}") require.NoError(t, err) @@ -274,7 +290,7 @@ func TestEngine_LicenseDockerCfgWithoutReleaseData(t *testing.T) { }, } - engine := NewEngine(nil, WithLicense(license)) + engine := NewEngine(nil, WithLicense(wrapLicense(license))) err := engine.Parse("{{repl LicenseDockerCfg }}") require.NoError(t, err) @@ -304,7 +320,7 @@ func TestEngine_LicenseDockerCfgStagingEndpoint(t *testing.T) { }, } - engine := NewEngine(config, WithLicense(license), WithReleaseData(releaseData)) + engine := NewEngine(config, WithLicense(wrapLicense(license)), WithReleaseData(releaseData)) err := engine.Parse("{{repl LicenseDockerCfg }}") require.NoError(t, err) @@ -367,7 +383,7 @@ func TestEngine_LicenseDockerCfgStagingEndpointWithReleaseData(t *testing.T) { }, } - engine := NewEngine(config, WithLicense(license), WithReleaseData(releaseData)) + engine := NewEngine(config, WithLicense(wrapLicense(license)), WithReleaseData(releaseData)) err := engine.Parse("{{repl LicenseDockerCfg }}") require.NoError(t, err) @@ -439,7 +455,7 @@ func TestEngine_ChannelName(t *testing.T) { }, } - engine := NewEngine(config, WithLicense(license), WithReleaseData(releaseData)) + engine := NewEngine(config, WithLicense(wrapLicense(license)), WithReleaseData(releaseData)) err := engine.Parse("{{repl ChannelName }}") require.NoError(t, err) @@ -479,7 +495,7 @@ func TestEngine_ChannelName_FallbackToLicenseChannel(t *testing.T) { }, } - engine := NewEngine(config, WithLicense(license), WithReleaseData(releaseData)) + engine := NewEngine(config, WithLicense(wrapLicense(license)), WithReleaseData(releaseData)) err := engine.Parse("{{repl ChannelName }}") require.NoError(t, err) @@ -519,7 +535,7 @@ func TestEngine_ChannelName_WithoutReleaseData(t *testing.T) { }, } - engine := NewEngine(config, WithLicense(license)) + engine := NewEngine(config, WithLicense(wrapLicense(license))) err := engine.Parse("{{repl ChannelName }}") require.NoError(t, err) @@ -550,7 +566,7 @@ func TestEngine_ChannelName_WithoutChannelRelease(t *testing.T) { }, } - engine := NewEngine(config, WithLicense(license), WithReleaseData(releaseData)) + engine := NewEngine(config, WithLicense(wrapLicense(license)), WithReleaseData(releaseData)) err := engine.Parse("{{repl ChannelName }}") require.NoError(t, err) @@ -590,7 +606,7 @@ func TestEngine_ChannelName_ChannelNotFound(t *testing.T) { }, } - engine := NewEngine(config, WithLicense(license), WithReleaseData(releaseData)) + engine := NewEngine(config, WithLicense(wrapLicense(license)), WithReleaseData(releaseData)) err := engine.Parse("{{repl ChannelName }}") require.NoError(t, err) diff --git a/cmd/installer/cli/install.go b/cmd/installer/cli/install.go index e2d7e2e877..bc0c529bf3 100644 --- a/cmd/installer/cli/install.go +++ b/cmd/installer/cli/install.go @@ -48,6 +48,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/versions" "github.com/replicatedhq/embedded-cluster/web" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -96,7 +97,7 @@ type installConfig struct { isAirgap bool enableManagerExperience bool licenseBytes []byte - license *kotsv1beta1.License + license *licensewrapper.LicenseWrapper airgapMetadata *airgap.AirgapMetadata embeddedAssetsSize int64 endUserConfig *ecv1beta1.Config @@ -378,7 +379,7 @@ func addManagementConsoleFlags(cmd *cobra.Command, flags *installFlags) error { func buildMetricsReporter(cmd *cobra.Command, installCfg *installConfig) *installReporter { return newInstallReporter( replicatedAppURL(), cmd.CalledAs(), flagsToStringSlice(cmd.Flags()), - installCfg.license.Spec.LicenseID, installCfg.clusterID, installCfg.license.Spec.AppSlug, + installCfg.license.GetLicenseID(), installCfg.clusterID, installCfg.license.GetAppSlug(), ) } @@ -396,7 +397,7 @@ func preRunInstall(cmd *cobra.Command, flags *installFlags, rc runtimeconfig.Run // sync the license if we are in the manager experience and a license is provided and we are // not in airgap mode - if installCfg.enableManagerExperience && installCfg.license != nil && !installCfg.isAirgap { + if installCfg.enableManagerExperience && !installCfg.license.IsEmpty() && installCfg.license.GetLicenseID() != "" && !installCfg.isAirgap { replicatedAPI, err := newReplicatedAPIClient(installCfg.license, installCfg.clusterID) if err != nil { return nil, fmt.Errorf("failed to create replicated API client: %w", err) @@ -979,8 +980,8 @@ func buildAddonInstallOpts(flags installFlags, installCfg *installConfig, rc run TLSCertBytes: installCfg.tlsCertBytes, TLSKeyBytes: installCfg.tlsKeyBytes, Hostname: flags.hostname, - DisasterRecoveryEnabled: installCfg.license.Spec.IsDisasterRecoverySupported, - IsMultiNodeEnabled: installCfg.license.Spec.IsEmbeddedClusterMultiNodeEnabled, + DisasterRecoveryEnabled: installCfg.license.IsDisasterRecoverySupported(), + IsMultiNodeEnabled: installCfg.license.IsEmbeddedClusterMultiNodeEnabled(), EmbeddedConfigSpec: embCfgSpec, EndUserConfigSpec: euCfgSpec, ProxySpec: rc.ProxySpec(), @@ -1000,7 +1001,7 @@ func buildAddonInstallOpts(flags installFlags, installCfg *installConfig, rc run // Hop: buildKotsInstallOptions builds kots install options from config and flags func buildKotsInstallOptions(installCfg *installConfig, flags installFlags, kotsadmNamespace string, loading *spinner.MessageWriter) kotscli.InstallOptions { return kotscli.InstallOptions{ - AppSlug: installCfg.license.Spec.AppSlug, + AppSlug: installCfg.license.GetAppSlug(), License: installCfg.licenseBytes, Namespace: kotsadmNamespace, ClusterID: installCfg.clusterID, @@ -1105,7 +1106,7 @@ func ensureAdminConsolePassword(flags *installFlags) error { return nil } -func verifyLicense(license *kotsv1beta1.License) (*kotsv1beta1.License, error) { +func verifyLicense(license *licensewrapper.LicenseWrapper) (*licensewrapper.LicenseWrapper, error) { channelRelease := release.GetChannelRelease() if err := verifyLicensePresence(license, channelRelease); err != nil { return nil, err @@ -1127,16 +1128,16 @@ func verifyLicense(license *kotsv1beta1.License) (*kotsv1beta1.License, error) { } // verifyLicensePresence checks if license presence matches the release requirements -func verifyLicensePresence(license *kotsv1beta1.License, channelRelease *release.ChannelRelease) error { +func verifyLicensePresence(license *licensewrapper.LicenseWrapper, channelRelease *release.ChannelRelease) error { if channelRelease == nil { - if license == nil { + if license.IsEmpty() { // No license, no release - valid return nil } // Valid license, no release - invalid return fmt.Errorf("a license was provided but no release was found in binary, please rerun without the license flag") } - if license == nil { + if license.IsEmpty() { // No license, with release - invalid return fmt.Errorf("no license was provided for %s and one is required, please rerun with '--license '", channelRelease.AppSlug) } @@ -1145,15 +1146,15 @@ func verifyLicensePresence(license *kotsv1beta1.License, channelRelease *release } // verifyLicenseFields validates license fields against the release data -func verifyLicenseFields(license *kotsv1beta1.License, channelRelease *release.ChannelRelease) error { - if channelRelease == nil || license == nil { +func verifyLicenseFields(license *licensewrapper.LicenseWrapper, channelRelease *release.ChannelRelease) error { + if channelRelease == nil || license.IsEmpty() { return nil } // Check if the license matches the application version data - if channelRelease.AppSlug != license.Spec.AppSlug { + if channelRelease.AppSlug != license.GetAppSlug() { // if the app is different, we will not be able to provide the correct vendor supplied charts and k0s overrides - return fmt.Errorf("license app %s does not match binary app %s, please provide the correct license", license.Spec.AppSlug, channelRelease.AppSlug) + return fmt.Errorf("license app %s does not match binary app %s, please provide the correct license", license.GetAppSlug(), channelRelease.AppSlug) } // Ensure the binary channel actually is present in the supplied license @@ -1161,19 +1162,23 @@ func verifyLicenseFields(license *kotsv1beta1.License, channelRelease *release.C return err } - expiresAt, ok := license.Spec.Entitlements["expires_at"] - if ok && expiresAt.Value.StrVal != "" { - // read the expiration date, and check it against the current date - expiration, err := time.Parse(time.RFC3339, expiresAt.Value.StrVal) - if err != nil { - return fmt.Errorf("parse expiration date: %w", err) - } - if time.Now().After(expiration) { - return fmt.Errorf("license expired on %s, please provide a valid license", expiration) + // Check expiration date + entitlements := license.GetEntitlements() + if expiresAtField, ok := entitlements["expires_at"]; ok { + expiresAtValue := expiresAtField.GetValue() + if expiresAtStr, ok := expiresAtValue.(string); ok && expiresAtStr != "" { + // read the expiration date, and check it against the current date + expiration, err := time.Parse(time.RFC3339, expiresAtStr) + if err != nil { + return fmt.Errorf("parse expiration date: %w", err) + } + if time.Now().After(expiration) { + return fmt.Errorf("license expired on %s, please provide a valid license", expiration) + } } } - if !license.Spec.IsEmbeddedClusterDownloadEnabled { + if !license.IsEmbeddedClusterDownloadEnabled() { return fmt.Errorf("license does not have embedded cluster enabled, please provide a valid license") } @@ -1182,15 +1187,19 @@ func verifyLicenseFields(license *kotsv1beta1.License, channelRelease *release.C // checkChannelExistence verifies that a channel exists in a supplied license, returning a user-friendly // error message actually listing available channels, if it does not. -func checkChannelExistence(license *kotsv1beta1.License, rel *release.ChannelRelease) error { +func checkChannelExistence(license *licensewrapper.LicenseWrapper, rel *release.ChannelRelease) error { + if license.IsEmpty() { + return fmt.Errorf("license is nil") + } var allowedChannels []string channelExists := false - if len(license.Spec.Channels) == 0 { // support pre-multichannel licenses - allowedChannels = append(allowedChannels, fmt.Sprintf("%s (%s)", license.Spec.ChannelName, license.Spec.ChannelID)) - channelExists = license.Spec.ChannelID == rel.ChannelID + channels := license.GetChannels() + if len(channels) == 0 { // support pre-multichannel licenses + allowedChannels = append(allowedChannels, fmt.Sprintf("%s (%s)", license.GetChannelName(), license.GetChannelID())) + channelExists = license.GetChannelID() == rel.ChannelID } else { - for _, channel := range license.Spec.Channels { + for _, channel := range channels { allowedChannels = append(allowedChannels, fmt.Sprintf("%s (%s)", channel.ChannelSlug, channel.ChannelID)) if channel.ChannelID == rel.ChannelID { channelExists = true @@ -1422,7 +1431,7 @@ func checkAirgapMatches(airgapInfo *kotsv1beta1.Airgap) error { // maybePromptForAppUpdate warns the user if the embedded release is not the latest for the current // channel. If stdout is a terminal, it will prompt the user to continue installing the out-of-date // release and return an error if the user chooses not to continue. -func maybePromptForAppUpdate(ctx context.Context, prompt prompts.Prompt, license *kotsv1beta1.License, assumeYes bool) error { +func maybePromptForAppUpdate(ctx context.Context, prompt prompts.Prompt, license *licensewrapper.LicenseWrapper, assumeYes bool) error { channelRelease := release.GetChannelRelease() if channelRelease == nil { // It is possible to install without embedding the release data. In this case, we cannot @@ -1430,7 +1439,7 @@ func maybePromptForAppUpdate(ctx context.Context, prompt prompts.Prompt, license return nil } - if license == nil { + if license.IsEmpty() || license.GetLicenseID() == "" { return errors.New("license required") } @@ -1454,7 +1463,7 @@ func maybePromptForAppUpdate(ctx context.Context, prompt prompts.Prompt, license logrus.Infof( "To download it, run:\n curl -fL \"%s\" \\\n -H \"Authorization: %s\" \\\n -o %s-%s.tgz\n", releaseURL, - license.Spec.LicenseID, + license.GetLicenseID(), channelRelease.AppSlug, channelRelease.ChannelSlug, ) @@ -1561,15 +1570,15 @@ func normalizeNoPromptToYes(f *pflag.FlagSet, name string) pflag.NormalizedName return pflag.NormalizedName(name) } -func printSuccessMessage(license *kotsv1beta1.License, hostname string, networkInterface string, rc runtimeconfig.RuntimeConfig, isHeadlessInstall bool) { +func printSuccessMessage(license *licensewrapper.LicenseWrapper, hostname string, networkInterface string, rc runtimeconfig.RuntimeConfig, isHeadlessInstall bool) { adminConsoleURL := getAdminConsoleURL(hostname, networkInterface, rc.AdminConsolePort()) // Create the message content var message string if isHeadlessInstall { - message = fmt.Sprintf("The Admin Console for %s is available at:", license.Spec.AppSlug) + message = fmt.Sprintf("The Admin Console for %s is available at:", license.GetAppSlug()) } else { - message = fmt.Sprintf("Visit the Admin Console to configure and install %s:", license.Spec.AppSlug) + message = fmt.Sprintf("Visit the Admin Console to configure and install %s:", license.GetAppSlug()) } // Determine the length of the longest line diff --git a/cmd/installer/cli/install_test.go b/cmd/installer/cli/install_test.go index a0e7f37eac..af9a35db19 100644 --- a/cmd/installer/cli/install_test.go +++ b/cmd/installer/cli/install_test.go @@ -29,6 +29,8 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/spinner" "github.com/replicatedhq/embedded-cluster/web" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + kotsv1beta2 "github.com/replicatedhq/kotskinds/apis/kots/v1beta2" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/stretchr/testify/assert" @@ -666,8 +668,8 @@ spec: if tt.expectLicense { assert.NotEmpty(t, installCfg.licenseBytes, "License bytes should be populated") assert.NotNil(t, installCfg.license, "License should be parsed") - assert.Equal(t, "test-license-id", installCfg.license.Spec.LicenseID) - assert.Equal(t, "test-app", installCfg.license.Spec.AppSlug) + assert.Equal(t, "test-license-id", installCfg.license.GetLicenseID()) + assert.Equal(t, "test-app", installCfg.license.GetAppSlug()) } else { assert.Empty(t, installCfg.licenseBytes, "License bytes should be empty") assert.Nil(t, installCfg.license, "License should be nil") @@ -1218,12 +1220,12 @@ func Test_buildMetricsReporter(t *testing.T) { return cmd }(), installCfg: &installConfig{ - license: &kotsv1beta1.License{ + license: &licensewrapper.LicenseWrapper{V1: &kotsv1beta1.License{ Spec: kotsv1beta1.LicenseSpec{ LicenseID: "license-123", AppSlug: "my-app", }, - }, + }}, clusterID: "cluster-456", }, validate: func(t *testing.T, reporter *installReporter) { @@ -1241,12 +1243,12 @@ func Test_buildMetricsReporter(t *testing.T) { return cmd }(), installCfg: &installConfig{ - license: &kotsv1beta1.License{ + license: &licensewrapper.LicenseWrapper{V1: &kotsv1beta1.License{ Spec: kotsv1beta1.LicenseSpec{ LicenseID: "license-789", AppSlug: "simple-app", }, - }, + }}, clusterID: "cluster-012", }, validate: func(t *testing.T, reporter *installReporter) { @@ -1468,11 +1470,11 @@ func Test_buildKotsInstallOptions(t *testing.T) { { name: "all options set", installCfg: &installConfig{ - license: &kotsv1beta1.License{ + license: &licensewrapper.LicenseWrapper{V1: &kotsv1beta1.License{ Spec: kotsv1beta1.LicenseSpec{ AppSlug: "my-app", }, - }, + }}, licenseBytes: []byte("license-data"), clusterID: "test-cluster-id", }, @@ -1499,11 +1501,11 @@ func Test_buildKotsInstallOptions(t *testing.T) { { name: "minimal options", installCfg: &installConfig{ - license: &kotsv1beta1.License{ + license: &licensewrapper.LicenseWrapper{V1: &kotsv1beta1.License{ Spec: kotsv1beta1.LicenseSpec{ AppSlug: "simple-app", }, - }, + }}, licenseBytes: []byte("license-data"), clusterID: "cluster-123", }, @@ -1575,12 +1577,12 @@ spec: }, installCfg: &installConfig{ clusterID: "cluster-123", - license: &kotsv1beta1.License{ + license: &licensewrapper.LicenseWrapper{V1: &kotsv1beta1.License{ Spec: kotsv1beta1.LicenseSpec{ IsDisasterRecoverySupported: true, IsEmbeddedClusterMultiNodeEnabled: true, }, - }, + }}, tlsCertBytes: []byte("cert-data"), tlsKeyBytes: []byte("key-data"), endUserConfig: &ecv1beta1.Config{ @@ -1643,12 +1645,12 @@ spec: }, installCfg: &installConfig{ clusterID: "cluster-456", - license: &kotsv1beta1.License{ + license: &licensewrapper.LicenseWrapper{V1: &kotsv1beta1.License{ Spec: kotsv1beta1.LicenseSpec{ IsDisasterRecoverySupported: false, IsEmbeddedClusterMultiNodeEnabled: false, }, - }, + }}, tlsCertBytes: []byte("cert-data"), tlsKeyBytes: []byte("key-data"), }, @@ -1840,11 +1842,11 @@ spec: installCfg: &installConfig{ clusterID: "cluster-123", isAirgap: true, - license: &kotsv1beta1.License{ + license: &licensewrapper.LicenseWrapper{V1: &kotsv1beta1.License{ ObjectMeta: metav1.ObjectMeta{ Name: "test-license", }, - }, + }}, airgapMetadata: &airgap.AirgapMetadata{ AirgapInfo: &kotsv1beta1.Airgap{ Spec: kotsv1beta1.AirgapSpec{ @@ -1883,11 +1885,11 @@ spec: installCfg: &installConfig{ clusterID: "cluster-456", isAirgap: true, - license: &kotsv1beta1.License{ + license: &licensewrapper.LicenseWrapper{V1: &kotsv1beta1.License{ ObjectMeta: metav1.ObjectMeta{ Name: "test-license", }, - }, + }}, airgapMetadata: &airgap.AirgapMetadata{ AirgapInfo: nil, }, @@ -1903,11 +1905,11 @@ spec: installCfg: &installConfig{ clusterID: "cluster-789", isAirgap: false, - license: &kotsv1beta1.License{ + license: &licensewrapper.LicenseWrapper{V1: &kotsv1beta1.License{ ObjectMeta: metav1.ObjectMeta{ Name: "test-license", }, - }, + }}, endUserConfig: &ecv1beta1.Config{ Spec: ecv1beta1.ConfigSpec{}, }, @@ -1924,11 +1926,11 @@ spec: installCfg: &installConfig{ clusterID: "cluster-abc", isAirgap: false, - license: &kotsv1beta1.License{ + license: &licensewrapper.LicenseWrapper{V1: &kotsv1beta1.License{ ObjectMeta: metav1.ObjectMeta{ Name: "test-license", }, - }, + }}, }, rc: runtimeconfig.New(nil), validate: func(t *testing.T, opts kubeutils.RecordInstallationOptions) { @@ -2300,7 +2302,10 @@ func Test_maybePromptForAppUpdate(t *testing.T) { prompts.SetTerminal(true) t.Cleanup(func() { prompts.SetTerminal(false) }) - err = maybePromptForAppUpdate(context.Background(), prompt, tt.license, tt.assumeYes) + // Wrap the license for the new API + wrappedLicense := &licensewrapper.LicenseWrapper{V1: tt.license} + err = maybePromptForAppUpdate(context.Background(), prompt, wrappedLicense, tt.assumeYes) + if tt.wantErr { require.Error(t, err) } else { @@ -2352,7 +2357,7 @@ func Test_verifyLicensePresence(t *testing.T) { tests := []struct { name string - license *kotsv1beta1.License + license *licensewrapper.LicenseWrapper release *release.ChannelRelease wantErr string }{ @@ -2367,11 +2372,18 @@ func Test_verifyLicensePresence(t *testing.T) { }, { name: "valid license, no release", - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ - AppSlug: "embedded-cluster-smoke-test-staging-app", - ChannelID: "2cHXb1RCttzpR0xvnNWyaZCgDBP", - IsEmbeddedClusterDownloadEnabled: true, + license: &licensewrapper.LicenseWrapper{ + V1: &kotsv1beta1.License{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "kots.io/v1beta1", + Kind: "License", + }, + Spec: kotsv1beta1.LicenseSpec{ + LicenseID: "test-license-no-release", + AppSlug: "embedded-cluster-smoke-test-staging-app", + ChannelID: "2cHXb1RCttzpR0xvnNWyaZCgDBP", + IsEmbeddedClusterDownloadEnabled: true, + }, }, }, wantErr: "a license was provided but no release was found in binary, please rerun without the license flag", @@ -2400,41 +2412,58 @@ func Test_verifyLicenseFields(t *testing.T) { tests := []struct { name string - license *kotsv1beta1.License + license *licensewrapper.LicenseWrapper release *release.ChannelRelease wantErr string }{ { - name: "valid license, with release", + name: "valid license (v2), with release", release: testRelease, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ - AppSlug: "embedded-cluster-smoke-test-staging-app", - ChannelID: "2cHXb1RCttzpR0xvnNWyaZCgDBP", - IsEmbeddedClusterDownloadEnabled: true, + license: &licensewrapper.LicenseWrapper{ + V2: &kotsv1beta2.License{ + Spec: kotsv1beta2.LicenseSpec{ + AppSlug: "embedded-cluster-smoke-test-staging-app", + ChannelID: "2cHXb1RCttzpR0xvnNWyaZCgDBP", + IsEmbeddedClusterDownloadEnabled: true, + }, + }, + }, + }, + { + name: "valid license (v1), with release", + release: testRelease, + license: &licensewrapper.LicenseWrapper{ + V1: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "embedded-cluster-smoke-test-staging-app", + ChannelID: "2cHXb1RCttzpR0xvnNWyaZCgDBP", + IsEmbeddedClusterDownloadEnabled: true, + }, }, }, }, { name: "valid multi-channel license, with release", release: testRelease, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ - AppSlug: "embedded-cluster-smoke-test-staging-app", - ChannelID: "OtherChannelID", - IsEmbeddedClusterDownloadEnabled: true, - Channels: []kotsv1beta1.Channel{ - { - ChannelID: "OtherChannelID", - ChannelName: "OtherChannel", - ChannelSlug: "other-channel", - IsDefault: true, - }, - { - ChannelID: "2cHXb1RCttzpR0xvnNWyaZCgDBP", - ChannelName: "ExpectedChannel", - ChannelSlug: "expected-channel", - IsDefault: false, + license: &licensewrapper.LicenseWrapper{ + V1: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "embedded-cluster-smoke-test-staging-app", + ChannelID: "OtherChannelID", + IsEmbeddedClusterDownloadEnabled: true, + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "OtherChannelID", + ChannelName: "OtherChannel", + ChannelSlug: "other-channel", + IsDefault: true, + }, + { + ChannelID: "2cHXb1RCttzpR0xvnNWyaZCgDBP", + ChannelName: "ExpectedChannel", + ChannelSlug: "expected-channel", + IsDefault: false, + }, }, }, }, @@ -2443,16 +2472,18 @@ func Test_verifyLicenseFields(t *testing.T) { { name: "expired license, with release", release: testRelease, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ - AppSlug: "embedded-cluster-smoke-test-staging-app", - ChannelID: "2cHXb1RCttzpR0xvnNWyaZCgDBP", - IsEmbeddedClusterDownloadEnabled: true, - Entitlements: map[string]kotsv1beta1.EntitlementField{ - "expires_at": { - Value: kotsv1beta1.EntitlementValue{ - Type: kotsv1beta1.String, - StrVal: "2024-06-03T00:00:00Z", + license: &licensewrapper.LicenseWrapper{ + V1: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "embedded-cluster-smoke-test-staging-app", + ChannelID: "2cHXb1RCttzpR0xvnNWyaZCgDBP", + IsEmbeddedClusterDownloadEnabled: true, + Entitlements: map[string]kotsv1beta1.EntitlementField{ + "expires_at": { + Value: kotsv1beta1.EntitlementValue{ + Type: kotsv1beta1.String, + StrVal: "2024-06-03T00:00:00Z", + }, }, }, }, @@ -2463,16 +2494,18 @@ func Test_verifyLicenseFields(t *testing.T) { { name: "license with no expiration, with release", release: testRelease, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ - AppSlug: "embedded-cluster-smoke-test-staging-app", - ChannelID: "2cHXb1RCttzpR0xvnNWyaZCgDBP", - IsEmbeddedClusterDownloadEnabled: true, - Entitlements: map[string]kotsv1beta1.EntitlementField{ - "expires_at": { - Value: kotsv1beta1.EntitlementValue{ - Type: kotsv1beta1.String, - StrVal: "", + license: &licensewrapper.LicenseWrapper{ + V1: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "embedded-cluster-smoke-test-staging-app", + ChannelID: "2cHXb1RCttzpR0xvnNWyaZCgDBP", + IsEmbeddedClusterDownloadEnabled: true, + Entitlements: map[string]kotsv1beta1.EntitlementField{ + "expires_at": { + Value: kotsv1beta1.EntitlementValue{ + Type: kotsv1beta1.String, + StrVal: "", + }, }, }, }, @@ -2482,16 +2515,18 @@ func Test_verifyLicenseFields(t *testing.T) { { name: "license with 100 year expiration, with release", release: testRelease, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ - AppSlug: "embedded-cluster-smoke-test-staging-app", - ChannelID: "2cHXb1RCttzpR0xvnNWyaZCgDBP", - IsEmbeddedClusterDownloadEnabled: true, - Entitlements: map[string]kotsv1beta1.EntitlementField{ - "expires_at": { - Value: kotsv1beta1.EntitlementValue{ - Type: kotsv1beta1.String, - StrVal: "2124-06-03T00:00:00Z", + license: &licensewrapper.LicenseWrapper{ + V1: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "embedded-cluster-smoke-test-staging-app", + ChannelID: "2cHXb1RCttzpR0xvnNWyaZCgDBP", + IsEmbeddedClusterDownloadEnabled: true, + Entitlements: map[string]kotsv1beta1.EntitlementField{ + "expires_at": { + Value: kotsv1beta1.EntitlementValue{ + Type: kotsv1beta1.String, + StrVal: "2124-06-03T00:00:00Z", + }, }, }, }, @@ -2501,11 +2536,13 @@ func Test_verifyLicenseFields(t *testing.T) { { name: "embedded cluster not enabled, with release", release: testRelease, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ - AppSlug: "embedded-cluster-smoke-test-staging-app", - ChannelID: "2cHXb1RCttzpR0xvnNWyaZCgDBP", - IsEmbeddedClusterDownloadEnabled: false, + license: &licensewrapper.LicenseWrapper{ + V1: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "embedded-cluster-smoke-test-staging-app", + ChannelID: "2cHXb1RCttzpR0xvnNWyaZCgDBP", + IsEmbeddedClusterDownloadEnabled: false, + }, }, }, wantErr: "license does not have embedded cluster enabled, please provide a valid license", @@ -2513,23 +2550,25 @@ func Test_verifyLicenseFields(t *testing.T) { { name: "incorrect license (multichan license)", release: testRelease, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ - AppSlug: "embedded-cluster-smoke-test-staging-app", - ChannelID: "2i9fCbxTNIhuAOaC6MoKMVeGzuK", - IsEmbeddedClusterDownloadEnabled: false, - Channels: []kotsv1beta1.Channel{ - { - ChannelID: "2i9fCbxTNIhuAOaC6MoKMVeGzuK", - ChannelName: "Stable", - ChannelSlug: "stable", - IsDefault: true, - }, - { - ChannelID: "4l9fCbxTNIhuAOaC6MoKMVeV3K", - ChannelName: "Alternate", - ChannelSlug: "alternate", - IsDefault: false, + license: &licensewrapper.LicenseWrapper{ + V1: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "embedded-cluster-smoke-test-staging-app", + ChannelID: "2i9fCbxTNIhuAOaC6MoKMVeGzuK", + IsEmbeddedClusterDownloadEnabled: false, + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "2i9fCbxTNIhuAOaC6MoKMVeGzuK", + ChannelName: "Stable", + ChannelSlug: "stable", + IsDefault: true, + }, + { + ChannelID: "4l9fCbxTNIhuAOaC6MoKMVeV3K", + ChannelName: "Alternate", + ChannelSlug: "alternate", + IsDefault: false, + }, }, }, }, @@ -2539,12 +2578,14 @@ func Test_verifyLicenseFields(t *testing.T) { { name: "incorrect license (pre-multichan license)", release: testRelease, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ - AppSlug: "embedded-cluster-smoke-test-staging-app", - ChannelID: "2i9fCbxTNIhuAOaC6MoKMVeGzuK", - ChannelName: "Stable", - IsEmbeddedClusterDownloadEnabled: false, + license: &licensewrapper.LicenseWrapper{ + V1: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "embedded-cluster-smoke-test-staging-app", + ChannelID: "2i9fCbxTNIhuAOaC6MoKMVeGzuK", + ChannelName: "Stable", + IsEmbeddedClusterDownloadEnabled: false, + }, }, }, wantErr: "binary channel 2cHXb1RCttzpR0xvnNWyaZCgDBP (CI) not present in license, channels allowed by license are: Stable (2i9fCbxTNIhuAOaC6MoKMVeGzuK)", diff --git a/cmd/installer/cli/release.go b/cmd/installer/cli/release.go index 92cb8ae308..066c894359 100644 --- a/cmd/installer/cli/release.go +++ b/cmd/installer/cli/release.go @@ -10,7 +10,7 @@ import ( "net/url" "time" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" ) type apiChannelRelease struct { @@ -26,20 +26,25 @@ type apiChannelRelease struct { ReplicatedProxyDomain string `json:"replicatedProxyDomain"` } -func getCurrentAppChannelRelease(ctx context.Context, license *kotsv1beta1.License, channelID string) (*apiChannelRelease, error) { +func getCurrentAppChannelRelease(ctx context.Context, license *licensewrapper.LicenseWrapper, channelID string) (*apiChannelRelease, error) { + if license.IsEmpty() { + return nil, fmt.Errorf("license is required") + } + query := url.Values{} query.Set("selectedChannelId", channelID) query.Set("channelSequence", "") // sending an empty string will return the latest channel release query.Set("isSemverSupported", "true") apiURL := replicatedAppURL() - url := fmt.Sprintf("%s/release/%s/pending?%s", apiURL, license.Spec.AppSlug, query.Encode()) + url := fmt.Sprintf("%s/release/%s/pending?%s", apiURL, license.GetAppSlug(), query.Encode()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } - auth := fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", license.Spec.LicenseID, license.Spec.LicenseID)))) + licenseID := license.GetLicenseID() + auth := fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", licenseID, licenseID)))) req.Header.Set("Authorization", auth) // This will use the proxy from the environment if set by the cli command. diff --git a/cmd/installer/cli/release_test.go b/cmd/installer/cli/release_test.go index 758fe410d3..3e5d602ab9 100644 --- a/cmd/installer/cli/release_test.go +++ b/cmd/installer/cli/release_test.go @@ -9,6 +9,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -91,7 +92,10 @@ func Test_getCurrentAppChannelRelease(t *testing.T) { }, } - got, err := getCurrentAppChannelRelease(context.Background(), license, tt.args.channelID) + // Wrap the license for the new API + wrappedLicense := &licensewrapper.LicenseWrapper{V1: license} + + got, err := getCurrentAppChannelRelease(context.Background(), wrappedLicense, tt.args.channelID) if tt.wantErr { require.Error(t, err) } else { diff --git a/cmd/installer/cli/replicatedapi.go b/cmd/installer/cli/replicatedapi.go index 097a333e7f..a4c8191fb2 100644 --- a/cmd/installer/cli/replicatedapi.go +++ b/cmd/installer/cli/replicatedapi.go @@ -7,7 +7,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg-new/replicatedapi" "github.com/replicatedhq/embedded-cluster/pkg/netutils" "github.com/replicatedhq/embedded-cluster/pkg/release" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/sirupsen/logrus" ) @@ -21,7 +21,8 @@ func proxyRegistryURL() string { return netutils.MaybeAddHTTPS(domains.ProxyRegistryDomain) } -func newReplicatedAPIClient(license *kotsv1beta1.License, clusterID string) (replicatedapi.Client, error) { +func newReplicatedAPIClient(license *licensewrapper.LicenseWrapper, clusterID string) (replicatedapi.Client, error) { + // Pass the wrapper directly - the API client now handles both v1beta1 and v1beta2 return replicatedapi.NewClient( replicatedAppURL(), license, @@ -30,7 +31,7 @@ func newReplicatedAPIClient(license *kotsv1beta1.License, clusterID string) (rep ) } -func syncLicense(ctx context.Context, client replicatedapi.Client, license *kotsv1beta1.License) (*kotsv1beta1.License, []byte, error) { +func syncLicense(ctx context.Context, client replicatedapi.Client, license *licensewrapper.LicenseWrapper) (*licensewrapper.LicenseWrapper, []byte, error) { logrus.Debug("Syncing license") updatedLicense, licenseBytes, err := client.SyncLicense(ctx) @@ -38,13 +39,16 @@ func syncLicense(ctx context.Context, client replicatedapi.Client, license *kots return nil, nil, fmt.Errorf("get latest license: %w", err) } - if updatedLicense.Spec.LicenseSequence != license.Spec.LicenseSequence { - logrus.Debugf("License synced successfully (sequence %d -> %d)", - license.Spec.LicenseSequence, - updatedLicense.Spec.LicenseSequence) - } else { - logrus.Debug("License is already up to date") + if license != nil { + oldSeq := license.GetLicenseSequence() + newSeq := updatedLicense.GetLicenseSequence() + if newSeq != oldSeq { + logrus.Debugf("License synced successfully (sequence %d -> %d)", oldSeq, newSeq) + } else { + logrus.Debug("License is already up to date") + } } + // Return wrapper directly - already wrapped by SyncLicense return updatedLicense, licenseBytes, nil } diff --git a/cmd/installer/cli/upgrade.go b/cmd/installer/cli/upgrade.go index 97b1928e51..3168e2fbb9 100644 --- a/cmd/installer/cli/upgrade.go +++ b/cmd/installer/cli/upgrade.go @@ -31,7 +31,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" rcutil "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig/util" "github.com/replicatedhq/embedded-cluster/web" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -54,7 +54,7 @@ type upgradeConfig struct { passwordHash []byte tlsConfig apitypes.TLSConfig tlsCert tls.Certificate - license *kotsv1beta1.License + license *licensewrapper.LicenseWrapper licenseBytes []byte airgapMetadata *airgap.AirgapMetadata embeddedAssetsSize int64 @@ -156,9 +156,14 @@ func UpgradeCmd(ctx context.Context, appSlug, appTitle string) *cobra.Command { initialVersion = currentInstallation.Spec.Config.Version } + // Verify license is available for metrics reporting + if upgradeConfig.license.IsEmpty() { + return fmt.Errorf("license is required for upgrade") + } + metricsReporter := newUpgradeReporter( replicatedAppURL(), cmd.CalledAs(), flagsToStringSlice(cmd.Flags()), - upgradeConfig.license.Spec.LicenseID, upgradeConfig.clusterID, upgradeConfig.license.Spec.AppSlug, + upgradeConfig.license.GetLicenseID(), upgradeConfig.clusterID, upgradeConfig.license.GetAppSlug(), targetVersion, initialVersion, ) metricsReporter.ReportUpgradeStarted(ctx) @@ -286,8 +291,8 @@ func preRunUpgrade(ctx context.Context, flags UpgradeCmdFlags, upgradeConfig *up } upgradeConfig.license = l - // sync the license and initialize the replicated api client if we are not in airgap mode - if flags.airgapBundle == "" { + // sync the license if a license is provided and we are not in airgap mode + if !upgradeConfig.license.IsEmpty() && flags.airgapBundle == "" { replicatedAPI, err := newReplicatedAPIClient(upgradeConfig.license, upgradeConfig.clusterID) if err != nil { return fmt.Errorf("failed to create replicated API client: %w", err) diff --git a/e2e/licenses/snapshot-license.yaml b/e2e/licenses/snapshot-license.yaml index 7c252e8ba6..579e0ecf10 100644 --- a/e2e/licenses/snapshot-license.yaml +++ b/e2e/licenses/snapshot-license.yaml @@ -1,4 +1,4 @@ -apiVersion: kots.io/v1beta1 +apiVersion: kots.io/v1beta2 kind: License metadata: name: githubsecretsnapshotcitestcustomer @@ -10,15 +10,16 @@ spec: - channelID: 2cHXb1RCttzpR0xvnNWyaZCgDBP channelName: CI channelSlug: ci - endpoint: https://staging.replicated.app + endpoint: https://ec-e2e-replicated-app.testcluster.net isDefault: true - replicatedProxyDomain: proxy.staging.replicated.com + replicatedProxyDomain: ec-e2e-proxy.testcluster.net customerName: GitHub Secret Snapshot CI Test Customer - endpoint: https://staging.replicated.app + endpoint: https://ec-e2e-replicated-app.testcluster.net entitlements: expires_at: description: License Expiration - signature: {} + signature: + v2: k72rnxGnQY9y0Nq/NyzxIaxDAWlAyJ4Ic8jFKVjNRqdq7GqsYt6fbX2YVQqNVyKE4ay8/luPr8Lc/+we3d3V+0Gmxctly3u+B1ptCr/VHKQDPICKG/Q75UTRjDTQbuqgdzdN26C1wnijvkm4HDaSgfEWVFGlPeW342ULZSO3M1Ufy5z9KnPUrGubXosv7PSjUTq1ycL2z5ID+bNddFMfL1aVMqE2SJAj61JVp/MqgnqOxFJPUVOjuXVGnRJrBKLXV+Xz3z3hnuOJi+n67Mo0QhhbLYUJuN9kpq54nwccJ04lOxb+qjybaqzEdySmf1AT+xqvMN7mqN6LSJPY0XKqWw== title: Expiration value: "" valueType: String @@ -26,9 +27,8 @@ spec: isEmbeddedClusterDownloadEnabled: true isEmbeddedClusterMultiNodeEnabled: true isKotsInstallEnabled: true - isNewKotsUiEnabled: true licenseID: 2fSe1CXtMOX9jNgHTe00mvqO502 licenseSequence: 5 licenseType: prod - replicatedProxyDomain: proxy.staging.replicated.com - signature: eyJsaWNlbnNlRGF0YSI6ImV5SmhjR2xXWlhKemFXOXVJam9pYTI5MGN5NXBieTkyTVdKbGRHRXhJaXdpYTJsdVpDSTZJa3hwWTJWdWMyVWlMQ0p0WlhSaFpHRjBZU0k2ZXlKdVlXMWxJam9pWjJsMGFIVmljMlZqY21WMGMyNWhjSE5vYjNSamFYUmxjM1JqZFhOMGIyMWxjaUo5TENKemNHVmpJanA3SW14cFkyVnVjMlZKUkNJNklqSm1VMlV4UTFoMFRVOVlPV3BPWjBoVVpUQXdiWFp4VHpVd01pSXNJbXhwWTJWdWMyVlVlWEJsSWpvaWNISnZaQ0lzSW1OMWMzUnZiV1Z5VG1GdFpTSTZJa2RwZEVoMVlpQlRaV055WlhRZ1UyNWhjSE5vYjNRZ1Ewa2dWR1Z6ZENCRGRYTjBiMjFsY2lJc0ltRndjRk5zZFdjaU9pSmxiV0psWkdSbFpDMWpiSFZ6ZEdWeUxYTnRiMnRsTFhSbGMzUXRjM1JoWjJsdVp5MWhjSEFpTENKamFHRnVibVZzU1VRaU9pSXlZMGhZWWpGU1EzUjBlbkJTTUhoMmJrNVhlV0ZhUTJkRVFsQWlMQ0pqYUdGdWJtVnNUbUZ0WlNJNklrTkpJaXdpWTJoaGJtNWxiSE1pT2x0N0ltTm9ZVzV1Wld4SlJDSTZJakpqU0ZoaU1WSkRkSFI2Y0ZJd2VIWnVUbGQ1WVZwRFowUkNVQ0lzSW1Ob1lXNXVaV3hUYkhWbklqb2lZMmtpTENKamFHRnVibVZzVG1GdFpTSTZJa05KSWl3aWFYTkVaV1poZFd4MElqcDBjblZsTENKbGJtUndiMmx1ZENJNkltaDBkSEJ6T2k4dmMzUmhaMmx1Wnk1eVpYQnNhV05oZEdWa0xtRndjQ0lzSW5KbGNHeHBZMkYwWldSUWNtOTRlVVJ2YldGcGJpSTZJbkJ5YjNoNUxuTjBZV2RwYm1jdWNtVndiR2xqWVhSbFpDNWpiMjBpZlYwc0lteHBZMlZ1YzJWVFpYRjFaVzVqWlNJNk5Td2laVzVrY0c5cGJuUWlPaUpvZEhSd2N6b3ZMM04wWVdkcGJtY3VjbVZ3YkdsallYUmxaQzVoY0hBaUxDSnlaWEJzYVdOaGRHVmtVSEp2ZUhsRWIyMWhhVzRpT2lKd2NtOTRlUzV6ZEdGbmFXNW5MbkpsY0d4cFkyRjBaV1F1WTI5dElpd2laVzUwYVhSc1pXMWxiblJ6SWpwN0ltVjRjR2x5WlhOZllYUWlPbnNpZEdsMGJHVWlPaUpGZUhCcGNtRjBhVzl1SWl3aVpHVnpZM0pwY0hScGIyNGlPaUpNYVdObGJuTmxJRVY0Y0dseVlYUnBiMjRpTENKMllXeDFaU0k2SWlJc0luWmhiSFZsVkhsd1pTSTZJbE4wY21sdVp5SXNJbk5wWjI1aGRIVnlaU0k2ZTMxOWZTd2lhWE5FYVhOaGMzUmxjbEpsWTI5MlpYSjVVM1Z3Y0c5eWRHVmtJanAwY25WbExDSnBjMDVsZDB0dmRITlZhVVZ1WVdKc1pXUWlPblJ5ZFdVc0ltbHpSVzFpWldSa1pXUkRiSFZ6ZEdWeVJHOTNibXh2WVdSRmJtRmliR1ZrSWpwMGNuVmxMQ0pwYzBWdFltVmtaR1ZrUTJ4MWMzUmxjazExYkhScGJtOWtaVVZ1WVdKc1pXUWlPblJ5ZFdVc0ltbHpTMjkwYzBsdWMzUmhiR3hGYm1GaWJHVmtJanAwY25WbGZYMD0iLCJpbm5lclNpZ25hdHVyZSI6ImV5SnNhV05sYm5ObFUybG5ibUYwZFhKbElqb2lSM05DWmxOTWRFOVplRXhLY21zeE5IVkZMMVowTVRCT1FsZDNVRUZRVERCdWFWbGxaR3B5ZVZaR2VWVmFiVzU1ZVN0UGNEUmpWa2RSU1ZSVE1IbEVjMnAyVFN0MVozTlNlRzB5TjI1TFVVcGhlWGg0VjFSUFJGTnNjV2xwTVRWUFFXcE9jMFZ5V1VZeWFtaHBkV00xT0VaTVoxZzNNVU5ZY0dkRlNHdGljME5tYVV4WFFqUjNZVzEzVEZkRWRHZ3JXR2xzVjBoS1pIUXhTRmhzWVZOcU1VVk1Oa0p3TDJwNFYwVlFTVVV3WXpSemEwTk5hemhHZEVsNVVWZE1TV3BhTnpWRE5WVkVOM293YjNKNFpEUjJSRzh2ZVRWNk9FUlRTMEZRZWtVdlNXZzVSMlpMVm1WSVRITXhVekpJTlZoRFMzTk1aVU5CWWpSVUwxUkNiMEp6V2tONlpXeGxOMGRvTTNkRWMxUlZWRlUyUVZKTVVIQlVXV1ZZY2tWcWJVTjBaREp0VEhOR1ExcDNOekExWkZwWFZrbHhNRXRITnlzek5FMDVPRzlyY0RsV01EWjRaVEk0VDFocGFFbFhVM00xWW1kNU1tSjNQVDBpTENKd2RXSnNhV05MWlhraU9pSXRMUzB0TFVKRlIwbE9JRkJWUWt4SlF5QkxSVmt0TFMwdExWeHVUVWxKUWtscVFVNUNaMnR4YUd0cFJ6bDNNRUpCVVVWR1FVRlBRMEZST0VGTlNVbENRMmRMUTBGUlJVRTBWSFZGVTBaMVZXSkxXVTh4V0hGVWRWVTNVMXh1YkUwMVZXRXZkV0ZITTNjeGJHRjJXVW94Vms5cE0yWkNaMUpTUm1Fd04wazFUMGxxZGtwU1NWRlVkMlExUnk5Vk5tOXJhbEpoVFZneE5tWTRZemgwYUZ4dWRXUlJhVEU0YWtZNFRtWmxkVXhFY0c1bE5WSmhTa05zTlc5WmEwOURRVmhuZEdKSmRVaHdSa1I0UzBjM1FUVmtNWFpXUkcxUWFubGtaVUp6U0haMlExeHVaWGgzY1VGS2VFZGtNamxOU0hOQllVTldUWHBsVW5SV09VVktkMFZ1TDJSdE9VVnpZMGhpUnpnMllscGpZWGxFWmpOblRYSXhNamN3T1RGUFRrOVdSVnh1T1ZWR1pVWldZV3hoTW5GbE5URmlNRGszTmtka1kzUnNSWEoyZG1OUVRWcHdTbVFyZWpKVGRtTnhVMlJLY0U5WlFqRXphbVJXYW5OTmRtRkJTVzh4VDF4dU4xcEZVMFZwVWtGNk5VaGFjSHAyV1c0emNpOXhhMHg2U0dScFRVTk9SeXRtZW1SbGR6ZGxaVUp0WTNrcmExQlZXRmd6WkdGeU0yRmFWeXR0WmtOU09WeHViRkZKUkVGUlFVSmNiaTB0TFMwdFJVNUVJRkJWUWt4SlF5QkxSVmt0TFMwdExWeHVJaXdpYTJWNVUybG5ibUYwZFhKbElqb2laWGxLZW1GWFpIVlpXRkl4WTIxVmFVOXBTbEpqUkdNelkwUkNVMVY2YUVkVFNHeEVVekZzYUdSRVJsUmhiVnBHVWxaS1dWTjZUakppTUVZelVrWk9iMkl3VlhwaWEwMTNWMFZTTWxveWRFVmxhMVo2V2xkT1YxRnFWa1JsU0ZVMFZtMVdTbFJ1Y0ZwbFIzUTBVMGhPVUdWdVkzbFRWMGx5WkZoR1ZFNVZjSGRSYkVKNFZqRmtkVnB1UmtoVk1tYzFZa2hhYWxReWQzZFNSMDVUWWpKc1ZtTkhPWGRoVkdjeVRrWmpkbVZ1YURCVlZuQmFWRmRyZDFSSVJqUlZiVWt3VTBSb1NGWlhkRkpoTWxGM1lsaEtTVlJFYkcxU01ITXdUREpHWVZSdFZsZFJWbWhHVkZob2EyUkZOVkprU0U1NlZGVmtlVlpVVmpCVVNGcFlZbXhCZW1OVldrSk9VM1JIVkRGb00xWnRjREpqUlhReVZWZEtNbGRHY0VsaFJscHVUbGhLUjFRd05XdE5la3BoVDBWMGJWVnFWa05VUjFwUldqSnZORlpyV1haaGEyaGFaREprZEU5WFNuSmtWVEI1VDFoc1dsb3dTa1ZXVkZwQ1YyMVdhVmxVYkVwU2FrSnJUakowZW1JelpHcGlWWFJFVTFoS2MxSXpXbGxVTWxadlZIazVXR05WVW5wWFYxWndWREJrZVZkSGJ6RmxiR3hJVW01V1JWSXpaRWxYVmtZMlkzcHNTV1JxVG0xYVZVWjZWREF4VEZwWVdraGhWMDVZWTFoYWJsb3hXbE5UUjBsNFRWZG9NbU5XYkZGVlJsSTJZbE01VUdKSVl6bFFVMGx6U1cxa2MySXlTbWhpUlhSc1pWVnNhMGxxYjJsYVIxVjVXWHBKTTA1VVdURk9iVkYzVGtkSmVGbHRTWGRhYWtVeFdUSlpNMDFIV1hkYVYwVjVXVlJKYVdaUlBUMGlmUT09In0= + replicatedProxyDomain: ec-e2e-proxy.testcluster.net + signature: eyJsaWNlbnNlRGF0YSI6ImV5SmhjR2xXWlhKemFXOXVJam9pYTI5MGN5NXBieTkyTVdKbGRHRXlJaXdpYTJsdVpDSTZJa3hwWTJWdWMyVWlMQ0p0WlhSaFpHRjBZU0k2ZXlKdVlXMWxJam9pWjJsMGFIVmljMlZqY21WMGMyNWhjSE5vYjNSamFYUmxjM1JqZFhOMGIyMWxjaUo5TENKemNHVmpJanA3SW14cFkyVnVjMlZKUkNJNklqSm1VMlV4UTFoMFRVOVlPV3BPWjBoVVpUQXdiWFp4VHpVd01pSXNJbXhwWTJWdWMyVlVlWEJsSWpvaWNISnZaQ0lzSW1OMWMzUnZiV1Z5VG1GdFpTSTZJa2RwZEVoMVlpQlRaV055WlhRZ1UyNWhjSE5vYjNRZ1Ewa2dWR1Z6ZENCRGRYTjBiMjFsY2lJc0ltRndjRk5zZFdjaU9pSmxiV0psWkdSbFpDMWpiSFZ6ZEdWeUxYTnRiMnRsTFhSbGMzUXRjM1JoWjJsdVp5MWhjSEFpTENKamFHRnVibVZzU1VRaU9pSXlZMGhZWWpGU1EzUjBlbkJTTUhoMmJrNVhlV0ZhUTJkRVFsQWlMQ0pqYUdGdWJtVnNUbUZ0WlNJNklrTkpJaXdpWTJoaGJtNWxiSE1pT2x0N0ltTm9ZVzV1Wld4SlJDSTZJakpqU0ZoaU1WSkRkSFI2Y0ZJd2VIWnVUbGQ1WVZwRFowUkNVQ0lzSW1Ob1lXNXVaV3hUYkhWbklqb2lZMmtpTENKamFHRnVibVZzVG1GdFpTSTZJa05KSWl3aWFYTkVaV1poZFd4MElqcDBjblZsTENKbGJtUndiMmx1ZENJNkltaDBkSEJ6T2k4dlpXTXRaVEpsTFhKbGNHeHBZMkYwWldRdFlYQndMblJsYzNSamJIVnpkR1Z5TG01bGRDSXNJbkpsY0d4cFkyRjBaV1JRY205NGVVUnZiV0ZwYmlJNkltVmpMV1V5WlMxd2NtOTRlUzUwWlhOMFkyeDFjM1JsY2k1dVpYUWlmVjBzSW14cFkyVnVjMlZUWlhGMVpXNWpaU0k2TlN3aVpXNWtjRzlwYm5RaU9pSm9kSFJ3Y3pvdkwyVmpMV1V5WlMxeVpYQnNhV05oZEdWa0xXRndjQzUwWlhOMFkyeDFjM1JsY2k1dVpYUWlMQ0p5WlhCc2FXTmhkR1ZrVUhKdmVIbEViMjFoYVc0aU9pSmxZeTFsTW1VdGNISnZlSGt1ZEdWemRHTnNkWE4wWlhJdWJtVjBJaXdpWlc1MGFYUnNaVzFsYm5SeklqcDdJbVY0Y0dseVpYTmZZWFFpT25zaWRHbDBiR1VpT2lKRmVIQnBjbUYwYVc5dUlpd2laR1Z6WTNKcGNIUnBiMjRpT2lKTWFXTmxibk5sSUVWNGNHbHlZWFJwYjI0aUxDSjJZV3gxWlNJNklpSXNJblpoYkhWbFZIbHdaU0k2SWxOMGNtbHVaeUlzSW5OcFoyNWhkSFZ5WlNJNmV5SjJNaUk2SW1zM01uSnVlRWR1VVZrNWVUQk9jUzlPZVhwNFNXRjRSRUZYYkVGNVNqUkpZemhxUmt0V2FrNVNjV1J4TjBkeGMxbDBObVppV0RKWlZsRnhUbFo1UzBVMFlYazRMMngxVUhJNFRHTXZLM2RsTTJRelZpc3dSMjE0WTNSc2VUTjFLMEl4Y0hSRGNpOVdTRXRSUkZCSlEwdEhMMUUzTlZWVVVtcEVWRkZpZFhGblpIcGtUakkyUXpGM2JtbHFkbXR0TkVoRVlWTm5aa1ZYVmtaSGJGQmxWek0wTWxWTVdsTlBNMDB4VldaNU5YbzVTMjVRVlhKSGRXSlliM04yTjFCVGFsVlVjVEY1WTB3eWVqVkpSQ3RpVG1Sa1JrMW1UREZoVmsxeFJUSlRTa0ZxTmpGS1ZuQXZUWEZuYm5GUGVFWktVRlZXVDJwMVdGWkhibEpLY2tKTFRGaFdLMWg2TTNvemFHNTFUMHBwSzI0Mk4wMXZNRkZvYUdKTVdWVktkVTQ1YTNCeE5UUnVkMk5qU2pBMGJFOTRZaXR4YW5saVlYRjZSV1I1VTIxbU1VRlVLM2h4ZGsxT04yMXhUalpNVTBwUVdUQllTM0ZYZHowOUluMTlmU3dpYVhORWFYTmhjM1JsY2xKbFkyOTJaWEo1VTNWd2NHOXlkR1ZrSWpwMGNuVmxMQ0pwYzBWdFltVmtaR1ZrUTJ4MWMzUmxja1J2ZDI1c2IyRmtSVzVoWW14bFpDSTZkSEoxWlN3aWFYTkZiV0psWkdSbFpFTnNkWE4wWlhKTmRXeDBhVTV2WkdWRmJtRmliR1ZrSWpwMGNuVmxMQ0pwYzB0dmRITkpibk4wWVd4c1JXNWhZbXhsWkNJNmRISjFaWDE5IiwiaW5uZXJTaWduYXR1cmUiOiJleUoyTWt4cFkyVnVjMlZUYVdkdVlYUjFjbVVpT2lKRk1VSXJhMU5MUnpaR1Ntc3hiQ3ROVTJ3eFUwWlFUVmQxVVRKcFdXeE1lV1YwVEV4M1JFbHdjbWhqT0VwR1FtdERSbE12U0ZaWlIwVk1TVE4xUVVKNU9YcHNRVlZHZVZNdlZGUTFVV1ZXVW1OaFlYZHlNM053YzFKRE56SlNRa3BDVkdrelRrTlFaMlpUTlRkSlNVSm5WVEpVTkZWWVdrRm1RM0ZxYkZFMVlqbDVjbXRtU0VKTE9HTjBVWHA2VmtaS2MzWm1NMmhMUWxCWVkyTTRXWEUzVkdKclRIcDRNbE5yYjB4cVZqZzVTbmhsVEhGV1VGWm9aMEZRU0dGdFVHdG9hMUp3VGpaeVdISlFabkJoTm1aVmFVVnBSa280VlN0aVpVMVZSREF2TlZoeWVXNWlURFJHVmxkSFVHMTVhV1J0UzNNMU5FbEVaRzU1YlRaRVNFRnRWamswWjB3MGJTOXdOVVJ2V2xWclF6ZENaMWsxT1ZkdFoyOUNhVnBKT0RONVVYVnpZWFZHUlZVeWNtdHdjWFkyUnk5MFVtUjBTbHA2VTBreFNWcHhjSHBSYWxwWFJUbHlhazlZVkROc2JEUk1hall4UzBFOVBTSXNJbkIxWW14cFkwdGxlU0k2SWkwdExTMHRRa1ZIU1U0Z1VGVkNURWxESUV0RldTMHRMUzB0WEc1TlNVbENTV3BCVGtKbmEzRm9hMmxIT1hjd1FrRlJSVVpCUVU5RFFWRTRRVTFKU1VKRFowdERRVkZGUVRSVWRVVlRSblZWWWt0WlR6RlljVlIxVlRkVFhHNXNUVFZWWVM5MVlVY3pkekZzWVhaWlNqRldUMmt6WmtKblVsSkdZVEEzU1RWUFNXcDJTbEpKVVZSM1pEVkhMMVUyYjJ0cVVtRk5XREUyWmpoak9IUm9YRzUxWkZGcE1UaHFSamhPWm1WMVRFUndibVUxVW1GS1EydzFiMWxyVDBOQldHZDBZa2wxU0hCR1JIaExSemRCTldReGRsWkViVkJxZVdSbFFuTklkblpEWEc1bGVIZHhRVXA0UjJReU9VMUljMEZoUTFaTmVtVlNkRlk1UlVwM1JXNHZaRzA1UlhOalNHSkhPRFppV21OaGVVUm1NMmROY2pFeU56QTVNVTlPVDFaRlhHNDVWVVpsUmxaaGJHRXljV1UxTVdJd09UYzJSMlJqZEd4RmNuWjJZMUJOV25CS1pDdDZNbE4yWTNGVFpFcHdUMWxDTVROcVpGWnFjMDEyWVVGSmJ6RlBYRzQzV2tWVFJXbFNRWG8xU0Zwd2VuWlpiak55TDNGclRIcElaR2xOUTA1SEsyWjZaR1YzTjJWbFFtMWplU3RyVUZWWVdETmtZWEl6WVZwWEsyMW1RMUk1WEc1c1VVbEVRVkZCUWx4dUxTMHRMUzFGVGtRZ1VGVkNURWxESUV0RldTMHRMUzB0WEc0aUxDSjJNa3RsZVZOcFoyNWhkSFZ5WlNJNkltVjVTbnBoVjJSMVdWaFNNV050VldsUGFVcFRXVzA1YTJOR1ZuaGxiVFZzVkVSak5FOURkR2xaTUhRMllWZGFjVTR3WkRCaWFteExXakprZUZkR2JEQlVhMlIyVEROQ1YxSjZRbWxXTUhCT1pGWlNkMVpVYXpOWFJHaDZXVlZ3ZDFWSWFGTlVNR2Q1VDBSa05HRkhORFJWZWtKaFdtdFNOVTVYVGxsaWJteHZZMjF6TUZKVVVqTmpiVFYyVFZSR1VtVnNTVEZOVlVaM1ZYazVVbGt5VWxOWmJXUlhaRlZXUmxSdWNESmtNRlpaWkVNNU1VNVRPVWhaYldoNFVWUktjMlJYZEVSU2EwNXJWV3hLYUZGVlNrOVRSbkJvV2xWcmNtSnJSbXBpTW5CU1VrTjBUMU15TVc1U2JFNTNVMGhKZVZSRmNFOVZSMDV2Vlc1R2NXTXlXVFJqVmtKelRVTTVkRmw2VGpWVFJWb3dUREZvUjJSdE9WUlpNWEJzWkRKT1NsTklTbnBQUm14UFRrWndhRnBWTldoU1JYQnZWREJHUzFReVVYcFBSbFowVlRCa1dWUldhREprVnpselRYcG9jVlZGVmtoTU1IZDVZVWh3YkdOdFVsZFpWMFpRVG14V2RWUnNhRmhpVld4Q1RXazRkbFZzUWs1U1ZFSkpVMFpXYjFscVRuaGxSVm95VlZkb01GUXhUbGhSTVdoM1lYcFNlbEpJUW1oaVZFazBUakJLVWxveGJIbE9NRGxKVTFkSk5HUXljRmxUUTNRelVtMWpNRTFZYkV4VGJGSmhXbFV4VFZScVRUVk5lbGt3VVd0R2VWUkZTVE5YVlVVNVVGTkpjMGx0WkhOaU1rcG9Za1YwYkdWVmJHdEphbTlwV2tkVmVWbDZTVE5PVkZreFRtMVJkMDVIU1hoWmJVbDNXbXBGTVZreVdUTk5SMWwzV2xkRmVWbFVTV2xtVVQwOUluMD0ifQ== diff --git a/go.mod b/go.mod index bf65b53bb5..068e49e228 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/replicatedhq/embedded-cluster/kinds v0.0.0 github.com/replicatedhq/embedded-cluster/utils v0.0.0 - github.com/replicatedhq/kotskinds v0.0.0-20251024162531-2174a5b85a4d + github.com/replicatedhq/kotskinds v0.0.0-20251118214543-70b6df55b238 github.com/replicatedhq/troubleshoot v0.123.16 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.10.2 @@ -54,6 +54,7 @@ require ( golang.org/x/crypto v0.46.0 golang.org/x/term v0.38.0 gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 gotest.tools v2.2.0+incompatible helm.sh/helm/v3 v3.19.4 k8s.io/api v0.34.3 @@ -360,7 +361,6 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiserver v0.34.3 // indirect k8s.io/cloud-provider v0.33.6 // indirect k8s.io/component-base v0.34.3 // indirect diff --git a/go.sum b/go.sum index 5a39277e70..cccaab0fba 100644 --- a/go.sum +++ b/go.sum @@ -711,8 +711,8 @@ github.com/redis/go-redis/extra/redisotel/v9 v9.5.3 h1:kuvuJL/+MZIEdvtb/kTBRiRgY github.com/redis/go-redis/extra/redisotel/v9 v9.5.3/go.mod h1:7f/FMrf5RRRVHXgfk7CzSVzXHiWeuOQUu2bsVqWoa+g= github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= -github.com/replicatedhq/kotskinds v0.0.0-20251024162531-2174a5b85a4d h1:N8t9W5SYs1MKPsuAp4PA5Haje4cOyCyubAq65qB1wzE= -github.com/replicatedhq/kotskinds v0.0.0-20251024162531-2174a5b85a4d/go.mod h1:+k4PHo2wukoU9kdiKrqqgi89Wmj+9AiwppYGVK11zig= +github.com/replicatedhq/kotskinds v0.0.0-20251118214543-70b6df55b238 h1:7W7174xNrg/JByodW90dI+D3PdN9cuTKHwsx2fFzvpQ= +github.com/replicatedhq/kotskinds v0.0.0-20251118214543-70b6df55b238/go.mod h1:+k4PHo2wukoU9kdiKrqqgi89Wmj+9AiwppYGVK11zig= github.com/replicatedhq/troubleshoot v0.123.16 h1:gbk8S+4XoVJDG8C/k1zRqKbCzreFj9+8WXk72PHOl4w= github.com/replicatedhq/troubleshoot v0.123.16/go.mod h1:RuiASh1R8HOhITzDYuFUt0i4VoGUlix2QurAfwIJ7PA= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/pkg-new/license/signature.go b/pkg-new/license/signature.go index 457f72596e..4afac56bbe 100644 --- a/pkg-new/license/signature.go +++ b/pkg-new/license/signature.go @@ -1,14 +1,8 @@ package license import ( - "crypto" - "crypto/rsa" - "crypto/x509" - "encoding/json" - "encoding/pem" "fmt" - - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" ) var ( @@ -83,244 +77,25 @@ KwIDAQAB -----END PUBLIC KEY-----`), // Dryrun (test-only, private key in tests/dryrun/assets) } -// VerifySignature verifies the cryptographic signature of a license. -// It returns the verified license with the signature field populated, or an error if verification fails. -func VerifySignature(license *kotsv1beta1.License) (*kotsv1beta1.License, error) { - outerSignature := &OuterSignature{} - if err := json.Unmarshal(license.Spec.Signature, outerSignature); err != nil { - return nil, fmt.Errorf("failed to unmarshal license outer signature: %w", err) - } - - isOldFormat := len(outerSignature.InnerSignature) == 0 - if isOldFormat { - return verifyOldSignature(license) - } - - innerSignature := &InnerSignature{} - if err := json.Unmarshal(outerSignature.InnerSignature, innerSignature); err != nil { - return nil, fmt.Errorf("failed to unmarshal license inner signature: %w", err) - } - - keySignature := &KeySignature{} - if err := json.Unmarshal(innerSignature.KeySignature, keySignature); err != nil { - return nil, fmt.Errorf("failed to unmarshal key signature: %w", err) - } - - globalKeyPEM, ok := PublicKeys[keySignature.GlobalKeyId] - if !ok { - return nil, fmt.Errorf("unknown global key") - } - - // verify that the app public key is properly signed with a replicated private key - if err := verifyRSAPSS([]byte(innerSignature.PublicKey), keySignature.Signature, globalKeyPEM); err != nil { - return nil, fmt.Errorf("failed to verify key signature: %w", err) - } - - // verify that the license data is properly signed with the app private key - if err := verifyRSAPSS(outerSignature.LicenseData, innerSignature.LicenseSignature, []byte(innerSignature.PublicKey)); err != nil { - return nil, fmt.Errorf("failed to verify license signature: %w", err) - } - - verifiedLicense := &kotsv1beta1.License{} - if err := json.Unmarshal(outerSignature.LicenseData, verifiedLicense); err != nil { - return nil, fmt.Errorf("failed to unmarshal license data: %w", err) - } - - if err := verifyLicenseData(license, verifiedLicense); err != nil { - return nil, LicenseDataError{message: err.Error()} - } - - verifiedLicense.Spec.Signature = license.Spec.Signature - - return verifiedLicense, nil -} - -// verifyRSAPSS verifies an RSA-PSS signature using MD5 hashing -func verifyRSAPSS(message, signature, publicKeyPEM []byte) error { - pubBlock, _ := pem.Decode(publicKeyPEM) - if pubBlock == nil { - return fmt.Errorf("failed to decode PEM block from public key") - } - - publicKey, err := x509.ParsePKIXPublicKey(pubBlock.Bytes) - if err != nil { - return fmt.Errorf("failed to load public key from PEM: %w", err) - } - - var opts rsa.PSSOptions - opts.SaltLength = rsa.PSSSaltLengthAuto - - newHash := crypto.MD5 - pssh := newHash.New() - pssh.Write(message) - hashed := pssh.Sum(nil) - - err = rsa.VerifyPSS(publicKey.(*rsa.PublicKey), newHash, hashed, signature, &opts) - if err != nil { - // this ordering makes errors.Cause a little more useful - return fmt.Errorf("%w: %s", ErrSignatureInvalid, err.Error()) - } - - return nil -} - -// verifyLicenseData ensures that critical license fields haven't been tampered with -// by comparing the outer license with the inner signed license -func verifyLicenseData(outerLicense *kotsv1beta1.License, innerLicense *kotsv1beta1.License) error { - if outerLicense.Spec.AppSlug != innerLicense.Spec.AppSlug { - return fmt.Errorf("\"appSlug\" field has changed to %q (license) from %q (within signature)", outerLicense.Spec.AppSlug, innerLicense.Spec.AppSlug) - } - if outerLicense.Spec.Endpoint != innerLicense.Spec.Endpoint { - return fmt.Errorf("\"endpoint\" field has changed to %q (license) from %q (within signature)", outerLicense.Spec.Endpoint, innerLicense.Spec.Endpoint) - } - if outerLicense.Spec.CustomerName != innerLicense.Spec.CustomerName { - return fmt.Errorf("\"CustomerName\" field has changed to %q (license) from %q (within signature)", outerLicense.Spec.CustomerName, innerLicense.Spec.CustomerName) - } - if outerLicense.Spec.CustomerEmail != innerLicense.Spec.CustomerEmail { - return fmt.Errorf("\"CustomerEmail\" field has changed to %q (license) from %q (within signature)", outerLicense.Spec.CustomerEmail, innerLicense.Spec.CustomerEmail) - } - if outerLicense.Spec.ChannelID != innerLicense.Spec.ChannelID { - return fmt.Errorf("\"channelID\" field has changed to %q (license) from %q (within signature)", outerLicense.Spec.ChannelID, innerLicense.Spec.ChannelID) - } - if outerLicense.Spec.ChannelName != innerLicense.Spec.ChannelName { - return fmt.Errorf("\"channelName\" field has changed to %q (license) from %q (within signature)", outerLicense.Spec.ChannelName, innerLicense.Spec.ChannelName) - } - if outerLicense.Spec.LicenseSequence != innerLicense.Spec.LicenseSequence { - return fmt.Errorf("\"licenseSequence\" field has changed to %q (license) from %q (within signature)", outerLicense.Spec.LicenseSequence, innerLicense.Spec.LicenseSequence) - } - if outerLicense.Spec.LicenseID != innerLicense.Spec.LicenseID { - return fmt.Errorf("\"licenseID\" field has changed to %q (license) from %q (within signature)", outerLicense.Spec.LicenseID, innerLicense.Spec.LicenseID) - } - if outerLicense.Spec.LicenseType != innerLicense.Spec.LicenseType { - return fmt.Errorf("\"LicenseType\" field has changed to %q (license) from %q (within signature)", outerLicense.Spec.LicenseType, innerLicense.Spec.LicenseType) - } - if outerLicense.Spec.IsAirgapSupported != innerLicense.Spec.IsAirgapSupported { - return fmt.Errorf("\"IsAirgapSupported\" field has changed to %t (license) from %t (within signature)", outerLicense.Spec.IsAirgapSupported, innerLicense.Spec.IsAirgapSupported) - } - if outerLicense.Spec.IsGitOpsSupported != innerLicense.Spec.IsGitOpsSupported { - return fmt.Errorf("\"IsGitOpsSupported\" field has changed to %t (license) from %t (within signature)", outerLicense.Spec.IsGitOpsSupported, innerLicense.Spec.IsGitOpsSupported) - } - if outerLicense.Spec.IsIdentityServiceSupported != innerLicense.Spec.IsIdentityServiceSupported { - return fmt.Errorf("\"IsIdentityServiceSupported\" field has changed to %t (license) from %t (within signature)", outerLicense.Spec.IsIdentityServiceSupported, innerLicense.Spec.IsIdentityServiceSupported) - } - if outerLicense.Spec.IsGeoaxisSupported != innerLicense.Spec.IsGeoaxisSupported { - return fmt.Errorf("\"IsGeoaxisSupported\" field has changed to %t (license) from %t (within signature)", outerLicense.Spec.IsGeoaxisSupported, innerLicense.Spec.IsGeoaxisSupported) - } - if outerLicense.Spec.IsSnapshotSupported != innerLicense.Spec.IsSnapshotSupported { - return fmt.Errorf("\"IsSnapshotSupported\" field has changed to %t (license) from %t (within signature)", outerLicense.Spec.IsSnapshotSupported, innerLicense.Spec.IsSnapshotSupported) - } - if outerLicense.Spec.IsDisasterRecoverySupported != innerLicense.Spec.IsDisasterRecoverySupported { - return fmt.Errorf("\"IsDisasterRecoverySupported\" field has changed to %t (license) from %t (within signature)", outerLicense.Spec.IsDisasterRecoverySupported, innerLicense.Spec.IsDisasterRecoverySupported) - } - if outerLicense.Spec.IsSupportBundleUploadSupported != innerLicense.Spec.IsSupportBundleUploadSupported { - return fmt.Errorf("\"IsSupportBundleUploadSupported\" field has changed to %t (license) from %t (within signature)", outerLicense.Spec.IsSupportBundleUploadSupported, innerLicense.Spec.IsSupportBundleUploadSupported) - } - if outerLicense.Spec.IsSemverRequired != innerLicense.Spec.IsSemverRequired { - return fmt.Errorf("\"IsSemverRequired\" field has changed to %t (license) from %t (within signature)", outerLicense.Spec.IsSemverRequired, innerLicense.Spec.IsSemverRequired) - } - - // Check entitlements - if len(outerLicense.Spec.Entitlements) != len(innerLicense.Spec.Entitlements) { - return fmt.Errorf("\"entitlements\" field length has changed to %d (license) from %d (within signature)", len(outerLicense.Spec.Entitlements), len(innerLicense.Spec.Entitlements)) - } - for k, outerEntitlement := range outerLicense.Spec.Entitlements { - innerEntitlement, ok := innerLicense.Spec.Entitlements[k] - if !ok { - return fmt.Errorf("entitlement %q not found in the inner license", k) - } - if outerEntitlement.Value.Value() != innerEntitlement.Value.Value() { - return fmt.Errorf("entitlement %q value has changed to %q (license) from %q (within signature)", k, outerEntitlement.Value.Value(), innerEntitlement.Value.Value()) - } - if outerEntitlement.Title != innerEntitlement.Title { - return fmt.Errorf("entitlement %q title has changed to %q (license) from %q (within signature)", k, outerEntitlement.Title, innerEntitlement.Title) - } - if outerEntitlement.Description != innerEntitlement.Description { - return fmt.Errorf("entitlement %q description has changed to %q (license) from %q (within signature)", k, outerEntitlement.Description, innerEntitlement.Description) - } - if outerEntitlement.IsHidden != innerEntitlement.IsHidden { - return fmt.Errorf("entitlement %q hidden has changed to %t (license) from %t (within signature)", k, outerEntitlement.IsHidden, innerEntitlement.IsHidden) - } - if outerEntitlement.ValueType != innerEntitlement.ValueType { - return fmt.Errorf("entitlement %q value type has changed to %q (license) from %q (within signature)", k, outerEntitlement.ValueType, innerEntitlement.ValueType) - } - } - - return nil -} - -// verifyOldSignature handles legacy license signature format verification -func verifyOldSignature(license *kotsv1beta1.License) (*kotsv1beta1.License, error) { - signature := &InnerSignature{} - if err := json.Unmarshal(license.Spec.Signature, signature); err != nil { - // old licenses's signature is a single space character - if len(license.Spec.Signature) == 0 || len(license.Spec.Signature) == 1 { - return nil, ErrSignatureMissing +// VerifySignature verifies the cryptographic signature of a license wrapper. +// It handles both v1beta1 and v1beta2 licenses by delegating to their ValidateLicense methods. +// Returns the wrapper unchanged if validation succeeds, or an error if validation fails. +func VerifySignature(wrapper *licensewrapper.LicenseWrapper) (*licensewrapper.LicenseWrapper, error) { + if wrapper.IsV1() { + _, err := wrapper.V1.ValidateLicense() + if err != nil { + return nil, fmt.Errorf("v1beta1 license validation failed: %w", err) } - return nil, fmt.Errorf("failed to unmarshal license signature: %w", err) - } - - keySignature := &KeySignature{} - if err := json.Unmarshal(signature.KeySignature, keySignature); err != nil { - return nil, fmt.Errorf("failed to unmarshal key signature: %w", err) - } - - globalKeyPEM, ok := PublicKeys[keySignature.GlobalKeyId] - if !ok { - return nil, fmt.Errorf("unknown global key") - } - - if err := verifyRSAPSS([]byte(signature.PublicKey), keySignature.Signature, globalKeyPEM); err != nil { - return nil, fmt.Errorf("failed to verify key signature: %w", err) - } - - licenseMessage, err := getMessageFromLicense(license) - if err != nil { - return nil, fmt.Errorf("failed to convert license to message: %w", err) - } - - if err := verifyRSAPSS(licenseMessage, signature.LicenseSignature, []byte(signature.PublicKey)); err != nil { - return nil, fmt.Errorf("failed to verify license signature: %w", err) - } - - return license, nil -} - -// getMessageFromLicense creates a canonical message representation for old-format licenses -func getMessageFromLicense(license *kotsv1beta1.License) ([]byte, error) { - // JSON marshaller will sort map keys automatically. - fields := map[string]string{ - "apiVersion": license.APIVersion, - "kind": license.Kind, - "metadata.name": license.GetObjectMeta().GetName(), - "spec.licenseID": license.Spec.LicenseID, - "spec.appSlug": license.Spec.AppSlug, - "spec.channelName": license.Spec.ChannelName, - "spec.endpoint": license.Spec.Endpoint, - "spec.isAirgapSupported": fmt.Sprintf("%t", license.Spec.IsAirgapSupported), + return wrapper, nil } - if license.Spec.LicenseSequence > 0 { - fields["spec.licenseSequence"] = fmt.Sprintf("%d", license.Spec.LicenseSequence) - } - - for k, v := range license.Spec.Entitlements { - key := fmt.Sprintf("spec.entitlements.%s", k) - val := map[string]string{ - "title": v.Title, - "description": v.Description, - "value": fmt.Sprintf("%v", v.Value.Value()), - } - valStr, err := json.Marshal(val) + if wrapper.IsV2() { + _, err := wrapper.V2.ValidateLicense() if err != nil { - return nil, fmt.Errorf("failed to marshal entitlement value: %s: %w", k, err) + return nil, fmt.Errorf("v1beta2 license validation failed: %w", err) } - fields[key] = string(valStr) - } - - message, err := json.Marshal(fields) - if err != nil { - return nil, fmt.Errorf("failed to marshal message JSON: %w", err) + return wrapper, nil } - return message, err + return wrapper, nil } diff --git a/pkg-new/license/signature_test.go b/pkg-new/license/signature_test.go index 53286d9fab..0f8d55d3ee 100644 --- a/pkg-new/license/signature_test.go +++ b/pkg-new/license/signature_test.go @@ -2,10 +2,12 @@ package license import ( "embed" + "encoding/json" "testing" "github.com/replicatedhq/embedded-cluster/pkg/helpers" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + kotsv1beta2 "github.com/replicatedhq/kotskinds/apis/kots/v1beta2" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -13,45 +15,69 @@ import ( //go:embed testdata/* var testdata embed.FS -func loadLicenseFromTestdata(t *testing.T, filename string) *kotsv1beta1.License { +func loadLicenseFromTestdata(t *testing.T, filename string) *licensewrapper.LicenseWrapper { t.Helper() licenseBytes, err := testdata.ReadFile(filename) require.NoError(t, err) - license, err := helpers.ParseLicenseFromBytes(licenseBytes) + wrapper, err := helpers.ParseLicenseFromBytes(licenseBytes) require.NoError(t, err) - return license + return wrapper } func Test_VerifySignature(t *testing.T) { tests := []struct { name string licenseFile string - modifyLicense func(*kotsv1beta1.License) + wrapper *licensewrapper.LicenseWrapper + modifyLicense func(*licensewrapper.LicenseWrapper) expectError bool errorContains string }{ { - name: "valid signature passes verification", + name: "v1beta1: valid signature passes verification", licenseFile: "testdata/valid-license.yaml", expectError: false, }, { - name: "tampered license fails verification", + name: "v1beta1: tampered license fails verification", licenseFile: "testdata/valid-license.yaml", - modifyLicense: func(license *kotsv1beta1.License) { - license.Spec.LicenseID = license.Spec.LicenseID + "-modified" + modifyLicense: func(wrapper *licensewrapper.LicenseWrapper) { + wrapper.V1.Spec.LicenseID = wrapper.V1.Spec.LicenseID + "-modified" }, expectError: true, errorContains: `"licenseID" field has changed`, }, { - name: "invalid signature fails verification", + name: "v1beta1: invalid signature fails verification", licenseFile: "testdata/invalid-signature.yaml", expectError: true, - errorContains: "signature is invalid", + errorContains: "verification error", + }, + { + name: "v1beta2: valid signature passes verification", + licenseFile: "testdata/valid-license-v2.yaml", + expectError: false, + }, + + { + name: "v1beta2: invalid signature fails verification", + wrapper: &licensewrapper.LicenseWrapper{ + V2: &kotsv1beta2.License{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "kots.io/v1beta2", + Kind: "License", + }, + Spec: kotsv1beta2.LicenseSpec{ + LicenseID: "test-license-v2", + Signature: json.RawMessage(`{"invalid": "signature"}`), + }, + }, + }, + expectError: true, + errorContains: "v1beta2 license validation failed", }, } @@ -59,13 +85,18 @@ func Test_VerifySignature(t *testing.T) { t.Run(tt.name, func(t *testing.T) { req := require.New(t) - license := loadLicenseFromTestdata(t, tt.licenseFile) + var wrapper *licensewrapper.LicenseWrapper + if tt.licenseFile != "" { + wrapper = loadLicenseFromTestdata(t, tt.licenseFile) + } else if tt.wrapper != nil || tt.name == "nil wrapper returns nil" { + wrapper = tt.wrapper + } if tt.modifyLicense != nil { - tt.modifyLicense(license) + tt.modifyLicense(wrapper) } - verifiedLicense, err := VerifySignature(license) + verifiedWrapper, err := VerifySignature(wrapper) if tt.expectError { req.Error(err) @@ -74,379 +105,109 @@ func Test_VerifySignature(t *testing.T) { } } else { req.NoError(err) - req.NotNil(verifiedLicense) + if wrapper != nil { + req.NotNil(verifiedWrapper) + } } }) } } -func Test_verifyLicenseData(t *testing.T) { - // Create a base license to use for all tests - baseLicense := &kotsv1beta1.License{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "kots.io/v1beta1", - Kind: "License", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-license", - }, - Spec: kotsv1beta1.LicenseSpec{ - AppSlug: "test-app-slug", - Endpoint: "https://replicated.app", - CustomerName: "Test Customer", - CustomerEmail: "test@example.com", - ChannelID: "test-channel-id", - ChannelName: "test-channel", - LicenseSequence: 42, - LicenseID: "test-license-id", - LicenseType: "prod", - IsAirgapSupported: true, - IsGitOpsSupported: false, - IsIdentityServiceSupported: true, - IsGeoaxisSupported: false, - IsSnapshotSupported: true, - IsDisasterRecoverySupported: true, - IsSupportBundleUploadSupported: true, - IsSemverRequired: false, - Entitlements: map[string]kotsv1beta1.EntitlementField{ - "expires_at": { - Title: "Expiration", - Description: "License Expiration", - Value: kotsv1beta1.EntitlementValue{Type: kotsv1beta1.String, StrVal: "2025-12-31"}, - ValueType: "String", - }, - }, - }, - } +// Test_LicenseTamperDetection verifies that the kotskinds ValidateLicense() properly detects +// when any critical license field has been tampered with after signing. +// This is an end-to-end test that ensures the validation logic in kotskinds catches all tampering. +func Test_LicenseTamperDetection(t *testing.T) { + // All tests use a valid license from testdata and modify it to simulate tampering + baseLicenseFile := "testdata/valid-license.yaml" tests := []struct { - name string - outer *kotsv1beta1.License - inner *kotsv1beta1.License - wantErr bool - wantErrMsg string + name string + modifyLicense func(*licensewrapper.LicenseWrapper) + errorContains string }{ { - name: "happy path - all fields match", - outer: baseLicense.DeepCopy(), - inner: baseLicense.DeepCopy(), - wantErr: false, - }, - { - name: "appSlug changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.AppSlug = "modified-app-slug" - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"appSlug" field has changed to "modified-app-slug" (license) from "test-app-slug" (within signature)`, - }, - { - name: "endpoint changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.Endpoint = "https://modified.app" - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"endpoint" field has changed to "https://modified.app" (license) from "https://replicated.app" (within signature)`, - }, - { - name: "customerName changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.CustomerName = "Modified Customer" - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"CustomerName" field has changed to "Modified Customer" (license) from "Test Customer" (within signature)`, - }, - { - name: "customerEmail changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.CustomerEmail = "modified@example.com" - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"CustomerEmail" field has changed to "modified@example.com" (license) from "test@example.com" (within signature)`, - }, - { - name: "channelID changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.ChannelID = "modified-channel-id" - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"channelID" field has changed to "modified-channel-id" (license) from "test-channel-id" (within signature)`, - }, - { - name: "channelName changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.ChannelName = "modified-channel" - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"channelName" field has changed to "modified-channel" (license) from "test-channel" (within signature)`, - }, - { - name: "licenseSequence changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.LicenseSequence = 99 - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"licenseSequence" field has changed`, - }, - { - name: "licenseID changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.LicenseID = "modified-license-id" - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"licenseID" field has changed to "modified-license-id" (license) from "test-license-id" (within signature)`, - }, - { - name: "licenseType changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.LicenseType = "dev" - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"LicenseType" field has changed to "dev" (license) from "prod" (within signature)`, - }, - { - name: "isAirgapSupported changed from true to false", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.IsAirgapSupported = false - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"IsAirgapSupported" field has changed to false (license) from true (within signature)`, - }, - { - name: "isGitOpsSupported changed from false to true", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.IsGitOpsSupported = true - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"IsGitOpsSupported" field has changed to true (license) from false (within signature)`, - }, - { - name: "isIdentityServiceSupported changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.IsIdentityServiceSupported = false - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"IsIdentityServiceSupported" field has changed to false (license) from true (within signature)`, - }, - { - name: "isGeoaxisSupported changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.IsGeoaxisSupported = true - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"IsGeoaxisSupported" field has changed to true (license) from false (within signature)`, - }, - { - name: "isSnapshotSupported changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.IsSnapshotSupported = false - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"IsSnapshotSupported" field has changed to false (license) from true (within signature)`, - }, - { - name: "isDisasterRecoverySupported changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.IsDisasterRecoverySupported = false - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"IsDisasterRecoverySupported" field has changed to false (license) from true (within signature)`, - }, - { - name: "isSupportBundleUploadSupported changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.IsSupportBundleUploadSupported = false - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"IsSupportBundleUploadSupported" field has changed to false (license) from true (within signature)`, + name: "appSlug tampered", + modifyLicense: func(wrapper *licensewrapper.LicenseWrapper) { + wrapper.V1.Spec.AppSlug = wrapper.V1.Spec.AppSlug + "-modified" + }, + errorContains: "license data validation failed", }, { - name: "isSemverRequired changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.IsSemverRequired = true - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"IsSemverRequired" field has changed to true (license) from false (within signature)`, + name: "endpoint tampered", + modifyLicense: func(wrapper *licensewrapper.LicenseWrapper) { + wrapper.V1.Spec.Endpoint = "https://tampered.app" + }, + errorContains: "license data validation failed", }, { - name: "entitlements - different lengths", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.Entitlements["new_entitlement"] = kotsv1beta1.EntitlementField{ - Title: "New Entitlement", - Value: kotsv1beta1.EntitlementValue{Type: kotsv1beta1.String, StrVal: "value"}, - } - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `"entitlements" field length has changed to 2 (license) from 1 (within signature)`, + name: "customerName tampered", + modifyLicense: func(wrapper *licensewrapper.LicenseWrapper) { + wrapper.V1.Spec.CustomerName = "Tampered Customer" + }, + errorContains: "license data validation failed", }, { - name: "entitlements - value changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.Entitlements["expires_at"] = kotsv1beta1.EntitlementField{ - Title: "Expiration", - Description: "License Expiration", - Value: kotsv1beta1.EntitlementValue{Type: kotsv1beta1.String, StrVal: "2026-12-31"}, - ValueType: "String", - } - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `entitlement "expires_at" value has changed to "2026-12-31" (license) from "2025-12-31" (within signature)`, + name: "customerEmail tampered", + modifyLicense: func(wrapper *licensewrapper.LicenseWrapper) { + wrapper.V1.Spec.CustomerEmail = "tampered@example.com" + }, + errorContains: "license data validation failed", }, { - name: "entitlements - title changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.Entitlements["expires_at"] = kotsv1beta1.EntitlementField{ - Title: "Modified Expiration", - Description: "License Expiration", - Value: kotsv1beta1.EntitlementValue{Type: kotsv1beta1.String, StrVal: "2025-12-31"}, - ValueType: "String", - } - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `entitlement "expires_at" title has changed to "Modified Expiration" (license) from "Expiration" (within signature)`, + name: "channelID tampered", + modifyLicense: func(wrapper *licensewrapper.LicenseWrapper) { + wrapper.V1.Spec.ChannelID = "tampered-channel-id" + }, + errorContains: "license data validation failed", }, { - name: "entitlements - description changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.Entitlements["expires_at"] = kotsv1beta1.EntitlementField{ - Title: "Expiration", - Description: "Modified Description", - Value: kotsv1beta1.EntitlementValue{Type: kotsv1beta1.String, StrVal: "2025-12-31"}, - ValueType: "String", - } - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `entitlement "expires_at" description has changed to "Modified Description" (license) from "License Expiration" (within signature)`, + name: "channelName tampered", + modifyLicense: func(wrapper *licensewrapper.LicenseWrapper) { + wrapper.V1.Spec.ChannelName = "tampered-channel" + }, + errorContains: "license data validation failed", }, { - name: "entitlements - hidden changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.Entitlements["expires_at"] = kotsv1beta1.EntitlementField{ - Title: "Expiration", - Description: "License Expiration", - Value: kotsv1beta1.EntitlementValue{Type: kotsv1beta1.String, StrVal: "2025-12-31"}, - ValueType: "String", - IsHidden: true, - } - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `entitlement "expires_at" hidden has changed to true (license) from false (within signature)`, + name: "licenseSequence tampered", + modifyLicense: func(wrapper *licensewrapper.LicenseWrapper) { + wrapper.V1.Spec.LicenseSequence = 999999 + }, + errorContains: "license data validation failed", }, { - name: "entitlements - valueType changed", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.Entitlements["expires_at"] = kotsv1beta1.EntitlementField{ - Title: "Expiration", - Description: "License Expiration", - Value: kotsv1beta1.EntitlementValue{Type: kotsv1beta1.String, StrVal: "2025-12-31"}, - ValueType: "Integer", - } - return l - }(), - inner: baseLicense.DeepCopy(), - wantErr: true, - wantErrMsg: `entitlement "expires_at" value type has changed to "Integer" (license) from "String" (within signature)`, + name: "licenseID tampered", + modifyLicense: func(wrapper *licensewrapper.LicenseWrapper) { + wrapper.V1.Spec.LicenseID = "tampered-license-id" + }, + errorContains: "license data validation failed", }, { - name: "entitlements - missing entitlement in inner", - outer: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.Entitlements["new_key"] = kotsv1beta1.EntitlementField{ - Title: "New", - Value: kotsv1beta1.EntitlementValue{Type: kotsv1beta1.String, StrVal: "value"}, - } - return l - }(), - inner: func() *kotsv1beta1.License { - l := baseLicense.DeepCopy() - l.Spec.Entitlements = map[string]kotsv1beta1.EntitlementField{} // empty entitlements - return l - }(), - wantErr: true, - wantErrMsg: `"entitlements" field length has changed`, + name: "licenseType tampered", + modifyLicense: func(wrapper *licensewrapper.LicenseWrapper) { + wrapper.V1.Spec.LicenseType = "tampered" + }, + errorContains: "license data validation failed", }, + // Note: Entitlement tampering is validated separately by kotskinds using individual + // entitlement signatures (EntitlementField.Signature.V1). The main license signature + // protects the core license fields above. Entitlement validation is tested in + // Test_VerifySignature/v1beta1:_tampered_license_fails_verification } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := require.New(t) - err := verifyLicenseData(tt.outer, tt.inner) - if tt.wantErr { - req.Error(err) - if tt.wantErrMsg != "" { - req.Contains(err.Error(), tt.wantErrMsg) - } - } else { - req.NoError(err) - } + // Load a valid signed license + wrapper := loadLicenseFromTestdata(t, baseLicenseFile) + + // Tamper with the license + tt.modifyLicense(wrapper) + + // Verify that kotskinds detects the tampering + _, err := VerifySignature(wrapper) + req.Error(err, "expected kotskinds to detect tampering") + req.Contains(err.Error(), tt.errorContains) }) } } diff --git a/pkg-new/license/testdata/valid-license-v2.yaml b/pkg-new/license/testdata/valid-license-v2.yaml new file mode 100644 index 0000000000..aa42a256bb --- /dev/null +++ b/pkg-new/license/testdata/valid-license-v2.yaml @@ -0,0 +1,39 @@ +apiVersion: kots.io/v1beta2 +kind: License +metadata: + name: shortriblabs +spec: + appSlug: slackernews + channelID: 34U0bWH0DzO6AXZNmBV55l2twIy + channelName: Unstable + channels: + - channelID: 34U0bWH0DzO6AXZNmBV55l2twIy + channelName: Unstable + channelSlug: unstable + endpoint: https://replicated-app-crdant.okteto.repldev.com + isDefault: true + isSemverRequired: true + replicatedProxyDomain: proxy-registry-crdant.okteto.repldev.com + customerEmail: crdant@shortrib.io + customerName: Shortrib Labs + endpoint: https://replicated-app-crdant.okteto.repldev.com + entitlements: + expires_at: + description: License Expiration + signature: + v2: KOSBlms/GGsJ2loNhHRw9p78tPa8pTRKTsGP0VCNwqXgXkU0DzxCoPQ6MkVEsckIyl1o07gMDBd4L8e0XPafTaX7MrTC7CMtmRBqhrtVlYsu3RMaS1l7s2hebohCxMAUMBB5ATnp/DVsV8RM8QzLYUdk/QdpUetRJ6b2PypwKqYBTLLHiqnKlEqG7i2HfcJKknGHjeQV5PqmTrXzCXtDh2Ph1kvVVqSJabouu8Xt0KmS+RnyyRN9G6mlfwmXqNVCcHMgAnR6lwg/oOCNys1gPTDOs/020RphD83fU9w/S3h41nr1YrB0Rr7NgVhMSEeo6f13ZlqwiM6UfiZgy3Yjdw== + title: Expiration + value: "" + valueType: String + isAirgapSupported: true + isEmbeddedClusterDownloadEnabled: true + isEmbeddedClusterMultiNodeEnabled: true + isKotsInstallEnabled: true + isNewKotsUiEnabled: true + isSemverRequired: true + isSupportBundleUploadSupported: true + licenseID: 34U3RxwYCWsQ62KatDm88KJRDyO + licenseSequence: 2 + licenseType: dev + replicatedProxyDomain: proxy-registry-crdant.okteto.repldev.com + signature: eyJsaWNlbnNlRGF0YSI6ImV5SmhjR2xXWlhKemFXOXVJam9pYTI5MGN5NXBieTkyTVdKbGRHRXlJaXdpYTJsdVpDSTZJa3hwWTJWdWMyVWlMQ0p0WlhSaFpHRjBZU0k2ZXlKdVlXMWxJam9pYzJodmNuUnlhV0pzWVdKekluMHNJbk53WldNaU9uc2liR2xqWlc1elpVbEVJam9pTXpSVk0xSjRkMWxEVjNOUk5qSkxZWFJFYlRnNFMwcFNSSGxQSWl3aWJHbGpaVzV6WlZSNWNHVWlPaUprWlhZaUxDSmpkWE4wYjIxbGNrNWhiV1VpT2lKVGFHOXlkSEpwWWlCTVlXSnpJaXdpWVhCd1UyeDFaeUk2SW5Oc1lXTnJaWEp1Wlhkeklpd2lZMmhoYm01bGJFbEVJam9pTXpSVk1HSlhTREJFZWs4MlFWaGFUbTFDVmpVMWJESjBkMGw1SWl3aVkyaGhibTVsYkU1aGJXVWlPaUpWYm5OMFlXSnNaU0lzSW1OMWMzUnZiV1Z5UlcxaGFXd2lPaUpqY21SaGJuUkFjMmh2Y25SeWFXSXVhVzhpTENKamFHRnVibVZzY3lJNlczc2lZMmhoYm01bGJFbEVJam9pTXpSVk1HSlhTREJFZWs4MlFWaGFUbTFDVmpVMWJESjBkMGw1SWl3aVkyaGhibTVsYkZOc2RXY2lPaUoxYm5OMFlXSnNaU0lzSW1Ob1lXNXVaV3hPWVcxbElqb2lWVzV6ZEdGaWJHVWlMQ0pwYzBSbFptRjFiSFFpT25SeWRXVXNJbVZ1WkhCdmFXNTBJam9pYUhSMGNITTZMeTl5WlhCc2FXTmhkR1ZrTFdGd2NDMWpjbVJoYm5RdWIydDBaWFJ2TG5KbGNHeGtaWFl1WTI5dElpd2ljbVZ3YkdsallYUmxaRkJ5YjNoNVJHOXRZV2x1SWpvaWNISnZlSGt0Y21WbmFYTjBjbmt0WTNKa1lXNTBMbTlyZEdWMGJ5NXlaWEJzWkdWMkxtTnZiU0lzSW1selUyVnRkbVZ5VW1WeGRXbHlaV1FpT25SeWRXVjlYU3dpYkdsalpXNXpaVk5sY1hWbGJtTmxJam95TENKbGJtUndiMmx1ZENJNkltaDBkSEJ6T2k4dmNtVndiR2xqWVhSbFpDMWhjSEF0WTNKa1lXNTBMbTlyZEdWMGJ5NXlaWEJzWkdWMkxtTnZiU0lzSW5KbGNHeHBZMkYwWldSUWNtOTRlVVJ2YldGcGJpSTZJbkJ5YjNoNUxYSmxaMmx6ZEhKNUxXTnlaR0Z1ZEM1dmEzUmxkRzh1Y21Wd2JHUmxkaTVqYjIwaUxDSmxiblJwZEd4bGJXVnVkSE1pT25zaVpYaHdhWEpsYzE5aGRDSTZleUowYVhSc1pTSTZJa1Y0Y0dseVlYUnBiMjRpTENKa1pYTmpjbWx3ZEdsdmJpSTZJa3hwWTJWdWMyVWdSWGh3YVhKaGRHbHZiaUlzSW5aaGJIVmxJam9pSWl3aWRtRnNkV1ZVZVhCbElqb2lVM1J5YVc1bklpd2ljMmxuYm1GMGRYSmxJanA3SW5ZeUlqb2lTMDlUUW14dGN5OUhSM05LTW14dlRtaElVbmM1Y0RjNGRGQmhPSEJVVWt0VWMwZFFNRlpEVG5keFdHZFlhMVV3UkhwNFEyOVFVVFpOYTFaRmMyTnJTWGxzTVc4d04yZE5SRUprTkV3NFpUQllVR0ZtVkdGWU4wMXlWRU0zUTAxMGJWSkNjV2h5ZEZac1dYTjFNMUpOWVZNeGJEZHpNbWhsWW05b1EzaE5RVlZOUWtJMVFWUnVjQzlFVm5OV09GSk5PRkY2VEZsVlpHc3ZVV1J3VldWMFVrbzJZakpRZVhCM1MzRlpRbFJNVEVocGNXNUxiRVZ4UnpkcE1raG1ZMHBMYTI1SFNHcGxVVlkxVUhGdFZISllla05ZZEVSb01sQm9NV3QyVmxaeFUwcGhZbTkxZFRoWWREQkxiVk1yVW01NWVWSk9PVWMyYld4bWQyMVljVTVXUTJOSVRXZEJibEkyYkhkbkwyOVBRMDU1Y3pGblVGUkVUM012TURJd1VuQm9SRGd6WmxVNWR5OVRNMmcwTVc1eU1WbHlRakJTY2pkT1oxWm9UVk5GWlc4MlpqRXpXbXh4ZDJsTk5sVm1hVnBuZVROWmFtUjNQVDBpZlgxOUxDSnBjMEZwY21kaGNGTjFjSEJ2Y25SbFpDSTZkSEoxWlN3aWFYTk9aWGRMYjNSelZXbEZibUZpYkdWa0lqcDBjblZsTENKcGMxTjFjSEJ2Y25SQ2RXNWtiR1ZWY0d4dllXUlRkWEJ3YjNKMFpXUWlPblJ5ZFdVc0ltbHpSVzFpWldSa1pXUkRiSFZ6ZEdWeVJHOTNibXh2WVdSRmJtRmliR1ZrSWpwMGNuVmxMQ0pwYzBWdFltVmtaR1ZrUTJ4MWMzUmxjazExYkhScFRtOWtaVVZ1WVdKc1pXUWlPblJ5ZFdVc0ltbHpTMjkwYzBsdWMzUmhiR3hGYm1GaWJHVmtJanAwY25WbExDSnBjMU5sYlhabGNsSmxjWFZwY21Wa0lqcDBjblZsZlgwPSIsImlubmVyU2lnbmF0dXJlIjoiZXlKMk1reHBZMlZ1YzJWVGFXZHVZWFIxY21VaU9pSndWU3RSTVdoMVQxcFNRMnd2VjJGMGRqQmlaa3AyVmpSc2FEZDBUbTFaWTJkUlZGRTNRelpWYVVneWJ5dDVlRU5RVGxjMFMySlVTa1JMYXpjelltSjFTVkYyYUdnMU1UQldNV2xNVW5CRVQwNUVTMnRMYzFwc1F6Tk5iVGhWUkZkck1rbGxiR0pwT1RNdmJESnRSVWRJY2xvMUx5OHpURmh2ZHpocU9UWk5ZV2wxY0VNMmVFdGhXVWx4UzNoaVpVaHdXR3MwVkdKemVqRkZkVkpFU21OemJWTk1XSFJqU2tzNGRIWnhhblEwUVVSSVZ6WTRjMUlyWTBsT2NHWlNXVU5DUlRVMmJYbFhSbVJhYmlzMVFURm9ZVTFsTlU4d2VWZFFTbXR0YVVFd1RVaEtVQ3RaVjBseGJsTk5RM1JPZWtveUwwUjVRbUZHVVRoTU9XeEVXa1p3UVU1b2Ftc3daV1JCYlhsdlpXMVVPVEpsWm5CSlVXcDRMM3BvWjJwSVIxcGplVmhZV0drMlpuZFBSME5OZDFaSGVYWTJPRE00VG5wNU1tZHZNMXAwU2tGd1ptZGtPR3RKTDFCQ2NXMUVLM0pvUkhWWGRrRTlQU0lzSW5CMVlteHBZMHRsZVNJNklpMHRMUzB0UWtWSFNVNGdVRlZDVEVsRElFdEZXUzB0TFMwdFhHNU5TVWxDU1dwQlRrSm5hM0ZvYTJsSE9YY3dRa0ZSUlVaQlFVOURRVkU0UVUxSlNVSkRaMHREUVZGRlFYZG9kbVF3Y1RSeEswZERUMjlDYzI1R1drNXJYRzVOVm1neVJISTRjblY1VW5vMFRIRlpUV1J2VTI0ek9IRXJXVkZRY3pWdVZWaGpkVzFpYkhSb2Jqa3ZVWGxQTURoaFNrNVJVRTAxUVhob1lsaEZVemxyWEc1TU9XSlJPWGMyVmxselptSm9NVGhJWlhGMWRIZFFSeXRIZGpCUlQzRXJUMFZWZEdGWGJubFZiVFZ5UjI5ak1rWkJNVFZQVGtGTldUQkpjR1p5YTFGaFhHNXJWM2d2ZW5odlVYRXZVa2xFYzJaYWJWZ3hLMk5WY25sVk1WbFhkalkyYkdSU1RWY3dXRlZUVGk5aWFVWm1NbFZLTm0xSGRpczVhRGczVFV4c00yZGlYRzR5YTFkWWNtVXZZM2xPVFVneFREazFMMnQxVDNSV1pqRTVLemx5ZUZkRVlrMXphMUJZWWtWR2EwZHlNVkpIUjA1blZsRk1ibXA0YVd4Q1lrZEpSM3BLWEc1SFdrZFlNSHBtWlc1SlREbGtTRUkzY21oNFZGRXdaMlo0YUcxMVlWTjNUWE4wVG1wM1ZsRlRkMHRTU0haWE5saEpWWFZXVVhSNWVtMHZVVFY2T0VkT1hHNXBkMGxFUVZGQlFseHVMUzB0TFMxRlRrUWdVRlZDVEVsRElFdEZXUzB0TFMwdFhHNGlMQ0oyTWt0bGVWTnBaMjVoZEhWeVpTSTZJbVY1U25waFYyUjFXVmhTTVdOdFZXbFBhVXBFWTIwNWFFOVhSVFJOYldoT1MzcG9SVmt5V2paT01WcFNWV3BXVkZReU1VVmlWRTVZVFZacmVGWnVXbFJQUmtZeVZqRlNjMU15ZEc1alZ6UjJXbXR3VEdOdFdrcE9WVWswWW01a2FWSnJNV3BpUld3d1RsaHNhV0ZWU21GVVZGRjRaRVZqZUdSSVFqRmFWV2d5VFRGR01tUXdkRkpTTUdzMVpHdGFRbVJFVGpSV1JHUTFWRVJTV1dJeVdqQmlha2w1Wkhwb1NGZEhlSGRsUlRsS1YxUldUMlJIZDNKa1YxSnRXVlpLVWxadGEzbFhibGt5WWxoa2MyVllhR3RqVmtJMVZXazVjMUl4Vm1saFJHaFBWMGRqZVZWRmFGbFhhMmh6VVRCV1QwMUlhRWxrZWxaTFRqRkNZVkZZUmt4YVIwWnJXbFZrVWxreWRFOVZWbFpQVW14a2JWUkhTbGRqVmtsNlRtcGFlbFZGTlVWbFJFSkxZVmhXYm1KR2F6Tk1NMXB2WW0xc2FVMTZWalZUTVdoeVlVVldOVmxWY0hOYU1uZDVWakJHVmsxdE9XcFNWazVDWkZoT1JWVnVRa1ZTTW14cFdWZGtTVk5WTUhaU1NGazFWbXRPYWxKRGRFNWlTSEF4VFdzd2QxRjZiRmRpZW1RMFZGUm9VMUZYU2xwTlYzUjRaRE5LTUZSNlVURmtiV3hXVmtWYVZrMUZOVlphUm1ScldqSkdSMXBXYjNsVWJtUXhaR3hHTms1WGVGSmxXR3h3VVRCcmVGcHJkRFZpVjFwVFVsZDBiR013UlhaVVJVVTVVRk5KYzBsdFpITmlNa3BvWWtWMGJHVlZiR3RKYW05cFRWZFJlbHBxWkcxT2JVa3hUVVJqZUU1SFdteE9Na2swVDFSVk1VNVVVbXRhUkZreFRucGplbGxxUVdsbVVUMDlJbjA9In0= diff --git a/pkg-new/replicatedapi/client.go b/pkg-new/replicatedapi/client.go index ad4633fced..8df0785087 100644 --- a/pkg-new/replicatedapi/client.go +++ b/pkg-new/replicatedapi/client.go @@ -12,6 +12,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/versions" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" kyaml "sigs.k8s.io/yaml" ) @@ -20,7 +21,7 @@ var _ Client = (*client)(nil) var defaultHTTPClient = newRetryableHTTPClient() // ClientFactory is a function type for creating replicatedapi clients -type ClientFactory func(replicatedAppURL string, license *kotsv1beta1.License, releaseData *release.ReleaseData, opts ...ClientOption) (Client, error) +type ClientFactory func(replicatedAppURL string, license *licensewrapper.LicenseWrapper, releaseData *release.ReleaseData, opts ...ClientOption) (Client, error) var clientFactory ClientFactory = defaultNewClient @@ -30,13 +31,13 @@ func SetClientFactory(factory ClientFactory) { } type Client interface { - SyncLicense(ctx context.Context) (*kotsv1beta1.License, []byte, error) + SyncLicense(ctx context.Context) (*licensewrapper.LicenseWrapper, []byte, error) GetPendingReleases(ctx context.Context, channelID string, currentSequence int64, opts *PendingReleasesOptions) (*PendingReleasesResponse, error) } type client struct { replicatedAppURL string - license *kotsv1beta1.License + license *licensewrapper.LicenseWrapper releaseData *release.ReleaseData clusterID string httpClient *retryablehttp.Client @@ -56,13 +57,13 @@ func WithHTTPClient(httpClient *retryablehttp.Client) ClientOption { } } -// NewClient creates a new replicatedapi client using the configured factory -func NewClient(replicatedAppURL string, license *kotsv1beta1.License, releaseData *release.ReleaseData, opts ...ClientOption) (Client, error) { +func NewClient(replicatedAppURL string, license *licensewrapper.LicenseWrapper, releaseData *release.ReleaseData, opts ...ClientOption) (Client, error) { + // NewClient creates a new replicatedapi client using the configured factory return clientFactory(replicatedAppURL, license, releaseData, opts...) } // defaultNewClient is the default implementation of NewClient -func defaultNewClient(replicatedAppURL string, license *kotsv1beta1.License, releaseData *release.ReleaseData, opts ...ClientOption) (Client, error) { +func defaultNewClient(replicatedAppURL string, license *licensewrapper.LicenseWrapper, releaseData *release.ReleaseData, opts ...ClientOption) (Client, error) { c := &client{ replicatedAppURL: replicatedAppURL, license: license, @@ -79,11 +80,15 @@ func defaultNewClient(replicatedAppURL string, license *kotsv1beta1.License, rel } // SyncLicense fetches the latest license from the Replicated API -func (c *client) SyncLicense(ctx context.Context) (*kotsv1beta1.License, []byte, error) { - u := fmt.Sprintf("%s/license/%s", c.replicatedAppURL, c.license.Spec.AppSlug) +func (c *client) SyncLicense(ctx context.Context) (*licensewrapper.LicenseWrapper, []byte, error) { + if c.license.IsEmpty() { + return nil, nil, fmt.Errorf("no license configured") + } + + u := fmt.Sprintf("%s/license/%s", c.replicatedAppURL, c.license.GetAppSlug()) params := url.Values{} - params.Set("licenseSequence", fmt.Sprintf("%d", c.license.Spec.LicenseSequence)) + params.Set("licenseSequence", fmt.Sprintf("%d", c.license.GetLicenseSequence())) if c.releaseData != nil && c.releaseData.ChannelRelease != nil { params.Set("selectedChannelId", c.releaseData.ChannelRelease.ChannelID) } @@ -112,22 +117,23 @@ func (c *client) SyncLicense(ctx context.Context) (*kotsv1beta1.License, []byte, return nil, nil, fmt.Errorf("read response body: %w", err) } - var licenseResp kotsv1beta1.License - if err := kyaml.Unmarshal(body, &licenseResp); err != nil { - return nil, nil, fmt.Errorf("unmarshal license response: %w", err) + // Parse response into wrapper (handles both v1beta1 and v1beta2 responses) + licenseWrapper, err := licensewrapper.LoadLicenseFromBytes(body) + if err != nil { + return nil, nil, fmt.Errorf("parse license response: %w", err) } - if licenseResp.Spec.LicenseID == "" { + if licenseWrapper.GetLicenseID() == "" { return nil, nil, fmt.Errorf("license is empty") } - c.license = &licenseResp + c.license = &licenseWrapper if _, err := c.getChannelFromLicense(); err != nil { return nil, nil, fmt.Errorf("get channel from license: %w", err) } - return &licenseResp, body, nil + return &licenseWrapper, body, nil } // newRetryableRequest returns a retryablehttp.Request object with kots defaults set, including a User-Agent header. @@ -144,9 +150,15 @@ func (c *client) newRetryableRequest(ctx context.Context, method string, url str // injectHeaders injects the basic auth header, user agent header, and reporting info headers into the http.Header. func (c *client) injectHeaders(header http.Header) { - header.Set("Authorization", "Basic "+basicAuth(c.license.Spec.LicenseID, c.license.Spec.LicenseID)) + licenseID := c.license.GetLicenseID() + header.Set("Authorization", "Basic "+basicAuth(licenseID, licenseID)) header.Set("User-Agent", fmt.Sprintf("Embedded-Cluster/%s", versions.Version)) + // Add license version header for v1beta2 licenses + if c.license.IsV2() { + header.Set("X-Replicated-License-Version", "v1beta2") + } + c.injectReportingInfoHeaders(header) } @@ -154,20 +166,26 @@ func (c *client) getChannelFromLicense() (*kotsv1beta1.Channel, error) { if c.releaseData == nil || c.releaseData.ChannelRelease == nil || c.releaseData.ChannelRelease.ChannelID == "" { return nil, fmt.Errorf("channel release is empty") } - if c.license == nil || c.license.Spec.LicenseID == "" { + if c.license.IsEmpty() || c.license.GetLicenseID() == "" { return nil, fmt.Errorf("license is empty") } - for _, channel := range c.license.Spec.Channels { + + // Check multi-channel licenses first + channels := c.license.GetChannels() + for _, channel := range channels { if channel.ChannelID == c.releaseData.ChannelRelease.ChannelID { return &channel, nil } } - if c.license.Spec.ChannelID == c.releaseData.ChannelRelease.ChannelID { + + // Fallback to legacy single-channel license + if c.license.GetChannelID() == c.releaseData.ChannelRelease.ChannelID { return &kotsv1beta1.Channel{ - ChannelID: c.license.Spec.ChannelID, - ChannelName: c.license.Spec.ChannelName, + ChannelID: c.license.GetChannelID(), + ChannelName: c.license.GetChannelName(), }, nil } + return nil, fmt.Errorf("channel %s not found in license", c.releaseData.ChannelRelease.ChannelID) } @@ -177,8 +195,8 @@ func basicAuth(username, password string) string { } // GetPendingReleases fetches pending releases from the Replicated API -func (c *client) GetPendingReleases(ctx context.Context, channelID string, channelSequence int64, opts *PendingReleasesOptions) (*PendingReleasesResponse, error) { - u := fmt.Sprintf("%s/release/%s/pending", c.replicatedAppURL, c.license.Spec.AppSlug) +func (c *client) GetPendingReleases(ctx context.Context, channelID string, currentSequence int64, opts *PendingReleasesOptions) (*PendingReleasesResponse, error) { + u := fmt.Sprintf("%s/release/%s/pending", c.replicatedAppURL, c.license.GetAppSlug()) params := url.Values{} params.Set("selectedChannelId", channelID) diff --git a/pkg-new/replicatedapi/client_test.go b/pkg-new/replicatedapi/client_test.go index ce252ae08e..3402331a19 100644 --- a/pkg-new/replicatedapi/client_test.go +++ b/pkg-new/replicatedapi/client_test.go @@ -9,20 +9,28 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/versions" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + kotsv1beta2 "github.com/replicatedhq/kotskinds/apis/kots/v1beta2" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.yaml.in/yaml/v3" + "gopkg.in/yaml.v3" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kyaml "sigs.k8s.io/yaml" ) func TestSyncLicense(t *testing.T) { tests := []struct { - name string - license kotsv1beta1.License - releaseData *release.ReleaseData - serverHandler func(t *testing.T) http.HandlerFunc - expectedLicense *kotsv1beta1.License - wantErr string + name string + license kotsv1beta1.License + licenseV2 *kotsv1beta2.License + releaseData *release.ReleaseData + serverHandler func(t *testing.T) http.HandlerFunc + wantLicenseSequence int64 + wantAppSlug string + wantLicenseID string + wantIsV1 bool + wantIsV2 bool + wantErr string }{ { name: "successful license sync", @@ -60,6 +68,9 @@ func TestSyncLicense(t *testing.T) { assert.NotEmpty(t, authHeader) assert.Contains(t, authHeader, "Basic ") + // Validate license version header is NOT present for v1beta1 + assert.Empty(t, r.Header.Get("X-Replicated-License-Version")) + // Return response as YAML resp := kotsv1beta1.License{ TypeMeta: metav1.TypeMeta{ @@ -83,23 +94,141 @@ func TestSyncLicense(t *testing.T) { } w.WriteHeader(http.StatusOK) - yaml.NewEncoder(w).Encode(resp) + respBytes, err := kyaml.Marshal(resp) + if err != nil { + t.Fatalf("failed to marshal license: %v", err) + } + w.Write(respBytes) } }, - expectedLicense: &kotsv1beta1.License{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "kots.io/v1beta1", - Kind: "License", - }, + wantLicenseSequence: 6, + wantAppSlug: "test-app", + wantLicenseID: "test-license-id", + wantIsV1: true, + }, + { + name: "successful license sync with v1beta2 response (from v1beta1)", + license: kotsv1beta1.License{ Spec: kotsv1beta1.LicenseSpec{ AppSlug: "test-app", LicenseID: "test-license-id", - LicenseSequence: 6, - CustomerName: "Test Customer", + LicenseSequence: 5, ChannelID: "test-channel-123", ChannelName: "Stable", + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "test-channel-123", + ChannelName: "Stable", + }, + }, + }, + }, + releaseData: &release.ReleaseData{ + ChannelRelease: &release.ChannelRelease{ + ChannelID: "test-channel-123", + }, + }, + serverHandler: func(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Validate request + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/license/test-app", r.URL.Path) + assert.Equal(t, "5", r.URL.Query().Get("licenseSequence")) + assert.Equal(t, "test-channel-123", r.URL.Query().Get("selectedChannelId")) + assert.Equal(t, "application/yaml", r.Header.Get("Accept")) + + // Validate auth header + authHeader := r.Header.Get("Authorization") + assert.NotEmpty(t, authHeader) + assert.Contains(t, authHeader, "Basic ") + + // Validate license version header is NOT present for v1beta1 (request uses v1beta1 license) + assert.Empty(t, r.Header.Get("X-Replicated-License-Version")) + + // Return v1beta2 license response + resp := `apiVersion: kots.io/v1beta2 +kind: License +spec: + licenseID: test-license-id-v2 + appSlug: test-app + licenseSequence: 6 + customerName: Test Customer + channelID: test-channel-123 + channelName: Stable + channels: + - channelID: test-channel-123 + channelName: Stable` + + w.WriteHeader(http.StatusOK) + w.Write([]byte(resp)) + } + }, + wantLicenseSequence: 6, + wantAppSlug: "test-app", + wantLicenseID: "test-license-id-v2", + wantIsV2: true, + }, + { + name: "successful license sync with v1beta2 request", + licenseV2: &kotsv1beta2.License{ + Spec: kotsv1beta2.LicenseSpec{ + AppSlug: "test-app-v2", + LicenseID: "test-license-id-v2", + LicenseSequence: 7, + ChannelID: "test-channel-456", + ChannelName: "Beta", + Channels: []kotsv1beta2.Channel{ + { + ChannelID: "test-channel-456", + ChannelName: "Beta", + }, + }, }, }, + releaseData: &release.ReleaseData{ + ChannelRelease: &release.ChannelRelease{ + ChannelID: "test-channel-456", + }, + }, + serverHandler: func(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Validate request + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/license/test-app-v2", r.URL.Path) + assert.Equal(t, "7", r.URL.Query().Get("licenseSequence")) + assert.Equal(t, "test-channel-456", r.URL.Query().Get("selectedChannelId")) + assert.Equal(t, "application/yaml", r.Header.Get("Accept")) + + // Validate auth header + authHeader := r.Header.Get("Authorization") + assert.NotEmpty(t, authHeader) + assert.Contains(t, authHeader, "Basic ") + + // Validate license version header IS present for v1beta2 + assert.Equal(t, "v1beta2", r.Header.Get("X-Replicated-License-Version")) + + // Return v1beta2 license response + resp := `apiVersion: kots.io/v1beta2 +kind: License +spec: + licenseID: test-license-id-v2 + appSlug: test-app-v2 + licenseSequence: 8 + customerName: Test Customer V2 + channelID: test-channel-456 + channelName: Beta + channels: + - channelID: test-channel-456 + channelName: Beta` + + w.WriteHeader(http.StatusOK) + w.Write([]byte(resp)) + } + }, + wantLicenseSequence: 8, + wantAppSlug: "test-app-v2", + wantLicenseID: "test-license-id-v2", + wantIsV2: true, }, { name: "returns error on 401 unauthorized", @@ -215,7 +344,7 @@ func TestSyncLicense(t *testing.T) { w.Write([]byte("invalid yaml")) } }, - wantErr: "unmarshal license response", + wantErr: "parse license response", }, } @@ -227,8 +356,16 @@ func TestSyncLicense(t *testing.T) { server := httptest.NewServer(tt.serverHandler(t)) defer server.Close() - // Create client - c, err := NewClient(server.URL, &tt.license, tt.releaseData) + // Wrap the license (v1beta1 or v1beta2) + var wrapper *licensewrapper.LicenseWrapper + if tt.licenseV2 != nil { + wrapper = &licensewrapper.LicenseWrapper{V2: tt.licenseV2} + } else { + wrapper = &licensewrapper.LicenseWrapper{V1: &tt.license} + } + + // Create client with wrapper + c, err := NewClient(server.URL, wrapper, tt.releaseData) req.NoError(err) // Execute test @@ -242,15 +379,26 @@ func TestSyncLicense(t *testing.T) { req.Nil(rawLicense) } else { req.NoError(err) - req.NotNil(license) req.NotNil(rawLicense) - assert.Equal(t, tt.expectedLicense.Spec.AppSlug, license.Spec.AppSlug) - assert.Equal(t, tt.expectedLicense.Spec.LicenseID, license.Spec.LicenseID) - assert.Equal(t, tt.expectedLicense.Spec.LicenseSequence, license.Spec.LicenseSequence) + + // Assert using wrapper methods (works for both v1beta1 and v1beta2) + assert.Equal(t, tt.wantLicenseSequence, license.GetLicenseSequence()) + assert.Equal(t, tt.wantAppSlug, license.GetAppSlug()) + assert.Equal(t, tt.wantLicenseID, license.GetLicenseID()) + + // Assert version + if tt.wantIsV1 { + assert.True(t, license.IsV1()) + assert.False(t, license.IsV2()) + } + if tt.wantIsV2 { + assert.False(t, license.IsV1()) + assert.True(t, license.IsV2()) + } // Validate raw license is valid YAML var parsedLicense kotsv1beta1.License - err = yaml.Unmarshal(rawLicense, &parsedLicense) + err = kyaml.Unmarshal(rawLicense, &parsedLicense) req.NoError(err, "rawLicense should be valid YAML") } }) @@ -259,14 +407,32 @@ func TestSyncLicense(t *testing.T) { func TestGetReportingInfoHeaders(t *testing.T) { tests := []struct { - name string - clusterID string - expectedCount int - checkHeaders map[string]string + name string + clusterID string + licenseWrapper *licensewrapper.LicenseWrapper + expectedCount int + checkHeaders map[string]string }{ { - name: "with cluster ID", - clusterID: "cluster-123", + name: "with cluster ID and v1beta1 license", + clusterID: "cluster-123", + licenseWrapper: &licensewrapper.LicenseWrapper{ + V1: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "test-app", + LicenseID: "test-license-id", + LicenseSequence: 1, + ChannelID: "test-channel-123", + ChannelName: "Stable", + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "test-channel-123", + ChannelName: "Stable", + }, + }, + }, + }, + }, expectedCount: 7, // EmbeddedClusterID, ChannelID, ChannelName, K8sVersion, K8sDistribution, EmbeddedClusterVersion, IsKurl checkHeaders: map[string]string{ "X-Replicated-EmbeddedClusterID": "cluster-123", @@ -279,8 +445,56 @@ func TestGetReportingInfoHeaders(t *testing.T) { }, }, { - name: "zero values should be skipped", - clusterID: "", + name: "with cluster ID and v1beta2 license", + clusterID: "cluster-456", + licenseWrapper: &licensewrapper.LicenseWrapper{ + V2: &kotsv1beta2.License{ + Spec: kotsv1beta2.LicenseSpec{ + AppSlug: "test-app-v2", + LicenseID: "test-license-id-v2", + LicenseSequence: 2, + ChannelID: "test-channel-456", + ChannelName: "Beta", + Channels: []kotsv1beta2.Channel{ + { + ChannelID: "test-channel-456", + ChannelName: "Beta", + }, + }, + }, + }, + }, + expectedCount: 7, // EmbeddedClusterID, ChannelID, ChannelName, K8sVersion, K8sDistribution, EmbeddedClusterVersion, IsKurl + checkHeaders: map[string]string{ + "X-Replicated-EmbeddedClusterID": "cluster-456", + "X-Replicated-DownstreamChannelID": "test-channel-456", + "X-Replicated-DownstreamChannelName": "Beta", + "X-Replicated-K8sVersion": versions.K0sVersion, + "X-Replicated-K8sDistribution": DistributionEmbeddedCluster, + "X-Replicated-EmbeddedClusterVersion": versions.Version, + "X-Replicated-IsKurl": "false", + }, + }, + { + name: "zero values should be skipped", + clusterID: "", + licenseWrapper: &licensewrapper.LicenseWrapper{ + V1: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "test-app", + LicenseID: "test-license-id", + LicenseSequence: 1, + ChannelID: "test-channel-123", + ChannelName: "Stable", + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "test-channel-123", + ChannelName: "Stable", + }, + }, + }, + }, + }, expectedCount: 6, // ChannelID, ChannelName, K8sVersion, K8sDistribution, EmbeddedClusterVersion, IsKurl checkHeaders: map[string]string{ "X-Replicated-IsKurl": "false", @@ -292,30 +506,19 @@ func TestGetReportingInfoHeaders(t *testing.T) { t.Run(tt.name, func(t *testing.T) { req := require.New(t) - license := kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ - AppSlug: "test-app", - LicenseID: "test-license-id", - LicenseSequence: 1, - ChannelID: "test-channel-123", - ChannelName: "Stable", - Channels: []kotsv1beta1.Channel{ - { - ChannelID: "test-channel-123", - ChannelName: "Stable", - }, - }, - }, + channelID := "test-channel-123" + if tt.licenseWrapper != nil && tt.licenseWrapper.GetChannelID() != "" { + channelID = tt.licenseWrapper.GetChannelID() } releaseData := &release.ReleaseData{ ChannelRelease: &release.ChannelRelease{ - ChannelID: "test-channel-123", + ChannelID: channelID, }, } c := &client{ - license: &license, + license: tt.licenseWrapper, releaseData: releaseData, clusterID: tt.clusterID, } @@ -360,7 +563,7 @@ func TestInjectHeaders(t *testing.T) { } c := &client{ - license: &license, + license: &licensewrapper.LicenseWrapper{V1: &license}, releaseData: releaseData, clusterID: "test-cluster-id", } @@ -388,6 +591,9 @@ func TestInjectHeaders(t *testing.T) { req.Equal(DistributionEmbeddedCluster, header.Get("X-Replicated-K8sDistribution")) req.Equal(versions.Version, header.Get("X-Replicated-EmbeddedClusterVersion")) req.Equal("false", header.Get("X-Replicated-IsKurl")) + + // Validate license version header is NOT present for v1beta1 + req.Empty(header.Get("X-Replicated-License-Version")) } func TestGetPendingReleases(t *testing.T) { @@ -636,16 +842,18 @@ func TestGetPendingReleases(t *testing.T) { t.Run(tt.name, func(t *testing.T) { req := require.New(t) - license := kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ - AppSlug: "test-app", - LicenseID: "test-license-id", - LicenseSequence: 1, - ChannelID: "test-channel-123", - Channels: []kotsv1beta1.Channel{ - { - ChannelID: "test-channel-123", - ChannelName: "Stable", + license := &licensewrapper.LicenseWrapper{ + V1: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "test-app", + LicenseID: "test-license-id", + LicenseSequence: 1, + ChannelID: "test-channel-123", + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "test-channel-123", + ChannelName: "Stable", + }, }, }, }, @@ -662,7 +870,7 @@ func TestGetPendingReleases(t *testing.T) { defer server.Close() // Create client - c, err := NewClient(server.URL, &license, releaseData) + c, err := NewClient(server.URL, license, releaseData) req.NoError(err) // Execute test @@ -700,16 +908,18 @@ func TestGetPendingReleases_ContextCancellation(t *testing.T) { })) defer server.Close() - license := kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ - AppSlug: "test-app", - LicenseID: "test-license-id", - LicenseSequence: 1, - ChannelID: "test-channel-123", - Channels: []kotsv1beta1.Channel{ - { - ChannelID: "test-channel-123", - ChannelName: "Stable", + license := &licensewrapper.LicenseWrapper{ + V1: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "test-app", + LicenseID: "test-license-id", + LicenseSequence: 1, + ChannelID: "test-channel-123", + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "test-channel-123", + ChannelName: "Stable", + }, }, }, }, @@ -722,7 +932,7 @@ func TestGetPendingReleases_ContextCancellation(t *testing.T) { } // Create client - c, err := NewClient(server.URL, &license, releaseData) + c, err := NewClient(server.URL, license, releaseData) req.NoError(err) // Create a context that is already cancelled diff --git a/pkg-new/validation/upgradable.go b/pkg-new/validation/upgradable.go index 94fe6d5acb..c9acc16c04 100644 --- a/pkg-new/validation/upgradable.go +++ b/pkg-new/validation/upgradable.go @@ -9,7 +9,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/replicatedhq/embedded-cluster/pkg-new/replicatedapi" "github.com/replicatedhq/embedded-cluster/pkg/airgap" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" ) // k8sBuildRegex holds the regex pattern we use for the build portion of our EC version - i.e. 2.11.3+k8s-1.33 @@ -24,7 +24,7 @@ type UpgradableOptions struct { TargetAppVersion string TargetAppSequence int64 TargetECVersion string - License *kotsv1beta1.License + License *licensewrapper.LicenseWrapper currentReleaseIsRequired bool requiredReleases []string } @@ -66,12 +66,13 @@ func (opts *UpgradableOptions) WithOnlineRequiredReleases(ctx context.Context, r } // We want to get current app sequence inclusive. In oder for us to do that in a way that works for both channel sequences and semver we need to set the CurrentChannelSequence to current app sequence and the channel sequence provided to the method to current app sequence - 1 options := &replicatedapi.PendingReleasesOptions{ - IsSemverSupported: opts.License.Spec.IsSemverRequired, + IsSemverSupported: opts.License.IsSemverRequired(), SortOrder: replicatedapi.SortOrderAscending, CurrentChannelSequence: opts.CurrentAppSequence, } // Get pending releases from the current app sequence in asceding order pendingReleases, err := replAPIClient.GetPendingReleases(ctx, opts.License.Spec.ChannelID, opts.CurrentAppSequence-1, options) + if err != nil { return fmt.Errorf("failed to get pending releases while checking required releases for upgrade: %w", err) } @@ -143,7 +144,7 @@ func validateRequiredReleases(opts UpgradableOptions) error { // validateAppVersionDowngrade checks if the target app version is older than the current version func validateAppVersionDowngrade(opts UpgradableOptions) error { // If using semver than compare using it - if opts.License.Spec.IsSemverRequired { + if opts.License.IsSemverRequired() { currentVer, err := semver.NewVersion(opts.CurrentAppVersion) if err != nil { return fmt.Errorf("failed to parse current app version %s: %w", opts.CurrentAppVersion, err) diff --git a/pkg-new/validation/upgradable_test.go b/pkg-new/validation/upgradable_test.go index 70cd4d34f0..c4eb5a1f15 100644 --- a/pkg-new/validation/upgradable_test.go +++ b/pkg-new/validation/upgradable_test.go @@ -7,6 +7,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg-new/replicatedapi" "github.com/replicatedhq/embedded-cluster/pkg/airgap" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -14,19 +15,21 @@ import ( // Test helpers -func newTestLicense(isSemverRequired bool) *kotsv1beta1.License { - return &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ - AppSlug: "test-app", - LicenseID: "test-license-id", - IsSemverRequired: isSemverRequired, - ChannelID: "test-channel-123", - ChannelName: "Stable", - LicenseSequence: 1, - Channels: []kotsv1beta1.Channel{ - { - ChannelID: "test-channel-123", - ChannelName: "Stable", +func newTestLicense(isSemverRequired bool) *licensewrapper.LicenseWrapper { + return &licensewrapper.LicenseWrapper{ + V1: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "test-app", + LicenseID: "test-license-id", + IsSemverRequired: isSemverRequired, + ChannelID: "test-channel-123", + ChannelName: "Stable", + LicenseSequence: 1, + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "test-channel-123", + ChannelName: "Stable", + }, }, }, }, diff --git a/pkg/addons/install.go b/pkg/addons/install.go index a9776b07d5..0980688ba6 100644 --- a/pkg/addons/install.go +++ b/pkg/addons/install.go @@ -12,13 +12,13 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/addons/registry" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/addons/velero" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" ) type InstallOptions struct { AdminConsolePwd string AdminConsolePort int - License *kotsv1beta1.License + License *licensewrapper.LicenseWrapper IsAirgap bool TLSCertBytes []byte TLSKeyBytes []byte @@ -43,7 +43,7 @@ type InstallOptions struct { type KubernetesInstallOptions struct { AdminConsolePwd string AdminConsolePort int - License *kotsv1beta1.License + License *licensewrapper.LicenseWrapper IsAirgap bool TLSCertBytes []byte TLSKeyBytes []byte diff --git a/pkg/dryrun/dryrun.go b/pkg/dryrun/dryrun.go index ed043e78d0..0c27d8b9a9 100644 --- a/pkg/dryrun/dryrun.go +++ b/pkg/dryrun/dryrun.go @@ -22,7 +22,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/netutils" "github.com/replicatedhq/embedded-cluster/pkg/release" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" "github.com/sirupsen/logrus" "github.com/spf13/pflag" @@ -84,7 +84,7 @@ func Init(outputFile string, client *Client) { client.Kotsadm = NewKotsadm() } if client.ReplicatedAPIClient != nil { - replicatedapi.SetClientFactory(func(replicatedAppURL string, license *kotsv1beta1.License, releaseData *release.ReleaseData, opts ...replicatedapi.ClientOption) (replicatedapi.Client, error) { + replicatedapi.SetClientFactory(func(replicatedAppURL string, license *licensewrapper.LicenseWrapper, releaseData *release.ReleaseData, opts ...replicatedapi.ClientOption) (replicatedapi.Client, error) { return client.ReplicatedAPIClient, nil }) } diff --git a/pkg/dryrun/replicatedapi.go b/pkg/dryrun/replicatedapi.go index b8b6295632..8d89dd0260 100644 --- a/pkg/dryrun/replicatedapi.go +++ b/pkg/dryrun/replicatedapi.go @@ -5,25 +5,24 @@ import ( "fmt" "github.com/replicatedhq/embedded-cluster/pkg-new/replicatedapi" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" - "sigs.k8s.io/yaml" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" ) var _ replicatedapi.Client = (*ReplicatedAPIClient)(nil) // ReplicatedAPIClient is a mockable implementation of the replicatedapi.Client interface. type ReplicatedAPIClient struct { - License *kotsv1beta1.License + License *licensewrapper.LicenseWrapper LicenseBytes []byte PendingReleases []replicatedapi.ChannelRelease } // SyncLicense returns the mocked license data. -func (c *ReplicatedAPIClient) SyncLicense(ctx context.Context) (*kotsv1beta1.License, []byte, error) { +func (c *ReplicatedAPIClient) SyncLicense(ctx context.Context) (*licensewrapper.LicenseWrapper, []byte, error) { // If License is not set but LicenseBytes is, parse the license from bytes if c.License == nil && len(c.LicenseBytes) > 0 { - var license kotsv1beta1.License - if err := yaml.Unmarshal(c.LicenseBytes, &license); err != nil { + license, err := licensewrapper.LoadLicenseFromBytes(c.LicenseBytes) + if err != nil { return nil, nil, fmt.Errorf("failed to parse license from bytes: %w", err) } c.License = &license diff --git a/pkg/helpers/parse.go b/pkg/helpers/parse.go index a846c2fa18..596f38f548 100644 --- a/pkg/helpers/parse.go +++ b/pkg/helpers/parse.go @@ -6,6 +6,7 @@ import ( embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" kyaml "sigs.k8s.io/yaml" ) @@ -33,25 +34,24 @@ func ParseEndUserConfig(fpath string) (*embeddedclusterv1beta1.Config, error) { return &cfg, nil } -// ParseLicense parses the license from the given file. -func ParseLicense(fpath string) (*kotsv1beta1.License, error) { +// ParseLicense parses the license from the given file and returns a LicenseWrapper +// that provides version-agnostic access to both v1beta1 and v1beta2 licenses. +func ParseLicense(fpath string) (*licensewrapper.LicenseWrapper, error) { data, err := os.ReadFile(fpath) if err != nil { - return nil, fmt.Errorf("failed to read license file: %w", err) + return nil, fmt.Errorf("unable to read license file: %w", err) } return ParseLicenseFromBytes(data) } -// ParseLicenseFromBytes parses the license from a byte slice -func ParseLicenseFromBytes(data []byte) (*kotsv1beta1.License, error) { - var license kotsv1beta1.License - if err := kyaml.Unmarshal(data, &license); err != nil { - return nil, ErrNotALicenseFile{Err: err} - } - if license.Spec.LicenseID == "" { - return nil, ErrNotALicenseFile{Err: fmt.Errorf("license id is empty")} +// ParseLicenseFromBytes parses license data from bytes and returns a LicenseWrapper +// that provides version-agnostic access to both v1beta1 and v1beta2 licenses. +func ParseLicenseFromBytes(data []byte) (*licensewrapper.LicenseWrapper, error) { + wrapper, err := licensewrapper.LoadLicenseFromBytes(data) + if err != nil { + return nil, ErrNotALicenseFile{Err: fmt.Errorf("failed to load license: %w", err)} } - return &license, nil + return &wrapper, nil } func ParseConfigValues(fpath string) (*kotsv1beta1.ConfigValues, error) { diff --git a/pkg/helpers/parse_test.go b/pkg/helpers/parse_test.go index 18a123ffe2..df48533a21 100644 --- a/pkg/helpers/parse_test.go +++ b/pkg/helpers/parse_test.go @@ -1,17 +1,21 @@ package helpers import ( - "errors" + "embed" "os" "path/filepath" "testing" embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +//go:embed testdata/* +var testdata embed.FS + func TestParseEndUserConfig(t *testing.T) { tests := []struct { name string @@ -113,108 +117,160 @@ kind: Config`, func TestParseLicense(t *testing.T) { tests := []struct { - name string - fpath string - fileContent string - expected *kotsv1beta1.License - wantErr error + name string + licenseFile string + wantErr bool + wantIsV1 bool + wantIsV2 bool + wantAppSlug string + wantLicenseID string + wantECEnabled bool + wantCustomer string }{ { - name: "file does not exist", - fpath: "nonexistent.yaml", - wantErr: os.ErrNotExist, + name: "v1beta1 license", + licenseFile: "testdata/license-v1beta1.yaml", + wantErr: false, + wantIsV1: true, + wantIsV2: false, + wantAppSlug: "embedded-cluster-test", + wantLicenseID: "test-license-id-v1", + wantECEnabled: true, + wantCustomer: "Test Customer V1", }, { - name: "invalid YAML returns ErrNotALicenseFile", - fpath: "invalid.yaml", - fileContent: `invalid: yaml: content: [ - unclosed bracket`, - wantErr: ErrNotALicenseFile{}, + name: "v1beta2 license", + licenseFile: "testdata/license-v1beta2.yaml", + wantErr: false, + wantIsV1: false, + wantIsV2: true, + wantAppSlug: "embedded-cluster-test", + wantLicenseID: "test-license-id-v2", + wantECEnabled: true, + wantCustomer: "Test Customer V2", }, { - name: "valid YAML but not a license returns ErrNotALicenseFile", - fpath: "not-license.yaml", - fileContent: `apiVersion: v1 -kind: ConfigMap -metadata: - name: test`, - wantErr: ErrNotALicenseFile{}, + name: "invalid version (v1beta3)", + licenseFile: "testdata/license-invalid-version.yaml", + wantErr: true, }, { - name: "valid license", - fpath: "license.yaml", - fileContent: `apiVersion: kots.io/v1beta1 -kind: License -metadata: - name: test-license -spec: - licenseID: "test-license-id" - appSlug: "test-app" - endpoint: "https://replicated.app"`, - expected: &kotsv1beta1.License{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "kots.io/v1beta1", - Kind: "License", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-license", - }, - Spec: kotsv1beta1.LicenseSpec{ - LicenseID: "test-license-id", - AppSlug: "test-app", - Endpoint: "https://replicated.app", - }, - }, - }, - { - name: "minimal valid license", - fpath: "minimal-license.yaml", - fileContent: `apiVersion: kots.io/v1beta1 -kind: License -spec: - licenseID: "test-license-id"`, - expected: &kotsv1beta1.License{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "kots.io/v1beta1", - Kind: "License", - }, - Spec: kotsv1beta1.LicenseSpec{ - LicenseID: "test-license-id", - }, - }, + name: "file not found", + licenseFile: "testdata/nonexistent.yaml", + wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - req := require.New(t) - var testFile string - if tt.fileContent != "" { - // Create temporary file + if tt.licenseFile != "testdata/nonexistent.yaml" { + // Read from embedded filesystem and write to temp file + data, err := testdata.ReadFile(tt.licenseFile) + require.NoError(t, err) tmpDir := t.TempDir() - testFile = filepath.Join(tmpDir, tt.fpath) - err := os.WriteFile(testFile, []byte(tt.fileContent), 0644) - req.NoError(err) + testFile = filepath.Join(tmpDir, filepath.Base(tt.licenseFile)) + err = os.WriteFile(testFile, data, 0644) + require.NoError(t, err) } else { - // Use the fpath as-is for non-existent file tests - testFile = tt.fpath + // Use non-existent path for the error case + testFile = tt.licenseFile } - result, err := ParseLicense(testFile) + wrapper, err := ParseLicense(testFile) - if tt.wantErr != nil { - req.Error(err) - if errors.Is(tt.wantErr, ErrNotALicenseFile{}) { - req.ErrorAs(err, &tt.wantErr) - } else { - req.ErrorIs(err, tt.wantErr) - } - req.Nil(result) - } else { - req.NoError(err) - req.Equal(tt.expected, result) + if tt.wantErr { + require.Error(t, err) + require.Nil(t, wrapper) + return + } + + require.NoError(t, err) + require.NotNil(t, wrapper) + assert.Equal(t, tt.wantIsV1, wrapper.IsV1()) + assert.Equal(t, tt.wantIsV2, wrapper.IsV2()) + assert.Equal(t, tt.wantAppSlug, wrapper.GetAppSlug()) + assert.Equal(t, tt.wantLicenseID, wrapper.GetLicenseID()) + assert.Equal(t, tt.wantECEnabled, wrapper.IsEmbeddedClusterDownloadEnabled()) + assert.Equal(t, tt.wantCustomer, wrapper.GetCustomerName()) + }) + } +} + +func TestParseLicenseFromBytes(t *testing.T) { + tests := []struct { + name string + setupData func(t *testing.T) []byte + wantErr bool + wantIsV1 bool + wantIsV2 bool + wantAppSlug string + wantLicenseID string + wantECEnabled bool + }{ + { + name: "v1beta1 license", + setupData: func(t *testing.T) []byte { + data, err := testdata.ReadFile("testdata/license-v1beta1.yaml") + require.NoError(t, err) + return data + }, + wantErr: false, + wantIsV1: true, + wantIsV2: false, + wantAppSlug: "embedded-cluster-test", + wantLicenseID: "test-license-id-v1", + wantECEnabled: true, + }, + { + name: "v1beta2 license", + setupData: func(t *testing.T) []byte { + data, err := testdata.ReadFile("testdata/license-v1beta2.yaml") + require.NoError(t, err) + return data + }, + wantErr: false, + wantIsV1: false, + wantIsV2: true, + wantAppSlug: "embedded-cluster-test", + wantLicenseID: "test-license-id-v2", + wantECEnabled: true, + }, + { + name: "invalid version (v1beta3)", + setupData: func(t *testing.T) []byte { + return []byte(`apiVersion: kots.io/v1beta3 +kind: License`) + }, + wantErr: true, + }, + { + name: "invalid YAML", + setupData: func(t *testing.T) []byte { + return []byte(`this is not valid yaml: [[[`) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := tt.setupData(t) + wrapper, err := ParseLicenseFromBytes(data) + + if tt.wantErr { + require.Error(t, err) + require.Nil(t, wrapper) + return } + + require.NoError(t, err) + require.NotNil(t, wrapper) + assert.Equal(t, tt.wantIsV1, wrapper.IsV1()) + assert.Equal(t, tt.wantIsV2, wrapper.IsV2()) + assert.Equal(t, tt.wantAppSlug, wrapper.GetAppSlug()) + assert.Equal(t, tt.wantLicenseID, wrapper.GetLicenseID()) + assert.Equal(t, tt.wantECEnabled, wrapper.IsEmbeddedClusterDownloadEnabled()) }) } } diff --git a/pkg/helpers/testdata/license-invalid-version.yaml b/pkg/helpers/testdata/license-invalid-version.yaml new file mode 100644 index 0000000000..4721c535aa --- /dev/null +++ b/pkg/helpers/testdata/license-invalid-version.yaml @@ -0,0 +1,13 @@ +apiVersion: kots.io/v1beta3 +kind: License +metadata: + name: test-license +spec: + appSlug: embedded-cluster-test + licenseID: test-license-id + licenseType: dev + customerName: Test Customer + customerEmail: test@example.com + endpoint: https://replicated.app + licenseSequence: 1 + isEmbeddedClusterDownloadEnabled: true diff --git a/pkg/helpers/testdata/license-v1beta1.yaml b/pkg/helpers/testdata/license-v1beta1.yaml new file mode 100644 index 0000000000..626cc45daa --- /dev/null +++ b/pkg/helpers/testdata/license-v1beta1.yaml @@ -0,0 +1,34 @@ +apiVersion: kots.io/v1beta1 +kind: License +metadata: + name: test-license +spec: + appSlug: embedded-cluster-test + licenseID: test-license-id-v1 + licenseType: dev + customerName: Test Customer V1 + customerEmail: test@example.com + endpoint: https://replicated.app + channelID: test-channel-id + channelName: Stable + licenseSequence: 1 + isAirgapSupported: true + isGitOpsSupported: false + isIdentityServiceSupported: false + isGeoaxisSupported: false + isSnapshotSupported: true + isSupportBundleUploadSupported: true + isSemverRequired: true + isDisasterRecoverySupported: true + isEmbeddedClusterDownloadEnabled: true + isEmbeddedClusterMultiNodeEnabled: true + replicatedProxyDomain: proxy.replicated.com + entitlements: + expires_at: + title: Expiration + description: License Expiration + value: "" + valueType: String + signature: {} + channels: [] + signature: dGVzdC1saWNlbnNlLXNpZ25hdHVyZQ== diff --git a/pkg/helpers/testdata/license-v1beta2-missing-appslug.yaml b/pkg/helpers/testdata/license-v1beta2-missing-appslug.yaml new file mode 100644 index 0000000000..2aa3419563 --- /dev/null +++ b/pkg/helpers/testdata/license-v1beta2-missing-appslug.yaml @@ -0,0 +1,13 @@ +apiVersion: kots.io/v1beta2 +kind: License +metadata: + name: test-license +spec: + # appSlug is intentionally missing for testing validation + licenseID: test-license-id-v2 + licenseType: dev + customerName: Test Customer V2 + customerEmail: test@example.com + endpoint: https://replicated.app + licenseSequence: 1 + isEmbeddedClusterDownloadEnabled: true diff --git a/pkg/helpers/testdata/license-v1beta2-no-ec-enabled.yaml b/pkg/helpers/testdata/license-v1beta2-no-ec-enabled.yaml new file mode 100644 index 0000000000..7fadd08795 --- /dev/null +++ b/pkg/helpers/testdata/license-v1beta2-no-ec-enabled.yaml @@ -0,0 +1,14 @@ +apiVersion: kots.io/v1beta2 +kind: License +metadata: + name: test-license +spec: + appSlug: embedded-cluster-test + licenseID: test-license-id-v2 + licenseType: dev + customerName: Test Customer V2 + customerEmail: test@example.com + endpoint: https://replicated.app + licenseSequence: 1 + # isEmbeddedClusterDownloadEnabled is intentionally false for testing validation + isEmbeddedClusterDownloadEnabled: false diff --git a/pkg/helpers/testdata/license-v1beta2.yaml b/pkg/helpers/testdata/license-v1beta2.yaml new file mode 100644 index 0000000000..6ea0b13e64 --- /dev/null +++ b/pkg/helpers/testdata/license-v1beta2.yaml @@ -0,0 +1,34 @@ +apiVersion: kots.io/v1beta2 +kind: License +metadata: + name: test-license +spec: + appSlug: embedded-cluster-test + licenseID: test-license-id-v2 + licenseType: dev + customerName: Test Customer V2 + customerEmail: test@example.com + endpoint: https://replicated.app + channelID: test-channel-id + channelName: Stable + licenseSequence: 1 + isAirgapSupported: true + isGitOpsSupported: false + isIdentityServiceSupported: false + isGeoaxisSupported: false + isSnapshotSupported: true + isSupportBundleUploadSupported: true + isSemverRequired: true + isDisasterRecoverySupported: true + isEmbeddedClusterDownloadEnabled: true + isEmbeddedClusterMultiNodeEnabled: true + replicatedProxyDomain: proxy.replicated.com + entitlements: + expires_at: + title: Expiration + description: License Expiration + value: "" + valueType: String + signature: {} + channels: [] + signature: dGVzdC1saWNlbnNlLXNpZ25hdHVyZQ== diff --git a/pkg/kubeutils/installation.go b/pkg/kubeutils/installation.go index 9498bff5c1..760dbfa45e 100644 --- a/pkg/kubeutils/installation.go +++ b/pkg/kubeutils/installation.go @@ -15,7 +15,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/crds" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/pkg/spinner" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -123,7 +123,7 @@ func writeInstallationStatusMessage(writer *spinner.MessageWriter, install *ecv1 type RecordInstallationOptions struct { ClusterID string IsAirgap bool - License *kotsv1beta1.License + License *licensewrapper.LicenseWrapper ConfigSpec *ecv1beta1.ConfigSpec MetricsBaseURL string RuntimeConfig *ecv1beta1.RuntimeConfigSpec @@ -133,6 +133,11 @@ type RecordInstallationOptions struct { } func RecordInstallation(ctx context.Context, kcli client.Client, opts RecordInstallationOptions) (*ecv1beta1.Installation, error) { + // Verify license is available before recording installation + if opts.License == nil { + return nil, fmt.Errorf("license is required for recording installation") + } + // ensure that the embedded-cluster namespace exists ns := corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ @@ -172,8 +177,8 @@ func RecordInstallation(ctx context.Context, kcli client.Client, opts RecordInst EndUserK0sConfigOverrides: euOverrides, BinaryName: runtimeconfig.AppSlug(), LicenseInfo: &ecv1beta1.LicenseInfo{ - IsDisasterRecoverySupported: opts.License.Spec.IsDisasterRecoverySupported, - IsMultiNodeEnabled: opts.License.Spec.IsEmbeddedClusterMultiNodeEnabled, + IsDisasterRecoverySupported: opts.License.IsDisasterRecoverySupported(), + IsMultiNodeEnabled: opts.License.IsEmbeddedClusterMultiNodeEnabled(), }, }, } diff --git a/pkg/kubeutils/installation_test.go b/pkg/kubeutils/installation_test.go index 0cabcfc0dc..d4f4a5d556 100644 --- a/pkg/kubeutils/installation_test.go +++ b/pkg/kubeutils/installation_test.go @@ -14,6 +14,7 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/crds" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -208,10 +209,12 @@ func TestRecordInstallation(t *testing.T) { opts: RecordInstallationOptions{ ClusterID: uuid.New().String(), IsAirgap: false, - License: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{ - IsDisasterRecoverySupported: true, - IsEmbeddedClusterMultiNodeEnabled: false, + License: &licensewrapper.LicenseWrapper{ + V1: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + IsDisasterRecoverySupported: true, + IsEmbeddedClusterMultiNodeEnabled: false, + }, }, }, ConfigSpec: &ecv1beta1.ConfigSpec{ @@ -248,12 +251,12 @@ func TestRecordInstallation(t *testing.T) { opts: RecordInstallationOptions{ ClusterID: uuid.New().String(), IsAirgap: true, - License: &kotsv1beta1.License{ + License: &licensewrapper.LicenseWrapper{V1: &kotsv1beta1.License{ Spec: kotsv1beta1.LicenseSpec{ IsDisasterRecoverySupported: false, IsEmbeddedClusterMultiNodeEnabled: true, }, - }, + }}, ConfigSpec: &ecv1beta1.ConfigSpec{ Version: "1.16.0+k8s-1.31", }, @@ -283,12 +286,12 @@ func TestRecordInstallation(t *testing.T) { opts: RecordInstallationOptions{ ClusterID: uuid.New().String(), IsAirgap: true, - License: &kotsv1beta1.License{ + License: &licensewrapper.LicenseWrapper{V1: &kotsv1beta1.License{ Spec: kotsv1beta1.LicenseSpec{ IsDisasterRecoverySupported: false, IsEmbeddedClusterMultiNodeEnabled: false, }, - }, + }}, ConfigSpec: &ecv1beta1.ConfigSpec{ Version: "1.18.0+k8s-1.33", }, diff --git a/pkg/metrics/reporter.go b/pkg/metrics/reporter.go index 27a4a84101..665faba636 100644 --- a/pkg/metrics/reporter.go +++ b/pkg/metrics/reporter.go @@ -12,7 +12,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/metrics/types" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/versions" - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/pkg/licensewrapper" "github.com/sirupsen/logrus" nodeutil "k8s.io/component-helpers/node/util" ) @@ -32,17 +32,24 @@ func (e ErrorNoFail) Error() string { return e.Err.Error() } -// LicenseID returns the license id. If the license is nil, it returns an empty string. -func LicenseID(license *kotsv1beta1.License) string { - if license != nil { - return license.Spec.LicenseID +// LicenseID returns the license id from a LicenseWrapper. +func LicenseID(license *licensewrapper.LicenseWrapper) string { + if license.IsEmpty() { + return "" } - return "" + return license.GetLicenseID() } -// License returns the parsed license. If something goes wrong, it returns nil. -func License(licenseFlag string) *kotsv1beta1.License { - license, _ := helpers.ParseLicense(licenseFlag) +// License returns the parsed license as a LicenseWrapper. If something goes wrong, it returns nil. +func License(licenseFlag string) *licensewrapper.LicenseWrapper { + if licenseFlag == "" { + return nil + } + license, err := helpers.ParseLicense(licenseFlag) + if err != nil { + logrus.WithError(err).Warn("failed to parse license") + return nil + } return license } diff --git a/tests/dryrun/install_prompts_test.go b/tests/dryrun/install_prompts_test.go index 68a8835cbb..d70676972f 100644 --- a/tests/dryrun/install_prompts_test.go +++ b/tests/dryrun/install_prompts_test.go @@ -11,6 +11,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/prompts" "github.com/replicatedhq/embedded-cluster/pkg/release" + kotscrypto "github.com/replicatedhq/kotskinds/pkg/crypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -187,6 +188,16 @@ func dryrunInstallWithCustomReleaseData(t *testing.T, c *dryrun.Client, clusterC // dryrunInstallWithCustomReleaseDataExpectError is a helper function that expects an error during installation func dryrunInstallWithCustomReleaseDataExpectError(t *testing.T, c *dryrun.Client, clusterConfig string, releaseData string, additionalFlags ...string) error { + // Inject the dryrun test public key for license signature verification. + // The kotskinds library uses a global custom key if set, otherwise looks up by key ID. + // Our test license uses a test-only key that's not in kotskinds' default key map. + if err := kotscrypto.SetCustomPublicKeyRSA(dryrunPublicKey); err != nil { + t.Fatalf("failed to set custom public key: %v", err) + } + t.Cleanup(func() { + kotscrypto.ResetCustomPublicKeyRSA() + }) + // Set custom release data if err := release.SetReleaseDataForTests(map[string][]byte{ "release.yaml": []byte(releaseData), diff --git a/tests/dryrun/util.go b/tests/dryrun/util.go index aef6d85f82..75143d82a2 100644 --- a/tests/dryrun/util.go +++ b/tests/dryrun/util.go @@ -26,6 +26,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + kotscrypto "github.com/replicatedhq/kotskinds/pkg/crypto" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" "github.com/stretchr/testify/assert" @@ -80,6 +81,18 @@ var ( testKeyPEM []byte ) +// dryrunPublicKey is the public key used for test license signature verification. +// This must match the key ID 6f21b4d9865f45b8a15bd884fb4028d2 in the test license. +const dryrunPublicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwWEoVA/AQhzgG81k4V+C +7c7xoNKSnP8XKSkuYiCbsYyicsWxMtwExkueVKXvEa/DQm7NCDBOdFQFhFQKzKvn +Jh2rXnPZn3OyNQ9Ru+4XBi4kOa1V9g5VFSgwbBttuVtWtPZC2B4vdCVXyX4TzLYe +c0rGbq+obBb4RNKBBGTdoWy+IHlObc5QOpEzubUmJ1VqmCTUyduKeOn24b+TvcmJ +i5PY1r8iKGhJJOAPt4KjBlIj67uqcGq3N9RA8pHQjn0ZXsfiLOmCeR6kFHbnNr4n +L7HvoEDR12K2Ci4+n7A/EAowHI/ZywcM7wADcWx4tOERPz0Pm2SUvVCjPVPc0xdN +KwIDAQAB +-----END PUBLIC KEY-----` + func dryrunJoin(t *testing.T, args ...string) dryruntypes.DryRun { if err := embedReleaseData(clusterConfigData); err != nil { t.Fatalf("fail to embed release data: %v", err) @@ -106,6 +119,16 @@ func dryrunInstall(t *testing.T, c *dryrun.Client, args ...string) dryruntypes.D } func dryrunInstallWithClusterConfig(t *testing.T, c *dryrun.Client, clusterConfig string, args ...string) dryruntypes.DryRun { + // Inject the dryrun test public key for license signature verification. + // The kotskinds library uses a global custom key if set, otherwise looks up by key ID. + // Our test license uses a test-only key that's not in kotskinds' default key map. + if err := kotscrypto.SetCustomPublicKeyRSA(dryrunPublicKey); err != nil { + t.Fatalf("failed to set custom public key: %v", err) + } + t.Cleanup(func() { + kotscrypto.ResetCustomPublicKeyRSA() + }) + if err := embedReleaseData(clusterConfig); err != nil { t.Fatalf("fail to embed release data: %v", err) } diff --git a/tests/dryrun/v3_install_test.go b/tests/dryrun/v3_install_test.go index e99b8f83e8..ad8c551af9 100644 --- a/tests/dryrun/v3_install_test.go +++ b/tests/dryrun/v3_install_test.go @@ -19,6 +19,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + kotscrypto "github.com/replicatedhq/kotskinds/pkg/crypto" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" "github.com/sirupsen/logrus" @@ -1758,6 +1759,16 @@ func setupV3Test(t *testing.T, opts setupV3TestOpts) (string, string) { // Set ENABLE_V3 environment variable t.Setenv("ENABLE_V3", "1") + // Inject the dryrun test public key for license signature verification. + // The kotskinds library uses a global custom key if set, otherwise looks up by key ID. + // Our test license uses a test-only key that's not in kotskinds' default key map. + if err := kotscrypto.SetCustomPublicKeyRSA(dryrunPublicKey); err != nil { + t.Fatalf("failed to set custom public key: %v", err) + } + t.Cleanup(func() { + kotscrypto.ResetCustomPublicKeyRSA() + }) + // Ensure UI assets are available when starting API in non-headless tests prepareWebAssetsForTests(t) diff --git a/tests/dryrun/v3_upgrade_test.go b/tests/dryrun/v3_upgrade_test.go index 1c1402e749..85bc23cfb9 100644 --- a/tests/dryrun/v3_upgrade_test.go +++ b/tests/dryrun/v3_upgrade_test.go @@ -24,6 +24,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/pkg/versions" + kotscrypto "github.com/replicatedhq/kotskinds/pkg/crypto" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -309,6 +310,16 @@ func setupV3UpgradeTest(t *testing.T, hcli helm.Client, setupArgs *v3UpgradeSetu // Set ENABLE_V3 environment variable t.Setenv("ENABLE_V3", "1") + // Inject the dryrun test public key for license signature verification. + // The kotskinds library uses a global custom key if set, otherwise looks up by key ID. + // Our test license uses a test-only key that's not in kotskinds' default key map. + if err := kotscrypto.SetCustomPublicKeyRSA(dryrunPublicKey); err != nil { + t.Fatalf("failed to set custom public key: %v", err) + } + t.Cleanup(func() { + kotscrypto.ResetCustomPublicKeyRSA() + }) + // Ensure UI assets are available when starting API in non-headless tests prepareWebAssetsForTests(t)