diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/clone.go b/controllers/packagerevisions/pkg/controllers/packagerevision/clone.go new file mode 100644 index 000000000..fd720c43f --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/clone.go @@ -0,0 +1,121 @@ +// Copyright 2026 The kpt Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package packagerevision + +import ( + "context" + "fmt" + "path" + + kptfilev1 "github.com/kptdev/kpt/api/kptfile/v1" + "github.com/kptdev/kpt/pkg/lib/kptops" + porchv1alpha2 "github.com/kptdev/porch/api/porch/v1alpha2" + "github.com/kptdev/porch/pkg/repository" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// clonePackage reads the source package referenced by CloneFrom and returns its resources +// with Kptfile upstream/upstreamLock updated. +// Currently only supports upstreamRef (registered repo). Raw git URL is not yet implemented. +func (r *PackageRevisionReconciler) clonePackage(ctx context.Context, pr *porchv1alpha2.PackageRevision) (map[string]string, error) { + cloneFrom := r.getCloneFrom(pr) + + if cloneFrom.UpstreamRef != nil { + return r.cloneFromUpstreamRef(ctx, pr, cloneFrom.UpstreamRef) + } + if cloneFrom.Git != nil { + return r.cloneFromGit(ctx, pr, cloneFrom.Git) + } + return nil, fmt.Errorf("clone source must specify either upstreamRef or git") +} + +func (r *PackageRevisionReconciler) cloneFromUpstreamRef(ctx context.Context, pr *porchv1alpha2.PackageRevision, ref *porchv1alpha2.PackageRevisionRef) (map[string]string, error) { + log := log.FromContext(ctx) + var sourcePR porchv1alpha2.PackageRevision + if err := r.Get(ctx, client.ObjectKey{Namespace: pr.Namespace, Name: ref.Name}, &sourcePR); err != nil { + return nil, fmt.Errorf("failed to get upstream package %q: %w", ref.Name, err) + } + + if !porchv1alpha2.LifecycleIsPublished(sourcePR.Spec.Lifecycle) { + return nil, fmt.Errorf("upstream package %q must be published", ref.Name) + } + + log.V(1).Info("cloning from upstream ref", "upstream", ref.Name) + + repoKey := repository.RepositoryKey{Namespace: pr.Namespace, Name: sourcePR.Spec.RepositoryName} + content, err := r.ContentCache.GetPackageContent(ctx, repoKey, sourcePR.Spec.PackageName, sourcePR.Spec.WorkspaceName) + if err != nil { + return nil, fmt.Errorf("failed to get upstream package content: %w", err) + } + + resources, err := content.GetResourceContents(ctx) + if err != nil { + return nil, fmt.Errorf("failed to read upstream resources: %w", err) + } + + upstream, lock, err := content.GetLock(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get upstream lock for %q: %w", ref.Name, err) + } + + if err := kptops.UpdateKptfileUpstream(r.getClonePackagename(pr), resources, upstream, lock); err != nil { + return nil, fmt.Errorf("failed to update Kptfile upstream: %w", err) + } + + return resources, nil +} + +func (r *PackageRevisionReconciler) cloneFromGit(ctx context.Context, pr *porchv1alpha2.PackageRevision, gitSpec *porchv1alpha2.GitPackage) (map[string]string, error) { + log.FromContext(ctx).V(1).Info("cloning from git", "repo", gitSpec.Repo, "ref", gitSpec.Ref, "directory", gitSpec.Directory) + resources, lock, err := r.ExternalPackageFetcher.FetchExternalGitPackage(ctx, gitSpec, pr.Namespace) + if err != nil { + return nil, fmt.Errorf("failed to fetch from git: %w", err) + } + + if err := kptops.UpdateKptfileUpstream(r.getClonePackagename(pr), resources, kptfilev1.Upstream{ + Type: kptfilev1.GitOrigin, + Git: &kptfilev1.Git{ + Repo: lock.Repo, + Directory: lock.Directory, + Ref: lock.Ref, + }, + }, kptfilev1.Locator{ + Type: kptfilev1.GitOrigin, + Git: &lock, + }); err != nil { + return nil, fmt.Errorf("failed to update Kptfile upstream: %w", err) + } + + return resources, nil +} + +// getCloneFrom returns the upstream package for a clone in the case of a source clone or a subpackage +// operation clone +func (r *PackageRevisionReconciler) getCloneFrom(pr *porchv1alpha2.PackageRevision) *porchv1alpha2.UpstreamPackage { + if pr.Status.CreationSource != "" && pr.Spec.SubpackageOperation != nil && pr.Spec.SubpackageOperation.CloneFrom != nil { + return pr.Spec.SubpackageOperation.CloneFrom + } + return pr.Spec.Source.CloneFrom +} + +// getClonePackagename returns the package name of a clone in the case of a source clone or a subpackage +// operation clone +func (r *PackageRevisionReconciler) getClonePackagename(pr *porchv1alpha2.PackageRevision) string { + if pr.Status.CreationSource != "" && pr.Spec.SubpackageOperation != nil && pr.Spec.SubpackageOperation.CloneFrom != nil { + return path.Base(pr.Spec.SubpackageOperation.SubpackageDir) + } + return pr.Spec.PackageName +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/clone_test.go b/controllers/packagerevisions/pkg/controllers/packagerevision/clone_test.go new file mode 100644 index 000000000..3d21a29a3 --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/clone_test.go @@ -0,0 +1,120 @@ +package packagerevision + +import ( + "testing" + + porchv1alpha2 "github.com/kptdev/porch/api/porch/v1alpha2" + "github.com/stretchr/testify/assert" +) + +func TestGetCloneFromSourceClone(t *testing.T) { + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "source.pkg.v1"}, + }, + }, + }, + } + + result := r.getCloneFrom(pr) + assert.Equal(t, "source.pkg.v1", result.UpstreamRef.Name) +} + +func TestGetCloneFromSubpackageClone(t *testing.T) { + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "source.pkg.v1"}, + }, + }, + SubpackageOperation: &porchv1alpha2.SubpackageOperation{ + SubpackageDir: "my-subpkg", + CloneFrom: &porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "subpkg-upstream.pkg.v1"}, + }, + }, + }, + Status: porchv1alpha2.PackageRevisionStatus{ + CreationSource: "init", + }, + } + + result := r.getCloneFrom(pr) + assert.Equal(t, "subpkg-upstream.pkg.v1", result.UpstreamRef.Name) +} + +func TestGetCloneFromSubpackageCloneWithoutCreationSource(t *testing.T) { + // Without CreationSource set, getCloneFrom should fall back to Source.CloneFrom + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "source.pkg.v1"}, + }, + }, + SubpackageOperation: &porchv1alpha2.SubpackageOperation{ + SubpackageDir: "my-subpkg", + CloneFrom: &porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "subpkg-upstream.pkg.v1"}, + }, + }, + }, + } + + result := r.getCloneFrom(pr) + assert.Equal(t, "source.pkg.v1", result.UpstreamRef.Name) +} + +func TestGetClonePackagenameSource(t *testing.T) { + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + }, + } + + assert.Equal(t, "my-pkg", r.getClonePackagename(pr)) +} + +func TestGetClonePackagenameSubpackage(t *testing.T) { + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + SubpackageOperation: &porchv1alpha2.SubpackageOperation{ + SubpackageDir: "level1/level2/my-subpkg", + CloneFrom: &porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v1"}, + }, + }, + }, + Status: porchv1alpha2.PackageRevisionStatus{ + CreationSource: "init", + }, + } + + assert.Equal(t, "my-subpkg", r.getClonePackagename(pr)) +} + +func TestGetClonePackagenameSubpackageWithoutCreationSource(t *testing.T) { + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + SubpackageOperation: &porchv1alpha2.SubpackageOperation{ + SubpackageDir: "level1/my-subpkg", + CloneFrom: &porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v1"}, + }, + }, + }, + } + + assert.Equal(t, "my-pkg", r.getClonePackagename(pr)) +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/packagerevision_controller.go b/controllers/packagerevisions/pkg/controllers/packagerevision/packagerevision_controller.go index 68742b782..dd9f9e519 100644 --- a/controllers/packagerevisions/pkg/controllers/packagerevision/packagerevision_controller.go +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/packagerevision_controller.go @@ -190,7 +190,7 @@ func resultOrDefault(result *ctrl.Result) ctrl.Result { // Returns (result, nil) if source was applied and status was updated. // Returns (nil, err) on failure. func (r *PackageRevisionReconciler) reconcileSource(ctx context.Context, pr *porchv1alpha2.PackageRevision, repoKey repository.RepositoryKey) (*ctrl.Result, error) { - resources, creationSource, err := r.applySource(ctx, pr) + resources, sourceOperationType, err := r.applySource(ctx, pr) if err != nil { return nil, r.setSourceFailed(ctx, pr, err) } @@ -199,7 +199,7 @@ func (r *PackageRevisionReconciler) reconcileSource(ctx context.Context, pr *por } log := log.FromContext(ctx) - log.Info("applying source", "type", creationSource, "name", pr.Name) + log.Info("applying source", "type", sourceOperationType, "name", pr.Name) // TODO: CreateNewDraft always receives lifecycle=Draft — consider removing the lifecycle parameter from the interface. draft, err := r.ContentCache.CreateNewDraft(ctx, repoKey, pr.Spec.PackageName, pr.Spec.WorkspaceName, string(porchv1alpha2.PackageRevisionLifecycleDraft)) @@ -207,7 +207,21 @@ func (r *PackageRevisionReconciler) reconcileSource(ctx context.Context, pr *por return nil, r.setSourceFailed(ctx, pr, fmt.Errorf("create draft: %w", err)) } - if err := draft.UpdateResources(ctx, resources, creationSource); err != nil { + return r.finalizeDraftAndUpdateStatus(ctx, pr, repoKey, draft, resources, sourceOperationType) +} + +// finalizeDraftAndUpdateStatus completes the draft operation by updating resources, +// closing the draft, and updating the package revision status. +func (r *PackageRevisionReconciler) finalizeDraftAndUpdateStatus( + ctx context.Context, + pr *porchv1alpha2.PackageRevision, + repoKey repository.RepositoryKey, + draft repository.PackageRevisionDraftSlim, + resources map[string]string, + operationType string) (*ctrl.Result, error) { + log := log.FromContext(ctx) + + if err := draft.UpdateResources(ctx, resources, operationType); err != nil { return nil, r.setSourceFailed(ctx, pr, fmt.Errorf("update resources: %w", err)) } @@ -221,13 +235,11 @@ func (r *PackageRevisionReconciler) reconcileSource(ctx context.Context, pr *por log.Error(err, "failed to read back package content after source execution") } - r.updateStatus(ctx, pr, content, creationSource, - readyCondition(pr.Generation, metav1.ConditionFalse, porchv1alpha2.ReasonPending, "awaiting render"), - ) + r.updateStatus(ctx, pr, content, operationType, + readyCondition(pr.Generation, metav1.ConditionFalse, porchv1alpha2.ReasonPending, "awaiting render")) // Set Rendered=Unknown via the render field manager. r.updateRenderStatus(ctx, pr, "", "", - renderedCondition(pr.Generation, metav1.ConditionUnknown, porchv1alpha2.ReasonPending, "awaiting render"), - ) + renderedCondition(pr.Generation, metav1.ConditionUnknown, porchv1alpha2.ReasonPending, "awaiting render")) r.ensureLatestRevisionLabel(ctx, pr) result := ctrl.Result{Requeue: true} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/source.go b/controllers/packagerevisions/pkg/controllers/packagerevision/source.go index c0d861b2f..3bff5fb22 100644 --- a/controllers/packagerevisions/pkg/controllers/packagerevision/source.go +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/source.go @@ -17,14 +17,8 @@ package packagerevision import ( "context" "fmt" - iofs "io/fs" - "maps" - "strings" - kptfilev1 "github.com/kptdev/kpt/api/kptfile/v1" - "github.com/kptdev/kpt/pkg/kptfile/kptfileutil" "github.com/kptdev/kpt/pkg/kptpkg" - "github.com/kptdev/kpt/pkg/lib/kptops" "github.com/kptdev/kpt/pkg/printer" "github.com/kptdev/kpt/pkg/printer/fake" porchv1alpha2 "github.com/kptdev/porch/api/porch/v1alpha2" @@ -32,7 +26,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/kustomize/kyaml/filesys" - "sigs.k8s.io/kustomize/kyaml/yaml" ) // applySource executes the package creation source and returns the resulting resources. @@ -115,241 +108,3 @@ func (r *PackageRevisionReconciler) copyPackage(ctx context.Context, pr *porchv1 return content.GetResourceContents(ctx) } - -// clonePackage reads the source package referenced by CloneFrom and returns its resources -// with Kptfile upstream/upstreamLock updated. -// Currently only supports upstreamRef (registered repo). Raw git URL is not yet implemented. -func (r *PackageRevisionReconciler) clonePackage(ctx context.Context, pr *porchv1alpha2.PackageRevision) (map[string]string, error) { - cloneFrom := pr.Spec.Source.CloneFrom - - if cloneFrom.UpstreamRef != nil { - return r.cloneFromUpstreamRef(ctx, pr, cloneFrom.UpstreamRef) - } - if cloneFrom.Git != nil { - return r.cloneFromGit(ctx, pr, cloneFrom.Git) - } - return nil, fmt.Errorf("clone source must specify either upstreamRef or git") -} - -func (r *PackageRevisionReconciler) cloneFromUpstreamRef(ctx context.Context, pr *porchv1alpha2.PackageRevision, ref *porchv1alpha2.PackageRevisionRef) (map[string]string, error) { - log := log.FromContext(ctx) - var sourcePR porchv1alpha2.PackageRevision - if err := r.Get(ctx, client.ObjectKey{Namespace: pr.Namespace, Name: ref.Name}, &sourcePR); err != nil { - return nil, fmt.Errorf("failed to get upstream package %q: %w", ref.Name, err) - } - - if !porchv1alpha2.LifecycleIsPublished(sourcePR.Spec.Lifecycle) { - return nil, fmt.Errorf("upstream package %q must be published", ref.Name) - } - - log.V(1).Info("cloning from upstream ref", "upstream", ref.Name) - - repoKey := repository.RepositoryKey{Namespace: pr.Namespace, Name: sourcePR.Spec.RepositoryName} - content, err := r.ContentCache.GetPackageContent(ctx, repoKey, sourcePR.Spec.PackageName, sourcePR.Spec.WorkspaceName) - if err != nil { - return nil, fmt.Errorf("failed to get upstream package content: %w", err) - } - - resources, err := content.GetResourceContents(ctx) - if err != nil { - return nil, fmt.Errorf("failed to read upstream resources: %w", err) - } - - upstream, lock, err := content.GetLock(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get upstream lock for %q: %w", ref.Name, err) - } - - if err := kptops.UpdateKptfileUpstream(pr.Spec.PackageName, resources, upstream, lock); err != nil { - return nil, fmt.Errorf("failed to update Kptfile upstream: %w", err) - } - - return resources, nil -} - -func (r *PackageRevisionReconciler) cloneFromGit(ctx context.Context, pr *porchv1alpha2.PackageRevision, gitSpec *porchv1alpha2.GitPackage) (map[string]string, error) { - log.FromContext(ctx).V(1).Info("cloning from git", "repo", gitSpec.Repo, "ref", gitSpec.Ref, "directory", gitSpec.Directory) - resources, lock, err := r.ExternalPackageFetcher.FetchExternalGitPackage(ctx, gitSpec, pr.Namespace) - if err != nil { - return nil, fmt.Errorf("failed to fetch from git: %w", err) - } - - if err := kptops.UpdateKptfileUpstream(pr.Spec.PackageName, resources, kptfilev1.Upstream{ - Type: kptfilev1.GitOrigin, - Git: &kptfilev1.Git{ - Repo: lock.Repo, - Directory: lock.Directory, - Ref: lock.Ref, - }, - }, kptfilev1.Locator{ - Type: kptfilev1.GitOrigin, - Git: &lock, - }); err != nil { - return nil, fmt.Errorf("failed to update Kptfile upstream: %w", err) - } - - return resources, nil -} - -// upgradePackage performs a 3-way merge between the old upstream, new upstream, -// and current local package, then updates the Kptfile upstream/upstreamLock to -// point at the new upstream. -func (r *PackageRevisionReconciler) upgradePackage(ctx context.Context, pr *porchv1alpha2.PackageRevision) (map[string]string, error) { - log := log.FromContext(ctx) - upgrade := pr.Spec.Source.Upgrade - log.V(1).Info("upgrading package", "oldUpstream", upgrade.OldUpstream.Name, - "newUpstream", upgrade.NewUpstream.Name, "current", upgrade.CurrentPackage.Name) - - strategy := string(upgrade.Strategy) - if strategy == "" { - strategy = string(porchv1alpha2.ResourceMerge) - } - - // Look up all three package revisions. - oldUpstreamPR, err := r.getPublishedPackageRevision(ctx, pr.Namespace, upgrade.OldUpstream.Name) - if err != nil { - return nil, fmt.Errorf("old upstream: %w", err) - } - newUpstreamPR, err := r.getPublishedPackageRevision(ctx, pr.Namespace, upgrade.NewUpstream.Name) - if err != nil { - return nil, fmt.Errorf("new upstream: %w", err) - } - currentPR, err := r.getPublishedPackageRevision(ctx, pr.Namespace, upgrade.CurrentPackage.Name) - if err != nil { - return nil, fmt.Errorf("current package: %w", err) - } - - // Read content and resources. Retain new upstream content for lock extraction. - oldUpstreamResources, err := r.getPackageResources(ctx, oldUpstreamPR) - if err != nil { - return nil, fmt.Errorf("failed to read old upstream resources: %w", err) - } - newUpstreamContent, newUpstreamResources, err := r.getPackageContentAndResources(ctx, newUpstreamPR) - if err != nil { - return nil, fmt.Errorf("failed to read new upstream resources: %w", err) - } - currentResources, err := r.getPackageResources(ctx, currentPR) - if err != nil { - return nil, fmt.Errorf("failed to read current package resources: %w", err) - } - - // Workaround for kpt bug: fast-forward's hasKfDiff strips Upstream and - // UpstreamLock but not Status, so the Rendered condition written by kpt - // render is treated as a local modification. Only strip for fast-forward - // since other strategies need status for the 3-way merge. - if strategy == string(porchv1alpha2.FastForward) { - currentResources = copyResources(currentResources) - stripKptfileStatus(currentResources) - } - - // 3-way merge. - updated, err := (&repository.DefaultPackageUpdater{}).Update(ctx, - repository.PackageResources{Contents: currentResources}, - repository.PackageResources{Contents: oldUpstreamResources}, - repository.PackageResources{Contents: newUpstreamResources}, - strategy, - ) - if err != nil { - return nil, fmt.Errorf("3-way merge failed: %w", err) - } - - // Update Kptfile upstream/upstreamLock to point at new upstream. - newUpstream, newUpstreamLock, err := newUpstreamContent.GetLock(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get new upstream lock: %w", err) - } - if err := kptops.UpdateKptfileUpstream(pr.Spec.PackageName, updated.Contents, newUpstream, newUpstreamLock); err != nil { - return nil, fmt.Errorf("failed to update Kptfile upstream: %w", err) - } - - // Add merge-key comments to newly added resources. - result, err := ensureMergeKey(updated.Contents) - if err != nil { - // Non-fatal — log and return unmodified resources. - log.V(1).Info("merge-key annotation failed, using unmodified resources") - result = updated.Contents - } - - return result, nil -} - -// getPublishedPackageRevision looks up a PackageRevision CRD and validates it is published. -func (r *PackageRevisionReconciler) getPublishedPackageRevision(ctx context.Context, namespace, name string) (*porchv1alpha2.PackageRevision, error) { - var pr porchv1alpha2.PackageRevision - if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, &pr); err != nil { - return nil, fmt.Errorf("failed to get package %q: %w", name, err) - } - if !porchv1alpha2.LifecycleIsPublished(pr.Spec.Lifecycle) { - return nil, fmt.Errorf("package %q must be published", name) - } - return &pr, nil -} - -// getPackageResources reads the resource contents for a package revision via the cache. -func (r *PackageRevisionReconciler) getPackageResources(ctx context.Context, pr *porchv1alpha2.PackageRevision) (map[string]string, error) { - _, resources, err := r.getPackageContentAndResources(ctx, pr) - return resources, err -} - -// getPackageContentAndResources reads both the content handle and resource map -// for a package revision. Use this when you need the content for more than just -// resources (e.g. to call GetLock). -func (r *PackageRevisionReconciler) getPackageContentAndResources(ctx context.Context, pr *porchv1alpha2.PackageRevision) (repository.PackageContent, map[string]string, error) { - repoKey := repository.RepositoryKey{Namespace: pr.Namespace, Name: pr.Spec.RepositoryName} - content, err := r.ContentCache.GetPackageContent(ctx, repoKey, pr.Spec.PackageName, pr.Spec.WorkspaceName) - if err != nil { - return nil, nil, err - } - resources, err := content.GetResourceContents(ctx) - if err != nil { - return nil, nil, err - } - return content, resources, nil -} - -// stripKptfileStatus removes the status section from the Kptfile in a resource map. -// Workaround for kpt bug: hasKfDiff in fastforward.go strips Upstream and -// UpstreamLock but not Status, so the Rendered condition written by kpt render -// is treated as a local modification and fast-forward rejects the upgrade. -func stripKptfileStatus(resources map[string]string) { - kfStr, ok := resources[kptfilev1.KptFileName] - if !ok { - return - } - kf, err := kptfileutil.DecodeKptfile(strings.NewReader(kfStr)) - if err != nil || kf.Status == nil { - return - } - kf.Status = nil - out, err := yaml.Marshal(kf) - if err != nil { - return - } - resources[kptfilev1.KptFileName] = string(out) -} - -func copyResources(src map[string]string) map[string]string { - dst := make(map[string]string, len(src)) - maps.Copy(dst, src) - return dst -} - -func readFsToMap(fs filesys.FileSystem) (map[string]string, error) { - contents := map[string]string{} - if err := fs.Walk("/", func(path string, info iofs.FileInfo, err error) error { - if err != nil { - return err - } - if info.Mode().IsRegular() { - data, err := fs.ReadFile(path) - if err != nil { - return err - } - contents[strings.TrimPrefix(path, "/")] = string(data) - } - return nil - }); err != nil { - return nil, err - } - return contents, nil -} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/upgrade.go b/controllers/packagerevisions/pkg/controllers/packagerevision/upgrade.go new file mode 100644 index 000000000..9b6049592 --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/upgrade.go @@ -0,0 +1,154 @@ +// Copyright 2026 The kpt Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package packagerevision + +import ( + "context" + "fmt" + "strings" + + "github.com/kptdev/kpt/pkg/lib/kptops" + porchv1alpha2 "github.com/kptdev/porch/api/porch/v1alpha2" + "github.com/kptdev/porch/pkg/repository" + pkgerrors "github.com/pkg/errors" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// upgradePackage performs a 3-way merge between the old upstream, new upstream, +// and current local package, then updates the Kptfile upstream/upstreamLock to +// point at the new upstream. +func (r *PackageRevisionReconciler) upgradePackage(ctx context.Context, pr *porchv1alpha2.PackageRevision) (map[string]string, error) { + upgrade := r.getUpgrade(pr) + + log := log.FromContext(ctx) + log.V(1).Info("upgrading package", "oldUpstream", upgrade.OldUpstream.Name, + "newUpstream", upgrade.NewUpstream.Name, "current", upgrade.CurrentPackage.Name) + + strategy := string(upgrade.Strategy) + if strategy == "" { + strategy = string(porchv1alpha2.ResourceMerge) + } + + // Look up all three package revisions. + oldUpstreamPR, err := r.getPublishedPackageRevision(ctx, pr.Namespace, upgrade.OldUpstream.Name) + if err != nil { + return nil, pkgerrors.Wrapf(err, "old upstream") + } + newUpstreamPR, err := r.getPublishedPackageRevision(ctx, pr.Namespace, upgrade.NewUpstream.Name) + if err != nil { + return nil, pkgerrors.Wrapf(err, "new upstream") + } + currentPR, err := r.getPackageRevisionForUpgrade(ctx, pr) + if err != nil { + return nil, pkgerrors.Wrapf(err, "current package") + } + + // Read content and resources. Retain new upstream content for lock extraction. + oldUpstreamResources, err := r.getPackageResources(ctx, oldUpstreamPR) + if err != nil { + return nil, pkgerrors.Wrapf(err, "failed to read old upstream resources") + } + newUpstreamContent, newUpstreamResources, err := r.getPackageContentAndResources(ctx, newUpstreamPR) + if err != nil { + return nil, pkgerrors.Wrapf(err, "failed to read new upstream resources") + } + currentResources, err := r.getPackageResourcesForUpgrade(ctx, currentPR) + if err != nil { + return nil, pkgerrors.Wrapf(err, "failed to read package resources for upgrade") + } + + // Workaround for kpt bug: fast-forward's hasKfDiff strips Upstream and + // UpstreamLock but not Status, so the Rendered condition written by kpt + // render is treated as a local modification. Only strip for fast-forward + // since other strategies need status for the 3-way merge. + if strategy == string(porchv1alpha2.FastForward) { + currentResources = copyResources(currentResources) + stripKptfileStatus(currentResources) + } + + // 3-way merge. + updated, err := (&repository.DefaultPackageUpdater{}).Update(ctx, + repository.PackageResources{Contents: currentResources}, + repository.PackageResources{Contents: oldUpstreamResources}, + repository.PackageResources{Contents: newUpstreamResources}, + strategy, + ) + if err != nil { + return nil, pkgerrors.Wrapf(err, "3-way merge failed") + } + + // Update Kptfile upstream/upstreamLock to point at new upstream. + newUpstream, newUpstreamLock, err := newUpstreamContent.GetLock(ctx) + if err != nil { + return nil, pkgerrors.Wrapf(err, "failed to get new upstream lock") + } + if err := kptops.UpdateKptfileUpstream(pr.Spec.PackageName, updated.Contents, newUpstream, newUpstreamLock); err != nil { + return nil, pkgerrors.Wrapf(err, "failed to update Kptfile upstream") + } + + // Add merge-key comments to newly added resources. + result, err := ensureMergeKey(updated.Contents) + if err != nil { + // Non-fatal — log and return unmodified resources. + log.V(1).Info("merge-key annotation failed, using unmodified resources") + result = updated.Contents + } + + return result, nil +} + +// getUpgrade returns the upstream package for a clone in the case of a source upgrade or a subpackage +// operation upgrade +func (r *PackageRevisionReconciler) getUpgrade(pr *porchv1alpha2.PackageRevision) *porchv1alpha2.PackageUpgradeSpec { + if pr.Status.CreationSource != "" && pr.Spec.SubpackageOperation != nil && pr.Spec.SubpackageOperation.Upgrade != nil { + return pr.Spec.SubpackageOperation.Upgrade + } + return pr.Spec.Source.Upgrade +} + +// getUpgrade returns the upstream package for a clone in the case of a source upgrade or a subpackage +// operation upgrade +func (r *PackageRevisionReconciler) getPackageRevisionForUpgrade(ctx context.Context, pr *porchv1alpha2.PackageRevision) (*porchv1alpha2.PackageRevision, error) { + if pr.Status.CreationSource != "" && pr.Spec.SubpackageOperation != nil && pr.Spec.SubpackageOperation.Upgrade != nil { + return r.getDraftPackageRevision(ctx, pr.Namespace, pr.Spec.SubpackageOperation.Upgrade.CurrentPackage.Name) + } + return r.getPublishedPackageRevision(ctx, pr.Namespace, pr.Spec.Source.Upgrade.CurrentPackage.Name) +} + +// getPackageResources reads the resource contents for a package revision via the cache. +func (r *PackageRevisionReconciler) getPackageResourcesForUpgrade(ctx context.Context, pr *porchv1alpha2.PackageRevision) (map[string]string, error) { + currentResources, err := r.getPackageResources(ctx, pr) + if err != nil { + return nil, pkgerrors.Wrapf(err, "failed to read current resources") + } + + if pr.Status.CreationSource == "" || pr.Spec.SubpackageOperation == nil || pr.Spec.SubpackageOperation.Upgrade == nil { + return currentResources, nil + } + + subpackageResources := make(map[string]string) + + for localResourceKey, localResourceValue := range currentResources { + if strings.HasPrefix(localResourceKey, pr.Spec.SubpackageOperation.SubpackageDir+"/") { + subpackageResources[strings.TrimPrefix(localResourceKey, pr.Spec.SubpackageOperation.SubpackageDir+"/")] = localResourceValue + } + } + + if len(subpackageResources) == 0 { + return nil, fmt.Errorf("subpackage %q not found in package", pr.Spec.SubpackageOperation.SubpackageDir) + } + + return subpackageResources, nil +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/upgrade_test.go b/controllers/packagerevisions/pkg/controllers/packagerevision/upgrade_test.go new file mode 100644 index 000000000..972965159 --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/upgrade_test.go @@ -0,0 +1,242 @@ +package packagerevision + +import ( + "context" + "testing" + + porchv1alpha2 "github.com/kptdev/porch/api/porch/v1alpha2" + "github.com/kptdev/porch/pkg/repository" + mockclient "github.com/kptdev/porch/test/mockery/mocks/external/sigs.k8s.io/controller-runtime/pkg/client" + mockrepository "github.com/kptdev/porch/test/mockery/mocks/porch/pkg/repository" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestGetUpgradeFromSource(t *testing.T) { + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + Source: &porchv1alpha2.PackageSource{ + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + OldUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v1"}, + NewUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v2"}, + }, + }, + }, + } + + result := r.getUpgrade(pr) + assert.Equal(t, "upstream.pkg.v1", result.OldUpstream.Name) + assert.Equal(t, "upstream.pkg.v2", result.NewUpstream.Name) +} + +func TestGetUpgradeFromSubpackageOperation(t *testing.T) { + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + Source: &porchv1alpha2.PackageSource{ + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + OldUpstream: porchv1alpha2.PackageRevisionRef{Name: "source-old"}, + NewUpstream: porchv1alpha2.PackageRevisionRef{Name: "source-new"}, + }, + }, + SubpackageOperation: &porchv1alpha2.SubpackageOperation{ + SubpackageDir: "my-subpkg", + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + OldUpstream: porchv1alpha2.PackageRevisionRef{Name: "subpkg-old"}, + NewUpstream: porchv1alpha2.PackageRevisionRef{Name: "subpkg-new"}, + }, + }, + }, + Status: porchv1alpha2.PackageRevisionStatus{ + CreationSource: "init", + }, + } + + result := r.getUpgrade(pr) + assert.Equal(t, "subpkg-old", result.OldUpstream.Name) + assert.Equal(t, "subpkg-new", result.NewUpstream.Name) +} + +func TestGetUpgradeFromSubpackageWithoutCreationSource(t *testing.T) { + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + Source: &porchv1alpha2.PackageSource{ + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + OldUpstream: porchv1alpha2.PackageRevisionRef{Name: "source-old"}, + NewUpstream: porchv1alpha2.PackageRevisionRef{Name: "source-new"}, + }, + }, + SubpackageOperation: &porchv1alpha2.SubpackageOperation{ + SubpackageDir: "my-subpkg", + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + OldUpstream: porchv1alpha2.PackageRevisionRef{Name: "subpkg-old"}, + NewUpstream: porchv1alpha2.PackageRevisionRef{Name: "subpkg-new"}, + }, + }, + }, + } + + result := r.getUpgrade(pr) + assert.Equal(t, "source-old", result.OldUpstream.Name) +} + +func TestGetPackageRevisionForUpgradeSource(t *testing.T) { + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "downstream.pkg.v1"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + obj.(*porchv1alpha2.PackageRevision).Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + + r := &PackageRevisionReconciler{Client: mc} + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + Source: &porchv1alpha2.PackageSource{ + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + CurrentPackage: porchv1alpha2.PackageRevisionRef{Name: "downstream.pkg.v1"}, + }, + }, + }, + } + + result, err := r.getPackageRevisionForUpgrade(context.Background(), pr) + require.NoError(t, err) + assert.Equal(t, porchv1alpha2.PackageRevisionLifecyclePublished, result.Spec.Lifecycle) +} + +func TestGetPackageRevisionForUpgradeSubpackage(t *testing.T) { + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "local.pkg.ws"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + obj.(*porchv1alpha2.PackageRevision).Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecycleDraft + return nil + }) + + r := &PackageRevisionReconciler{Client: mc} + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + Source: &porchv1alpha2.PackageSource{ + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + CurrentPackage: porchv1alpha2.PackageRevisionRef{Name: "should-not-use"}, + }, + }, + SubpackageOperation: &porchv1alpha2.SubpackageOperation{ + SubpackageDir: "my-subpkg", + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + CurrentPackage: porchv1alpha2.PackageRevisionRef{Name: "local.pkg.ws"}, + }, + }, + }, + Status: porchv1alpha2.PackageRevisionStatus{CreationSource: "init"}, + } + + result, err := r.getPackageRevisionForUpgrade(context.Background(), pr) + require.NoError(t, err) + assert.Equal(t, porchv1alpha2.PackageRevisionLifecycleDraft, result.Spec.Lifecycle) +} + +func TestGetPackageResourcesForUpgradeSourcePath(t *testing.T) { + ctx := context.Background() + + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(ctx).Return(map[string]string{ + "Kptfile": "kptfile-content", + "local.yaml": "local-content", + }, nil) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(ctx, repository.RepositoryKey{Namespace: "default", Name: "my-repo"}, "my-pkg", "v1").Return(mockContent, nil) + + r := &PackageRevisionReconciler{ContentCache: mockCache} + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "v1", + }, + } + + resources, err := r.getPackageResourcesForUpgrade(ctx, pr) + require.NoError(t, err) + assert.Equal(t, "kptfile-content", resources["Kptfile"]) + assert.Equal(t, "local-content", resources["local.yaml"]) +} + +func TestGetPackageResourcesForUpgradeSubpackagePath(t *testing.T) { + ctx := context.Background() + + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(ctx).Return(map[string]string{ + "Kptfile": "parent-kptfile", + "parent.yaml": "parent-resource", + "my-subpkg/Kptfile": "subpkg-kptfile", + "my-subpkg/resource.yaml": "subpkg-resource", + }, nil) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(ctx, repository.RepositoryKey{Namespace: "default", Name: "my-repo"}, "my-pkg", "v1").Return(mockContent, nil) + + r := &PackageRevisionReconciler{ContentCache: mockCache} + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "v1", + SubpackageOperation: &porchv1alpha2.SubpackageOperation{ + SubpackageDir: "my-subpkg", + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + CurrentPackage: porchv1alpha2.PackageRevisionRef{Name: "local.pkg.ws"}, + }, + }, + }, + Status: porchv1alpha2.PackageRevisionStatus{CreationSource: "init"}, + } + + resources, err := r.getPackageResourcesForUpgrade(ctx, pr) + require.NoError(t, err) + assert.Equal(t, "subpkg-kptfile", resources["Kptfile"]) + assert.Equal(t, "subpkg-resource", resources["resource.yaml"]) + assert.NotContains(t, resources, "parent.yaml") +} + +func TestGetPackageResourcesForUpgradeSubpackageNotFound(t *testing.T) { + ctx := context.Background() + + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(ctx).Return(map[string]string{ + "Kptfile": "parent-kptfile", + "parent.yaml": "parent-resource", + }, nil) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(ctx, repository.RepositoryKey{Namespace: "default", Name: "my-repo"}, "my-pkg", "v1").Return(mockContent, nil) + + r := &PackageRevisionReconciler{ContentCache: mockCache} + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "v1", + SubpackageOperation: &porchv1alpha2.SubpackageOperation{ + SubpackageDir: "nonexistent-subpkg", + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + CurrentPackage: porchv1alpha2.PackageRevisionRef{Name: "local.pkg.ws"}, + }, + }, + }, + Status: porchv1alpha2.PackageRevisionStatus{CreationSource: "init"}, + } + + _, err := r.getPackageResourcesForUpgrade(ctx, pr) + assert.ErrorContains(t, err, "not found in package") +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/util.go b/controllers/packagerevisions/pkg/controllers/packagerevision/util.go new file mode 100644 index 000000000..d762f8685 --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/util.go @@ -0,0 +1,124 @@ +// Copyright 2026 The kpt Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package packagerevision + +import ( + "context" + "fmt" + iofs "io/fs" + "maps" + "strings" + + kptfilev1 "github.com/kptdev/kpt/api/kptfile/v1" + "github.com/kptdev/kpt/pkg/kptfile/kptfileutil" + porchv1alpha2 "github.com/kptdev/porch/api/porch/v1alpha2" + "github.com/kptdev/porch/pkg/repository" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/kustomize/kyaml/filesys" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +// getDraftPackageRevision looks up a PackageRevision CRD and validates it is a draft. +func (r *PackageRevisionReconciler) getDraftPackageRevision(ctx context.Context, namespace, name string) (*porchv1alpha2.PackageRevision, error) { + var pr porchv1alpha2.PackageRevision + if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, &pr); err != nil { + return nil, fmt.Errorf("failed to get package %q: %w", name, err) + } + if pr.Spec.Lifecycle != porchv1alpha2.PackageRevisionLifecycleDraft { + return nil, fmt.Errorf("package %q must be a draft", name) + } + return &pr, nil +} + +// getPublishedPackageRevision looks up a PackageRevision CRD and validates it is published. +func (r *PackageRevisionReconciler) getPublishedPackageRevision(ctx context.Context, namespace, name string) (*porchv1alpha2.PackageRevision, error) { + var pr porchv1alpha2.PackageRevision + if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, &pr); err != nil { + return nil, fmt.Errorf("failed to get package %q: %w", name, err) + } + if !porchv1alpha2.LifecycleIsPublished(pr.Spec.Lifecycle) { + return nil, fmt.Errorf("package %q must be published", name) + } + return &pr, nil +} + +// getPackageResources reads the resource contents for a package revision via the cache. +func (r *PackageRevisionReconciler) getPackageResources(ctx context.Context, pr *porchv1alpha2.PackageRevision) (map[string]string, error) { + _, resources, err := r.getPackageContentAndResources(ctx, pr) + return resources, err +} + +// getPackageContentAndResources reads both the content handle and resource map +// for a package revision. Use this when you need the content for more than just +// resources (e.g. to call GetLock). +func (r *PackageRevisionReconciler) getPackageContentAndResources(ctx context.Context, pr *porchv1alpha2.PackageRevision) (repository.PackageContent, map[string]string, error) { + repoKey := repository.RepositoryKey{Namespace: pr.Namespace, Name: pr.Spec.RepositoryName} + content, err := r.ContentCache.GetPackageContent(ctx, repoKey, pr.Spec.PackageName, pr.Spec.WorkspaceName) + if err != nil { + return nil, nil, err + } + resources, err := content.GetResourceContents(ctx) + if err != nil { + return nil, nil, err + } + return content, resources, nil +} + +// stripKptfileStatus removes the status section from the Kptfile in a resource map. +// Workaround for kpt bug: hasKfDiff in fastforward.go strips Upstream and +// UpstreamLock but not Status, so the Rendered condition written by kpt render +// is treated as a local modification and fast-forward rejects the upgrade. +func stripKptfileStatus(resources map[string]string) { + kfStr, ok := resources[kptfilev1.KptFileName] + if !ok { + return + } + kf, err := kptfileutil.DecodeKptfile(strings.NewReader(kfStr)) + if err != nil || kf.Status == nil { + return + } + kf.Status = nil + out, err := yaml.Marshal(kf) + if err != nil { + return + } + resources[kptfilev1.KptFileName] = string(out) +} + +func copyResources(src map[string]string) map[string]string { + dst := make(map[string]string, len(src)) + maps.Copy(dst, src) + return dst +} + +func readFsToMap(fs filesys.FileSystem) (map[string]string, error) { + contents := map[string]string{} + if err := fs.Walk("/", func(path string, info iofs.FileInfo, err error) error { + if err != nil { + return err + } + if info.Mode().IsRegular() { + data, err := fs.ReadFile(path) + if err != nil { + return err + } + contents[strings.TrimPrefix(path, "/")] = string(data) + } + return nil + }); err != nil { + return nil, err + } + return contents, nil +}