Skip to content
Open
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
6 changes: 3 additions & 3 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,18 +108,18 @@
"--repositories.health-check-frequency=1m",
"--repositories.full-sync-frequency=3m",
"--packagerevisions.repo-operation-retry-attempts=3",
"--webhook-cert-dir=${workspaceFolder}/.build/deploy/.webhook-certs",
"-v=2"
],
"cwd": "${workspaceFolder}",
"envFile": "${workspaceFolder}/.env",
"env": {
"GIT_CACHE_DIR": "${workspaceFolder}/.cache-controller-v1alpha2",
"DB_HOST": "${env:DB_HOST}",
"DB_PORT": "5432",
"DB_NAME": "porch",
"DB_USER": "porch",
"DB_PASSWORD": "porch",
"DB_DRIVER": "pgx",
Comment on lines +111 to -121

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason were messing with the launch.json here? like removal of DB_HOST? or did that just get pulled in by mistake?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When getting the launch to work for controllers, I decided to move the ${env} ones out and move to use a local .env instead. Means we can each have our own gitignored .env file locally. I think we should do this for all launch targets, as having to set those (DB_HOST, FUNCTION_RUNNER_IP, etc) each time is messy. It's ok having a shared launch.json but ideally it shouldn't be commited to the repo.

"FUNCTION_RUNNER_ADDRESS": "${env:FUNCTION_RUNNER_IP}:9445"
"DB_DRIVER": "pgx"
}
},
// A configuration for running a porchctl command using the VS Code debugger.
Expand Down
5 changes: 4 additions & 1 deletion controllers/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ var (
&packagevariant.PackageVariantReconciler{},
&packagevariantset.PackageVariantSetReconciler{},
)

webhookCertDir string
)

