Skip to content

Commit 74a86cc

Browse files
Add CRD → Kptfile metadata synchronization for v1alpha2 PackageRevisions (#1108)
* Add CRD → Kptfile metadata synchronization for v1alpha2 PackageRevisions Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> * Address copilot comments Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> * Address review comments Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> * Fix CRD to Kptfile sync flake Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> * Fix failing pkgMeta sync e2e Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> * Fix v1alpha1 meta sync flake Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> * Fix another meta sync flake Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> * Update controllers/packagerevisions/pkg/controllers/packagerevision/metadata.go Co-authored-by: mozesl-nokia <laszlo.mozes@nokia.com> Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> --------- Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> Co-authored-by: mozesl-nokia <laszlo.mozes@nokia.com>
1 parent 896914c commit 74a86cc

10 files changed

Lines changed: 2018 additions & 46 deletions

File tree

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Copyright 2026 The kpt Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package packagerevision
16+
17+
import (
18+
"context"
19+
20+
kptfilev1 "github.com/kptdev/kpt/api/kptfile/v1"
21+
22+
porchv1alpha2 "github.com/kptdev/porch/api/porch/v1alpha2"
23+
"github.com/kptdev/porch/pkg/repository"
24+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
25+
ctrl "sigs.k8s.io/controller-runtime"
26+
"sigs.k8s.io/controller-runtime/pkg/client"
27+
"sigs.k8s.io/controller-runtime/pkg/log"
28+
"sigs.k8s.io/kustomize/kyaml/yaml"
29+
)
30+
31+
// reconcilePackageMetadata syncs user-set spec.packageMetadata to the Kptfile.
32+
// Only applies to Draft packages; Proposed and Published are immutable (aligns with v1alpha1).
33+
func (r *PackageRevisionReconciler) reconcilePackageMetadata(ctx context.Context, pr *porchv1alpha2.PackageRevision, repoKey repository.RepositoryKey) (*ctrl.Result, error) {
34+
log := log.FromContext(ctx)
35+
36+
// Only Draft packages can have metadata synced.
37+
if pr.Spec.Lifecycle != porchv1alpha2.PackageRevisionLifecycleDraft {
38+
return nil, nil
39+
}
40+
41+
// Nothing to sync if user hasn't set packageMetadata.
42+
if pr.Spec.PackageMetadata == nil {
43+
return nil, nil
44+
}
45+
46+
// Skip if a render is already pending (from PRR push) to respect "PRR push wins" semantics.
47+
if pr.Annotations[porchv1alpha2.AnnotationRenderRequest] != pr.Status.ObservedPrrResourceVersion {
48+
log.V(3).Info("render pending from PRR push, skipping metadata sync")
49+
return nil, nil
50+
}
51+
52+
// Skip if source render hasn't completed yet — content is still being initialized.
53+
if pr.Status.CreationSource != "" && !isRenderedTrue(pr) {
54+
log.V(3).Info("source render pending, skipping metadata sync")
55+
return nil, nil
56+
}
57+
58+
// Read and parse current package content.
59+
resources, kf, err := r.readAndParseKptfile(ctx, repoKey, pr)
60+
if err != nil {
61+
return nil, nil
62+
}
63+
64+
// Apply metadata changes and sync to draft. Returns false if Kptfile already matches spec.
65+
result, err := r.applyAndWriteMetadata(ctx, repoKey, pr, resources, kf)
66+
if err != nil {
67+
return nil, nil
68+
}
69+
70+
if result {
71+
return r.triggerRenderIfNeeded(ctx, pr)
72+
}
73+
74+
return nil, nil
75+
}
76+
77+
// readAndParseKptfile reads all package resources and parses the Kptfile.
78+
func (r *PackageRevisionReconciler) readAndParseKptfile(ctx context.Context, repoKey repository.RepositoryKey, pr *porchv1alpha2.PackageRevision) (map[string]string, kptfilev1.KptFile, error) {
79+
log := log.FromContext(ctx)
80+
81+
content, err := r.ContentCache.GetPackageContent(ctx, repoKey, pr.Spec.PackageName, pr.Spec.WorkspaceName)
82+
if err != nil {
83+
log.Error(err, "failed to get package content")
84+
return nil, kptfilev1.KptFile{}, err
85+
}
86+
87+
resources, err := content.GetResourceContents(ctx)
88+
if err != nil {
89+
log.Error(err, "failed to read resources")
90+
return nil, kptfilev1.KptFile{}, err
91+
}
92+
93+
kf, err := kptfileFromResources(resources)
94+
if err != nil {
95+
log.Error(err, "failed to parse Kptfile")
96+
return nil, kptfilev1.KptFile{}, err
97+
}
98+
99+
return resources, kf, nil
100+
}
101+
102+
// applyAndWriteMetadata applies metadata changes and writes to a draft.
103+
func (r *PackageRevisionReconciler) applyAndWriteMetadata(ctx context.Context, repoKey repository.RepositoryKey, pr *porchv1alpha2.PackageRevision, resources map[string]string, kf kptfilev1.KptFile) (bool, error) {
104+
log := log.FromContext(ctx)
105+
106+
// Apply spec.packageMetadata to Kptfile (merge mode).
107+
if !applyPackageMetadataToKptfile(&kf, pr) {
108+
return false, nil // No changes, nothing to write
109+
}
110+
111+
// Serialize Kptfile.
112+
updatedKfBytes, err := yaml.MarshalWithOptions(&kf, &yaml.EncoderOptions{SeqIndent: yaml.WideSequenceStyle})
113+
if err != nil {
114+
log.Error(err, "failed to serialize Kptfile")
115+
return false, err
116+
}
117+
118+
// Create draft and write updated resources.
119+
draft, err := r.ContentCache.CreateDraftFromExisting(ctx, repoKey, pr.Spec.PackageName, pr.Spec.WorkspaceName)
120+
if err != nil {
121+
log.Error(err, "failed to create draft")
122+
return false, err
123+
}
124+
125+
// UpdateResources is a full replace — include all files to avoid data loss.
126+
resources["Kptfile"] = string(updatedKfBytes)
127+
log.Info("metadata sync writing resources", "resourceCount", len(resources))
128+
if err := draft.UpdateResources(ctx, resources, "metadata-sync"); err != nil {
129+
log.Error(err, "failed to write resources")
130+
return false, err
131+
}
132+
133+
if err := r.ContentCache.CloseDraft(ctx, repoKey, draft, 0); err != nil {
134+
log.Error(err, "failed to close draft")
135+
return false, err
136+
}
137+
138+
log.V(3).Info("metadata synced to draft")
139+
return true, nil
140+
}
141+
142+
// triggerRenderIfNeeded triggers a render cycle for packages that have already been rendered.
143+
// For new (unrendered) packages, render will occur via sourceTrigger without annotation patching.
144+
func (r *PackageRevisionReconciler) triggerRenderIfNeeded(ctx context.Context, pr *porchv1alpha2.PackageRevision) (*ctrl.Result, error) {
145+
log := log.FromContext(ctx)
146+
147+
// Trigger render based on package state:
148+
// - Already-rendered packages: patch annotation to trigger render in next cycle via annotationTrigger
149+
// - New packages: skip annotation, rely on sourceTrigger (no requeue needed)
150+
if isRenderedTrue(pr) {
151+
if err := r.setRenderRequestAnnotation(ctx, pr); err != nil {
152+
log.Error(err, "failed to set render annotation")
153+
return nil, nil
154+
}
155+
log.Info("metadata updated in already-rendered draft, render queued via annotation")
156+
return &ctrl.Result{Requeue: true}, nil
157+
}
158+
159+
log.Info("metadata synced to new package, render will trigger via sourceTrigger")
160+
return nil, nil
161+
}
162+
163+
// applyPackageMetadataToKptfile applies labels and annotations to Kptfile (merge mode).
164+
func applyPackageMetadataToKptfile(kf *kptfilev1.KptFile, pr *porchv1alpha2.PackageRevision) bool {
165+
if pr.Spec.PackageMetadata == nil {
166+
return false
167+
}
168+
169+
var labelsChanged bool
170+
kf.Labels, labelsChanged = applyMetadataMap(kf.Labels, pr.Spec.PackageMetadata.Labels)
171+
172+
var annotationsChanged bool
173+
kf.Annotations, annotationsChanged = applyMetadataMap(kf.Annotations, pr.Spec.PackageMetadata.Annotations)
174+
175+
return labelsChanged || annotationsChanged
176+
}
177+
178+
// applyMetadataMap merges desired key-value pairs into current, returning the resulting map and whether any changes were made.
179+
// Safe to call with nil current or desired maps.
180+
func applyMetadataMap(current, desired map[string]string) (map[string]string, bool) {
181+
if len(desired) == 0 {
182+
return current, false
183+
}
184+
185+
if current == nil {
186+
current = make(map[string]string, len(desired))
187+
}
188+
189+
changed := false
190+
for k, v := range desired {
191+
if cv, exists := current[k]; !exists || cv != v {
192+
current[k] = v
193+
changed = true
194+
}
195+
}
196+
197+
return current, changed
198+
}
199+
200+
// setRenderRequestAnnotation triggers render by updating the render-request annotation with nanosecond precision.
201+
func (r *PackageRevisionReconciler) setRenderRequestAnnotation(ctx context.Context, pr *porchv1alpha2.PackageRevision) error {
202+
// Capture original before mutation to generate a proper MergeFrom patch.
203+
original := pr.DeepCopy()
204+
205+
if pr.Annotations == nil {
206+
pr.Annotations = make(map[string]string)
207+
}
208+
// Value just needs to differ from the previous one to trigger a reconcile.
209+
// Nanosecond precision avoids collisions on rapid successive updates; human-readable format aids debugging.
210+
pr.Annotations[porchv1alpha2.AnnotationRenderRequest] = metav1.Now().Format("2006-01-02T15:04:05.000000000Z07:00")
211+
return r.Patch(ctx, pr, client.MergeFrom(original))
212+
}

0 commit comments

Comments
 (0)