Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 23 additions & 14 deletions pkg/packageinstall/packageinstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,22 +181,25 @@ func (pi *PackageInstallCR) reconcile(modelStatus *reconciler.Status) (reconcile
return reconcile.Result{Requeue: true}, err
}

if existingApp.Generation != existingApp.Status.ObservedGeneration {
modelStatus.SetReconciling(pi.model.ObjectMeta)
} else {
appStatus := reconciler.Status{S: existingApp.Status.GenericStatus}
switch {
case appStatus.IsReconciling():
// Create a status updater closure that sets PackageInstall status based on App state
statusUpdater := func(app *kcv1alpha1.App) {
if app.Generation != app.Status.ObservedGeneration {
modelStatus.SetReconciling(pi.model.ObjectMeta)
case appStatus.IsReconcileSucceeded():
modelStatus.SetReconcileCompleted(nil)
case appStatus.IsReconcileFailed():
modelStatus.SetUsefulErrorMessage(existingApp.Status.UsefulErrorMessage)
modelStatus.SetReconcileCompleted(fmt.Errorf("Error (see .status.usefulErrorMessage for details)"))
} else {
appStatus := reconciler.Status{S: app.Status.GenericStatus}
switch {
case appStatus.IsReconciling():
modelStatus.SetReconciling(pi.model.ObjectMeta)
case appStatus.IsReconcileSucceeded():
modelStatus.SetReconcileCompleted(nil)
case appStatus.IsReconcileFailed():
modelStatus.SetUsefulErrorMessage(app.Status.UsefulErrorMessage)
modelStatus.SetReconcileCompleted(fmt.Errorf("Error (see .status.usefulErrorMessage for details)"))
}
}
}

return pi.reconcileAppWithPackage(existingApp, pkg)
return pi.reconcileAppWithPackage(existingApp, pkg, statusUpdater)
}

func (pi *PackageInstallCR) createAppFromPackage(pkg datapkgingv1alpha1.Package) (reconcile.Result, error) {
Expand All @@ -213,7 +216,7 @@ func (pi *PackageInstallCR) createAppFromPackage(pkg datapkgingv1alpha1.Package)
return reconcile.Result{}, nil
}

func (pi *PackageInstallCR) reconcileAppWithPackage(existingApp *kcv1alpha1.App, pkg datapkgingv1alpha1.Package) (reconcile.Result, error) {
func (pi *PackageInstallCR) reconcileAppWithPackage(existingApp *kcv1alpha1.App, pkg datapkgingv1alpha1.Package, statusUpdater func(*kcv1alpha1.App)) (reconcile.Result, error) {
pkgWithPlaceholderSecrets, err := pi.reconcileFetchPlaceholderSecrets(pkg)
if err != nil {
return reconcile.Result{}, err
Expand All @@ -224,14 +227,20 @@ func (pi *PackageInstallCR) reconcileAppWithPackage(existingApp *kcv1alpha1.App,
return reconcile.Result{Requeue: true}, err
}

var appToCheckStatus *kcv1alpha1.App = existingApp

if !equality.Semantic.DeepEqual(desiredApp, existingApp) {
_, err = pi.kcclient.KappctrlV1alpha1().Apps(desiredApp.Namespace).Update(
updatedApp, err := pi.kcclient.KappctrlV1alpha1().Apps(desiredApp.Namespace).Update(
context.Background(), desiredApp, metav1.UpdateOptions{})
if err != nil {
return reconcile.Result{Requeue: true}, err
}
appToCheckStatus = updatedApp
}

// Call the status updater with the current app state (either existing or updated)
statusUpdater(appToCheckStatus)

return reconcile.Result{}, nil
}

Expand Down
283 changes: 283 additions & 0 deletions pkg/packageinstall/packageinstall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ import (
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/version"
fakediscovery "k8s.io/client-go/discovery/fake"
"k8s.io/client-go/kubernetes/fake"
k8stesting "k8s.io/client-go/testing"
logf "sigs.k8s.io/controller-runtime/pkg/log"
)

Expand Down Expand Up @@ -688,6 +690,287 @@ func Test_PlaceHolderSecretCreated_WhenPackageInstallUpdated(t *testing.T) {
assert.Equal(t, "instl-pkg-fetch-0", app.Spec.Fetch[0].ImgpkgBundle.SecretRef.Name)
}

// Test_StatusUpdaterClosureWithAppUpdateFromReconcileFailed tests this exact scenario:
// 1. App exists in ReconcileFailed state (generation == observedGeneration)
// 2. PackageInstall gets updated to reference a new package version
// 3. This updates the App spec (and in real K8s would increment generation)
// 4. PackageInstall status must now reflect the updated App state
func Test_StatusUpdaterClosureWithAppUpdateFromReconcileFailed(t *testing.T) {
log := logf.Log.WithName("kc")

// Create a package
pkg := datapkgingv1alpha1.Package{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pkg.2.0.0",
},
Spec: datapkgingv1alpha1.PackageSpec{
RefName: "test-pkg",
Version: "2.0.0",
Template: datapkgingv1alpha1.AppTemplateSpec{
Spec: &v1alpha1.AppSpec{
Fetch: []v1alpha1.AppFetch{
{
ImgpkgBundle: &v1alpha1.AppFetchImgpkgBundle{
Image: "test-pkg:2.0.0",
},
},
},
Template: []v1alpha1.AppTemplate{
{
Ytt: &v1alpha1.AppTemplateYtt{},
},
},
Deploy: []v1alpha1.AppDeploy{
{
Kapp: &v1alpha1.AppDeployKapp{},
},
},
},
},
},
}

fakePkgClient := fakeapiserver.NewSimpleClientset(&pkg)

model := &pkgingv1alpha1.PackageInstall{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pkg-install",
},
Spec: pkgingv1alpha1.PackageInstallSpec{
PackageRef: &pkgingv1alpha1.PackageRef{
RefName: "test-pkg",
VersionSelection: &versions.VersionSelectionSemver{
Constraints: "2.0.0",
},
},
ServiceAccountName: "use-local-cluster-sa",
},
}

// Create an existing App that will be updated
existingApp := &v1alpha1.App{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pkg-install",
Generation: 3,
Annotations: map[string]string{
"packaging.carvel.dev/package-ref-name": "test-pkg",
"packaging.carvel.dev/package-version": "1.0.0", // Old version
},
},
Spec: v1alpha1.AppSpec{
Fetch: []v1alpha1.AppFetch{
{
ImgpkgBundle: &v1alpha1.AppFetchImgpkgBundle{
Image: "test-pkg:1.0.0", // Old image
},
},
},
Template: []v1alpha1.AppTemplate{
{
Ytt: &v1alpha1.AppTemplateYtt{},
},
},
Deploy: []v1alpha1.AppDeploy{
{
Kapp: &v1alpha1.AppDeployKapp{},
},
},
ServiceAccountName: "use-local-cluster-sa",
},
Status: v1alpha1.AppStatus{
GenericStatus: v1alpha1.GenericStatus{
ObservedGeneration: 3, // Generation == ObservedGeneration
Conditions: []v1alpha1.Condition{
{
Type: v1alpha1.ReconcileFailed,
Status: corev1.ConditionTrue,
},
},
FriendlyDescription: "Reconcile failed",
UsefulErrorMessage: "Original error from v1.0.0",
},
},
}

// Create a custom fake client that increments generation on update (like real K8s)
fakekctrl := fakekappctrl.NewSimpleClientset(model, existingApp)

// Add a reactor to simulate generation increment on App updates
fakekctrl.PrependReactor("update", "apps", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) {
updateAction := action.(k8stesting.UpdateAction)
if app, ok := updateAction.GetObject().(*v1alpha1.App); ok {
// Simulate generation increment when App is updated (like real K8s)
app.Generation = app.Generation + 1
}
return false, nil, nil // Let the default handler process the update
})

fakek8s := fake.NewSimpleClientset()
fakeDiscovery, _ := fakek8s.Discovery().(*fakediscovery.FakeDiscovery)
fakeDiscovery.FakedServerVersion = &version.Info{
GitVersion: "v0.20.0",
}

ip := NewPackageInstallCR(model, log, fakekctrl, fakePkgClient, fakek8s,
FakeComponentInfo{KCVersion: semver.MustParse("0.42.31337")}, Opts{},
metrics.NewMetrics())

// Reconcile should update the App and set PackageInstall status
_, err := ip.Reconcile()
assert.Nil(t, err)

// Verify the App was updated
gvr := schema.GroupVersionResource{"kappctrl.k14s.io", "v1alpha1", "apps"}
obj, err := fakekctrl.Tracker().Get(gvr, "", "test-pkg-install")
assert.Nil(t, err)
require.NotNil(t, obj)
updatedApp := obj.(*v1alpha1.App)

// Verify App was updated with new package content
assert.Equal(t, "test-pkg:2.0.0", updatedApp.Spec.Fetch[0].ImgpkgBundle.Image)
assert.Equal(t, "2.0.0", updatedApp.Annotations["packaging.carvel.dev/package-version"])
assert.Equal(t, int64(4), updatedApp.Generation, "Generation should have incremented from 3 to 4")

// Verify PackageInstall status reflects the updated App state
assert.Len(t, ip.model.Status.Conditions, 1)
assert.Equal(t, v1alpha1.Reconciling, ip.model.Status.Conditions[0].Type)
assert.Equal(t, corev1.ConditionTrue, ip.model.Status.Conditions[0].Status)
assert.Equal(t, "Reconciling", ip.model.Status.FriendlyDescription)
}