// Reconciler is the interface implemented by (our) reconcilers, which includes some configuration and initialization.
Expand Down Expand Up @@ -169,6 +171,7 @@ func parseFlags() string {
klog.InitFlags(nil)

flag.StringVar(&enabledReconcilersString, "reconcilers", "", "reconcilers that should be enabled; use * to mean 'enable all'")
flag.StringVar(&webhookCertDir, "webhook-cert-dir", "/etc/webhook/certs", "directory containing TLS certs for the webhook server")

for name, reconciler := range reconcilers {
reconciler.BindFlags(name+".", flag.CommandLine)
Expand Down Expand Up @@ -220,7 +223,7 @@ func newManager(scheme *runtime.Scheme) (ctrl.Manager, error) {
},
WebhookServer: webhook.NewServer(webhook.Options{
Port: 9443,
CertDir: "/etc/webhook/certs",
CertDir: webhookCertDir,
}),
HealthProbeBindAddress: ":8081",
LeaderElection: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package packagerevision

import (
"context"
"maps"

kptfilev1 "github.com/kptdev/kpt/api/kptfile/v1"

Expand Down Expand Up @@ -160,7 +161,8 @@ func (r *PackageRevisionReconciler) triggerRenderIfNeeded(ctx context.Context, p
return nil, nil
}

// applyPackageMetadataToKptfile applies labels and annotations to Kptfile (merge mode).
// applyPackageMetadataToKptfile applies labels and annotations to the Kptfile.
// spec.packageMetadata is the complete desired set, so omitted keys are removed.
func applyPackageMetadataToKptfile(kf *kptfilev1.KptFile, pr *porchv1alpha2.PackageRevision) bool {
if pr.Spec.PackageMetadata == nil {
return false
Expand All @@ -175,26 +177,17 @@ func applyPackageMetadataToKptfile(kf *kptfilev1.KptFile, pr *porchv1alpha2.Pack
return labelsChanged || annotationsChanged
}

// applyMetadataMap merges desired key-value pairs into current, returning the resulting map and whether any changes were made.
// Safe to call with nil current or desired maps.
// applyMetadataMap replaces current with desired, reporting whether it changed.
// nil desired means the client is not managing the map, so current is untouched;
// an empty non-nil map clears it. Matches v1alpha1 applyMapMetadata.
func applyMetadataMap(current, desired map[string]string) (map[string]string, bool) {
if len(desired) == 0 {
if desired == nil || maps.Equal(current, desired) {
return current, false
}

if current == nil {
current = make(map[string]string, len(desired))
}

changed := false
for k, v := range desired {
if cv, exists := current[k]; !exists || cv != v {
current[k] = v
changed = true
}
if len(desired) == 0 {
return nil, true
}

return current, changed
return maps.Clone(desired), true
}

// setRenderRequestAnnotation triggers render by updating the render-request annotation with nanosecond precision.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// 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.

// Reproduction tests for packageMetadata key deletion.
//
// These assert the desired behaviour and fail against the current
// implementation, so they are skipped. Remove the skips as part of the fix that
// makes the two sync directions agree on deletion.
//
// The two sync directions disagree on deletion semantics:
//
// spec -> Kptfile (applyMetadataMap) merge only, never deletes
// Kptfile -> spec (updateKptfileFields) full replace, deletes
//
// v1alpha1 has both modes: pkg/task/generictaskhandler.go PatchKptfile calls
// applyMetadataToKptfile(kf, obj, true) with replace semantics on the update
// path, and false on the create path. v1alpha2 only ever merges.

package packagerevision

import (
"context"
"testing"

kptfilev1 "github.com/kptdev/kpt/api/kptfile/v1"
porchv1alpha2 "github.com/kptdev/porch/api/porch/v1alpha2"
mockclient "github.com/kptdev/porch/test/mockery/mocks/external/sigs.k8s.io/controller-runtime/pkg/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/kustomize/kyaml/yaml"
)

// kptfileWithMetadata builds a Kptfile carrying the given labels and annotations.
func kptfileWithMetadata(labels, annotations map[string]string) kptfilev1.KptFile {
return kptfilev1.KptFile{
ResourceMeta: yaml.ResourceMeta{
ObjectMeta: yaml.ObjectMeta{
Labels: labels,
Annotations: annotations,
},
},
}
}

// TestSpecKeyRemovalPropagatesToKptfile covers defect 1: a user removing a key
// from spec.packageMetadata should remove it from the Kptfile. Today
// applyMetadataMap only adds and overwrites, so the key survives.
func TestSpecKeyRemovalPropagatesToKptfile(t *testing.T) {
kf := kptfileWithMetadata(
map[string]string{"keep": "yes", "remove-me": "still-here"},
map[string]string{"keep-anno": "yes", "remove-anno": "still-here"},
)

// User has dropped "remove-me" / "remove-anno" from the CR spec.
pr := newTestPR(
withLifecycle(porchv1alpha2.PackageRevisionLifecycleDraft),
withMetadata(
map[string]string{"keep": "yes"},
map[string]string{"keep-anno": "yes"},
),
)

changed := applyPackageMetadataToKptfile(&kf, pr)

assert.True(t, changed, "removing a key from spec should be a change to apply")
assert.Equal(t, map[string]string{"keep": "yes"}, kf.Labels,
"label removed from spec.packageMetadata should be removed from the Kptfile")
assert.Equal(t, map[string]string{"keep-anno": "yes"}, kf.Annotations,
"annotation removed from spec.packageMetadata should be removed from the Kptfile")
}

// TestSpecKeyRemovalIsNotRevertedByKptfileSync covers the second half of
// defect 1: because the Kptfile keeps the dropped key, the next render syncs it
// straight back into spec, silently undoing the user's edit.
func TestSpecKeyRemovalIsNotRevertedByKptfileSync(t *testing.T) {
kf := kptfileWithMetadata(map[string]string{"keep": "yes", "remove-me": "still-here"}, nil)

pr := newTestPR(
withLifecycle(porchv1alpha2.PackageRevisionLifecycleDraft),
withMetadata(map[string]string{"keep": "yes"}, nil),
)

// Step 1: spec -> Kptfile. Should drop "remove-me" from the Kptfile.
applyPackageMetadataToKptfile(&kf, pr)

// Step 2: Kptfile -> spec, as run post-render by updateKptfileFields.
synced := porchv1alpha2.KptfileToPackageMetadata(kf)

assert.NotContains(t, synced.Labels, "remove-me",
"Kptfile->spec sync must not resurrect a label the user removed from spec")
assert.True(t, packageMetadataEqual(pr.Spec.PackageMetadata, synced),
"spec and Kptfile must converge after one round trip")
}

// TestUpdateKptfileFieldsClearsMetadataWhenKptfileEmptied verifies that
// an empty Kptfile results in no spec patch (early return). Stale metadata
// is cleared by the CRD→Kptfile sync path (reconcilePackageMetadata), not here.
func TestUpdateKptfileFieldsClearsMetadataWhenKptfileEmptied(t *testing.T) {
mockClient := mockclient.NewMockClient(t)

patched := false

mockClient.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything).
Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.PatchOption) {
patched = true
}).Return(nil).Maybe()

r := &PackageRevisionReconciler{Client: mockClient}

// spec carries metadata that was synced from an earlier Kptfile revision.
pr := basePR()
pr.Spec.PackageMetadata = &porchv1alpha2.PackageMetadata{
Labels: map[string]string{"stale": "value"},
}

// The Kptfile has since had all labels and annotations removed.
// Empty Kptfile → gates=nil, meta=nil, conds=nil → early return, no patch.
r.updateKptfileFields(t.Context(), pr, kptfilev1.KptFile{})

assert.False(t, patched, "empty Kptfile should not trigger a spec patch (stale metadata cleared by reconcilePackageMetadata)")
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func TestApplyPackageMetadataToKptfile(t *testing.T) {
expectLabels: map[string]string{"app": "myapp"},
},
{
name: "merge labels",
name: "spec replaces existing labels",
kf: &kptfilev1.KptFile{
ResourceMeta: yaml.ResourceMeta{
ObjectMeta: yaml.ObjectMeta{
Expand All @@ -112,7 +112,7 @@ func TestApplyPackageMetadataToKptfile(t *testing.T) {
},
prOpts: []func(*porchv1alpha2.PackageRevision){withMetadata(map[string]string{"app": "myapp"}, nil)},
expectChanged: true,
expectLabels: map[string]string{"existing": "label", "app": "myapp"},
expectLabels: map[string]string{"app": "myapp"},
},
{
name: "add annotations",
Expand Down Expand Up @@ -184,11 +184,11 @@ func TestApplyMetadataMap(t *testing.T) {
expectResult: map[string]string{"app": "test"},
},
{
name: "merge into existing map",
name: "desired replaces existing map",
current: map[string]string{"env": "prod"},
desired: map[string]string{"app": "test"},
expectChanged: true,
expectResult: map[string]string{"env": "prod", "app": "test"},
expectResult: map[string]string{"app": "test"},
},
{
name: "no change when identical",
Expand All @@ -205,28 +205,35 @@ func TestApplyMetadataMap(t *testing.T) {
expectResult: map[string]string{"app": "new"},
},
{
name: "merge multiple entries",
name: "disjoint desired drops all existing entries",
current: map[string]string{"a": "1", "b": "2"},
desired: map[string]string{"c": "3", "d": "4"},
expectChanged: true,
expectResult: map[string]string{"a": "1", "b": "2", "c": "3", "d": "4"},
expectResult: map[string]string{"c": "3", "d": "4"},
},
{
name: "partial update keeps existing keys",
name: "keys omitted from desired are dropped",
current: map[string]string{"keep": "this", "update": "old"},
desired: map[string]string{"update": "new"},
expectChanged: true,
expectResult: map[string]string{"keep": "this", "update": "new"},
expectResult: map[string]string{"update": "new"},
},
{
name: "empty desired map (no change)",
name: "empty desired map clears",
current: map[string]string{"existing": "val"},
desired: map[string]string{},
expectChanged: true,
expectResult: nil,
},
{
name: "empty desired map against empty current is no change",
current: nil,
desired: map[string]string{},
expectChanged: false,
expectResult: map[string]string{"existing": "val"},
expectResult: nil,
},
{
name: "nil desired map (no change)",
name: "nil desired map means not managed, leaves current alone",
current: map[string]string{"existing": "val"},
desired: nil,
expectChanged: false,
Expand Down Expand Up @@ -289,7 +296,7 @@ func TestApplyPackageMetadataToKptfileComprehensive(t *testing.T) {
},
},
{
name: "both labels and annotations with existing values (merge)",
name: "both labels and annotations with existing values (replace)",
kf: &kptfilev1.KptFile{
ResourceMeta: yaml.ResourceMeta{
ObjectMeta: yaml.ObjectMeta{
Expand All @@ -301,10 +308,8 @@ func TestApplyPackageMetadataToKptfileComprehensive(t *testing.T) {
prOpts: []func(*porchv1alpha2.PackageRevision){withMetadata(map[string]string{"new-l": "val"}, map[string]string{"new-a": "val"})},
expectChanged: true,
verify: func(t *testing.T, kf *kptfilev1.KptFile) {
assert.Equal(t, "val", kf.ResourceMeta.ObjectMeta.Labels["existing-l"])
assert.Equal(t, "val", kf.ResourceMeta.ObjectMeta.Labels["new-l"])
assert.Equal(t, "val", kf.ResourceMeta.ObjectMeta.Annotations["existing-a"])
assert.Equal(t, "val", kf.ResourceMeta.ObjectMeta.Annotations["new-a"])
assert.Equal(t, map[string]string{"new-l": "val"}, kf.ResourceMeta.ObjectMeta.Labels)
assert.Equal(t, map[string]string{"new-a": "val"}, kf.ResourceMeta.ObjectMeta.Annotations)
},
},
{
Expand Down Expand Up @@ -456,7 +461,7 @@ func TestApplyPackageMetadataToKptfileEdgeCases(t *testing.T) {
},
},
{
name: "only update one label out of many existing",
name: "single-entry spec replaces the whole label set",
kf: &kptfilev1.KptFile{
ResourceMeta: yaml.ResourceMeta{
ObjectMeta: yaml.ObjectMeta{
Expand All @@ -469,10 +474,7 @@ func TestApplyPackageMetadataToKptfileEdgeCases(t *testing.T) {
prOpts: []func(*porchv1alpha2.PackageRevision){withMetadata(map[string]string{"b": "updated"}, nil)},
expectChanged: true,
verify: func(t *testing.T, kf *kptfilev1.KptFile) {
assert.Equal(t, "1", kf.ResourceMeta.ObjectMeta.Labels["a"])
assert.Equal(t, "updated", kf.ResourceMeta.ObjectMeta.Labels["b"])
assert.Equal(t, "3", kf.ResourceMeta.ObjectMeta.Labels["c"])
assert.Equal(t, "4", kf.ResourceMeta.ObjectMeta.Labels["d"])
assert.Equal(t, map[string]string{"b": "updated"}, kf.ResourceMeta.ObjectMeta.Labels)
},
},
}
Expand Down
Loading
Loading