diff --git a/api/porch/v1alpha2/packagerevision_types.go b/api/porch/v1alpha2/packagerevision_types.go index 4a10267df..888885a32 100644 --- a/api/porch/v1alpha2/packagerevision_types.go +++ b/api/porch/v1alpha2/packagerevision_types.go @@ -200,6 +200,10 @@ type PackageRevisionStatus struct { // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:"conditions,omitempty"` + + // PrrSizeBytes is the total file size, in bytes, of the package revision's resources. + // +optional + PrrSizeBytes int64 `json:"prrSizeBytes,omitempty"` } // PackageSource specifies how a package was created. diff --git a/api/porch/v1alpha2/porch.kpt.dev_packagerevisions.yaml b/api/porch/v1alpha2/porch.kpt.dev_packagerevisions.yaml index 23820a4be..f73bf10b6 100644 --- a/api/porch/v1alpha2/porch.kpt.dev_packagerevisions.yaml +++ b/api/porch/v1alpha2/porch.kpt.dev_packagerevisions.yaml @@ -373,6 +373,11 @@ spec: - type type: object type: array + prrSizeBytes: + description: PrrSizeBytes is the total file size, in bytes, of the + package revision's resources. + format: int64 + type: integer publishedAt: description: PublishedAt is the time when the packagerevision were approved. diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/packagerevision_controller_test.go b/controllers/packagerevisions/pkg/controllers/packagerevision/packagerevision_controller_test.go index 7f8e3f021..c7043f2e8 100644 --- a/controllers/packagerevisions/pkg/controllers/packagerevision/packagerevision_controller_test.go +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/packagerevision_controller_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" + kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -31,6 +31,7 @@ func setupMockContentDefaults(m *mockrepository.MockPackageContent) { m.EXPECT().GetCommitInfo().Return(time.Time{}, "").Maybe() m.EXPECT().GetLock(mock.Anything).Return(kptfilev1.Upstream{}, kptfilev1.Locator{}, nil).Maybe() m.EXPECT().GetUpstreamLock(mock.Anything).Return(kptfilev1.Upstream{}, kptfilev1.Locator{}, nil).Maybe() + m.EXPECT().GetResourceContents(mock.Anything).Return(map[string]string{"Kptfile": "test"}, nil).Maybe() } func newTestReconciler(mockClient *mockclient.MockClient, cache *mockrepository.MockContentCache) *PackageRevisionReconciler { @@ -385,7 +386,7 @@ func TestReconcileDeletionProposedNoFinalizer(t *testing.T) { *obj.(*porchv1alpha2.PackageRevision) = *pr }).Return(nil) // No Patch expected — finalizer already absent. - + // updateLatestRevisionLabels is called after finalizer removal mockClient.EXPECT().List(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevisionList"), mock.Anything, mock.Anything).Return(nil) @@ -715,7 +716,7 @@ func TestReconcileOwnerRefRepoLookupFails(t *testing.T) { pr := &porchv1alpha2.PackageRevision{ ObjectMeta: metav1.ObjectMeta{Name: "test-pr", Namespace: "default"}, - Spec: porchv1alpha2.PackageRevisionSpec{RepositoryName: "missing-repo"}, + Spec: porchv1alpha2.PackageRevisionSpec{RepositoryName: "missing-repo"}, } mockClient := mockclient.NewMockClient(t) @@ -1206,7 +1207,6 @@ func TestReconcileNoSource(t *testing.T) { assert.Equal(t, ctrl.Result{}, result) } - // mockRenderer is a test double for the renderer interface. type mockRenderer struct { resources map[string]string @@ -1294,7 +1294,6 @@ func TestReconcileRenderAlreadyRendered(t *testing.T) { assert.Nil(t, result) } - func TestReconcileRenderSourceTrigger(t *testing.T) { ctx := t.Context() rendered := map[string]string{"Kptfile": "rendered"} @@ -1553,7 +1552,6 @@ func TestReconcileSourceCloseDraftFails(t *testing.T) { assert.Equal(t, ctrl.Result{}, result) } - func TestReconcileRenderErrorSetsStatus(t *testing.T) { // Reconcile should handle reconcileRender returning an error // by logging and returning (no crash, no requeue). @@ -1665,7 +1663,6 @@ func TestWriteRenderedResourcesCloseDraftFails(t *testing.T) { assert.Contains(t, err.Error(), "close draft after render") } - func TestReconcileRenderPipelineFailureNoPush(t *testing.T) { ctx := t.Context() @@ -1760,7 +1757,6 @@ func TestReconcileRenderPipelineFailureWithPushWriteFails(t *testing.T) { assert.Nil(t, result) } - func TestRenderWithConcurrencyLimitRequeues(t *testing.T) { limiter := make(chan struct{}, 1) limiter <- struct{}{} // fill the limiter diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/status.go b/controllers/packagerevisions/pkg/controllers/packagerevision/status.go index 775a80047..a00d79145 100644 --- a/controllers/packagerevisions/pkg/controllers/packagerevision/status.go +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/status.go @@ -61,6 +61,9 @@ func (r *PackageRevisionReconciler) updateStatus(ctx context.Context, pr *porchv if _, upstreamLock, err := content.GetUpstreamLock(ctx); err == nil { status.UpstreamLock = porchv1alpha2.KptLocatorToLocator(upstreamLock) } + if resources, err := content.GetResourceContents(ctx); err == nil { + status.PrrSizeBytes = repository.CalculateResourcesSize(resources) + } } applyObj := &porchv1alpha2.PackageRevision{ diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/status_test.go b/controllers/packagerevisions/pkg/controllers/packagerevision/status_test.go index f09d3b99d..f99cfcb77 100644 --- a/controllers/packagerevisions/pkg/controllers/packagerevision/status_test.go +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/status_test.go @@ -88,6 +88,7 @@ func TestUpdateStatusWithPublishedContent(t *testing.T) { content.EXPECT().GetCommitInfo().Return(commitTime, "user@example.com") content.EXPECT().GetLock(mock.Anything).Return(kptfilev1.Upstream{}, kptfilev1.Locator{}, nil) content.EXPECT().GetUpstreamLock(mock.Anything).Return(kptfilev1.Upstream{}, kptfilev1.Locator{}, nil) + content.EXPECT().GetResourceContents(mock.Anything).Return(map[string]string{"Kptfile": "abc", "cm.yaml": "defgh"}, nil) r := &PackageRevisionReconciler{Client: mockClient} pr := basePR() @@ -99,6 +100,7 @@ func TestUpdateStatusWithPublishedContent(t *testing.T) { assert.Equal(t, 5, captured.Revision) assert.Equal(t, "user@example.com", captured.PublishedBy) assert.NotNil(t, captured.PublishedAt) + assert.Equal(t, int64(8), captured.PrrSizeBytes) } func TestUpdateStatusWithDraftContent(t *testing.T) { @@ -109,6 +111,7 @@ func TestUpdateStatusWithDraftContent(t *testing.T) { content.EXPECT().Lifecycle(mock.Anything).Return("Draft") content.EXPECT().GetLock(mock.Anything).Return(kptfilev1.Upstream{}, kptfilev1.Locator{}, nil) content.EXPECT().GetUpstreamLock(mock.Anything).Return(kptfilev1.Upstream{}, kptfilev1.Locator{}, nil) + content.EXPECT().GetResourceContents(mock.Anything).Return(map[string]string{"Kptfile": "draft-content"}, nil) r := &PackageRevisionReconciler{Client: mockClient} pr := basePR() @@ -118,6 +121,7 @@ func TestUpdateStatusWithDraftContent(t *testing.T) { assert.Equal(t, 0, captured.Revision) assert.Empty(t, captured.PublishedBy) assert.Nil(t, captured.PublishedAt) + assert.Equal(t, int64(13), captured.PrrSizeBytes) } func TestUpdateRenderStatusInProgress(t *testing.T) { @@ -180,7 +184,6 @@ func TestSetRenderFailed(t *testing.T) { assert.Equal(t, porchv1alpha2.ReasonRenderFailed, renderPatch.Conditions[0].Reason) } - func TestUpdateKptfileFields(t *testing.T) { mockClient := mockclient.NewMockClient(t) diff --git a/controllers/repositories/pkg/controllers/repository/pkgrevsync.go b/controllers/repositories/pkg/controllers/repository/pkgrevsync.go index ce86d89f4..17c55097c 100644 --- a/controllers/repositories/pkg/controllers/repository/pkgrevsync.go +++ b/controllers/repositories/pkg/controllers/repository/pkgrevsync.go @@ -199,6 +199,7 @@ func (r *RepositoryReconciler) applySeedFields(ctx context.Context, repo *config func packageRevisionUpToDate(existing, desired *porchv1alpha2.PackageRevision) bool { return equality.Semantic.DeepEqual(existing.Labels, desired.Labels) && existing.Status.Deployment == desired.Status.Deployment && + existing.Status.PrrSizeBytes == desired.Status.PrrSizeBytes && equality.Semantic.DeepEqual(existing.Status.UpstreamLock, desired.Status.UpstreamLock) && equality.Semantic.DeepEqual(existing.Status.SelfLock, desired.Status.SelfLock) } @@ -219,6 +220,11 @@ func buildPackageRevision(ctx context.Context, repo *configapi.Repository, pkgRe // PackageConditions omitted — PR controller owns after first render. } + // Calculate resource size for status.prrSizeBytes. + if prr, err := pkgRev.GetResources(ctx); err == nil && prr != nil && prr.Spec.Resources != nil { + status.PrrSizeBytes = repository.CalculateResourcesSize(prr.Spec.Resources) + } + crd := &porchv1alpha2.PackageRevision{ TypeMeta: metav1.TypeMeta{ Kind: "PackageRevision", diff --git a/controllers/repositories/pkg/controllers/repository/pkgrevsync_test.go b/controllers/repositories/pkg/controllers/repository/pkgrevsync_test.go index 3742c200a..8aca7ffc7 100644 --- a/controllers/repositories/pkg/controllers/repository/pkgrevsync_test.go +++ b/controllers/repositories/pkg/controllers/repository/pkgrevsync_test.go @@ -91,6 +91,7 @@ type fakePackageRevision struct { commitTime time.Time commitAuthor string isLatest bool + resources map[string]string } func (f *fakePackageRevision) KubeObjectNamespace() string { return f.key.RKey().Namespace } @@ -110,6 +111,13 @@ func (f *fakePackageRevision) GetPackageRevision(_ context.Context) (*porchv1alp return nil, nil } func (f *fakePackageRevision) GetResources(_ context.Context) (*porchv1alpha1.PackageRevisionResources, error) { + if f.resources != nil { + return &porchv1alpha1.PackageRevisionResources{ + Spec: porchv1alpha1.PackageRevisionResourcesSpec{ + Resources: f.resources, + }, + }, nil + } return nil, nil } func (f *fakePackageRevision) GetUpstreamLock(_ context.Context) (kptfilev1.Upstream, kptfilev1.Locator, error) { @@ -219,6 +227,27 @@ func TestBuildPackageRevision(t *testing.T) { assert.Nil(t, crd.Status.UpstreamLock) assert.Nil(t, crd.Status.SelfLock) }) + + t.Run("PrrSizeBytes calculated from resources", func(t *testing.T) { + pkgRev := newFakePkgRev("sized-pkg", "ws1", porchv1alpha2.PackageRevisionLifecyclePublished) + pkgRev.resources = map[string]string{ + "Kptfile": "abc", // 3 bytes + "cm.yaml": "defgh", // 5 bytes + "ns.yaml": "ij", // 2 bytes + } + + crd, err := buildPackageRevision(ctx, repo, pkgRev) + assert.NoError(t, err) + assert.Equal(t, int64(10), crd.Status.PrrSizeBytes) + }) + + t.Run("PrrSizeBytes zero when no resources", func(t *testing.T) { + pkgRev := newFakePkgRev("empty-pkg", "ws1", porchv1alpha2.PackageRevisionLifecycleDraft) + + crd, err := buildPackageRevision(ctx, repo, pkgRev) + assert.NoError(t, err) + assert.Equal(t, int64(0), crd.Status.PrrSizeBytes) + }) } // --- Tests: packageRevisionUpToDate --- @@ -253,6 +282,9 @@ func TestPackageRevisionUpToDate(t *testing.T) { {name: "annotations differ - still up to date", modify: func(pr *porchv1alpha2.PackageRevision) { pr.Annotations = map[string]string{"foo": "bar"} }, expected: true}, + {name: "PrrSizeBytes changed", modify: func(pr *porchv1alpha2.PackageRevision) { + pr.Status.PrrSizeBytes = 12345 + }, expected: false}, } for _, tt := range tests { diff --git a/pkg/repository/util.go b/pkg/repository/util.go index b7db4bfe4..92c5b0af3 100644 --- a/pkg/repository/util.go +++ b/pkg/repository/util.go @@ -212,6 +212,16 @@ func PackageRevisionIsPlaceholder(ctx context.Context, namespace string, referen return false, nil } +// CalculateResourcesSize returns the total byte size of a package's resource +// file contents. This is used to populate status.prrSizeBytes on the CRD. +func CalculateResourcesSize(resources map[string]string) int64 { + var total int64 + for _, v := range resources { + total += int64(len(v)) + } + return total +} + func WriteResourcesToFS(fs filesys.FileSystem, rootDir string, resources map[string]string) (string, error) { if rootDir != "" { if err := fs.MkdirAll(rootDir); err != nil { diff --git a/pkg/repository/util_test.go b/pkg/repository/util_test.go index 927c7ec09..2a6fcfc61 100644 --- a/pkg/repository/util_test.go +++ b/pkg/repository/util_test.go @@ -217,6 +217,51 @@ func TestPathsOverlap(t *testing.T) { assert.False(t, PathsOverlap("pkg", "pkg-other")) } +func TestCalculateResourcesSize(t *testing.T) { + tests := []struct { + name string + resources map[string]string + want int64 + }{ + { + name: "nil map", + resources: nil, + want: 0, + }, + { + name: "empty map", + resources: map[string]string{}, + want: 0, + }, + { + name: "single file", + resources: map[string]string{"Kptfile": "hello"}, + want: 5, + }, + { + name: "multiple files", + resources: map[string]string{ + "Kptfile": "abc", + "cm.yaml": "defgh", + "nested.txt": "ij", + }, + want: 10, + }, + { + name: "multi-byte UTF-8 characters", + resources: map[string]string{"file.yaml": "héllo"}, // é is 2 bytes in UTF-8 + want: 6, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CalculateResourcesSize(tt.resources) + assert.Equal(t, tt.want, got) + }) + } +} + func TestValidatePackagePathOverlap(t *testing.T) { newPr := &porchapi.PackageRevision{ Spec: porchapi.PackageRevisionSpec{ diff --git a/test/e2e/crd/clone_test.go b/test/e2e/crd/clone_test.go index 1cd8d21f0..b5011ccff 100644 --- a/test/e2e/crd/clone_test.go +++ b/test/e2e/crd/clone_test.go @@ -58,6 +58,9 @@ var _ = Describe("Clone", Ordered, Label("lifecycle"), func() { Expect(pr.Status.SelfLock).NotTo(BeNil()) Expect(pr.Status.SelfLock.Git).NotTo(BeNil()) Expect(pr.Status.SelfLock.Git.Commit).NotTo(BeEmpty()) + + By("verifying PrrSizeBytes is populated") + Expect(pr.Status.PrrSizeBytes).To(BeNumerically(">", int64(0))) }) It("should clone into a deployment repository", func() { diff --git a/test/e2e/crd/init_test.go b/test/e2e/crd/init_test.go index 8e0b187a6..d7af3a52c 100644 --- a/test/e2e/crd/init_test.go +++ b/test/e2e/crd/init_test.go @@ -38,6 +38,9 @@ var _ = Describe("Init", Ordered, Label("lifecycle"), func() { By("verifying Kptfile exists in package content") resources := getPRRResources(env.Ctx, env.Namespace, pr.Name) Expect(resources).To(HaveKey("Kptfile")) + + By("verifying PrrSizeBytes is populated") + Expect(pr.Status.PrrSizeBytes).To(BeNumerically(">", int64(0))) }) It("should init a package with full metadata", func() { diff --git a/test/e2e/crd/push_test.go b/test/e2e/crd/push_test.go index 9c3656504..1a6d18090 100644 --- a/test/e2e/crd/push_test.go +++ b/test/e2e/crd/push_test.go @@ -37,6 +37,10 @@ var _ = Describe("Push", Ordered, Label("content"), func() { waitForReady(env.Ctx, pr) waitForPRRVisible(env.Ctx, env.Namespace, pr.Name) + By("recording initial PrrSizeBytes") + initialSize := pr.Status.PrrSizeBytes + Expect(initialSize).To(BeNumerically(">", int64(0))) + By("pushing a new ConfigMap via PRR") updatePRRResources(env.Ctx, env.Namespace, pr.Name, map[string]string{ "configmap.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: push-test-cm\ndata:\n key: value\n", @@ -52,6 +56,12 @@ var _ = Describe("Push", Ordered, Label("content"), func() { g.Expect(resources["configmap.yaml"]).To(ContainSubstring("push-test-cm")) g.Expect(resources).To(HaveKey("Kptfile")) }).WithTimeout(defaultTimeout).WithPolling(defaultInterval).Should(Succeed()) + + By("verifying PrrSizeBytes increased after push") + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(env.Ctx, client.ObjectKeyFromObject(pr), pr)).To(Succeed()) + g.Expect(pr.Status.PrrSizeBytes).To(BeNumerically(">", initialSize)) + }).WithTimeout(defaultTimeout).WithPolling(defaultInterval).Should(Succeed()) }) It("should handle empty PRR update without error", func() { diff --git a/test/e2e/crd/repository_test.go b/test/e2e/crd/repository_test.go index 2e07e86f9..7638853c0 100644 --- a/test/e2e/crd/repository_test.go +++ b/test/e2e/crd/repository_test.go @@ -327,6 +327,9 @@ var _ = Describe("Repository", Ordered, Label("infra"), func() { Expect(pr.Spec.PackageName).To(Equal("basens")) Expect(pr.Spec.RepositoryName).To(Equal(testBlueprintsRepo)) Expect(pr.Spec.Lifecycle).To(Equal(porchv1alpha2.PackageRevisionLifecyclePublished)) + + By("verifying PrrSizeBytes is populated for discovered package") + Expect(pr.Status.PrrSizeBytes).To(BeNumerically(">", int64(0))) }) It("should seed lifecycle and revision for discovered published packages", func() {