From 733dc4e7b231c9a4cc0b147e464afb92e3dd007a Mon Sep 17 00:00:00 2001 From: Varsha Prasad Narsing Date: Mon, 11 Mar 2024 18:38:53 -0400 Subject: [PATCH] [Add] Unit tests for Extension reconciler Signed-off-by: Varsha Prasad Narsing --- cmd/manager/main.go | 35 - internal/controllers/extension_controller.go | 15 - .../controllers/extension_controller_test.go | 1058 ++++++++++++++++- internal/controllers/suite_test.go | 6 +- testdata/crds/kappctrl.k14s.io_app.yaml | 728 ++++++++++++ 5 files changed, 1771 insertions(+), 71 deletions(-) create mode 100644 testdata/crds/kappctrl.k14s.io_app.yaml diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 9aab1dd413..5e887ed208 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -18,7 +18,6 @@ package main import ( "flag" - "fmt" "net/http" "os" "time" @@ -28,13 +27,10 @@ import ( rukpakv1alpha2 "github.com/operator-framework/rukpak/api/v1alpha2" "github.com/spf13/pflag" "go.uber.org/zap/zapcore" - "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" - "k8s.io/client-go/discovery" clientgoscheme "k8s.io/client-go/kubernetes/scheme" _ "k8s.io/client-go/plugin/pkg/client/auth" - "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" @@ -131,16 +127,9 @@ func main() { os.Exit(1) } - hasKappApis, err := hasKappApis(mgr.GetConfig()) - if err != nil { - setupLog.Error(err, "unable to evaluate if App needs to be created") - os.Exit(1) - } - if err = (&controllers.ExtensionReconciler{ Client: cl, BundleProvider: catalogClient, - HasKappApis: hasKappApis, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Extension") os.Exit(1) @@ -162,27 +151,3 @@ func main() { os.Exit(1) } } - -// hasKappApis checks whether the cluster has Kapp APIs installed in the cluster. -// This does not guarantee that the controller is present to reconcile the App CRs. -func hasKappApis(config *rest.Config) (bool, error) { - discoveryClient, err := discovery.NewDiscoveryClientForConfig(config) - if err != nil { - return false, fmt.Errorf("creating discovery client: %v", err) - } - apiResourceList, err := discoveryClient.ServerResourcesForGroupVersion(carvelv1alpha1.SchemeGroupVersion.String()) - if err != nil && !errors.IsNotFound(err) { - return false, fmt.Errorf("listing resource APIs: %v", err) - } - - if apiResourceList == nil { - return false, nil - } - - for _, resource := range apiResourceList.APIResources { - if resource.Kind == "App" { - return true, nil - } - } - return false, nil -} diff --git a/internal/controllers/extension_controller.go b/internal/controllers/extension_controller.go index 04310164c1..a1de77339b 100644 --- a/internal/controllers/extension_controller.go +++ b/internal/controllers/extension_controller.go @@ -18,7 +18,6 @@ package controllers import ( "context" - "errors" "fmt" "sort" "strings" @@ -55,11 +54,8 @@ import ( type ExtensionReconciler struct { client.Client BundleProvider BundleProvider - HasKappApis bool } -var errkappAPIUnavailable = errors.New("kapp-controller apis unavailable on cluster") - //+kubebuilder:rbac:groups=olm.operatorframework.io,resources=extensions,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=olm.operatorframework.io,resources=extensions/status,verbs=update;patch //+kubebuilder:rbac:groups=olm.operatorframework.io,resources=extensions/finalizers,verbs=update @@ -145,17 +141,6 @@ func (r *ExtensionReconciler) reconcile(ctx context.Context, ext *ocv1alpha1.Ext return ctrl.Result{}, nil } - if !r.HasKappApis { - ext.Status.InstalledBundleResource = "" - setInstalledStatusConditionFailed(&ext.Status.Conditions, errkappAPIUnavailable.Error(), ext.GetGeneration()) - - ext.Status.ResolvedBundleResource = "" - setResolvedStatusConditionUnknown(&ext.Status.Conditions, "kapp apis are unavailable", ext.GetGeneration()) - - setDeprecationStatusesUnknown(&ext.Status.Conditions, "kapp apis are unavailable", ext.GetGeneration()) - return ctrl.Result{}, errkappAPIUnavailable - } - // TODO: Improve the resolution logic. bundle, err := r.resolve(ctx, *ext) if err != nil { diff --git a/internal/controllers/extension_controller_test.go b/internal/controllers/extension_controller_test.go index 0d59995a94..520f87c04e 100644 --- a/internal/controllers/extension_controller_test.go +++ b/internal/controllers/extension_controller_test.go @@ -2,23 +2,35 @@ package controllers_test import ( "context" + "encoding/json" "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + kappctrlv1alpha1 "github.com/vmware-tanzu/carvel-kapp-controller/pkg/apis/kappctrl/v1alpha1" + corev1 "k8s.io/api/core/v1" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/rand" featuregatetesting "k8s.io/component-base/featuregate/testing" + "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/alpha/property" + ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/internal/catalogmetadata" "github.com/operator-framework/operator-controller/internal/conditionsets" "github.com/operator-framework/operator-controller/pkg/features" ) +const ( + testServiceAccount = "test-sa" +) + // Describe: Extension Controller Test func TestExtensionDoesNotExist(t *testing.T) { _, reconciler := newClientAndExtensionReconciler(t) @@ -30,8 +42,7 @@ func TestExtensionDoesNotExist(t *testing.T) { require.NoError(t, err) } -func TestExtensionReconcile(t *testing.T) { - t.Skip("Skipping this till kapp-controller test setup is implemented.") +func TestExtensionReconcileFeatureGateDisabled(t *testing.T) { c, reconciler := newClientAndExtensionReconciler(t) ctx := context.Background() @@ -52,23 +63,6 @@ func TestExtensionReconcile(t *testing.T) { assert.Equal(t, "extension feature is disabled", cond.Message) } }}, - {"feature gate enabled and paused", true, true, func(t *testing.T, res ctrl.Result, err error, ext *ocv1alpha1.Extension) { - assert.Equal(t, ctrl.Result{}, res) - assert.NoError(t, err) - assert.Equal(t, ocv1alpha1.ExtensionStatus{Paused: true}, ext.Status) - }}, - {"feature gate enabled and active", true, false, func(t *testing.T, res ctrl.Result, err error, ext *ocv1alpha1.Extension) { - assert.Equal(t, ctrl.Result{}, res) - assert.NoError(t, err) - verifyExtensionInvariants(t, ext) - assert.False(t, ext.Status.Paused) - assert.Empty(t, ext.Status.InstalledBundleResource) - assert.Empty(t, ext.Status.ResolvedBundleResource) - for _, cond := range ext.Status.Conditions { - assert.Equal(t, metav1.ConditionUnknown, cond.Status) - assert.Equal(t, "the Extension interface is not fully implemented", cond.Message) - } - }}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -77,7 +71,7 @@ func TestExtensionReconcile(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, Spec: ocv1alpha1.ExtensionSpec{ Paused: tc.paused, - ServiceAccountName: "test-service-account", + ServiceAccountName: testServiceAccount, Source: ocv1alpha1.ExtensionSource{SourceType: ocv1alpha1.SourceTypePackage, Package: &ocv1alpha1.ExtensionSourcePackage{Name: "test-package"}}, }, } @@ -92,6 +86,931 @@ func TestExtensionReconcile(t *testing.T) { } } +func TestExtensionNonExistentPackage(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.EnableExtensionAPI, true)() + + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{Name: fmt.Sprintf("extension-test-%s", rand.String(8)), Namespace: fmt.Sprintf("test-namespace-%s", rand.String(8))} + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: extKey.Namespace}} + t.Log("Create namespace for extension") + require.NoError(t, cl.Create(ctx, ns)) + defer func() { + t.Logf("Cleaning up namespace %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, ns)) + }() + + t.Log("When the cluster extension specifies a non-existent package") + t.Log("By initializing cluster state") + pkgName := fmt.Sprintf("non-existent-%s", rand.String(6)) + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: "test-sa", + Source: ocv1alpha1.ExtensionSource{ + SourceType: ocv1alpha1.SourceTypePackage, + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + }, + }}, + } + require.NoError(t, cl.Create(ctx, extension)) + defer func() { + t.Logf("Cleaning up extensions %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, extension)) + }() + + t.Log("It sets resolution failure status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.EqualError(t, err, fmt.Sprintf("no package %q found", pkgName)) + + t.Log("By fetching updated cluster extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Empty(t, extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + require.Equal(t, fmt.Sprintf("no package %q found", pkgName), cond.Message) + + verifyExtensionInvariants(t, extension) +} + +func TestExtensionNonExistentVersion(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.EnableExtensionAPI, true)() + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{Name: fmt.Sprintf("extension-test-%s", rand.String(8)), Namespace: fmt.Sprintf("test-namespace-%s", rand.String(8))} + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: extKey.Namespace}} + t.Log("Create namespace for extension") + require.NoError(t, cl.Create(ctx, ns)) + defer func() { + t.Logf("Cleaning up namespace %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, ns)) + }() + + t.Log("When the extension specifies a version that does not exist") + t.Log("By initializing cluster state") + pkgName := "prometheus" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: testServiceAccount, + Source: ocv1alpha1.ExtensionSource{ + SourceType: ocv1alpha1.SourceTypePackage, + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Version: "0.50.0", + }, + }}, + } + require.NoError(t, cl.Create(ctx, extension)) + defer func() { + t.Logf("Cleaning up extensions %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, extension)) + }() + + t.Log("It sets resolution failure status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.EqualError(t, err, fmt.Sprintf(`no package %q matching version "0.50.0" found`, pkgName)) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Empty(t, extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + require.Equal(t, fmt.Sprintf(`no package %q matching version "0.50.0" found`, pkgName), cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "installation has not been attempted as resolution failed", cond.Message) + + verifyExtensionConditionsInvariants(t, extension) +} + +func TestExtensionAppDoesNotExist(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.EnableExtensionAPI, true)() + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{Name: fmt.Sprintf("extension-test-%s", rand.String(8)), Namespace: fmt.Sprintf("test-namespace-%s", rand.String(8))} + const pkgName = "prometheus" + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: extKey.Namespace}} + t.Log("Create namespace for extension") + require.NoError(t, cl.Create(ctx, ns)) + defer func() { + t.Logf("Cleaning up namespace %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, ns)) + }() + + t.Log("When the extension specifies a valid available package") + t.Log("By initializing cluster state") + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: testServiceAccount, + Source: ocv1alpha1.ExtensionSource{ + SourceType: ocv1alpha1.SourceTypePackage, + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + }, + }}, + } + require.NoError(t, cl.Create(ctx, extension)) + defer func() { + t.Logf("Cleaning up extensions %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, extension)) + }() + + t.Log("When the App does not exist") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching updated cluster extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("It results in the expected App") + app := &kappctrlv1alpha1.App{} + require.NoError(t, cl.Get(ctx, extKey, app)) + require.NotEmpty(t, app.Spec.Fetch) + require.Len(t, app.Spec.Fetch, 1) + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", app.Spec.Fetch[0].Image.URL) + + t.Log("It sets the resolvedBundleResource status field") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", extension.Status.ResolvedBundleResource) + + t.Log("It sets the InstalledBundleResource status field") + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("It sets the status on the cluster extension") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "install status unknown", cond.Message) + + verifyExtensionConditionsInvariants(t, extension) +} + +func TestExtensionAppOutOfDate(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.EnableExtensionAPI, true)() + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{Name: fmt.Sprintf("extension-test-%s", rand.String(8)), Namespace: fmt.Sprintf("test-namespace-%s", rand.String(8))} + const pkgName = "prometheus" + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: extKey.Namespace}} + t.Log("Create namespace for extension") + require.NoError(t, cl.Create(ctx, ns)) + defer func() { + t.Logf("Cleaning up namespace %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, ns)) + }() + + t.Log("When the extension specifies a valid available package") + t.Log("By initializing cluster state") + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: testServiceAccount, + Source: ocv1alpha1.ExtensionSource{ + SourceType: ocv1alpha1.SourceTypePackage, + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + }, + }}, + } + require.NoError(t, cl.Create(ctx, extension)) + defer func() { + t.Logf("Cleaning up extensions %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, extension)) + }() + + t.Log("When the expected App already exists") + t.Log("When the App spec is out of date") + t.Log("By patching the existing App") + + app := &kappctrlv1alpha1.App{ + ObjectMeta: metav1.ObjectMeta{ + Name: extension.Name, + Namespace: extension.Namespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "kappctrl.k14s.io/v1alpha1", + Kind: "App", + Name: extension.Name, + UID: extension.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + }, + }, + }, + Spec: kappctrlv1alpha1.AppSpec{ + ServiceAccountName: testServiceAccount, + Fetch: []kappctrlv1alpha1.AppFetch{ + { + Image: &kappctrlv1alpha1.AppFetchImage{ + URL: "quay.io/operatorhubio/prometheus@fake2.0.0", + }, + }, + }, + Template: []kappctrlv1alpha1.AppTemplate{}, + Deploy: []kappctrlv1alpha1.AppDeploy{ + { + Kapp: &kappctrlv1alpha1.AppDeployKapp{}, + }, + }, + }, + } + + t.Log("By modifying the App spec and creating the object") + app.Spec.Fetch = []kappctrlv1alpha1.AppFetch{ + { + Image: &kappctrlv1alpha1.AppFetchImage{ + URL: "quay.io/operatorhubio/prometheussomething@fake2.0.1", + }, + }, + } + require.NoError(t, cl.Create(ctx, app)) + + t.Log("It results in the expected App") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the expected App spec") + app = &kappctrlv1alpha1.App{} + require.NoError(t, cl.Get(ctx, extKey, app)) + require.NotEmpty(t, app.Spec.Fetch) + require.Len(t, app.Spec.Fetch, 1) + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", app.Spec.Fetch[0].Image.URL) + + t.Log("By checking the status fields") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected status conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "install status unknown", cond.Message) + + verifyExtensionConditionsInvariants(t, extension) +} + +func TestExtensionAppUpToDate(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.EnableExtensionAPI, true)() + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{Name: fmt.Sprintf("extension-test-%s", rand.String(8)), Namespace: fmt.Sprintf("test-namespace-%s", rand.String(8))} + const pkgName = "prometheus" + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: extKey.Namespace}} + t.Log("Create namespace for extension") + require.NoError(t, cl.Create(ctx, ns)) + defer func() { + t.Logf("Cleaning up namespace %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, ns)) + }() + + t.Log("When the extension specifies a valid available package") + t.Log("By initializing cluster state") + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: testServiceAccount, + Source: ocv1alpha1.ExtensionSource{ + SourceType: ocv1alpha1.SourceTypePackage, + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + }, + }}, + } + require.NoError(t, cl.Create(ctx, extension)) + defer func() { + t.Logf("Cleaning up extensions %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, extension)) + }() + + t.Log("When the app already exists") + t.Log("When the App spec is up-to-date") + t.Log("By patching the existing App") + + app := &kappctrlv1alpha1.App{ + ObjectMeta: metav1.ObjectMeta{ + Name: extension.Name, + Namespace: extension.Namespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "kappctrl.k14s.io/v1alpha1", + Kind: "App", + Name: extension.Name, + UID: extension.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + }, + }, + }, + Spec: kappctrlv1alpha1.AppSpec{ + ServiceAccountName: testServiceAccount, + Fetch: []kappctrlv1alpha1.AppFetch{ + { + Image: &kappctrlv1alpha1.AppFetchImage{ + URL: "quay.io/operatorhubio/prometheus@fake2.0.0", + }, + }, + }, + Template: []kappctrlv1alpha1.AppTemplate{}, + Deploy: []kappctrlv1alpha1.AppDeploy{ + { + Kapp: &kappctrlv1alpha1.AppDeployKapp{}, + }, + }, + }, + } + + require.NoError(t, cl.Create(ctx, app)) + app.Status.ObservedGeneration = app.GetGeneration() + + t.Log("When the App status is mapped to the expected Extension status") + t.Log("It verifies extension status when app is waiting to be created") + t.Log("By updating the status of app") + require.NoError(t, cl.Status().Update(ctx, app)) + + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching the updated cluster extension after reconcile") + ext := &ocv1alpha1.Extension{} + require.NoError(t, cl.Get(ctx, extKey, ext)) + + t.Log("By checking the status fields") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", ext.Status.ResolvedBundleResource) + require.Empty(t, ext.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(ext.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + cond = apimeta.FindStatusCondition(ext.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "install status unknown", cond.Message) + + // TODO: Add tests to verify the mapping of status between App and Extension. + // Patch App's statuses and verify if the right one appears on the Extension. +} + +func TestExtensionExpectedApp(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.EnableExtensionAPI, true)() + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{Name: fmt.Sprintf("extension-test-%s", rand.String(8)), Namespace: fmt.Sprintf("test-namespace-%s", rand.String(8))} + const pkgName = "prometheus" + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: extKey.Namespace}} + t.Log("Create namespace for extension") + require.NoError(t, cl.Create(ctx, ns)) + defer func() { + t.Logf("Cleaning up namespace %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, ns)) + }() + + t.Log("When the extension specifies a valid available package") + t.Log("By initializing cluster state") + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: testServiceAccount, + Source: ocv1alpha1.ExtensionSource{ + SourceType: ocv1alpha1.SourceTypePackage, + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + }, + }}, + } + require.NoError(t, cl.Create(ctx, extension)) + defer func() { + t.Logf("Cleaning up extensions %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, extension)) + }() + + t.Log("When an out-of-date App exists") + t.Log("By creating the expected App") + app := &kappctrlv1alpha1.App{ + ObjectMeta: metav1.ObjectMeta{ + Name: extension.Name, + Namespace: extension.Namespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "kappctrl.k14s.io/v1alpha1", + Kind: "App", + Name: extension.Name, + UID: extension.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + }, + }, + }, + Spec: kappctrlv1alpha1.AppSpec{ + ServiceAccountName: testServiceAccount, + Fetch: []kappctrlv1alpha1.AppFetch{ + { + Image: &kappctrlv1alpha1.AppFetchImage{ + URL: "quay.io/operatorhubio/prometheus@fake2.0.0", + }, + }, + }, + Template: []kappctrlv1alpha1.AppTemplate{}, + Deploy: []kappctrlv1alpha1.AppDeploy{ + { + Kapp: &kappctrlv1alpha1.AppDeployKapp{}, + }, + }, + }, + } + require.NoError(t, cl.Create(ctx, app)) + + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("It results in the expected App") + app = &kappctrlv1alpha1.App{} + require.NoError(t, cl.Get(ctx, extKey, app)) + require.NotEmpty(t, app.Spec.Fetch) + require.Len(t, app.Spec.Fetch, 1) + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", app.Spec.Fetch[0].Image.URL) + + t.Log("It sets the resolvedBundleResource status field") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", extension.Status.ResolvedBundleResource) + + t.Log("It sets the InstalledBundleResource status field") + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("It sets resolution to unknown status") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "install status unknown", cond.Message) + + verifyExtensionConditionsInvariants(t, extension) +} + +func TestExtensionDuplicatePackage(t *testing.T) { + t.Skip("Include this test after resolution logic is modified to not contain duplicate packages.") + + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.EnableExtensionAPI, true)() + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{Name: fmt.Sprintf("extension-test-%s", rand.String(8)), Namespace: fmt.Sprintf("test-namespace-%s", rand.String(8))} + const pkgName = "prometheus" + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: extKey.Namespace}} + t.Log("Create namespace for extension") + require.NoError(t, cl.Create(ctx, ns)) + defer func() { + t.Logf("Cleaning up namespace %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, ns)) + }() + + t.Log("When the extension specifies a duplicate package") + t.Log("By initializing cluster state") + dupExtension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("orig-%s", extKey.Name), Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: testServiceAccount, + Source: ocv1alpha1.ExtensionSource{ + SourceType: ocv1alpha1.SourceTypePackage, + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + }, + }}, + } + require.NoError(t, cl.Create(ctx, dupExtension)) + defer func() { + t.Logf("Cleaning up extensions %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, dupExtension)) + }() + + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: testServiceAccount, + Source: ocv1alpha1.ExtensionSource{ + SourceType: ocv1alpha1.SourceTypePackage, + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + }, + }}, + } + require.NoError(t, cl.Create(ctx, extension)) + defer func() { + t.Logf("Cleaning up extensions %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, extension)) + }() + + t.Log("It sets resolution failure status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.EqualError(t, err, `duplicate identifier "required package prometheus" in input`) +} + +func TestExtensionChannelVersionExists(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.EnableExtensionAPI, true)() + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{Name: fmt.Sprintf("extension-test-%s", rand.String(8)), Namespace: fmt.Sprintf("test-namespace-%s", rand.String(8))} + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: extKey.Namespace}} + t.Log("Create namespace for extension") + require.NoError(t, cl.Create(ctx, ns)) + defer func() { + t.Logf("Cleaning up namespace %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, ns)) + }() + + t.Log("When the extension specifies a channel with version that exist") + t.Log("By initializing cluster state") + pkgName := "prometheus" + pkgVer := "1.0.0" + pkgChan := "beta" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: testServiceAccount, + Source: ocv1alpha1.ExtensionSource{ + SourceType: ocv1alpha1.SourceTypePackage, + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Version: pkgVer, + Channel: pkgChan, + }, + }}, + } + require.NoError(t, cl.Create(ctx, extension)) + defer func() { + t.Logf("Cleaning up extensions %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, extension)) + }() + + t.Log("It sets resolution success status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + + t.Log("By fetching updated extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake1.0.0", extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake1.0.0\"", cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "install status unknown", cond.Message) + + t.Log("By fetching the App") + app := &kappctrlv1alpha1.App{} + require.NoError(t, cl.Get(ctx, extKey, app)) + require.NotEmpty(t, app.Spec.Fetch) + require.Len(t, app.Spec.Fetch, 1) + require.Equal(t, "quay.io/operatorhubio/prometheus@fake1.0.0", app.Spec.Fetch[0].Image.URL) + + verifyExtensionConditionsInvariants(t, extension) +} + +func TestExtensionChannelExistsNoVersion(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.EnableExtensionAPI, true)() + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{Name: fmt.Sprintf("extension-test-%s", rand.String(8)), Namespace: fmt.Sprintf("test-namespace-%s", rand.String(8))} + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: extKey.Namespace}} + t.Log("Create namespace for extension") + require.NoError(t, cl.Create(ctx, ns)) + defer func() { + t.Logf("Cleaning up namespace %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, ns)) + }() + + t.Log("When the extension specifies a package that exists within a channel but no version specified") + t.Log("By initializing cluster state") + pkgName := "prometheus" + pkgVer := "" + pkgChan := "beta" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: testServiceAccount, + Source: ocv1alpha1.ExtensionSource{ + SourceType: ocv1alpha1.SourceTypePackage, + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Version: pkgVer, + Channel: pkgChan, + }, + }}, + } + require.NoError(t, cl.Create(ctx, extension)) + defer func() { + t.Logf("Cleaning up extensions %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, extension)) + }() + + t.Log("It sets resolution success status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.NoError(t, err) + t.Log("By fetching updated cluster extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, cond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake2.0.0\"", cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "install status unknown", cond.Message) + + t.Log("By fetching the App") + app := &kappctrlv1alpha1.App{} + require.NoError(t, cl.Get(ctx, extKey, app)) + require.NotEmpty(t, app.Spec.Fetch) + require.Len(t, app.Spec.Fetch, 1) + require.Equal(t, "quay.io/operatorhubio/prometheus@fake2.0.0", app.Spec.Fetch[0].Image.URL) + + verifyExtensionConditionsInvariants(t, extension) +} + +func TestExtensionVersionNoChannel(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.EnableExtensionAPI, true)() + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{Name: fmt.Sprintf("extension-test-%s", rand.String(8)), Namespace: fmt.Sprintf("test-namespace-%s", rand.String(8))} + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: extKey.Namespace}} + t.Log("Create namespace for extension") + require.NoError(t, cl.Create(ctx, ns)) + defer func() { + t.Logf("Cleaning up namespace %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, ns)) + }() + + t.Log("When the extension specifies a package version in a channel that does not exist") + t.Log("By initializing cluster state") + pkgName := "prometheus" + pkgVer := "0.47.0" + pkgChan := "alpha" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: testServiceAccount, + Source: ocv1alpha1.ExtensionSource{ + SourceType: ocv1alpha1.SourceTypePackage, + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Version: pkgVer, + Channel: pkgChan, + }, + }}, + } + require.NoError(t, cl.Create(ctx, extension)) + defer func() { + t.Logf("Cleaning up extensions %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, extension)) + }() + + t.Log("It sets resolution failure status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.EqualError(t, err, fmt.Sprintf("no package %q matching version %q found in channel %q", pkgName, pkgVer, pkgChan)) + + t.Log("By fetching updated cluster extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Empty(t, extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + require.Equal(t, fmt.Sprintf("no package %q matching version %q found in channel %q", pkgName, pkgVer, pkgChan), cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "installation has not been attempted as resolution failed", cond.Message) + + verifyExtensionConditionsInvariants(t, extension) +} + +func TestExtensionNoChannel(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.EnableExtensionAPI, true)() + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{Name: fmt.Sprintf("extension-test-%s", rand.String(8)), Namespace: fmt.Sprintf("test-namespace-%s", rand.String(8))} + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: extKey.Namespace}} + t.Log("Create namespace for extension") + require.NoError(t, cl.Create(ctx, ns)) + defer func() { + t.Logf("Cleaning up namespace %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, ns)) + }() + + t.Log("When the extension specifies a package in a channel that does not exist") + t.Log("By initializing cluster state") + pkgName := "prometheus" + pkgChan := "non-existent" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: testServiceAccount, + Source: ocv1alpha1.ExtensionSource{ + SourceType: ocv1alpha1.SourceTypePackage, + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Channel: pkgChan, + }, + }}, + } + require.NoError(t, cl.Create(ctx, extension)) + defer func() { + t.Logf("Cleaning up extensions %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, extension)) + }() + + t.Log("It sets resolution failure status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.EqualError(t, err, fmt.Sprintf("no package %q found in channel %q", pkgName, pkgChan)) + + t.Log("By fetching updated cluster extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Empty(t, extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + require.Equal(t, fmt.Sprintf("no package %q found in channel %q", pkgName, pkgChan), cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "installation has not been attempted as resolution failed", cond.Message) + + verifyExtensionConditionsInvariants(t, extension) +} + +func TestExtensionNoVersion(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.EnableExtensionAPI, true)() + cl, reconciler := newClientAndExtensionReconciler(t) + ctx := context.Background() + extKey := types.NamespacedName{Name: fmt.Sprintf("extension-test-%s", rand.String(8)), Namespace: fmt.Sprintf("test-namespace-%s", rand.String(8))} + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: extKey.Namespace}} + t.Log("Create namespace for extension") + require.NoError(t, cl.Create(ctx, ns)) + defer func() { + t.Logf("Cleaning up namespace %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, ns)) + }() + + t.Log("When the extension specifies a package version that does not exist in the channel") + t.Log("By initializing cluster state") + pkgName := "prometheus" + pkgVer := "0.57.0" + pkgChan := "non-existent" + extension := &ocv1alpha1.Extension{ + ObjectMeta: metav1.ObjectMeta{Name: extKey.Name, Namespace: extKey.Namespace}, + Spec: ocv1alpha1.ExtensionSpec{ + ServiceAccountName: testServiceAccount, + Source: ocv1alpha1.ExtensionSource{ + SourceType: ocv1alpha1.SourceTypePackage, + Package: &ocv1alpha1.ExtensionSourcePackage{ + Name: pkgName, + Version: pkgVer, + Channel: pkgChan, + }, + }}, + } + require.NoError(t, cl.Create(ctx, extension)) + defer func() { + t.Logf("Cleaning up extensions %s", extKey.Namespace) + require.NoError(t, cl.Delete(ctx, extension)) + }() + + t.Log("It sets resolution failure status") + t.Log("By running reconcile") + res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) + require.Equal(t, ctrl.Result{}, res) + require.EqualError(t, err, fmt.Sprintf("no package %q matching version %q found in channel %q", pkgName, pkgVer, pkgChan)) + + t.Log("By fetching updated cluster extension after reconcile") + require.NoError(t, cl.Get(ctx, extKey, extension)) + + t.Log("By checking the status fields") + require.Empty(t, extension.Status.ResolvedBundleResource) + require.Empty(t, extension.Status.InstalledBundleResource) + + t.Log("By checking the expected conditions") + cond := apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, ocv1alpha1.ReasonResolutionFailed, cond.Reason) + require.Equal(t, fmt.Sprintf("no package %q matching version %q found in channel %q", pkgName, pkgVer, pkgChan), cond.Message) + cond = apimeta.FindStatusCondition(extension.Status.Conditions, ocv1alpha1.TypeInstalled) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionUnknown, cond.Status) + require.Equal(t, ocv1alpha1.ReasonInstallationStatusUnknown, cond.Reason) + require.Equal(t, "installation has not been attempted as resolution failed", cond.Message) + + verifyExtensionConditionsInvariants(t, extension) +} + func verifyExtensionInvariants(t *testing.T, ext *ocv1alpha1.Extension) { verifyExtensionConditionsInvariants(t, ext) } @@ -110,3 +1029,102 @@ func verifyExtensionConditionsInvariants(t *testing.T, ext *ocv1alpha1.Extension require.Equal(t, ext.GetGeneration(), cond.ObservedGeneration) } } + +// Using a separate list of bundles for test that do not break existing cluster extension tests. +var testBundleListForExtension = []*catalogmetadata.Bundle{ + { + Bundle: declcfg.Bundle{ + Name: "operatorhub/prometheus/alpha/0.37.0", + Package: "prometheus", + Image: "quay.io/operatorhubio/prometheus@sha256:3e281e587de3d03011440685fc4fb782672beab044c1ebadc42788ce05a21c35", + Properties: []property.Property{ + {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"prometheus","version":"0.37.0"}`)}, + {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, + }, + }, + CatalogName: "fake-catalog", + InChannels: []*catalogmetadata.Channel{&prometheusAlphaChannel}, + }, + { + Bundle: declcfg.Bundle{ + Name: "operatorhub/prometheus/beta/1.0.0", + Package: "prometheus", + Image: "quay.io/operatorhubio/prometheus@fake1.0.0", + Properties: []property.Property{ + {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"prometheus","version":"1.0.0"}`)}, + {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, + {Type: "olm.bundle.mediatype", Value: json.RawMessage(`"plain+v0"`)}, + }, + }, + CatalogName: "fake-catalog", + InChannels: []*catalogmetadata.Channel{&prometheusBetaChannel}, + }, + { + Bundle: declcfg.Bundle{ + Name: "operatorhub/prometheus/beta/1.0.1", + Package: "prometheus", + Image: "quay.io/operatorhubio/prometheus@fake1.0.1", + Properties: []property.Property{ + {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"prometheus","version":"1.0.1"}`)}, + {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, + }, + }, + CatalogName: "fake-catalog", + InChannels: []*catalogmetadata.Channel{&prometheusBetaChannel}, + }, + { + Bundle: declcfg.Bundle{ + Name: "operatorhub/prometheus/beta/1.2.0", + Package: "prometheus", + Image: "quay.io/operatorhubio/prometheus@fake1.2.0", + Properties: []property.Property{ + {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"prometheus","version":"1.2.0"}`)}, + {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, + }, + }, + CatalogName: "fake-catalog", + InChannels: []*catalogmetadata.Channel{&prometheusBetaChannel}, + }, + { + Bundle: declcfg.Bundle{ + Name: "operatorhub/prometheus/beta/2.0.0", + Package: "prometheus", + Image: "quay.io/operatorhubio/prometheus@fake2.0.0", + Properties: []property.Property{ + {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"prometheus","version":"2.0.0"}`)}, + {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, + {Type: "olm.bundle.mediatype", Value: json.RawMessage(`"plain+v0"`)}, + }, + }, + CatalogName: "fake-catalog", + InChannels: []*catalogmetadata.Channel{&prometheusBetaChannel}, + }, + { + Bundle: declcfg.Bundle{ + Name: "operatorhub/plain/0.1.0", + Package: "plain", + Image: "quay.io/operatorhub/plain@sha256:plain", + Properties: []property.Property{ + {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"plain","version":"0.1.0"}`)}, + {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, + {Type: "olm.bundle.mediatype", Value: json.RawMessage(`"plain+v0"`)}, + }, + }, + CatalogName: "fake-catalog", + InChannels: []*catalogmetadata.Channel{&plainBetaChannel}, + }, + { + Bundle: declcfg.Bundle{ + Name: "operatorhub/badmedia/0.1.0", + Package: "badmedia", + Image: "quay.io/operatorhub/badmedia@sha256:badmedia", + Properties: []property.Property{ + {Type: property.TypePackage, Value: json.RawMessage(`{"packageName":"badmedia","version":"0.1.0"}`)}, + {Type: property.TypeGVK, Value: json.RawMessage(`[]`)}, + {Type: "olm.bundle.mediatype", Value: json.RawMessage(`"badmedia+v1"`)}, + }, + }, + CatalogName: "fake-catalog", + InChannels: []*catalogmetadata.Channel{&badmediaBetaChannel}, + }, +} diff --git a/internal/controllers/suite_test.go b/internal/controllers/suite_test.go index 8ffcd31e55..019ad4b7f6 100644 --- a/internal/controllers/suite_test.go +++ b/internal/controllers/suite_test.go @@ -25,6 +25,7 @@ import ( "github.com/operator-framework/deppy/pkg/deppy/solver" rukpakv1alpha2 "github.com/operator-framework/rukpak/api/v1alpha2" "github.com/stretchr/testify/require" + carvelv1alpha1 "github.com/vmware-tanzu/carvel-kapp-controller/pkg/apis/kappctrl/v1alpha1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -61,8 +62,10 @@ func newClientAndReconciler(t *testing.T) (client.Client, *controllers.ClusterEx func newClientAndExtensionReconciler(t *testing.T) (client.Client, *controllers.ExtensionReconciler) { cl := newClient(t) + fakeCatalogClient := testutil.NewFakeCatalogClient(testBundleListForExtension) reconciler := &controllers.ExtensionReconciler{ - Client: cl, + Client: cl, + BundleProvider: &fakeCatalogClient, } return cl, reconciler } @@ -91,6 +94,7 @@ func TestMain(m *testing.M) { utilruntime.Must(ocv1alpha1.AddToScheme(sch)) utilruntime.Must(rukpakv1alpha2.AddToScheme(sch)) utilruntime.Must(corev1.AddToScheme(sch)) + utilruntime.Must(carvelv1alpha1.AddToScheme(sch)) code := m.Run() utilruntime.Must(testEnv.Stop()) diff --git a/testdata/crds/kappctrl.k14s.io_app.yaml b/testdata/crds/kappctrl.k14s.io_app.yaml new file mode 100644 index 0000000000..a3524c0811 --- /dev/null +++ b/testdata/crds/kappctrl.k14s.io_app.yaml @@ -0,0 +1,728 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: apps.kappctrl.k14s.io +spec: + group: kappctrl.k14s.io + names: + categories: + - carvel + kind: App + listKind: AppList + plural: apps + singular: app + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Friendly description + jsonPath: .status.friendlyDescription + name: Description + type: string + - description: Last time app started being deployed. Does not mean anything was changed. + jsonPath: .status.deploy.startedAt + name: Since-Deploy + type: date + - description: Time since creation + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: 'An App is a set of Kubernetes resources. These resources could span any number of namespaces or could be cluster-wide (e.g. CRDs). An App is represented in kapp-controller using a App CR. The App CR comprises of three main sections: spec.fetch – declare source for fetching configuration and OCI images spec.template – declare templating tool and values spec.deploy – declare deployment tool and any deploy specific configuration' + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + canceled: + description: Cancels current and future reconciliations (optional; default=false) + type: boolean + cluster: + description: Specifies that app should be deployed to destination cluster; by default, cluster is same as where this resource resides (optional; v0.5.0+) + properties: + kubeconfigSecretRef: + description: Specifies secret containing kubeconfig (required) + properties: + key: + description: Specifies key that contains kubeconfig (optional) + type: string + name: + description: Specifies secret name within app's namespace (required) + type: string + type: object + namespace: + description: Specifies namespace in destination cluster (optional) + type: string + type: object + defaultNamespace: + description: Specifies the default namespace to install the App resources, by default this is same as the App's namespace (optional; v0.48.0+) + type: string + deploy: + items: + properties: + kapp: + description: Use kapp to deploy resources + properties: + delete: + description: Configuration for delete command (optional) + properties: + rawOptions: + description: Pass through options to kapp delete (optional) + items: + type: string + type: array + type: object + inspect: + description: 'Configuration for inspect command (optional) as of kapp-controller v0.31.0, inspect is disabled by default add rawOptions or use an empty inspect config like `inspect: {}` to enable' + properties: + rawOptions: + description: Pass through options to kapp inspect (optional) + items: + type: string + type: array + type: object + intoNs: + description: Override namespace for all resources (optional) + type: string + mapNs: + description: Provide custom namespace override mapping (optional) + items: + type: string + type: array + rawOptions: + description: Pass through options to kapp deploy (optional) + items: + type: string + type: array + type: object + type: object + type: array + fetch: + items: + properties: + git: + description: Uses git to clone repository + properties: + forceHTTPBasicAuth: + description: Force the usage of HTTP Basic Auth when Basic Auth is provided (optional) + type: boolean + lfsSkipSmudge: + description: Skip lfs download (optional) + type: boolean + ref: + description: Branch, tag, commit; origin is the name of the remote (optional) + type: string + refSelection: + description: Specifies a strategy to resolve to an explicit ref (optional; v0.24.0+) + properties: + semver: + properties: + constraints: + type: string + prereleases: + properties: + identifiers: + items: + type: string + type: array + type: object + type: object + type: object + secretRef: + description: 'Secret with auth details. allowed keys: ssh-privatekey, ssh-knownhosts, username, password (optional) (if ssh-knownhosts is not specified, git will not perform strict host checking)' + properties: + name: + description: Object is expected to be within same namespace + type: string + type: object + subPath: + description: Grab only portion of repository (optional) + type: string + url: + description: http or ssh urls are supported (required) + type: string + type: object + helmChart: + description: Uses helm fetch to fetch specified chart + properties: + name: + description: 'Example: stable/redis' + type: string + repository: + properties: + secretRef: + properties: + name: + description: Object is expected to be within same namespace + type: string + type: object + url: + description: Repository url; scheme of oci:// will fetch experimental helm oci chart (v0.19.0+) (required) + type: string + type: object + version: + type: string + type: object + http: + description: Uses http library to fetch file + properties: + secretRef: + description: 'Secret to provide auth details (optional) Secret may include one or more keys: username, password' + properties: + name: + description: Object is expected to be within same namespace + type: string + type: object + sha256: + description: Checksum to verify after download (optional) + type: string + subPath: + description: Grab only portion of download (optional) + type: string + url: + description: 'URL can point to one of following formats: text, tgz, zip http and https url are supported; plain file, tgz and tar types are supported (required)' + type: string + type: object + image: + description: Pulls content from Docker/OCI registry + properties: + secretRef: + description: 'Secret may include one or more keys: username, password, token. By default anonymous access is used for authentication.' + properties: + name: + description: Object is expected to be within same namespace + type: string + type: object + subPath: + description: Grab only portion of image (optional) + type: string + tagSelection: + description: Specifies a strategy to choose a tag (optional; v0.24.0+) if specified, do not include a tag in url key + properties: + semver: + properties: + constraints: + type: string + prereleases: + properties: + identifiers: + items: + type: string + type: array + type: object + type: object + type: object + url: + description: 'Docker image url; unqualified, tagged, or digest references supported (required) Example: username/app1-config:v0.1.0' + type: string + type: object + imgpkgBundle: + description: Pulls imgpkg bundle from Docker/OCI registry (v0.17.0+) + properties: + image: + description: Docker image url; unqualified, tagged, or digest references supported (required) + type: string + secretRef: + description: 'Secret may include one or more keys: username, password, token. By default anonymous access is used for authentication.' + properties: + name: + description: Object is expected to be within same namespace + type: string + type: object + tagSelection: + description: Specifies a strategy to choose a tag (optional; v0.24.0+) if specified, do not include a tag in url key + properties: + semver: + properties: + constraints: + type: string + prereleases: + properties: + identifiers: + items: + type: string + type: array + type: object + type: object + type: object + type: object + inline: + description: Pulls content from within this resource; or other resources in the cluster + properties: + paths: + additionalProperties: + type: string + description: Specifies mapping of paths to their content; not recommended for sensitive values as CR is not encrypted (optional) + type: object + pathsFrom: + description: Specifies content via secrets and config maps; data values are recommended to be placed in secrets (optional) + items: + properties: + configMapRef: + properties: + directoryPath: + description: Specifies where to place files found in secret (optional) + type: string + name: + type: string + type: object + secretRef: + properties: + directoryPath: + description: Specifies where to place files found in secret (optional) + type: string + name: + type: string + type: object + type: object + type: array + type: object + path: + description: Relative path to place the fetched artifacts + type: string + type: object + type: array + noopDelete: + description: Deletion requests for the App will result in the App CR being deleted, but its associated resources will not be deleted (optional; default=false; v0.18.0+) + type: boolean + paused: + description: Pauses _future_ reconciliation; does _not_ affect currently running reconciliation (optional; default=false) + type: boolean + serviceAccountName: + description: Specifies that app should be deployed authenticated via given service account, found in this namespace (optional; v0.6.0+) + type: string + syncPeriod: + description: Specifies the length of time to wait, in time + unit format, before reconciling. Always >= 30s. If value below 30s is specified, 30s will be used. (optional; v0.9.0+; default=30s) + type: string + template: + items: + properties: + cue: + properties: + inputExpression: + description: Cue expression for single path component, can be used to unify ValuesFrom into a given field (optional) + type: string + outputExpression: + description: Cue expression to output, default will export all visible fields (optional) + type: string + paths: + description: Explicit list of files/directories (optional) + items: + type: string + type: array + valuesFrom: + description: Provide values (optional) + items: + properties: + configMapRef: + properties: + name: + type: string + type: object + downwardAPI: + properties: + items: + items: + properties: + fieldPath: + description: 'Required: Selects a field of the app: only annotations, labels, uid, name and namespace are supported.' + type: string + kappControllerVersion: + description: 'Optional: Get running KappController version, defaults (empty) to retrieving the current running version.. Can be manually supplied instead.' + properties: + version: + type: string + type: object + kubernetesAPIs: + description: 'Optional: Get running KubernetesAPIs from cluster, defaults (empty) to retrieving the APIs from the cluster. Can be manually supplied instead, e.g ["group/version", "group2/version2"]' + properties: + groupVersions: + items: + type: string + type: array + type: object + kubernetesVersion: + description: 'Optional: Get running Kubernetes version from cluster, defaults (empty) to retrieving the version from the cluster. Can be manually supplied instead.' + properties: + version: + type: string + type: object + name: + type: string + type: object + type: array + type: object + path: + type: string + secretRef: + properties: + name: + type: string + type: object + type: object + type: array + type: object + helmTemplate: + description: Use helm template command to render helm chart + properties: + kubernetesAPIs: + description: 'Optional: Use kubernetes group/versions resources available in the live cluster' + properties: + groupVersions: + items: + type: string + type: array + type: object + kubernetesVersion: + description: 'Optional: Get Kubernetes version, defaults (empty) to retrieving the version from the cluster. Can be manually overridden to a value instead.' + properties: + version: + type: string + type: object + name: + description: Set name explicitly, default is App CR's name (optional; v0.13.0+) + type: string + namespace: + description: Set namespace explicitly, default is App CR's namespace (optional; v0.13.0+) + type: string + path: + description: Path to chart (optional; v0.13.0+) + type: string + valuesFrom: + description: One or more secrets, config maps, paths that provide values (optional) + items: + properties: + configMapRef: + properties: + name: + type: string + type: object + downwardAPI: + properties: + items: + items: + properties: + fieldPath: + description: 'Required: Selects a field of the app: only annotations, labels, uid, name and namespace are supported.' + type: string + kappControllerVersion: + description: 'Optional: Get running KappController version, defaults (empty) to retrieving the current running version.. Can be manually supplied instead.' + properties: + version: + type: string + type: object + kubernetesAPIs: + description: 'Optional: Get running KubernetesAPIs from cluster, defaults (empty) to retrieving the APIs from the cluster. Can be manually supplied instead, e.g ["group/version", "group2/version2"]' + properties: + groupVersions: + items: + type: string + type: array + type: object + kubernetesVersion: + description: 'Optional: Get running Kubernetes version from cluster, defaults (empty) to retrieving the version from the cluster. Can be manually supplied instead.' + properties: + version: + type: string + type: object + name: + type: string + type: object + type: array + type: object + path: + type: string + secretRef: + properties: + name: + type: string + type: object + type: object + type: array + type: object + jsonnet: + description: TODO implement jsonnet + type: object + kbld: + description: Use kbld to resolve image references to use digests + properties: + paths: + items: + type: string + type: array + type: object + kustomize: + description: TODO implement kustomize + type: object + sops: + description: Use sops to decrypt *.sops.yml files (optional; v0.11.0+) + properties: + age: + properties: + privateKeysSecretRef: + description: Secret with private armored PGP private keys (required) + properties: + name: + type: string + type: object + type: object + paths: + description: Lists paths to decrypt explicitly (optional; v0.13.0+) + items: + type: string + type: array + pgp: + description: Use PGP to decrypt files (required) + properties: + privateKeysSecretRef: + description: Secret with private armored PGP private keys (required) + properties: + name: + type: string + type: object + type: object + type: object + ytt: + description: Use ytt to template configuration + properties: + fileMarks: + description: Control metadata about input files passed to ytt (optional; v0.18.0+) see https://carvel.dev/ytt/docs/latest/file-marks/ for more details + items: + type: string + type: array + ignoreUnknownComments: + description: Ignores comments that ytt doesn't recognize (optional; default=false) + type: boolean + inline: + description: Specify additional files, including data values (optional) + properties: + paths: + additionalProperties: + type: string + description: Specifies mapping of paths to their content; not recommended for sensitive values as CR is not encrypted (optional) + type: object + pathsFrom: + description: Specifies content via secrets and config maps; data values are recommended to be placed in secrets (optional) + items: + properties: + configMapRef: + properties: + directoryPath: + description: Specifies where to place files found in secret (optional) + type: string + name: + type: string + type: object + secretRef: + properties: + directoryPath: + description: Specifies where to place files found in secret (optional) + type: string + name: + type: string + type: object + type: object + type: array + type: object + paths: + description: Lists paths to provide to ytt explicitly (optional) + items: + type: string + type: array + strict: + description: Forces strict mode https://github.com/k14s/ytt/blob/develop/docs/strict.md (optional; default=false) + type: boolean + valuesFrom: + description: Provide values via ytt's --data-values-file (optional; v0.19.0-alpha.9) + items: + properties: + configMapRef: + properties: + name: + type: string + type: object + downwardAPI: + properties: + items: + items: + properties: + fieldPath: + description: 'Required: Selects a field of the app: only annotations, labels, uid, name and namespace are supported.' + type: string + kappControllerVersion: + description: 'Optional: Get running KappController version, defaults (empty) to retrieving the current running version.. Can be manually supplied instead.' + properties: + version: + type: string + type: object + kubernetesAPIs: + description: 'Optional: Get running KubernetesAPIs from cluster, defaults (empty) to retrieving the APIs from the cluster. Can be manually supplied instead, e.g ["group/version", "group2/version2"]' + properties: + groupVersions: + items: + type: string + type: array + type: object + kubernetesVersion: + description: 'Optional: Get running Kubernetes version from cluster, defaults (empty) to retrieving the version from the cluster. Can be manually supplied instead.' + properties: + version: + type: string + type: object + name: + type: string + type: object + type: array + type: object + path: + type: string + secretRef: + properties: + name: + type: string + type: object + type: object + type: array + type: object + type: object + type: array + type: object + status: + properties: + conditions: + items: + properties: + message: + description: Human-readable message indicating details about last transition. + type: string + reason: + description: Unique, this should be a short, machine understandable string that gives the reason for condition's last transition. If it reports "ResizeStarted" that means the underlying persistent volume is being resized. + type: string + status: + type: string + type: + description: ConditionType represents reconciler state + type: string + required: + - status + - type + type: object + type: array + consecutiveReconcileFailures: + type: integer + consecutiveReconcileSuccesses: + type: integer + deploy: + properties: + error: + type: string + exitCode: + type: integer + finished: + type: boolean + kapp: + description: KappDeployStatus contains the associated AppCR deployed resources + properties: + associatedResources: + description: AssociatedResources contains the associated App label, namespaces and GKs + properties: + groupKinds: + items: + description: GroupKind specifies a Group and a Kind, but does not force a version. This is useful for identifying concepts during lookup stages without having partially valid types + properties: + group: + type: string + kind: + type: string + required: + - group + - kind + type: object + type: array + label: + type: string + namespaces: + items: + type: string + type: array + type: object + type: object + startedAt: + format: date-time + type: string + stderr: + type: string + stdout: + type: string + updatedAt: + format: date-time + type: string + type: object + fetch: + properties: + error: + type: string + exitCode: + type: integer + startedAt: + format: date-time + type: string + stderr: + type: string + stdout: + type: string + updatedAt: + format: date-time + type: string + type: object + friendlyDescription: + type: string + inspect: + properties: + error: + type: string + exitCode: + type: integer + stderr: + type: string + stdout: + type: string + updatedAt: + format: date-time + type: string + type: object + managedAppName: + type: string + observedGeneration: + description: Populated based on metadata.generation when controller observes a change to the resource; if this value is out of data, other status fields do not reflect latest state + format: int64 + type: integer + template: + properties: + error: + type: string + exitCode: + type: integer + stderr: + type: string + updatedAt: + format: date-time + type: string + type: object + usefulErrorMessage: + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} \ No newline at end of file