// Test_StatusUpdaterClosureWithNoAppUpdate verifies the closure works when App doesn't need updating:
// 1. App exists in ReconcileFailed state (generation == observedGeneration)
// 2. PackageInstall points to same package version as App
// 3. No App update needed, so PackageInstall status should reflect existing App state
func Test_StatusUpdaterClosureWithNoAppUpdate(t *testing.T) {
log := logf.Log.WithName("kc")

// Create a package
pkg := datapkgingv1alpha1.Package{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pkg.1.0.0",
},
Spec: datapkgingv1alpha1.PackageSpec{
RefName: "test-pkg",
Version: "1.0.0",
Template: datapkgingv1alpha1.AppTemplateSpec{
Spec: &v1alpha1.AppSpec{
Fetch: []v1alpha1.AppFetch{
{
ImgpkgBundle: &v1alpha1.AppFetchImgpkgBundle{
Image: "test-pkg:1.0.0",
},
},
},
Template: []v1alpha1.AppTemplate{
{
Ytt: &v1alpha1.AppTemplateYtt{},
},
},
Deploy: []v1alpha1.AppDeploy{
{
Kapp: &v1alpha1.AppDeployKapp{},
},
},
},
},
},
}

fakePkgClient := fakeapiserver.NewSimpleClientset(&pkg)

// Create a PackageInstall pointing to v1.0.0
model := &pkgingv1alpha1.PackageInstall{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pkg-install",
},
Spec: pkgingv1alpha1.PackageInstallSpec{
PackageRef: &pkgingv1alpha1.PackageRef{
RefName: "test-pkg",
VersionSelection: &versions.VersionSelectionSemver{
Constraints: "1.0.0",
},
},
ServiceAccountName: "use-local-cluster-sa",
},
}

// Create an existing App in ReconcileFailed state with matching spec (no update needed)
existingApp := &v1alpha1.App{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pkg-install",
Generation: 3,
},
Spec: v1alpha1.AppSpec{
Fetch: []v1alpha1.AppFetch{
{
ImgpkgBundle: &v1alpha1.AppFetchImgpkgBundle{
Image: "test-pkg:1.0.0", // Same as package spec
},
},
},
Template: []v1alpha1.AppTemplate{
{
Ytt: &v1alpha1.AppTemplateYtt{},
},
},
Deploy: []v1alpha1.AppDeploy{
{
Kapp: &v1alpha1.AppDeployKapp{},
},
},
ServiceAccountName: "use-local-cluster-sa",
},
Status: v1alpha1.AppStatus{
GenericStatus: v1alpha1.GenericStatus{
ObservedGeneration: 3, // Generation == ObservedGeneration (stable failed state)
Conditions: []v1alpha1.Condition{
{
Type: v1alpha1.ReconcileFailed,
Status: corev1.ConditionTrue,
},
},
FriendlyDescription: "Reconcile failed",
UsefulErrorMessage: "Deploy failed for v1.0.0",
},
},
}

fakekctrl := fakekappctrl.NewSimpleClientset(model, existingApp)
fakek8s := fake.NewSimpleClientset()

// mock the kubernetes server version
fakeDiscovery, _ := fakek8s.Discovery().(*fakediscovery.FakeDiscovery)
fakeDiscovery.FakedServerVersion = &version.Info{
GitVersion: "v0.20.0",
}

ip := NewPackageInstallCR(model, log, fakekctrl, fakePkgClient, fakek8s,
FakeComponentInfo{KCVersion: semver.MustParse("0.42.31337")}, Opts{},
metrics.NewMetrics())

// Reconcile should NOT update the App since specs match
_, err := ip.Reconcile()
assert.Nil(t, err)

// Verify the App was NOT updated (generation should remain the same)
gvr := schema.GroupVersionResource{"kappctrl.k14s.io", "v1alpha1", "apps"}
obj, err := fakekctrl.Tracker().Get(gvr, "", "test-pkg-install")
assert.Nil(t, err)
require.NotNil(t, obj)
appAfterReconcile := obj.(*v1alpha1.App)

// App should be unchanged
assert.Equal(t, int64(3), appAfterReconcile.Generation, "App generation should not have changed")
assert.Equal(t, "test-pkg:1.0.0", appAfterReconcile.Spec.Fetch[0].ImgpkgBundle.Image)

// PackageInstall status should reflect the existing App state (ReconcileFailed)
assert.Len(t, ip.model.Status.Conditions, 1)
assert.Equal(t, v1alpha1.ReconcileFailed, ip.model.Status.Conditions[0].Type)
assert.Equal(t, corev1.ConditionTrue, ip.model.Status.Conditions[0].Status)
assert.Equal(t, "Deploy failed for v1.0.0", ip.model.Status.UsefulErrorMessage)
}

func generatePackageWithConstraints(name, version, kcConstraint string, k8sConstraint string) datapkgingv1alpha1.Package {
return datapkgingv1alpha1.Package{
ObjectMeta: metav1.ObjectMeta{
Expand Down
Loading