diff --git a/.gitignore b/.gitignore index 32233bad7..12b27f668 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ apiserver.local.config/ default.etcd/ # Local cache files -.cache/ +.cache* # Local backup files .$*.bkp diff --git a/.mockery.yaml b/.mockery.yaml index 8ff7fae46..3ab69d918 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -40,6 +40,9 @@ packages: Repository: {} PackageRevision: {} PackageRevisionDraft: {} + ContentCache: {} + PackageContent: {} + ExternalPackageFetcher: {} github.com/nephio-project/porch/pkg/engine: interfaces: diff --git a/.vscode/launch.json b/.vscode/launch.json index 08cc3ab0b..4389d26b3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -70,15 +70,60 @@ } }, { - "name": "Launch Controllers", + "name": "Launch Controllers (repositories)", "type": "go", "request": "launch", "mode": "auto", "program": "${workspaceFolder}/controllers", + "args": [ + "--reconcilers=repositories", + "--repositories.cache-directory=${workspaceFolder}/.cache-controller-repo", + "--repositories.max-concurrent-reconciles=100", + "--repositories.max-concurrent-syncs=50", + "--repositories.repo-operation-retry-attempts=3", + "--repositories.health-check-frequency=1m", + "--repositories.full-sync-frequency=3m", + "-v=2" + ], + "cwd": "${workspaceFolder}", + "env": { + "GIT_CACHE_DIR": "${workspaceFolder}/.cache-controller-repo", + "DB_HOST": "172.18.255.201", + "DB_PORT": "5432", + "DB_NAME": "porch", + "DB_USER": "porch", + "DB_PASSWORD": "porch", + "DB_DRIVER": "pgx" + } + }, + { + "name": "Launch Controllers (repositories + packagerevisions v1alpha2)", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/controllers", + "args": [ + "--reconcilers=repositories,packagerevisions", + "--repositories.create-v1alpha2-rpkg=true", + "--repositories.max-concurrent-reconciles=100", + "--repositories.max-concurrent-syncs=50", + "--repositories.cache-directory=${workspaceFolder}/.cache-controller-v1alpha2", + "--repositories.repo-operation-retry-attempts=3", + "--repositories.health-check-frequency=1m", + "--repositories.full-sync-frequency=3m", + "--packagerevisions.repo-operation-retry-attempts=3", + "-v=2" + ], "cwd": "${workspaceFolder}", "env": { - "ENABLE_PACKAGEVARIANTS": "true", - "ENABLE_PACKAGEVARIANTSETS": "true" + "GIT_CACHE_DIR": "${workspaceFolder}/.cache-controller-v1alpha2", + "DB_HOST": "172.18.255.201", + "DB_PORT": "5432", + "DB_NAME": "porch", + "DB_USER": "porch", + "DB_PASSWORD": "porch", + "DB_DRIVER": "pgx", + "FUNCTION_RUNNER_ADDRESS": "172.18.255.202:9445" } }, // A configuration for running a porchctl command using the VS Code debugger. diff --git a/api/porch/v1alpha2/kptdata_conversion.go b/api/porch/v1alpha2/kptdata_conversion.go new file mode 100644 index 000000000..af43040ab --- /dev/null +++ b/api/porch/v1alpha2/kptdata_conversion.go @@ -0,0 +1,76 @@ +// Copyright 2026 The kpt and Nephio 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 v1alpha2 + +import ( + kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" +) + +// KptfileToPackageConditions converts Kptfile status conditions to v1alpha2 PackageConditions. +func KptfileToPackageConditions(kf kptfilev1.KptFile) []PackageCondition { + if kf.Status == nil { + return nil + } + conds := make([]PackageCondition, 0, len(kf.Status.Conditions)) + for _, c := range kf.Status.Conditions { + conds = append(conds, PackageCondition{ + Type: c.Type, + Status: PackageConditionStatus(c.Status), + Reason: c.Reason, + Message: c.Message, + }) + } + return conds +} + +// KptfileToReadinessGates converts Kptfile readiness gates to v1alpha2 ReadinessGates. +func KptfileToReadinessGates(kf kptfilev1.KptFile) []ReadinessGate { + if kf.Info == nil { + return nil + } + gates := make([]ReadinessGate, 0, len(kf.Info.ReadinessGates)) + for _, rg := range kf.Info.ReadinessGates { + gates = append(gates, ReadinessGate{ConditionType: rg.ConditionType}) + } + return gates +} + +// KptfileToPackageMetadata converts Kptfile labels and annotations to v1alpha2 PackageMetadata. +func KptfileToPackageMetadata(kf kptfilev1.KptFile) *PackageMetadata { + if len(kf.Labels) == 0 && len(kf.Annotations) == 0 { + return nil + } + return &PackageMetadata{ + Labels: kf.Labels, + Annotations: kf.Annotations, + } +} + +// KptLocatorToLocator converts a kpt Locator to a v1alpha2 Locator. +// The two types are structurally identical but live in different packages. +func KptLocatorToLocator(lock kptfilev1.Locator) *Locator { + if lock.Git == nil { + return nil + } + return &Locator{ + Type: OriginType(lock.Type), + Git: &GitLock{ + Repo: lock.Git.Repo, + Directory: lock.Git.Directory, + Ref: lock.Git.Ref, + Commit: lock.Git.Commit, + }, + } +} diff --git a/api/porch/v1alpha2/kptdata_conversion_test.go b/api/porch/v1alpha2/kptdata_conversion_test.go new file mode 100644 index 000000000..5967d8270 --- /dev/null +++ b/api/porch/v1alpha2/kptdata_conversion_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 The kpt and Nephio 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 v1alpha2 + +import ( + "testing" + + kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" + "github.com/stretchr/testify/assert" +) + +func TestKptfileToPackageConditions(t *testing.T) { + // nil status + assert.Nil(t, KptfileToPackageConditions(kptfilev1.KptFile{})) + + // empty conditions + kf := kptfilev1.KptFile{Status: &kptfilev1.Status{}} + assert.Empty(t, KptfileToPackageConditions(kf)) + + // populated conditions + kf.Status.Conditions = []kptfilev1.Condition{ + {Type: "Ready", Status: kptfilev1.ConditionTrue, Reason: "AllGood", Message: "all good"}, + {Type: "Validated", Status: kptfilev1.ConditionFalse, Reason: "Failed", Message: "bad input"}, + } + conds := KptfileToPackageConditions(kf) + assert.Len(t, conds, 2) + assert.Equal(t, "Ready", conds[0].Type) + assert.Equal(t, PackageConditionTrue, conds[0].Status) + assert.Equal(t, "AllGood", conds[0].Reason) + assert.Equal(t, "all good", conds[0].Message) + assert.Equal(t, "Validated", conds[1].Type) + assert.Equal(t, PackageConditionFalse, conds[1].Status) +} + +func TestKptfileToReadinessGates(t *testing.T) { + // nil info + assert.Nil(t, KptfileToReadinessGates(kptfilev1.KptFile{})) + + // empty gates + kf := kptfilev1.KptFile{Info: &kptfilev1.PackageInfo{}} + assert.Empty(t, KptfileToReadinessGates(kf)) + + // populated gates + kf.Info.ReadinessGates = []kptfilev1.ReadinessGate{ + {ConditionType: "Ready"}, + {ConditionType: "Validated"}, + } + gates := KptfileToReadinessGates(kf) + assert.Len(t, gates, 2) + assert.Equal(t, "Ready", gates[0].ConditionType) + assert.Equal(t, "Validated", gates[1].ConditionType) +} + +func TestKptfileToPackageMetadata(t *testing.T) { + // no labels or annotations + assert.Nil(t, KptfileToPackageMetadata(kptfilev1.KptFile{})) + + // labels only + kf := kptfilev1.KptFile{} + kf.Labels = map[string]string{"app": "foo"} + meta := KptfileToPackageMetadata(kf) + assert.Equal(t, "foo", meta.Labels["app"]) + assert.Nil(t, meta.Annotations) + + // annotations only + kf = kptfilev1.KptFile{} + kf.Annotations = map[string]string{"note": "bar"} + meta = KptfileToPackageMetadata(kf) + assert.Nil(t, meta.Labels) + assert.Equal(t, "bar", meta.Annotations["note"]) + + // both + kf = kptfilev1.KptFile{} + kf.Labels = map[string]string{"app": "foo"} + kf.Annotations = map[string]string{"note": "bar"} + meta = KptfileToPackageMetadata(kf) + assert.Equal(t, "foo", meta.Labels["app"]) + assert.Equal(t, "bar", meta.Annotations["note"]) +} + +func TestKptLocatorToLocator(t *testing.T) { + // nil git + assert.Nil(t, KptLocatorToLocator(kptfilev1.Locator{})) + + // populated + lock := kptfilev1.Locator{ + Type: kptfilev1.GitOrigin, + Git: &kptfilev1.GitLock{ + Repo: "https://github.com/example/repo.git", + Directory: "pkg/foo", + Ref: "v1.0.0", + Commit: "abc123", + }, + } + loc := KptLocatorToLocator(lock) + assert.Equal(t, OriginType(kptfilev1.GitOrigin), loc.Type) + assert.Equal(t, "https://github.com/example/repo.git", loc.Git.Repo) + assert.Equal(t, "pkg/foo", loc.Git.Directory) + assert.Equal(t, "v1.0.0", loc.Git.Ref) + assert.Equal(t, "abc123", loc.Git.Commit) +} diff --git a/api/porch/v1alpha2/packagerevision_types.go b/api/porch/v1alpha2/packagerevision_types.go index 1b382ca6a..4f87c24b3 100644 --- a/api/porch/v1alpha2/packagerevision_types.go +++ b/api/porch/v1alpha2/packagerevision_types.go @@ -35,6 +35,11 @@ import ( // +kubebuilder:printcolumn:name="Lifecycle",type=string,JSONPath=`.spec.lifecycle` // +kubebuilder:printcolumn:name="Repository",type=string,JSONPath=`.spec.repository` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +kubebuilder:selectablefield:JSONPath=`.spec.lifecycle` +// +kubebuilder:selectablefield:JSONPath=`.spec.repository` +// +kubebuilder:selectablefield:JSONPath=`.spec.packageName` +// +kubebuilder:selectablefield:JSONPath=`.spec.workspaceName` +// +kubebuilder:selectablefield:JSONPath=`.status.revision` type PackageRevision struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` @@ -59,6 +64,19 @@ const ( LatestPackageRevisionValue = "true" ) +// AnnotationRenderRequest triggers async rendering when patched by the PRR handler. +const AnnotationRenderRequest = "porch.kpt.dev/render-request" + +// PackageRevisionFinalizer prevents deletion of packages that have not been through DeletionProposed. +const PackageRevisionFinalizer = "porch.kpt.dev/packagerevisions" + +const ( + // PushOnFnRenderFailureKey controls whether resources are written back + // to storage when the render pipeline fails. + PushOnFnRenderFailureKey = "porch.kpt.dev/push-on-render-failure" + PushOnFnRenderFailureValue = "true" +) + // PkgRevFieldSelector defines field selectors for PackageRevision. // Requires controller-runtime field indexing setup in controller. type PkgRevFieldSelector string @@ -137,6 +155,10 @@ type ReadinessGate struct { // PackageRevisionStatus defines the observed state of PackageRevision type PackageRevisionStatus struct { + // ObservedGeneration is the generation of the PackageRevision spec that was last reconciled. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // Revision identifies the version of the package. // This is assigned by the system when the package is published. Revision int `json:"revision,omitempty"` @@ -151,7 +173,8 @@ type PackageRevisionStatus struct { PublishedBy string `json:"publishedBy,omitempty"` // PublishedAt is the time when the packagerevision were approved. - PublishedAt metav1.Time `json:"publishedAt,omitempty"` + // +optional + PublishedAt *metav1.Time `json:"publishedAt,omitempty"` // Deployment is true if this is a deployment package (in a deployment repository). Deployment bool `json:"deployment,omitempty"` @@ -173,7 +196,9 @@ type PackageRevisionStatus struct { // PackageConditions from Kptfile. Set by KRM functions, used for ReadinessGates. PackageConditions []PackageCondition `json:"packageConditions,omitempty"` - // Conditions for controller state (e.g., Ready). + // Conditions for controller state (e.g., Ready, Rendered). + // +listType=map + // +listMapKey=type Conditions []metav1.Condition `json:"conditions,omitempty"` } @@ -220,21 +245,20 @@ const ( ) // Condition types for PackageRevision.Conditions (controller state) -// Additional condition types may be added in future (e.g., Rendered, Validated, Synced) const ( // ConditionReady indicates the package is ready for use. // This is a summary condition that aggregates other conditions. ConditionReady = "Ready" + + // ConditionRendered indicates whether the latest content has been rendered. + ConditionRendered = "Rendered" ) // Condition reasons for PackageRevision.Conditions const ( - // ReasonReady indicates the package is ready - ReasonReady = "Ready" - - // ReasonPending indicates the package is pending some operation - ReasonPending = "Pending" - - // ReasonFailed indicates an operation failed - ReasonFailed = "Failed" + ReasonReady = "Ready" + ReasonPending = "Pending" + ReasonFailed = "Failed" + ReasonRendered = "Rendered" + ReasonRenderFailed = "RenderFailed" ) diff --git a/api/porch/v1alpha2/porch.kpt.dev_packagerevisions.yaml b/api/porch/v1alpha2/porch.kpt.dev_packagerevisions.yaml index 4bec4c84d..4ea2b0866 100644 --- a/api/porch/v1alpha2/porch.kpt.dev_packagerevisions.yaml +++ b/api/porch/v1alpha2/porch.kpt.dev_packagerevisions.yaml @@ -274,7 +274,7 @@ spec: description: PackageRevisionStatus defines the observed state of PackageRevision properties: conditions: - description: Conditions for controller state (e.g., Ready). + description: Conditions for controller state (e.g., Ready, Rendered). items: description: Condition contains details for one aspect of the current state of this API Resource. @@ -330,6 +330,9 @@ spec: - type type: object type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map creationSource: description: |- CreationSource indicates how this package was created (for debugging/history). @@ -340,6 +343,11 @@ spec: description: Deployment is true if this is a deployment package (in a deployment repository). type: boolean + observedGeneration: + description: ObservedGeneration is the generation of the PackageRevision + spec that was last reconciled. + format: int64 + type: integer observedPrrResourceVersion: description: |- ObservedPrrResourceVersion tracks the last observed PRR resourceVersion. @@ -454,6 +462,12 @@ spec: type: object type: object type: object + selectableFields: + - jsonPath: .spec.lifecycle + - jsonPath: .spec.repository + - jsonPath: .spec.packageName + - jsonPath: .spec.workspaceName + - jsonPath: .status.revision served: true storage: true subresources: diff --git a/api/porch/v1alpha2/selectable_fields_test.go b/api/porch/v1alpha2/selectable_fields_test.go new file mode 100644 index 000000000..59fcf23e1 --- /dev/null +++ b/api/porch/v1alpha2/selectable_fields_test.go @@ -0,0 +1,74 @@ +package v1alpha2 + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "sigs.k8s.io/yaml" +) + +// TestCRDSelectableFieldsMatchConstants verifies that the generated CRD YAML +// contains selectableFields entries for every non-metadata PkgRevFieldSelector +// constant. This catches drift between the kubebuilder markers on the struct +// and the Go constants used by the controller field indexes and CLI. +func TestCRDSelectableFieldsMatchConstants(t *testing.T) { + crdPath := filepath.Join(testdataDir(), "porch.kpt.dev_packagerevisions.yaml") + data, err := os.ReadFile(crdPath) + require.NoError(t, err, "failed to read CRD YAML — run 'make generate' first") + + var crd apiextensionsv1.CustomResourceDefinition + require.NoError(t, yaml.Unmarshal(data, &crd), "failed to parse CRD YAML") + + // Find the v1alpha2 version + var version *apiextensionsv1.CustomResourceDefinitionVersion + for i := range crd.Spec.Versions { + if crd.Spec.Versions[i].Name == "v1alpha2" { + version = &crd.Spec.Versions[i] + break + } + } + require.NotNil(t, version, "v1alpha2 version not found in CRD") + require.NotEmpty(t, version.SelectableFields, "selectableFields is empty — kubebuilder markers missing?") + + // Build set of jsonPaths from CRD + crdFields := make(map[string]bool) + for _, sf := range version.SelectableFields { + crdFields[sf.JSONPath] = true + } + + // Every non-metadata selector constant must have a matching selectableField + for _, sel := range PackageRevisionSelectableFields { + if sel == PkgRevSelectorName || sel == PkgRevSelectorNamespace { + continue // metadata fields are handled natively by k8s + } + jsonPath := "." + string(sel) // "spec.lifecycle" -> ".spec.lifecycle" + assert.True(t, crdFields[jsonPath], + "PkgRevFieldSelector %q (jsonPath %q) missing from CRD selectableFields", sel, jsonPath) + } + + // Every selectableField in the CRD must have a matching constant + for _, sf := range version.SelectableFields { + // Strip leading dot: ".spec.lifecycle" -> "spec.lifecycle" + fieldPath := sf.JSONPath[1:] + found := false + for _, sel := range PackageRevisionSelectableFields { + if string(sel) == fieldPath { + found = true + break + } + } + assert.True(t, found, + "CRD selectableField %q has no matching PkgRevFieldSelector constant", sf.JSONPath) + } +} + +// testdataDir returns the directory containing this test file (where the CRD YAML lives). +func testdataDir() string { + _, filename, _, _ := runtime.Caller(0) + return filepath.Dir(filename) +} diff --git a/api/porch/v1alpha2/util_test.go b/api/porch/v1alpha2/util_test.go new file mode 100644 index 000000000..4bb26bf78 --- /dev/null +++ b/api/porch/v1alpha2/util_test.go @@ -0,0 +1,134 @@ +// Copyright 2026 The kpt and Nephio 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 v1alpha2 + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLifecycleIsPublished(t *testing.T) { + tests := []struct { + lifecycle PackageRevisionLifecycle + expected bool + }{ + {PackageRevisionLifecyclePublished, true}, + {PackageRevisionLifecycleDeletionProposed, true}, + {PackageRevisionLifecycleDraft, false}, + {PackageRevisionLifecycleProposed, false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(string(tt.lifecycle), func(t *testing.T) { + assert.Equal(t, tt.expected, LifecycleIsPublished(tt.lifecycle)) + }) + } +} + +func TestIsPublished(t *testing.T) { + published := &PackageRevision{Spec: PackageRevisionSpec{Lifecycle: PackageRevisionLifecyclePublished}} + draft := &PackageRevision{Spec: PackageRevisionSpec{Lifecycle: PackageRevisionLifecycleDraft}} + + assert.True(t, published.IsPublished()) + assert.False(t, draft.IsPublished()) +} + +func TestPackageRevisionIsReady(t *testing.T) { + tests := []struct { + name string + gates []ReadinessGate + conditions []PackageCondition + expected bool + }{ + { + name: "no gates - always ready", + expected: true, + }, + { + name: "gate met", + gates: []ReadinessGate{{ConditionType: "Ready"}}, + conditions: []PackageCondition{ + {Type: "Ready", Status: PackageConditionTrue}, + }, + expected: true, + }, + { + name: "gate missing condition", + gates: []ReadinessGate{{ConditionType: "Ready"}}, + conditions: nil, + expected: false, + }, + { + name: "gate condition not true", + gates: []ReadinessGate{{ConditionType: "Ready"}}, + conditions: []PackageCondition{ + {Type: "Ready", Status: PackageConditionFalse}, + }, + expected: false, + }, + { + name: "multiple gates - one unmet", + gates: []ReadinessGate{ + {ConditionType: "Ready"}, + {ConditionType: "Validated"}, + }, + conditions: []PackageCondition{ + {Type: "Ready", Status: PackageConditionTrue}, + }, + expected: false, + }, + { + name: "multiple gates - all met", + gates: []ReadinessGate{ + {ConditionType: "Ready"}, + {ConditionType: "Validated"}, + }, + conditions: []PackageCondition{ + {Type: "Ready", Status: PackageConditionTrue}, + {Type: "Validated", Status: PackageConditionTrue}, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, PackageRevisionIsReady(tt.gates, tt.conditions)) + }) + } +} + +func TestIsPackageCreation(t *testing.T) { + tests := []struct { + name string + source *PackageSource + expected bool + }{ + {name: "nil source", source: nil, expected: false}, + {name: "init", source: &PackageSource{Init: &PackageInitSpec{}}, expected: true}, + {name: "clone", source: &PackageSource{CloneFrom: &UpstreamPackage{}}, expected: true}, + {name: "copy", source: &PackageSource{CopyFrom: &PackageRevisionRef{}}, expected: false}, + {name: "upgrade", source: &PackageSource{Upgrade: &PackageUpgradeSpec{}}, expected: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pr := &PackageRevision{Spec: PackageRevisionSpec{Source: tt.source}} + assert.Equal(t, tt.expected, IsPackageCreation(pr)) + }) + } +} diff --git a/api/porch/v1alpha2/zz_generated.deepcopy.go b/api/porch/v1alpha2/zz_generated.deepcopy.go index 99417b811..4d8987f64 100644 --- a/api/porch/v1alpha2/zz_generated.deepcopy.go +++ b/api/porch/v1alpha2/zz_generated.deepcopy.go @@ -255,7 +255,10 @@ func (in *PackageRevisionStatus) DeepCopyInto(out *PackageRevisionStatus) { *out = new(Locator) (*in).DeepCopyInto(*out) } - in.PublishedAt.DeepCopyInto(&out.PublishedAt) + if in.PublishedAt != nil { + in, out := &in.PublishedAt, &out.PublishedAt + *out = (*in).DeepCopy() + } if in.PackageConditions != nil { in, out := &in.PackageConditions, &out.PackageConditions *out = make([]PackageCondition, len(*in)) diff --git a/api/porchconfig/v1alpha1/config.porch.kpt.dev_functionconfigs.yaml b/api/porchconfig/v1alpha1/config.porch.kpt.dev_functionconfigs.yaml index efe4aa1a2..b0a6ae9e1 100644 --- a/api/porchconfig/v1alpha1/config.porch.kpt.dev_functionconfigs.yaml +++ b/api/porchconfig/v1alpha1/config.porch.kpt.dev_functionconfigs.yaml @@ -34,6 +34,9 @@ spec: - jsonPath: .status.functionRunnerObservedGeneration name: FnRunner Applied type: integer + - jsonPath: .status.controllerObservedGeneration + name: Controller Applied + type: integer name: v1alpha1 schema: openAPIV3Schema: @@ -911,6 +914,11 @@ spec: of the config the porch server has applied to the build-in runtime format: int64 type: integer + controllerObservedGeneration: + description: ControllerObservedGeneration indicates which generation + of the config the porch controller has applied to its builtin runtime + format: int64 + type: integer error: description: Contains an error message if one occurred whilst trying to apply the FunctionConfig diff --git a/api/porchconfig/v1alpha1/function_config_types.go b/api/porchconfig/v1alpha1/function_config_types.go index 3fb8b0132..ff24901f0 100644 --- a/api/porchconfig/v1alpha1/function_config_types.go +++ b/api/porchconfig/v1alpha1/function_config_types.go @@ -24,6 +24,7 @@ import ( // +kubebuilder:resource:path=functionconfigs,singular=functionconfig // +kubebuilder:printcolumn:name="Server Applied",type=integer,JSONPath=`.status.apiServerObservedGeneration` // +kubebuilder:printcolumn:name="FnRunner Applied",type=integer,JSONPath=`.status.functionRunnerObservedGeneration` +// +kubebuilder:printcolumn:name="Controller Applied",type=integer,JSONPath=`.status.controllerObservedGeneration` type FunctionConfig struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` @@ -58,6 +59,8 @@ type FunctionConfigStatus struct { ApiServerObservedGeneration int64 `json:"apiServerObservedGeneration,omitempty"` // FunctionRunnerObservedGeneration indicates which generation of the config the function-runner has applied to the executable and pod evaluator FunctionRunnerObservedGeneration int64 `json:"functionRunnerObservedGeneration,omitempty"` + // ControllerObservedGeneration indicates which generation of the config the porch controller has applied to its builtin runtime + ControllerObservedGeneration int64 `json:"controllerObservedGeneration,omitempty"` // Contains an error message if one occurred whilst trying to apply the FunctionConfig Error string `json:"error,omitempty"` } diff --git a/api/porchconfig/v1alpha1/repository_types.go b/api/porchconfig/v1alpha1/repository_types.go index 1c7184413..fdeaf2e8b 100644 --- a/api/porchconfig/v1alpha1/repository_types.go +++ b/api/porchconfig/v1alpha1/repository_types.go @@ -165,6 +165,11 @@ const ( ReasonReconciling = "Reconciling" ) +const ( + AnnotationKeyV1Alpha2Migration = "porch.kpt.dev/v1alpha2-migration" + AnnotationValueMigrationEnabled = "true" +) + // RepositoryStatus defines the observed state of Repository type RepositoryStatus struct { // Conditions describes the reconciliation state of the object. diff --git a/controllers/functionconfigs/reconciler/functionconfigreconciler.go b/controllers/functionconfigs/reconciler/functionconfigreconciler.go index 6ed930b19..2cf2926cc 100644 --- a/controllers/functionconfigs/reconciler/functionconfigreconciler.go +++ b/controllers/functionconfigs/reconciler/functionconfigreconciler.go @@ -41,6 +41,7 @@ import ( const BaseFinalizer = "config.porch.kpt.dev/functionconfig" const ServerFinalizer = BaseFinalizer + "-porch-server" const FunctionRunnerFinalizer = BaseFinalizer + "-function-runner" +const ControllerFinalizer = BaseFinalizer + "-controller" type BinaryCacheEntry struct { PrefixRegex string @@ -212,6 +213,7 @@ type ReconcilerFor string const ( ReconcilerForFunctionRunner ReconcilerFor = "function-runner" ReconcilerForServer ReconcilerFor = "server" + ReconcilerForController ReconcilerFor = "controller" ) type FunctionConfigReconciler struct { @@ -259,6 +261,8 @@ func (r *FunctionConfigReconciler) Reconcile(ctx context.Context, req ctrl.Reque obj.Status.FunctionRunnerObservedGeneration = obj.Generation case ReconcilerForServer: obj.Status.ApiServerObservedGeneration = obj.Generation + case ReconcilerForController: + obj.Status.ControllerObservedGeneration = obj.Generation } } @@ -297,6 +301,8 @@ func (r *FunctionConfigReconciler) removeFinalizer(ctx context.Context, obj *con controllerutil.RemoveFinalizer(obj, FunctionRunnerFinalizer) case ReconcilerForServer: controllerutil.RemoveFinalizer(obj, ServerFinalizer) + case ReconcilerForController: + controllerutil.RemoveFinalizer(obj, ControllerFinalizer) } if err := r.Client.Patch(ctx, obj, patch); err != nil { @@ -316,6 +322,8 @@ func (r *FunctionConfigReconciler) addFinalizer(ctx context.Context, obj *config updated = controllerutil.AddFinalizer(obj, FunctionRunnerFinalizer) case ReconcilerForServer: updated = controllerutil.AddFinalizer(obj, ServerFinalizer) + case ReconcilerForController: + updated = controllerutil.AddFinalizer(obj, ControllerFinalizer) } if updated { diff --git a/controllers/functionconfigs/reconciler/functionconfigreconciler_test.go b/controllers/functionconfigs/reconciler/functionconfigreconciler_test.go index a2ec21497..590d4e421 100644 --- a/controllers/functionconfigs/reconciler/functionconfigreconciler_test.go +++ b/controllers/functionconfigs/reconciler/functionconfigreconciler_test.go @@ -238,6 +238,10 @@ func TestFinalizersAdded(t *testing.T) { forValue: ReconcilerForServer, finalizer: ServerFinalizer, }, + string(ReconcilerForController): { + forValue: ReconcilerForController, + finalizer: ControllerFinalizer, + }, } for name, tc := range cases { @@ -291,6 +295,10 @@ func TestFinalizersRemoved(t *testing.T) { forValue: ReconcilerForServer, finalizer: ServerFinalizer, }, + string(ReconcilerForController): { + forValue: ReconcilerForController, + finalizer: ControllerFinalizer, + }, } for name, tc := range cases { diff --git a/controllers/main.go b/controllers/main.go index a7529d3c9..24ed14c21 100644 --- a/controllers/main.go +++ b/controllers/main.go @@ -38,19 +38,25 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook" + "github.com/kptdev/kpt/pkg/lib/runneroptions" + "github.com/nephio-project/porch/controllers/functionconfigs/reconciler" + "github.com/nephio-project/porch/controllers/packagerevisions/pkg/controllers/packagerevision" "github.com/nephio-project/porch/controllers/packagevariants/pkg/controllers/packagevariant" "github.com/nephio-project/porch/controllers/packagevariantsets/pkg/controllers/packagevariantset" "github.com/nephio-project/porch/controllers/repositories/pkg/controllers/repository" porchotel "github.com/nephio-project/porch/internal/otel" + "github.com/nephio-project/porch/pkg/cache/contentcache" "github.com/nephio-project/porch/pkg/controllerrestmapper" "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" configapi "github.com/nephio-project/porch/api/porchconfig/v1alpha1" porchinternal "github.com/nephio-project/porch/internal/api/porchinternal/v1alpha1" //+kubebuilder:scaffold:imports @@ -59,17 +65,27 @@ import ( const errInitScheme = "error initializing scheme: %w" var ( - reconcilers = map[string]Reconciler{ - "packagevariants": &packagevariant.PackageVariantReconciler{}, - "packagevariantsets": &packagevariantset.PackageVariantSetReconciler{}, - "repositories": &repository.RepositoryReconciler{}, - } + // repoReconciler and prReconciler are declared separately so main can + // inject the shared cache: prReconciler.Cache = repoReconciler.Cache. + // Repo must be set up first because it creates the cache. + repoReconciler = &repository.RepositoryReconciler{} + prReconciler = &packagerevision.PackageRevisionReconciler{} + + reconcilers = buildReconcilerMap( + repoReconciler, + prReconciler, + &packagevariant.PackageVariantReconciler{}, + &packagevariantset.PackageVariantSetReconciler{}, + ) ) // Reconciler is the interface implemented by (our) reconcilers, which includes some configuration and initialization. type Reconciler interface { reconcile.Reconciler + // Name returns the reconciler's unique name (used as map key, flag prefix, logger name). + Name() string + // InitDefaults populates default values into our options InitDefaults() @@ -78,9 +94,13 @@ type Reconciler interface { // SetupWithManager registers the reconciler to run under the specified manager SetupWithManager(ctrl.Manager) error +} - // SetLogger sets the logger for the reconciler - SetLogger(name string) +// Initializer is an optional interface for reconcilers that need to wire +// runtime dependencies (caches, credential resolvers, etc.) after the +// manager is created but before SetupWithManager. +type Initializer interface { + Init(ctrl.Manager) error } // We include our lease / events permissions in the main RBAC role @@ -89,17 +109,47 @@ type Reconciler interface { //+kubebuilder:rbac:groups="",resources=events,verbs=create;patch func main() { - err := run(context.Background()) - if err != nil { + if err := run(context.Background()); err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } } func run(ctx context.Context) error { - // var metricsAddr string - // var enableLeaderElection bool - // var probeAddr string + enabledReconcilersString := parseFlags() + + scheme, err := initScheme() + if err != nil { + return err + } + + mgr, err := newManager(ctx, scheme) + if err != nil { + return err + } + + if err := enableReconcilers(mgr, enabledReconcilersString); err != nil { + return err + } + + //+kubebuilder:scaffold:builder + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + return fmt.Errorf("error adding health check: %w", err) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + return fmt.Errorf("error adding ready check: %w", err) + } + + klog.Infof("starting manager") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + return fmt.Errorf("error running manager: %w", err) + } + return nil +} + +// --- Flag parsing --- + +func parseFlags() string { var enabledReconcilersString string for _, reconciler := range reconcilers { @@ -108,11 +158,6 @@ func run(ctx context.Context) error { klog.InitFlags(nil) - // flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") - // flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") - // flag.BoolVar(&enableLeaderElection, "leader-elect", false, - // "Enable leader election for controller manager. "+ - // "Enabling this will ensure there is only one active controller manager.") flag.StringVar(&enabledReconcilersString, "reconcilers", "", "reconcilers that should be enabled; use * to mean 'enable all'") for name, reconciler := range reconcilers { @@ -121,31 +166,50 @@ func run(ctx context.Context) error { flag.Parse() - if len(flag.Args()) != 0 { - return fmt.Errorf("unexpected additional (non-flag) arguments: %v", flag.Args()) - } + return enabledReconcilersString +} +// --- Scheme --- + +func initScheme() (*runtime.Scheme, error) { scheme := runtime.NewScheme() - if err := clientgoscheme.AddToScheme(scheme); err != nil { - return fmt.Errorf(errInitScheme, err) + for _, addToScheme := range []func(*runtime.Scheme) error{ + clientgoscheme.AddToScheme, + porchapi.AddToScheme, + porchv1alpha2.AddToScheme, + configapi.AddToScheme, + porchinternal.AddToScheme, + } { + if err := addToScheme(scheme); err != nil { + return nil, fmt.Errorf(errInitScheme, err) + } } + return scheme, nil +} - if err := porchapi.AddToScheme(scheme); err != nil { - return fmt.Errorf(errInitScheme, err) - } +// --- Manager --- - if err := configapi.AddToScheme(scheme); err != nil { - return fmt.Errorf(errInitScheme, err) +func newManager(ctx context.Context, scheme *runtime.Scheme) (ctrl.Manager, error) { + config := textlogger.NewConfig( + textlogger.Verbosity(4), + textlogger.Output(os.Stdout), + ) + ctrl.SetLogger(textlogger.NewLogger(config)) + + cfg := ctrl.GetConfigOrDie() + cfg.WrapTransport = func(rt http.RoundTripper) http.RoundTripper { + klog.Infof("Wrapping client-go transport with OpenTelemetry") + return otelhttp.NewTransport(rt) } - if err := porchinternal.AddToScheme(scheme); err != nil { - return fmt.Errorf(errInitScheme, err) + otel.SetLogger(klog.NewKlogr()) + if err := porchotel.SetupOpenTelemetry(ctx); err != nil { + return nil, fmt.Errorf("error setting up OpenTelemetry: %w", err) } - managerOptions := ctrl.Options{ + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ Scheme: scheme, Metrics: metricsserver.Options{ - // Disable the inbuilt metrics server in favor of the OpenTelemetry server BindAddress: "0", }, WebhookServer: webhook.NewServer(webhook.Options{ @@ -162,82 +226,119 @@ func run(ctx context.Context) error { &porchapi.PackageRevisionResources{}}, }, }, + }) + if err != nil { + return nil, fmt.Errorf("error creating manager: %w", err) } + return mgr, nil +} - config := textlogger.NewConfig( - textlogger.Verbosity(4), - textlogger.Output(os.Stdout), - ) - ctrl.SetLogger(textlogger.NewLogger(config)) - cfg := ctrl.GetConfigOrDie() - cfg.WrapTransport = func(rt http.RoundTripper) http.RoundTripper { - klog.Infof("Wrapping client-go transport with OpenTelemetry") - return otelhttp.NewTransport(rt) - } +// --- Reconciler setup --- - otel.SetLogger(klog.NewKlogr()) - err := porchotel.SetupOpenTelemetry(ctx) +func enableReconcilers(mgr ctrl.Manager, enabledReconcilersString string) error { + enabled := strings.Split(enabledReconcilersString, ",") + var started []string + + // Set up repo controller first — it creates the shared cache. + started, err := setupReconciler(mgr, enabled, repoReconciler, started) if err != nil { - return fmt.Errorf("error setting up OpenTelemetry: %w", err) + return err } - mgr, err := ctrl.NewManager(cfg, managerOptions) + // Wire shared cache into PR controller before setup. + if reconcilerIsEnabled(enabled, prReconciler.Name()) { + if repoReconciler.Cache == nil { + return fmt.Errorf("%s reconciler requires %s reconciler (shared cache)", prReconciler.Name(), repoReconciler.Name()) + } + prReconciler.ContentCache = contentcache.NewContentCache(repoReconciler.Cache) + + functionConfigStore, err := setupFunctionConfigReconciler(mgr) + if err != nil { + return err + } + prReconciler.FunctionConfigStore = functionConfigStore + } + started, err = setupReconciler(mgr, enabled, prReconciler, started) if err != nil { - return fmt.Errorf("error creating manager: %w", err) + return err } - enabledReconcilers := parseReconcilers(enabledReconcilersString) - var enabled []string - for name, reconciler := range reconcilers { - if !reconcilerIsEnabled(enabledReconcilers, name) { + // Set up remaining controllers (no ordering dependency). + for _, reconciler := range reconcilers { + if reconciler.Name() == repoReconciler.Name() || reconciler.Name() == prReconciler.Name() { continue } - reconciler.SetLogger(name) - ctrl.Log.WithName(name).Info("setting up controller") - if err = reconciler.SetupWithManager(mgr); err != nil { - return fmt.Errorf("error creating %s reconciler: %w", name, err) + started, err = setupReconciler(mgr, enabled, reconciler, started) + if err != nil { + return err } - enabled = append(enabled, name) } - if len(enabled) == 0 { + if len(started) == 0 { klog.Warningf("no reconcilers are enabled; did you forget to pass the --reconcilers flag?") } else { - klog.Infof("enabled reconcilers: %v", strings.Join(enabled, ",")) + klog.Infof("enabled reconcilers: %v", strings.Join(started, ",")) + } + return nil +} + +func setupReconciler(mgr ctrl.Manager, enabled []string, r Reconciler, started []string) ([]string, error) { + if !reconcilerIsEnabled(enabled, r.Name()) { + return started, nil + } + name := r.Name() + if init, ok := r.(Initializer); ok { + if err := init.Init(mgr); err != nil { + return started, fmt.Errorf("error initializing %s reconciler: %w", name, err) + } + } + ctrl.Log.WithName(name).Info("setting up controller") + if err := r.SetupWithManager(mgr); err != nil { + return started, fmt.Errorf("error creating %s reconciler: %w", name, err) } + return append(started, name), nil +} - //+kubebuilder:scaffold:builder - if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { - return fmt.Errorf("error adding health check: %w", err) +func setupFunctionConfigReconciler(mgr ctrl.Manager) (*reconciler.FunctionConfigStore, error) { + prefix := os.Getenv("DEFAULT_IMAGE_PREFIX") + if prefix == "" { + prefix = runneroptions.GHCRImagePrefix } - if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { - return fmt.Errorf("error adding ready check: %w", err) + functionConfigStore := reconciler.NewFunctionConfigStore(prefix, "") + + rec := &reconciler.FunctionConfigReconciler{ + Client: mgr.GetClient(), + FunctionConfigStore: functionConfigStore, + For: reconciler.ReconcilerForController, } - klog.Infof("starting manager") - if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { - return fmt.Errorf("error running manager: %w", err) + if err := ctrl.NewControllerManagedBy(mgr). + For(&configapi.FunctionConfig{}). + WithEventFilter(predicate.GenerationChangedPredicate{}). + Complete(rec); err != nil { + return nil, fmt.Errorf("error creating FunctionConfig controller: %w", err) } - return nil + + klog.Infof("FunctionConfig reconciler registered (for: %s)", reconciler.ReconcilerForController) + return functionConfigStore, nil } -func parseReconcilers(reconcilers string) []string { - return strings.Split(reconcilers, ",") + +// --- Helpers --- + +func buildReconcilerMap(reconcilers ...Reconciler) map[string]Reconciler { + m := make(map[string]Reconciler, len(reconcilers)) + for _, r := range reconcilers { + m[r.Name()] = r + } + return m } func reconcilerIsEnabled(reconcilers []string, reconciler string) bool { - if slices.Contains(reconcilers, "*") { + if slices.Contains(reconcilers, "*") || slices.Contains(reconcilers, reconciler) { return true } - if slices.Contains(reconcilers, reconciler) { - return true - } - // Check env var value (not just existence) envVar := fmt.Sprintf("ENABLE_%s", strings.ToUpper(reconciler)) - if val := os.Getenv(envVar); val != "" { - // Parse as boolean: "true", "1", "yes" = enabled - valLower := strings.ToLower(val) - return valLower == "true" || val == "1" || valLower == "yes" - } - return false + val := strings.ToLower(os.Getenv(envVar)) + return val == "true" || val == "1" || val == "yes" } diff --git a/controllers/main_test.go b/controllers/main_test.go new file mode 100644 index 000000000..faefac0a2 --- /dev/null +++ b/controllers/main_test.go @@ -0,0 +1,134 @@ +// Copyright 2026 The kpt and Nephio 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 main + +import ( + "context" + "flag" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// --- reconcilerIsEnabled --- + +func TestReconcilerIsEnabled_Wildcard(t *testing.T) { + assert.True(t, reconcilerIsEnabled([]string{"*"}, "anything")) +} + +func TestReconcilerIsEnabled_ExactMatch(t *testing.T) { + assert.True(t, reconcilerIsEnabled([]string{"repositories", "packagerevisions"}, "repositories")) + assert.False(t, reconcilerIsEnabled([]string{"repositories"}, "packagerevisions")) +} + +func TestReconcilerIsEnabled_Empty(t *testing.T) { + assert.False(t, reconcilerIsEnabled([]string{""}, "repositories")) + assert.False(t, reconcilerIsEnabled([]string{}, "repositories")) +} + +func TestReconcilerIsEnabled_EnvVar(t *testing.T) { + tests := []struct { + name string + envVal string + reconciler string + want bool + }{ + {"true", "true", "repositories", true}, + {"TRUE", "TRUE", "repositories", true}, + {"1", "1", "repositories", true}, + {"yes", "yes", "repositories", true}, + {"YES", "YES", "repositories", true}, + {"false", "false", "repositories", false}, + {"0", "0", "repositories", false}, + {"no", "no", "repositories", false}, + {"empty", "", "repositories", false}, + {"random", "random", "repositories", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envKey := "ENABLE_REPOSITORIES" + t.Setenv(envKey, tt.envVal) + got := reconcilerIsEnabled([]string{}, tt.reconciler) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestReconcilerIsEnabled_FlagTakesPrecedenceOverEnv(t *testing.T) { + t.Setenv("ENABLE_REPOSITORIES", "false") + assert.True(t, reconcilerIsEnabled([]string{"repositories"}, "repositories")) +} + +// --- buildReconcilerMap --- + +type fakeReconciler struct { + name string +} + +func (f *fakeReconciler) Name() string { return f.name } +func (f *fakeReconciler) Reconcile(context.Context, reconcile.Request) (reconcile.Result, error) { + return reconcile.Result{}, nil +} +func (f *fakeReconciler) InitDefaults() {} +func (f *fakeReconciler) BindFlags(string, *flag.FlagSet) {} +func (f *fakeReconciler) SetupWithManager(ctrl.Manager) error { return nil } + +func TestBuildReconcilerMap(t *testing.T) { + a := &fakeReconciler{name: "alpha"} + b := &fakeReconciler{name: "beta"} + + m := buildReconcilerMap(a, b) + + require.Len(t, m, 2) + assert.Equal(t, a, m["alpha"]) + assert.Equal(t, b, m["beta"]) +} + +func TestBuildReconcilerMap_Empty(t *testing.T) { + m := buildReconcilerMap() + assert.Empty(t, m) +} + +// --- initScheme --- + +func TestInitScheme(t *testing.T) { + scheme, err := initScheme() + require.NoError(t, err) + require.NotNil(t, scheme) + + expectedGVKs := []schema.GroupVersionKind{ + {Group: "porch.kpt.dev", Version: "v1alpha1", Kind: "PackageRevision"}, + {Group: "porch.kpt.dev", Version: "v1alpha2", Kind: "PackageRevision"}, + {Group: "config.porch.kpt.dev", Version: "v1alpha1", Kind: "Repository"}, + } + for _, gvk := range expectedGVKs { + assert.True(t, scheme.Recognizes(gvk), "scheme should recognize %s", gvk) + } +} + +// --- reconcilers map --- + +func TestReconcilersMapContainsAllReconcilers(t *testing.T) { + expected := []string{"repositories", "packagerevisions", "packagevariants", "packagevariantsets"} + for _, name := range expected { + _, ok := reconcilers[name] + assert.True(t, ok, "reconcilers map should contain %q", name) + } + assert.Len(t, reconcilers, len(expected)) +} diff --git a/controllers/packagerevisions/Makefile b/controllers/packagerevisions/Makefile new file mode 100644 index 000000000..b3b56f08c --- /dev/null +++ b/controllers/packagerevisions/Makefile @@ -0,0 +1,30 @@ +# Integration test targets for PackageRevision Controller + +.PHONY: test-integration +test-integration: setup-envtest ## Run integration tests only + export KUBEBUILDER_ASSETS=$$(setup-envtest use 1.34.x -p path) && cd integration && ginkgo -v --trace + +.PHONY: test-integration-watch +test-integration-watch: setup-envtest ## Run integration tests in watch mode + export KUBEBUILDER_ASSETS=$$(setup-envtest use 1.34.x -p path) && cd integration && ginkgo watch -v --trace + +.PHONY: test-unit +test-unit: ## Run unit tests only + cd pkg && go test -v ./... + +.PHONY: test-all +test-all: test-unit test-integration ## Run all tests (unit + integration) + +.PHONY: setup-envtest +setup-envtest: ## Setup test environment binaries + @which setup-envtest > /dev/null || go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest + @which ginkgo > /dev/null || go install github.com/onsi/ginkgo/v2/ginkgo@latest + @setup-envtest use 1.34.x --bin-dir ./testbin + +.PHONY: clean-testbin +clean-testbin: ## Clean test binaries + rm -rf ./testbin + +.PHONY: help +help: ## Display this help + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) diff --git a/controllers/packagerevisions/config/rbac/role.yaml b/controllers/packagerevisions/config/rbac/role.yaml new file mode 100644 index 000000000..fb2b6eae6 --- /dev/null +++ b/controllers/packagerevisions/config/rbac/role.yaml @@ -0,0 +1,66 @@ +# Copyright 2026 The kpt and Nephio 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. +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: porch-controllers-packagerevisions +rules: +- apiGroups: + - config.porch.kpt.dev + resources: + - functionconfigs + verbs: + - get + - list + - patch + - watch +- apiGroups: + - config.porch.kpt.dev + resources: + - functionconfigs/status + verbs: + - get + - patch + - update +- apiGroups: + - config.porch.kpt.dev + resources: + - repositories + verbs: + - get +- apiGroups: + - porch.kpt.dev + resources: + - packagerevisions + verbs: + - get + - list + - patch + - update + - watch +- apiGroups: + - porch.kpt.dev + resources: + - packagerevisions/finalizers + verbs: + - update +- apiGroups: + - porch.kpt.dev + resources: + - packagerevisions/status + verbs: + - get + - patch + - update diff --git a/controllers/packagerevisions/config/rbac/rolebinding.yaml b/controllers/packagerevisions/config/rbac/rolebinding.yaml new file mode 100644 index 000000000..a633f2d93 --- /dev/null +++ b/controllers/packagerevisions/config/rbac/rolebinding.yaml @@ -0,0 +1,25 @@ +# Copyright 2026 The kpt and Nephio 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. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: porch-system:porch-controllers-packagerevisions +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: porch-controllers-packagerevisions +subjects: +- kind: ServiceAccount + name: porch-controllers + namespace: porch-system diff --git a/controllers/packagerevisions/integration/packagerevision_integration_test.go b/controllers/packagerevisions/integration/packagerevision_integration_test.go new file mode 100644 index 000000000..2a8633fc4 --- /dev/null +++ b/controllers/packagerevisions/integration/packagerevision_integration_test.go @@ -0,0 +1,368 @@ +package integration + +import ( + "context" + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/controllers/packagerevisions/pkg/controllers/packagerevision" + "github.com/nephio-project/porch/pkg/repository" + mockrepository "github.com/nephio-project/porch/test/mockery/mocks/porch/pkg/repository" +) + +var _ = Describe("PackageRevision Controller Integration", func() { + var ( + reconciler *packagerevision.PackageRevisionReconciler + mockCache *mockrepository.MockContentCache + testPR *porchv1alpha2.PackageRevision + nn types.NamespacedName + testCounter int + ) + + BeforeEach(func() { + testCounter++ + reconciler, mockCache = createReconcilerWithMockCache() + testPR = createTestPackageRevision(uniqueName("test-pr", testCounter), "default") + nn = types.NamespacedName{Name: testPR.Name, Namespace: testPR.Namespace} + + Expect(k8sClient.Create(ctx, testPR)).To(Succeed()) + }) + + AfterEach(func() { + if testPR != nil { + k8sClient.Delete(ctx, testPR) + } + }) + + Context("When reconciling a new PackageRevision", func() { + It("Should reconcile successfully when current matches desired", func() { + mockContent := mockrepository.NewMockPackageContent(GinkgoT()) + mockContent.EXPECT().Lifecycle(mock.Anything).Return("Draft") + mockCache.EXPECT().GetPackageContent(mock.Anything, mock.Anything, "test-package", "workspace-1").Return(mockContent, nil) + + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + + It("Should handle not-found gracefully", func() { + missingNN := types.NamespacedName{Name: "does-not-exist", Namespace: "default"} + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: missingNN}) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + }) + + Context("When reconciling different lifecycle states (no-op)", func() { + It("Should reconcile a Draft PackageRevision (no-op)", func() { + mockContent := mockrepository.NewMockPackageContent(GinkgoT()) + mockContent.EXPECT().Lifecycle(mock.Anything).Return("Draft") + mockCache.EXPECT().GetPackageContent(mock.Anything, mock.Anything, "test-package", "workspace-1").Return(mockContent, nil) + + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + + var fetched porchv1alpha2.PackageRevision + Expect(k8sClient.Get(ctx, nn, &fetched)).To(Succeed()) + Expect(fetched.Spec.Lifecycle).To(Equal(porchv1alpha2.PackageRevisionLifecycleDraft)) + }) + + It("Should reconcile a Proposed PackageRevision", func() { + var fetched porchv1alpha2.PackageRevision + Expect(k8sClient.Get(ctx, nn, &fetched)).To(Succeed()) + fetched.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecycleProposed + Expect(k8sClient.Update(ctx, &fetched)).To(Succeed()) + + mockContent := mockrepository.NewMockPackageContent(GinkgoT()) + mockContent.EXPECT().Lifecycle(mock.Anything).Return("Proposed") + mockCache.EXPECT().GetPackageContent(mock.Anything, mock.Anything, "test-package", "workspace-1").Return(mockContent, nil) + + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + + It("Should reconcile a Published PackageRevision", func() { + var fetched porchv1alpha2.PackageRevision + Expect(k8sClient.Get(ctx, nn, &fetched)).To(Succeed()) + fetched.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + Expect(k8sClient.Update(ctx, &fetched)).To(Succeed()) + + mockContent := mockrepository.NewMockPackageContent(GinkgoT()) + mockContent.EXPECT().Lifecycle(mock.Anything).Return("Published") + mockContent.EXPECT().Key().Return(repository.PackageRevisionKey{}).Maybe() + mockContent.EXPECT().GetCommitInfo().Return(time.Time{}, "").Maybe() + mockCache.EXPECT().GetPackageContent(mock.Anything, mock.Anything, "test-package", "workspace-1").Return(mockContent, nil) + + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + }) + + Context("When performing lifecycle transitions", func() { + type transitionCase struct { + desired porchv1alpha2.PackageRevisionLifecycle + current string + } + + transitions := []transitionCase{ + {porchv1alpha2.PackageRevisionLifecycleProposed, "Draft"}, + {porchv1alpha2.PackageRevisionLifecycleDraft, "Proposed"}, + {porchv1alpha2.PackageRevisionLifecyclePublished, "Proposed"}, + {porchv1alpha2.PackageRevisionLifecycleDeletionProposed, "Published"}, + {porchv1alpha2.PackageRevisionLifecyclePublished, "DeletionProposed"}, + } + + for _, tc := range transitions { + tc := tc + It(fmt.Sprintf("Should transition %s -> %s and set Ready=True", tc.current, tc.desired), func() { + var fetched porchv1alpha2.PackageRevision + Expect(k8sClient.Get(ctx, nn, &fetched)).To(Succeed()) + fetched.Spec.Lifecycle = tc.desired + Expect(k8sClient.Update(ctx, &fetched)).To(Succeed()) + + repoKey := repository.RepositoryKey{Namespace: "default", Name: "test-repo"} + mockContent := mockrepository.NewMockPackageContent(GinkgoT()) + mockContent.EXPECT().Lifecycle(mock.Anything).Return(tc.current) + mockCache.EXPECT().GetPackageContent(mock.Anything, repoKey, "test-package", "workspace-1").Return(mockContent, nil) + updatedContent := mockrepository.NewMockPackageContent(GinkgoT()) + updatedContent.EXPECT().Lifecycle(mock.Anything).Return(string(tc.desired)).Maybe() + updatedContent.EXPECT().Key().Return(repository.PackageRevisionKey{}).Maybe() + updatedContent.EXPECT().GetCommitInfo().Return(time.Time{}, "").Maybe() + mockCache.EXPECT().UpdateLifecycle(mock.Anything, repoKey, "test-package", "workspace-1", string(tc.desired)).Return(updatedContent, nil) + + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + + Expect(k8sClient.Get(ctx, nn, &fetched)).To(Succeed()) + readyCond := findCondition(fetched.Status.Conditions, porchv1alpha2.ConditionReady) + Expect(readyCond).NotTo(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionTrue)) + Expect(readyCond.Reason).To(Equal(porchv1alpha2.ReasonReady)) + }) + } + + It("Should set Ready=False when UpdateLifecycle fails", func() { + var fetched porchv1alpha2.PackageRevision + Expect(k8sClient.Get(ctx, nn, &fetched)).To(Succeed()) + fetched.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecycleProposed + Expect(k8sClient.Update(ctx, &fetched)).To(Succeed()) + + repoKey := repository.RepositoryKey{Namespace: "default", Name: "test-repo"} + mockContent := mockrepository.NewMockPackageContent(GinkgoT()) + mockContent.EXPECT().Lifecycle(mock.Anything).Return("Draft") + mockCache.EXPECT().GetPackageContent(mock.Anything, repoKey, "test-package", "workspace-1").Return(mockContent, nil) + mockCache.EXPECT().UpdateLifecycle(mock.Anything, repoKey, "test-package", "workspace-1", "Proposed").Return(nil, fmt.Errorf("git push failed")) + + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + + Expect(k8sClient.Get(ctx, nn, &fetched)).To(Succeed()) + readyCond := findCondition(fetched.Status.Conditions, porchv1alpha2.ConditionReady) + Expect(readyCond).NotTo(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal(porchv1alpha2.ReasonFailed)) + Expect(readyCond.Message).To(ContainSubstring("git push failed")) + }) + + It("Should set Ready=False when GetPackageContent fails", func() { + repoKey := repository.RepositoryKey{Namespace: "default", Name: "test-repo"} + mockCache.EXPECT().GetPackageContent(mock.Anything, repoKey, "test-package", "workspace-1").Return(nil, fmt.Errorf("repo not found")) + + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + + var fetched porchv1alpha2.PackageRevision + Expect(k8sClient.Get(ctx, nn, &fetched)).To(Succeed()) + readyCond := findCondition(fetched.Status.Conditions, porchv1alpha2.ConditionReady) + Expect(readyCond).NotTo(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal(porchv1alpha2.ReasonFailed)) + Expect(readyCond.Message).To(ContainSubstring("repo not found")) + }) + + It("Should set Ready=True on no-op and verify condition", func() { + mockContent := mockrepository.NewMockPackageContent(GinkgoT()) + mockContent.EXPECT().Lifecycle(mock.Anything).Return("Draft") + mockCache.EXPECT().GetPackageContent(mock.Anything, mock.Anything, "test-package", "workspace-1").Return(mockContent, nil) + + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + + var fetched porchv1alpha2.PackageRevision + Expect(k8sClient.Get(ctx, nn, &fetched)).To(Succeed()) + readyCond := findCondition(fetched.Status.Conditions, porchv1alpha2.ConditionReady) + Expect(readyCond).NotTo(BeNil()) + Expect(readyCond.Status).To(Equal(metav1.ConditionTrue)) + Expect(readyCond.Reason).To(Equal(porchv1alpha2.ReasonReady)) + }) + }) + + Context("When SetupWithManager registers field indexes", Ordered, func() { + var ( + mgr manager.Manager + mgrCtx context.Context + mgrCancel context.CancelFunc + ) + + BeforeAll(func() { + var err error + mgr, err = ctrl.NewManager(cfg, ctrl.Options{ + Scheme: k8sClient.Scheme(), + }) + Expect(err).NotTo(HaveOccurred()) + + // The manager's controller will reconcile in a goroutine. + // Use a permissive mock that returns an error for any GetPackageContent call + // (the controller handles errors gracefully via status conditions). + mgrMockCache := mockrepository.NewMockContentCache(GinkgoT()) + mgrMockCache.EXPECT().GetPackageContent(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, fmt.Errorf("mock: no repository configured")).Maybe() + + r := &packagerevision.PackageRevisionReconciler{ + Scheme: mgr.GetScheme(), + ContentCache: mgrMockCache, + MaxConcurrentReconciles: 1, + } + Expect(r.SetupWithManager(mgr)).To(Succeed()) + + mgrCtx, mgrCancel = context.WithCancel(ctx) + go func() { + defer GinkgoRecover() + _ = mgr.Start(mgrCtx) + }() + + Expect(mgr.GetCache().WaitForCacheSync(ctx)).To(BeTrue()) + }) + + AfterAll(func() { + mgrCancel() + }) + + It("Should filter by lifecycle field", func() { + var list porchv1alpha2.PackageRevisionList + err := mgr.GetClient().List(ctx, &list, client.MatchingFields{ + string(porchv1alpha2.PkgRevSelectorLifecycle): string(porchv1alpha2.PackageRevisionLifecycleDraft), + }) + Expect(err).NotTo(HaveOccurred()) + + found := false + for _, item := range list.Items { + if item.Name == testPR.Name { + found = true + break + } + } + Expect(found).To(BeTrue(), "expected to find test PR via lifecycle index") + }) + + It("Should filter by repository field", func() { + var list porchv1alpha2.PackageRevisionList + err := mgr.GetClient().List(ctx, &list, client.MatchingFields{ + string(porchv1alpha2.PkgRevSelectorRepository): "test-repo", + }) + Expect(err).NotTo(HaveOccurred()) + + found := false + for _, item := range list.Items { + if item.Name == testPR.Name { + found = true + break + } + } + Expect(found).To(BeTrue(), "expected to find test PR via repository index") + }) + + It("Should filter by package name field", func() { + var list porchv1alpha2.PackageRevisionList + err := mgr.GetClient().List(ctx, &list, client.MatchingFields{ + string(porchv1alpha2.PkgRevSelectorPackageName): "test-package", + }) + Expect(err).NotTo(HaveOccurred()) + + found := false + for _, item := range list.Items { + if item.Name == testPR.Name { + found = true + break + } + } + Expect(found).To(BeTrue(), "expected to find test PR via packageName index") + }) + + It("Should return empty for non-matching field values", func() { + var list porchv1alpha2.PackageRevisionList + err := mgr.GetClient().List(ctx, &list, client.MatchingFields{ + string(porchv1alpha2.PkgRevSelectorRepository): "nonexistent-repo", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(list.Items).To(BeEmpty()) + }) + + It("Should filter by workspace name field", func() { + var list porchv1alpha2.PackageRevisionList + err := mgr.GetClient().List(ctx, &list, client.MatchingFields{ + string(porchv1alpha2.PkgRevSelectorWorkspaceName): "workspace-1", + }) + Expect(err).NotTo(HaveOccurred()) + + found := false + for _, item := range list.Items { + if item.Name == testPR.Name { + found = true + break + } + } + Expect(found).To(BeTrue(), "expected to find test PR via workspaceName index") + }) + + It("Should find multiple PackageRevisions by same repository", func() { + secondPR := createTestPackageRevision(fmt.Sprintf("test-pr-second-%d", testCounter), "default") + secondPR.Spec.WorkspaceName = "workspace-2" + Expect(k8sClient.Create(ctx, secondPR)).To(Succeed()) + defer k8sClient.Delete(ctx, secondPR) + + Eventually(func() int { + var list porchv1alpha2.PackageRevisionList + err := mgr.GetClient().List(ctx, &list, client.MatchingFields{ + string(porchv1alpha2.PkgRevSelectorRepository): "test-repo", + }) + if err != nil { + return 0 + } + count := 0 + for _, item := range list.Items { + if item.Name == testPR.Name || item.Name == secondPR.Name { + count++ + } + } + return count + }).Should(Equal(2)) + }) + }) +}) + +func findCondition(conditions []metav1.Condition, condType string) *metav1.Condition { + for i := range conditions { + if conditions[i].Type == condType { + return &conditions[i] + } + } + return nil +} diff --git a/controllers/packagerevisions/integration/suite_test.go b/controllers/packagerevisions/integration/suite_test.go new file mode 100644 index 000000000..ec36de85d --- /dev/null +++ b/controllers/packagerevisions/integration/suite_test.go @@ -0,0 +1,107 @@ +package integration + +import ( + "context" + "fmt" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/controllers/packagerevisions/pkg/controllers/packagerevision" + mockrepository "github.com/nephio-project/porch/test/mockery/mocks/porch/pkg/repository" +) + +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment + ctx context.Context + cancel context.CancelFunc +) + +func TestPackageRevisionIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration tests in short mode") + } + RegisterFailHandler(Fail) + RunSpecs(t, "PackageRevision Integration Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDInstallOptions: envtest.CRDInstallOptions{ + Paths: []string{filepath.Join("..", "..", "..", "api", "porch", "v1alpha2", "porch.kpt.dev_packagerevisions.yaml")}, + }, + } + + var err error + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + scheme := runtime.NewScheme() + err = porchv1alpha2.AddToScheme(scheme) + Expect(err).NotTo(HaveOccurred()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) +}) + +var _ = AfterSuite(func() { + cancel() + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +func createTestPackageRevision(name, namespace string) *porchv1alpha2.PackageRevision { + return &porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + APIVersion: porchv1alpha2.SchemeGroupVersion.String(), + Kind: "PackageRevision", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "test-package", + RepositoryName: "test-repo", + WorkspaceName: "workspace-1", + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + }, + } +} + +func createReconcilerWithMockCache() (*packagerevision.PackageRevisionReconciler, *mockrepository.MockContentCache) { + mockCache := mockrepository.NewMockContentCache(GinkgoT()) + + reconciler := &packagerevision.PackageRevisionReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + ContentCache: mockCache, + MaxConcurrentReconciles: 1, + } + + return reconciler, mockCache +} + +func uniqueName(prefix string, counter int) string { + return fmt.Sprintf("%s-%d", prefix, counter) +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/config.go b/controllers/packagerevisions/pkg/controllers/packagerevision/config.go new file mode 100644 index 000000000..1166d9b39 --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/config.go @@ -0,0 +1,96 @@ +// Copyright 2026 The kpt and Nephio 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 ( + "flag" + "fmt" + "os" + "time" + + "github.com/kptdev/kpt/pkg/lib/runneroptions" + "github.com/nephio-project/porch/pkg/cache/contentcache" + "github.com/nephio-project/porch/pkg/engine" + porch "github.com/nephio-project/porch/pkg/registry/porch" + ctrl "sigs.k8s.io/controller-runtime" +) + +const ( + defaultMaxConcurrentReconciles = 50 + defaultMaxConcurrentRenders = 20 + defaultRenderRequeueDelay = 2 * time.Second + defaultRepoOperationRetryAttempts = 3 + defaultMaxGRPCMessageSize = 6 * 1024 * 1024 // 6MB +) + +func (r *PackageRevisionReconciler) InitDefaults() { + r.MaxConcurrentReconciles = defaultMaxConcurrentReconciles + r.MaxConcurrentRenders = defaultMaxConcurrentRenders + r.RenderRequeueDelay = defaultRenderRequeueDelay + r.RepoOperationRetryAttempts = defaultRepoOperationRetryAttempts + r.MaxGRPCMessageSize = defaultMaxGRPCMessageSize +} + +func (r *PackageRevisionReconciler) BindFlags(prefix string, flags *flag.FlagSet) { + flags.IntVar(&r.MaxConcurrentReconciles, prefix+"max-concurrent-reconciles", defaultMaxConcurrentReconciles, "Maximum number of concurrent PackageRevision reconciles") + flags.IntVar(&r.MaxConcurrentRenders, prefix+"max-concurrent-renders", defaultMaxConcurrentRenders, "Maximum number of concurrent renders (0 = unbounded)") + flags.DurationVar(&r.RenderRequeueDelay, prefix+"render-requeue-delay", defaultRenderRequeueDelay, "Delay before requeuing when render concurrency limit is reached") + flags.IntVar(&r.RepoOperationRetryAttempts, prefix+"repo-operation-retry-attempts", defaultRepoOperationRetryAttempts, "Number of retry attempts for git operations") + flags.IntVar(&r.MaxGRPCMessageSize, prefix+"max-grpc-message-size", defaultMaxGRPCMessageSize, "Maximum gRPC message size in bytes for fn-runner communication") +} + +// Init wires runtime dependencies (credential resolvers, renderer) +// that require the manager. ContentCache must be set before calling Init. +func (r *PackageRevisionReconciler) Init(mgr ctrl.Manager) error { + log := ctrl.Log.WithName(r.Name()) + log.Info("PackageRevision controller configuration", + "maxConcurrentReconciles", r.MaxConcurrentReconciles, + "maxConcurrentRenders", r.MaxConcurrentRenders, + "renderRequeueDelay", r.RenderRequeueDelay, + "repoOperationRetryAttempts", r.RepoOperationRetryAttempts, + "maxGRPCMessageSize", r.MaxGRPCMessageSize, + ) + + coreClient := mgr.GetClient() + credResolver := porch.NewCredentialResolver(coreClient, []porch.Resolver{ + porch.NewBasicAuthResolver(), + porch.NewBearerTokenAuthResolver(), + }) + caBundleResolver := porch.NewCredentialResolver(coreClient, []porch.Resolver{ + porch.NewCaBundleResolver(), + }) + r.ExternalPackageFetcher = contentcache.NewExternalPackageFetcher( + credResolver, caBundleResolver, r.RepoOperationRetryAttempts, + ) + + fnRunnerAddr := os.Getenv("FUNCTION_RUNNER_ADDRESS") + functionRuntime, err := engine.NewMultiFunctionRuntime(fnRunnerAddr, r.MaxGRPCMessageSize, r.FunctionConfigStore) + if err != nil { + return fmt.Errorf("failed to create function runtime: %w", err) + } + opts := runneroptions.RunnerOptions{} + prefix := os.Getenv("DEFAULT_IMAGE_PREFIX") + if prefix == "" { + prefix = runneroptions.GHCRImagePrefix + } + opts.InitDefaults(prefix) + r.Renderer = newKptRenderer(functionRuntime, opts) + if fnRunnerAddr != "" { + ctrl.Log.WithName(r.Name()).Info("function runtime enabled (builtin + fn-runner)", "address", fnRunnerAddr) + } else { + ctrl.Log.WithName(r.Name()).Info("function runtime enabled (builtin only, FUNCTION_RUNNER_ADDRESS not set)") + } + return nil +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/config_test.go b/controllers/packagerevisions/pkg/controllers/packagerevision/config_test.go new file mode 100644 index 000000000..8a16e3f1a --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/config_test.go @@ -0,0 +1,105 @@ +package packagerevision + +import ( + "flag" + "testing" + + "github.com/nephio-project/porch/controllers/functionconfigs/reconciler" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestInitDefaults(t *testing.T) { + r := &PackageRevisionReconciler{} + r.InitDefaults() + + assert.Equal(t, defaultMaxConcurrentReconciles, r.MaxConcurrentReconciles) + assert.Equal(t, defaultMaxConcurrentRenders, r.MaxConcurrentRenders) + assert.Equal(t, defaultRepoOperationRetryAttempts, r.RepoOperationRetryAttempts) + assert.Equal(t, defaultMaxGRPCMessageSize, r.MaxGRPCMessageSize) +} + +func TestBindFlags(t *testing.T) { + r := &PackageRevisionReconciler{} + flags := flag.NewFlagSet("test", flag.ContinueOnError) + + r.BindFlags("pr-", flags) + + err := flags.Parse([]string{ + "--pr-max-concurrent-reconciles=10", + "--pr-max-concurrent-renders=5", + "--pr-repo-operation-retry-attempts=7", + "--pr-max-grpc-message-size=10485760", + }) + require.NoError(t, err) + + assert.Equal(t, 10, r.MaxConcurrentReconciles) + assert.Equal(t, 5, r.MaxConcurrentRenders) + assert.Equal(t, 7, r.RepoOperationRetryAttempts) + assert.Equal(t, 10485760, r.MaxGRPCMessageSize) +} + +func TestBindFlagsDefaults(t *testing.T) { + r := &PackageRevisionReconciler{} + flags := flag.NewFlagSet("test", flag.ContinueOnError) + + r.BindFlags("pr-", flags) + require.NoError(t, flags.Parse([]string{})) + + assert.Equal(t, defaultMaxConcurrentReconciles, r.MaxConcurrentReconciles) + assert.Equal(t, defaultMaxConcurrentRenders, r.MaxConcurrentRenders) + assert.Equal(t, defaultRepoOperationRetryAttempts, r.RepoOperationRetryAttempts) + assert.Equal(t, defaultMaxGRPCMessageSize, r.MaxGRPCMessageSize) +} + +func TestInit_NilCache(t *testing.T) { + // Cache guard is now in main.go's enableReconcilers, not in Init. + // Init no longer checks for nil Cache — it only wires cred resolvers and renderer. + client := fake.NewClientBuilder().Build() + mgr := &fakeManager{client: client} + + r := &PackageRevisionReconciler{ + RepoOperationRetryAttempts: 3, + MaxGRPCMessageSize: defaultMaxGRPCMessageSize, + FunctionConfigStore: reconciler.NewFunctionConfigStore("", ""), + } + err := r.Init(mgr) + require.NoError(t, err) +} + +func TestInit_SetsCredResolverAndFetcher(t *testing.T) { + client := fake.NewClientBuilder().Build() + mgr := &fakeManager{client: client} + + r := &PackageRevisionReconciler{ + RepoOperationRetryAttempts: 3, + MaxGRPCMessageSize: defaultMaxGRPCMessageSize, + FunctionConfigStore: reconciler.NewFunctionConfigStore("", ""), + } + + err := r.Init(mgr) + require.NoError(t, err) + assert.NotNil(t, r.ExternalPackageFetcher, "ExternalPackageFetcher should be set") + assert.NotNil(t, r.Renderer, "Renderer should be set (builtin-only when FUNCTION_RUNNER_ADDRESS is not set)") +} + +func TestInit_RendererEnabledWithFnRunner(t *testing.T) { + // We can't test a real gRPC connection, but we can verify Init attempts + // to create the runtime and fails with a connection error. + client := fake.NewClientBuilder().Build() + mgr := &fakeManager{client: client} + + r := &PackageRevisionReconciler{ + RepoOperationRetryAttempts: 3, + MaxGRPCMessageSize: defaultMaxGRPCMessageSize, + FunctionConfigStore: reconciler.NewFunctionConfigStore("", ""), + } + + t.Setenv("FUNCTION_RUNNER_ADDRESS", "localhost:0") + + err := r.Init(mgr) + // Init should succeed — NewMultiFunctionRuntime doesn't dial eagerly. + require.NoError(t, err) + assert.NotNil(t, r.Renderer, "Renderer should be set when FUNCTION_RUNNER_ADDRESS is provided") +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/fieldindex.go b/controllers/packagerevisions/pkg/controllers/packagerevision/fieldindex.go new file mode 100644 index 000000000..1b6f9fb0e --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/fieldindex.go @@ -0,0 +1,64 @@ +// Copyright 2026 The kpt and Nephio 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" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type fieldIndex struct { + field porchv1alpha2.PkgRevFieldSelector + extract func(*porchv1alpha2.PackageRevision) string +} + +var fieldIndexes = []fieldIndex{ + {porchv1alpha2.PkgRevSelectorLifecycle, func(pr *porchv1alpha2.PackageRevision) string { + return string(pr.Spec.Lifecycle) + }}, + {porchv1alpha2.PkgRevSelectorRepository, func(pr *porchv1alpha2.PackageRevision) string { + return pr.Spec.RepositoryName + }}, + {porchv1alpha2.PkgRevSelectorPackageName, func(pr *porchv1alpha2.PackageRevision) string { + return pr.Spec.PackageName + }}, + {porchv1alpha2.PkgRevSelectorWorkspaceName, func(pr *porchv1alpha2.PackageRevision) string { + return pr.Spec.WorkspaceName + }}, + {porchv1alpha2.PkgRevSelectorRevision, func(pr *porchv1alpha2.PackageRevision) string { + return fmt.Sprint(pr.Status.Revision) + }}, +} + +func setupFieldIndexes(mgr ctrl.Manager) error { + ctx := context.Background() + indexer := mgr.GetFieldIndexer() + + for _, idx := range fieldIndexes { + extract := idx.extract + if err := indexer.IndexField(ctx, &porchv1alpha2.PackageRevision{}, string(idx.field), + func(obj client.Object) []string { + return []string{extract(obj.(*porchv1alpha2.PackageRevision))} + }); err != nil { + return fmt.Errorf("failed to index field %s: %w", idx.field, err) + } + } + + return nil +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/fieldindex_test.go b/controllers/packagerevisions/pkg/controllers/packagerevision/fieldindex_test.go new file mode 100644 index 000000000..5c08488ef --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/fieldindex_test.go @@ -0,0 +1,74 @@ +package packagerevision + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" +) + +func TestFieldIndexExtractors(t *testing.T) { + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished, + RepositoryName: "my-repo", + PackageName: "my-pkg", + WorkspaceName: "ws-1", + }, + Status: porchv1alpha2.PackageRevisionStatus{ + Revision: 3, + }, + } + + expected := map[porchv1alpha2.PkgRevFieldSelector]string{ + porchv1alpha2.PkgRevSelectorLifecycle: "Published", + porchv1alpha2.PkgRevSelectorRepository: "my-repo", + porchv1alpha2.PkgRevSelectorPackageName: "my-pkg", + porchv1alpha2.PkgRevSelectorWorkspaceName: "ws-1", + porchv1alpha2.PkgRevSelectorRevision: "3", + } + + for _, idx := range fieldIndexes { + t.Run(string(idx.field), func(t *testing.T) { + got := idx.extract(pr) + want, ok := expected[idx.field] + assert.True(t, ok, "unexpected field index: %s", idx.field) + assert.Equal(t, want, got) + }) + } +} + +func TestFieldIndexExtractorsEmpty(t *testing.T) { + pr := &porchv1alpha2.PackageRevision{} + + expected := map[porchv1alpha2.PkgRevFieldSelector]string{ + porchv1alpha2.PkgRevSelectorLifecycle: "", + porchv1alpha2.PkgRevSelectorRepository: "", + porchv1alpha2.PkgRevSelectorPackageName: "", + porchv1alpha2.PkgRevSelectorWorkspaceName: "", + porchv1alpha2.PkgRevSelectorRevision: "0", + } + + for _, idx := range fieldIndexes { + t.Run(string(idx.field)+"_empty", func(t *testing.T) { + got := idx.extract(pr) + assert.Equal(t, expected[idx.field], got) + }) + } +} + +func TestFieldIndexCoversAllSelectors(t *testing.T) { + // Ensure every non-metadata selector has an extractor. + indexed := make(map[porchv1alpha2.PkgRevFieldSelector]bool) + for _, idx := range fieldIndexes { + indexed[idx.field] = true + } + + for _, sel := range porchv1alpha2.PackageRevisionSelectableFields { + if sel == porchv1alpha2.PkgRevSelectorName || sel == porchv1alpha2.PkgRevSelectorNamespace { + continue // metadata fields are handled by k8s natively + } + assert.True(t, indexed[sel], "missing field index for selector %s", sel) + } +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/helpers_test.go b/controllers/packagerevisions/pkg/controllers/packagerevision/helpers_test.go new file mode 100644 index 000000000..a2c59f6cf --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/helpers_test.go @@ -0,0 +1,17 @@ +package packagerevision + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +// fakeManager is a minimal ctrl.Manager for unit testing Init(). +// Only GetClient() is implemented; all other methods will panic if called. +type fakeManager struct { + manager.Manager + client client.Client +} + +func (f *fakeManager) GetClient() client.Client { + return f.client +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/labels.go b/controllers/packagerevisions/pkg/controllers/packagerevision/labels.go new file mode 100644 index 000000000..17510c94c --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/labels.go @@ -0,0 +1,93 @@ +// Copyright 2026 The kpt and Nephio 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" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// ensureLatestRevisionLabel sets the latest-revision label to "false" if not already set. +func (r *PackageRevisionReconciler) ensureLatestRevisionLabel(ctx context.Context, pr *porchv1alpha2.PackageRevision) { + if _, ok := pr.Labels[porchv1alpha2.LatestPackageRevisionKey]; ok { + return + } + patch := client.MergeFrom(pr.DeepCopy()) + if pr.Labels == nil { + pr.Labels = map[string]string{} + } + pr.Labels[porchv1alpha2.LatestPackageRevisionKey] = "false" + if err := r.Patch(ctx, pr, patch); err != nil { + log.FromContext(ctx).Error(err, "failed to set latest-revision label") + } +} + +// updateLatestRevisionLabels sets latest-revision=true on the published package +// with the highest revision number, and false on all others for the same package/repo. +func (r *PackageRevisionReconciler) updateLatestRevisionLabels(ctx context.Context, pr *porchv1alpha2.PackageRevision) { + log := log.FromContext(ctx) + + var allRevs porchv1alpha2.PackageRevisionList + if err := r.List(ctx, &allRevs, + client.InNamespace(pr.Namespace), + client.MatchingFields{ + string(porchv1alpha2.PkgRevSelectorRepository): pr.Spec.RepositoryName, + string(porchv1alpha2.PkgRevSelectorPackageName): pr.Spec.PackageName, + }, + ); err != nil { + log.Error(err, "failed to list package revisions for latest-revision update") + return + } + + highestRev := 0 + var latestName string + for i := range allRevs.Items { + rev := &allRevs.Items[i] + if !rev.DeletionTimestamp.IsZero() { + continue + } + if porchv1alpha2.LifecycleIsPublished(rev.Spec.Lifecycle) && rev.Status.Revision > highestRev { + highestRev = rev.Status.Revision + latestName = rev.Name + } + } + + for i := range allRevs.Items { + rev := &allRevs.Items[i] + if !rev.DeletionTimestamp.IsZero() { + continue + } + desiredLabel := "false" + if rev.Name == latestName { + desiredLabel = porchv1alpha2.LatestPackageRevisionValue + } + currentLabel := rev.Labels[porchv1alpha2.LatestPackageRevisionKey] + if currentLabel == desiredLabel { + continue + } + log.V(1).Info("updating latest-revision label", "name", rev.Name, "value", desiredLabel) + patch := client.MergeFrom(rev.DeepCopy()) + if rev.Labels == nil { + rev.Labels = map[string]string{} + } + rev.Labels[porchv1alpha2.LatestPackageRevisionKey] = desiredLabel + if err := r.Patch(ctx, rev, patch); err != nil { + log.Error(err, "failed to update latest-revision label", "name", rev.Name) + } + } +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/labels_test.go b/controllers/packagerevisions/pkg/controllers/packagerevision/labels_test.go new file mode 100644 index 000000000..1e8e64f77 --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/labels_test.go @@ -0,0 +1,218 @@ +package packagerevision + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + mockclient "github.com/nephio-project/porch/test/mockery/mocks/external/sigs.k8s.io/controller-runtime/pkg/client" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestEnsureLatestRevisionLabelAlreadySet(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + // No Patch expected — label already exists. + + r := &PackageRevisionReconciler{Client: mockClient} + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + Labels: map[string]string{porchv1alpha2.LatestPackageRevisionKey: "true"}, + }, + } + + r.ensureLatestRevisionLabel(t.Context(), pr) + // Test passes if no unexpected mock calls. +} + +func TestEnsureLatestRevisionLabelNotSet(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.PatchOption) { + pr := obj.(*porchv1alpha2.PackageRevision) + assert.Equal(t, "false", pr.Labels[porchv1alpha2.LatestPackageRevisionKey]) + }).Return(nil) + + r := &PackageRevisionReconciler{Client: mockClient} + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + }, + } + + r.ensureLatestRevisionLabel(t.Context(), pr) +} + +func TestEnsureLatestRevisionLabelNilLabels(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.PatchOption) { + pr := obj.(*porchv1alpha2.PackageRevision) + assert.Equal(t, "false", pr.Labels[porchv1alpha2.LatestPackageRevisionKey]) + }).Return(nil) + + r := &PackageRevisionReconciler{Client: mockClient} + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + Labels: nil, + }, + } + + r.ensureLatestRevisionLabel(t.Context(), pr) +} + +func TestUpdateLatestRevisionLabels(t *testing.T) { + tests := []struct { + name string + items []porchv1alpha2.PackageRevision + expectPatches int + expectedLatest string + }{ + { + name: "single published — set to true", + items: []porchv1alpha2.PackageRevision{ + { + ObjectMeta: metav1.ObjectMeta{Name: "pr-v1", Labels: map[string]string{porchv1alpha2.LatestPackageRevisionKey: "false"}}, + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished}, + Status: porchv1alpha2.PackageRevisionStatus{Revision: 1}, + }, + }, + expectPatches: 1, + expectedLatest: "pr-v1", + }, + { + name: "two published — highest wins", + items: []porchv1alpha2.PackageRevision{ + { + ObjectMeta: metav1.ObjectMeta{Name: "pr-v1", Labels: map[string]string{porchv1alpha2.LatestPackageRevisionKey: "true"}}, + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished}, + Status: porchv1alpha2.PackageRevisionStatus{Revision: 1}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "pr-v2", Labels: map[string]string{porchv1alpha2.LatestPackageRevisionKey: "false"}}, + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished}, + Status: porchv1alpha2.PackageRevisionStatus{Revision: 2}, + }, + }, + expectPatches: 2, // v1 false→false (no-op skipped), v2 false→true + expectedLatest: "pr-v2", + }, + { + name: "already correct — no patches", + items: []porchv1alpha2.PackageRevision{ + { + ObjectMeta: metav1.ObjectMeta{Name: "pr-v1", Labels: map[string]string{porchv1alpha2.LatestPackageRevisionKey: "true"}}, + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished}, + Status: porchv1alpha2.PackageRevisionStatus{Revision: 1}, + }, + }, + expectPatches: 0, + expectedLatest: "pr-v1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + + mockClient.EXPECT().List(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevisionList"), mock.Anything, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + prList := list.(*porchv1alpha2.PackageRevisionList) + prList.Items = tt.items + }).Return(nil) + + patchCount := 0 + if tt.expectPatches > 0 { + mockClient.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything). + Run(func(_ context.Context, _ client.Object, _ client.Patch, _ ...client.PatchOption) { + patchCount++ + }).Return(nil).Maybe() + } + + r := &PackageRevisionReconciler{Client: mockClient} + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pr", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + RepositoryName: "my-repo", + PackageName: "my-pkg", + }, + } + + r.updateLatestRevisionLabels(t.Context(), pr) + + if tt.expectPatches == 0 { + assert.Equal(t, 0, patchCount) + } + }) + } +} + +func TestUpdateLatestRevisionLabelsListError(t *testing.T) { mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().List(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevisionList"), mock.Anything, mock.Anything). + Return(assert.AnError) + + r := &PackageRevisionReconciler{Client: mockClient} + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pr", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + RepositoryName: "my-repo", + PackageName: "my-pkg", + }, + } + + // Should not panic, just log. + r.updateLatestRevisionLabels(t.Context(), pr) +} + +func TestUpdateLatestRevisionLabelsSkipsDeletingPackage(t *testing.T) { + now := metav1.NewTime(time.Now()) + items := []porchv1alpha2.PackageRevision{ + { + ObjectMeta: metav1.ObjectMeta{Name: "pr-v1", Labels: map[string]string{porchv1alpha2.LatestPackageRevisionKey: "false"}}, + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished}, + Status: porchv1alpha2.PackageRevisionStatus{Revision: 1}, + }, + { + // v2 is being deleted — should be skipped. + ObjectMeta: metav1.ObjectMeta{ + Name: "pr-v2", + DeletionTimestamp: &now, + Labels: map[string]string{porchv1alpha2.LatestPackageRevisionKey: "true"}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished}, + Status: porchv1alpha2.PackageRevisionStatus{Revision: 2}, + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().List(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevisionList"), mock.Anything, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + list.(*porchv1alpha2.PackageRevisionList).Items = items + }).Return(nil) + + // Expect v1 to be patched to "true" (promoted to latest). + mockClient.EXPECT().Patch(mock.Anything, mock.MatchedBy(func(obj client.Object) bool { + pr := obj.(*porchv1alpha2.PackageRevision) + return pr.Name == "pr-v1" && pr.Labels[porchv1alpha2.LatestPackageRevisionKey] == porchv1alpha2.LatestPackageRevisionValue + }), mock.Anything).Return(nil).Once() + + r := &PackageRevisionReconciler{Client: mockClient} + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "pr-v2", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + RepositoryName: "my-repo", + PackageName: "my-pkg", + }, + } + + r.updateLatestRevisionLabels(t.Context(), pr) +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/mergekey.go b/controllers/packagerevisions/pkg/controllers/packagerevision/mergekey.go new file mode 100644 index 000000000..428a253a7 --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/mergekey.go @@ -0,0 +1,133 @@ +// Copyright 2022, 2024, 2026 The kpt and Nephio 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 + +// This file is a local copy of merge-key utilities from pkg/task/mergekey.go +// and pkg/task/kio.go. Duplicated here to avoid importing pkg/task which +// transitively pulls in v1alpha1 types. + +import ( + "bytes" + "fmt" + "path" + "strings" + + "github.com/kptdev/kpt/pkg/lib/util/addmergecomment" + "sigs.k8s.io/kustomize/kyaml/kio" + "sigs.k8s.io/kustomize/kyaml/kio/kioutil" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +// ensureMergeKey adds merge-key comment directives to resources so that +// package update can reconcile resource identity between upstream and downstream. +func ensureMergeKey(resources map[string]string) (map[string]string, error) { + pr := &mapReader{ + input: resources, + extra: map[string]string{}, + } + + result := map[string]string{} + + pipeline := kio.Pipeline{ + Inputs: []kio.Reader{pr}, + Filters: []kio.Filter{kio.FilterAll(&addmergecomment.AddMergeComment{})}, + Outputs: []kio.Writer{&mapWriter{output: result}}, + } + + if err := pipeline.Execute(); err != nil { + return nil, fmt.Errorf("failed to add merge-key directive: %w", err) + } + + for k, v := range pr.extra { + result[k] = v + } + + return result, nil +} + +type mapReader struct { + input map[string]string + extra map[string]string +} + +func (r *mapReader) Read() ([]*yaml.RNode, error) { + var results []*yaml.RNode + for k, v := range r.input { + base := path.Base(k) + ext := path.Ext(base) + + if ext != ".yaml" && ext != ".yml" && base != "Kptfile" { + r.extra[k] = v + continue + } + + nodes, err := (&kio.ByteReader{ + Reader: strings.NewReader(v), + SetAnnotations: map[string]string{kioutil.PathAnnotation: k}, + DisableUnwrapping: true, + }).Read() + if err != nil { + return nil, err + } + results = append(results, nodes...) + } + return results, nil +} + +type mapWriter struct { + output map[string]string +} + +func (w *mapWriter) Write(nodes []*yaml.RNode) error { + paths := map[string][]*yaml.RNode{} + for _, node := range nodes { + p := nodePathAnnotation(node) + paths[p] = append(paths[p], node) + } + + buf := &bytes.Buffer{} + for p, nodes := range paths { + bw := kio.ByteWriter{ + Writer: buf, + ClearAnnotations: []string{ + kioutil.PathAnnotation, + //nolint:staticcheck + kioutil.LegacyPathAnnotation, + }, + } + if err := bw.Write(nodes); err != nil { + return err + } + w.output[p] = buf.String() + buf.Reset() + } + return nil +} + +func nodePathAnnotation(node *yaml.RNode) string { + ann := node.GetAnnotations() + if p, ok := ann[kioutil.PathAnnotation]; ok { + return p + } + ns := node.GetNamespace() + if ns == "" { + ns = "non-namespaced" + } + name := node.GetName() + if name == "" { + name = "unnamed" + } + return path.Join(ns, fmt.Sprintf("%s.yaml", name)) +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/mergekey_test.go b/controllers/packagerevisions/pkg/controllers/packagerevision/mergekey_test.go new file mode 100644 index 000000000..68bbf1237 --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/mergekey_test.go @@ -0,0 +1,130 @@ +// Copyright 2026 The kpt and Nephio 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 ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +func TestEnsureMergeKeyAddsComment(t *testing.T) { + input := map[string]string{ + "cm.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: my-cm\n namespace: default\n", + } + + result, err := ensureMergeKey(input) + require.NoError(t, err) + assert.Contains(t, result["cm.yaml"], "kpt-merge: default/my-cm") +} + +func TestEnsureMergeKeyPreservesExisting(t *testing.T) { + input := map[string]string{ + "cm.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: my-cm\n namespace: default # kpt-merge: default/my-cm\n", + } + + result, err := ensureMergeKey(input) + require.NoError(t, err) + assert.Contains(t, result["cm.yaml"], "kpt-merge: default/my-cm") +} + +func TestEnsureMergeKeyNonYamlPassthrough(t *testing.T) { + input := map[string]string{ + "README.md": "# hello", + "config.txt": "some config", + "cm.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n", + } + + result, err := ensureMergeKey(input) + require.NoError(t, err) + assert.Equal(t, "# hello", result["README.md"]) + assert.Equal(t, "some config", result["config.txt"]) + assert.Contains(t, result, "cm.yaml") +} + +func TestEnsureMergeKeyEmptyInput(t *testing.T) { + result, err := ensureMergeKey(map[string]string{}) + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestEnsureMergeKeyKptfile(t *testing.T) { + input := map[string]string{ + "Kptfile": "apiVersion: kpt.dev/v1\nkind: Kptfile\nmetadata:\n name: my-pkg\n", + } + + result, err := ensureMergeKey(input) + require.NoError(t, err) + assert.Contains(t, result, "Kptfile") +} + +func TestEnsureMergeKeyMultipleResources(t *testing.T) { + input := map[string]string{ + "a.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n namespace: ns1\n", + "b.yml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: b\n namespace: ns2\n", + } + + result, err := ensureMergeKey(input) + require.NoError(t, err) + assert.Contains(t, result["a.yaml"], "kpt-merge: ns1/a") + assert.Contains(t, result["b.yml"], "kpt-merge: ns2/b") +} + +func TestEnsureMergeKeyInvalidYaml(t *testing.T) { + input := map[string]string{ + "bad.yaml": "not: valid: yaml: [", + } + + _, err := ensureMergeKey(input) + assert.Error(t, err) +} + +func TestMapReaderSeparatesNonYaml(t *testing.T) { + r := &mapReader{ + input: map[string]string{ + "a.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n", + "b.json": `{"key": "value"}`, + "c.txt": "plain text", + "Kptfile": "apiVersion: kpt.dev/v1\nkind: Kptfile\nmetadata:\n name: pkg\n", + }, + extra: map[string]string{}, + } + + nodes, err := r.Read() + require.NoError(t, err) + assert.Len(t, nodes, 2) + assert.Equal(t, `{"key": "value"}`, r.extra["b.json"]) + assert.Equal(t, "plain text", r.extra["c.txt"]) +} + +func TestNodePathAnnotationFallback(t *testing.T) { + node, err := yaml.Parse("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n namespace: myns\n") + require.NoError(t, err) + assert.Equal(t, "myns/test.yaml", nodePathAnnotation(node)) +} + +func TestNodePathAnnotationNoNamespace(t *testing.T) { + node, err := yaml.Parse("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n") + require.NoError(t, err) + assert.Equal(t, "non-namespaced/test.yaml", nodePathAnnotation(node)) +} + +func TestNodePathAnnotationNoName(t *testing.T) { + node, err := yaml.Parse("apiVersion: v1\nkind: ConfigMap\nmetadata:\n namespace: myns\n") + require.NoError(t, err) + assert.Equal(t, "myns/unnamed.yaml", nodePathAnnotation(node)) +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/ownership.go b/controllers/packagerevisions/pkg/controllers/packagerevision/ownership.go new file mode 100644 index 000000000..c9c8c3564 --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/ownership.go @@ -0,0 +1,155 @@ +// Copyright 2026 The kpt and Nephio 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" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + configapi "github.com/nephio-project/porch/api/porchconfig/v1alpha1" + "github.com/nephio-project/porch/pkg/repository" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// handleDeletion gates deletion of Published packages: they must be +// DeletionProposed first, unless the owner Repository is already gone +// (GC cascade). For all other lifecycles, git refs are cleaned up before +// the finalizer is removed. +func (r *PackageRevisionReconciler) handleDeletion(ctx context.Context, pr *porchv1alpha2.PackageRevision) (*ctrl.Result, error) { + if pr.Spec.Lifecycle == porchv1alpha2.PackageRevisionLifecyclePublished { + repoGone, err := r.ownerRepoGone(ctx, pr) + if err != nil { + return nil, fmt.Errorf("failed to check owner repository: %w", err) + } + if !repoGone { + // TODO(GH #1113): Once the validating webhook rejects DELETE on + // published packages at admission time, this becomes a silent + // safety net. Until then, the user sees a stuck Terminating + // object with no explanation. + log.FromContext(ctx).Info("blocking deletion: published package must be DeletionProposed first", "lifecycle", pr.Spec.Lifecycle) + return &ctrl.Result{}, nil + } + // Repo is gone — best-effort git cleanup, don't block on failure + // since all sibling revisions are being garbage-collected too. + if err := r.deleteFromGit(ctx, pr); err != nil { + log.FromContext(ctx).Error(err, "best-effort git cleanup failed during GC cascade") + } + return r.removeFinalizer(ctx, pr, false) + } + + // Delete git refs before removing the finalizer. If this fails the + // finalizer stays and the controller retries on the next reconcile. + if err := r.deleteFromGit(ctx, pr); err != nil { + return nil, fmt.Errorf("failed to delete package from git: %w", err) + } + return r.removeFinalizer(ctx, pr, true) +} + +// deleteFromGit removes the package's git refs (tags, branches) via the +// shared content cache. "Not found" errors are treated as success — there +// is nothing to clean up if the package or repo doesn't exist in the cache. +func (r *PackageRevisionReconciler) deleteFromGit(ctx context.Context, pr *porchv1alpha2.PackageRevision) error { + repoKey := repository.RepositoryKey{ + Namespace: pr.Namespace, + Name: pr.Spec.RepositoryName, + } + err := r.ContentCache.DeletePackage(ctx, repoKey, pr.Spec.PackageName, pr.Spec.WorkspaceName) + if repository.IsNotFoundError(err) { + log.FromContext(ctx).Info("package not found in git, nothing to clean up") + return nil + } + return err +} + +func (r *PackageRevisionReconciler) removeFinalizer(ctx context.Context, pr *porchv1alpha2.PackageRevision, updateLabels bool) (*ctrl.Result, error) { + patch := client.MergeFrom(pr.DeepCopy()) + if controllerutil.RemoveFinalizer(pr, porchv1alpha2.PackageRevisionFinalizer) { + if err := r.Patch(ctx, pr, patch); client.IgnoreNotFound(err) != nil { + return nil, fmt.Errorf("failed to remove finalizer: %w", err) + } + } + + if updateLabels { + r.updateLatestRevisionLabels(ctx, pr) + } + + return &ctrl.Result{}, nil +} + +// ensureFinalizerAndOwner adds the finalizer and Repository ownerReference +// if not already present, in a single patch. +func (r *PackageRevisionReconciler) ensureFinalizerAndOwner(ctx context.Context, pr *porchv1alpha2.PackageRevision) error { + patch := client.MergeFrom(pr.DeepCopy()) + needsPatch := controllerutil.AddFinalizer(pr, porchv1alpha2.PackageRevisionFinalizer) + + if !hasOwnerReference(pr, pr.Spec.RepositoryName) { + if err := r.setOwnerReference(ctx, pr); err != nil { + log.FromContext(ctx).Error(err, "failed to set owner reference") + } else { + needsPatch = true + } + } + + if needsPatch { + if err := r.Patch(ctx, pr, patch); err != nil { + return fmt.Errorf("failed to patch finalizer/ownerReference: %w", err) + } + } + return nil +} + +func hasOwnerReference(pr *porchv1alpha2.PackageRevision, repoName string) bool { + for _, ref := range pr.OwnerReferences { + if ref.Kind == configapi.TypeRepository.Kind && ref.Name == repoName { + return true + } + } + return false +} + +func (r *PackageRevisionReconciler) setOwnerReference(ctx context.Context, pr *porchv1alpha2.PackageRevision) error { + var repo configapi.Repository + if err := r.Get(ctx, types.NamespacedName{Namespace: pr.Namespace, Name: pr.Spec.RepositoryName}, &repo); err != nil { + return err + } + pr.OwnerReferences = append(pr.OwnerReferences, metav1.OwnerReference{ + APIVersion: configapi.GroupVersion.Identifier(), + Kind: configapi.TypeRepository.Kind, + Name: repo.Name, + UID: repo.UID, + }) + return nil +} + +// ownerRepoGone returns true only when the Repository is confirmed deleted +// (NotFound). Transient errors are returned so the caller can retry. +func (r *PackageRevisionReconciler) ownerRepoGone(ctx context.Context, pr *porchv1alpha2.PackageRevision) (bool, error) { + var repo configapi.Repository + err := r.Get(ctx, types.NamespacedName{Namespace: pr.Namespace, Name: pr.Spec.RepositoryName}, &repo) + if err == nil { + return false, nil + } + if apierrors.IsNotFound(err) { + return true, nil + } + return false, err +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/packagerevision_controller.go b/controllers/packagerevisions/pkg/controllers/packagerevision/packagerevision_controller.go new file mode 100644 index 000000000..b5dd2adf8 --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/packagerevision_controller.go @@ -0,0 +1,280 @@ +// Copyright 2026 The kpt and Nephio 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" + "time" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/controllers/functionconfigs/reconciler" + "github.com/nephio-project/porch/pkg/repository" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +//go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen@v0.19.0 rbac:headerFile=../../../../../scripts/boilerplate.yaml.txt,roleName=porch-controllers-packagerevisions,year=$YEAR_GEN webhook paths="." output:rbac:artifacts:config=../../../config/rbac + +//+kubebuilder:rbac:groups=porch.kpt.dev,resources=packagerevisions,verbs=get;list;watch;update;patch +//+kubebuilder:rbac:groups=porch.kpt.dev,resources=packagerevisions/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=porch.kpt.dev,resources=packagerevisions/finalizers,verbs=update +//+kubebuilder:rbac:groups=config.porch.kpt.dev,resources=repositories,verbs=get +//+kubebuilder:rbac:groups=config.porch.kpt.dev,resources=functionconfigs,verbs=get;list;watch;patch +//+kubebuilder:rbac:groups=config.porch.kpt.dev,resources=functionconfigs/status,verbs=get;update;patch + +const reconcilerName = "packagerevisions" + +// PackageRevisionReconciler reconciles v1alpha2 PackageRevision CRDs. +// It handles lifecycle transitions (draft/proposed/published) by executing +// git operations via the shared cache. +type PackageRevisionReconciler struct { + client.Client + Scheme *runtime.Scheme + ContentCache repository.ContentCache + ExternalPackageFetcher repository.ExternalPackageFetcher + FunctionConfigStore *reconciler.FunctionConfigStore + Renderer renderer // nil = skip rendering + + MaxConcurrentReconciles int + MaxConcurrentRenders int + RenderRequeueDelay time.Duration + RepoOperationRetryAttempts int + MaxGRPCMessageSize int + renderLimiter chan struct{} // bounds concurrent fn-runner calls + apiReader client.Reader // bypasses informer cache for direct etcd reads +} + +func (r *PackageRevisionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var pr porchv1alpha2.PackageRevision + if err := r.Get(ctx, req.NamespacedName, &pr); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if result, err := r.reconcileFinalizer(ctx, &pr); err != nil || result != nil { + return resultOrDefault(result), err + } + + desired := string(pr.Spec.Lifecycle) + if desired == "" { + return ctrl.Result{}, nil + } + + repoKey := repository.RepositoryKey{ + Namespace: pr.Namespace, + Name: pr.Spec.RepositoryName, + } + + // TODO: Errors from sub-reconciles are swallowed (returned as nil to controller-runtime), + // so the work queue doesn't apply exponential backoff on persistent failures. + // Consider returning errors to enable backoff, but note the side effects: + // double logging, error metrics, and inconsistency with reconcileLifecycle. + if result, err := r.reconcileSource(ctx, &pr, repoKey); err != nil || result != nil { + return resultOrDefault(result), nil + } + + if result, err := r.reconcileRender(ctx, &pr, repoKey); err != nil || result != nil { + return resultOrDefault(result), nil + } + + return r.reconcileLifecycle(ctx, &pr, repoKey) +} + +// reconcileFinalizer ensures the finalizer and ownerReference are present, +// and handles deletion gating. +// Published packages require DeletionProposed before deletion, unless the +// owner Repository has been deleted (GC cascade). +// Returns (nil, nil) when reconciliation should continue. +func (r *PackageRevisionReconciler) reconcileFinalizer(ctx context.Context, pr *porchv1alpha2.PackageRevision) (*ctrl.Result, error) { + if !pr.DeletionTimestamp.IsZero() { + return r.handleDeletion(ctx, pr) + } + return nil, r.ensureFinalizerAndOwner(ctx, pr) +} + +func (r *PackageRevisionReconciler) reconcileLifecycle(ctx context.Context, pr *porchv1alpha2.PackageRevision, repoKey repository.RepositoryKey) (ctrl.Result, error) { + log := log.FromContext(ctx) + desired := string(pr.Spec.Lifecycle) + + content, err := r.ContentCache.GetPackageContent(ctx, repoKey, pr.Spec.PackageName, pr.Spec.WorkspaceName) + if err != nil { + log.Error(err, "failed to get package content") + r.updateStatus(ctx, pr, nil, "", readyCondition(pr.Generation, metav1.ConditionFalse, porchv1alpha2.ReasonFailed, err.Error())) + return ctrl.Result{}, nil + } + + current := content.Lifecycle(ctx) + if current == desired { + r.updateStatus(ctx, pr, content, "", readyCondition(pr.Generation, metav1.ConditionTrue, porchv1alpha2.ReasonReady, "")) + if porchv1alpha2.LifecycleIsPublished(porchv1alpha2.PackageRevisionLifecycle(desired)) { + r.updateLatestRevisionLabels(ctx, pr) + } + return ctrl.Result{}, nil + } + + log.Info("lifecycle transition", "name", pr.Name, "current", current, "desired", desired) + + updated, err := r.ContentCache.UpdateLifecycle(ctx, repoKey, pr.Spec.PackageName, pr.Spec.WorkspaceName, desired) + if err != nil { + log.Error(err, "lifecycle transition failed") + r.updateStatus(ctx, pr, nil, "", readyCondition(pr.Generation, metav1.ConditionFalse, porchv1alpha2.ReasonFailed, err.Error())) + return ctrl.Result{}, nil + } + + r.updateStatus(ctx, pr, updated, "", readyCondition(pr.Generation, metav1.ConditionTrue, porchv1alpha2.ReasonReady, "")) + + if porchv1alpha2.LifecycleIsPublished(porchv1alpha2.PackageRevisionLifecycle(desired)) { + // Requeue so the informer cache indexes the new status.revision + // before updateLatestRevisionLabels runs its List query. + return ctrl.Result{Requeue: true}, nil + } + + return ctrl.Result{}, nil +} + +func resultOrDefault(result *ctrl.Result) ctrl.Result { + if result != nil { + return *result + } + return ctrl.Result{} +} + +// reconcileSource handles one-time package creation from spec.source. +// Returns (nil, nil) if no source needs to be applied. +// 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) + if err != nil { + return nil, r.setSourceFailed(ctx, pr, err) + } + if resources == nil { + return nil, nil + } + + log := log.FromContext(ctx) + log.Info("applying source", "type", creationSource, "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)) + if err != nil { + return nil, r.setSourceFailed(ctx, pr, fmt.Errorf("create draft: %w", err)) + } + + if err := draft.UpdateResources(ctx, resources, creationSource); err != nil { + return nil, r.setSourceFailed(ctx, pr, fmt.Errorf("update resources: %w", err)) + } + + if err := r.ContentCache.CloseDraft(ctx, repoKey, draft, 0); err != nil { + return nil, r.setSourceFailed(ctx, pr, fmt.Errorf("close draft: %w", err)) + } + + // Read back the created package to get lock info for status. + content, err := r.ContentCache.GetPackageContent(ctx, repoKey, pr.Spec.PackageName, pr.Spec.WorkspaceName) + if err != nil { + 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"), + ) + // Set Rendered=Unknown via the render field manager. + r.updateRenderStatus(ctx, pr, "", "", + renderedCondition(pr.Generation, metav1.ConditionUnknown, porchv1alpha2.ReasonPending, "awaiting render"), + ) + r.ensureLatestRevisionLabel(ctx, pr) + + result := ctrl.Result{Requeue: true} + return &result, nil +} + +// reconcileRender checks if rendering is needed and renders if so. +// Two triggers: +// - Annotation porch.kpt.dev/render-request differs from status.observedPrrResourceVersion (push path) +// - Source was executed but Rendered != True (source execution path) +// +// Returns (nil, nil) if no render is needed. +// TODO: Consider centralising all ctrl.Result creation in packagerevision_controller.go +// so requeue decisions are visible in one place. Sub-functions would return signals +// and the controller translates them into ctrl.Result. +func (r *PackageRevisionReconciler) reconcileRender(ctx context.Context, pr *porchv1alpha2.PackageRevision, repoKey repository.RepositoryKey) (*ctrl.Result, error) { + if r.Renderer == nil { + return nil, nil + } + + requested, annotationTrigger, sourceTrigger := renderTrigger(pr) + if !annotationTrigger && !sourceTrigger { + return nil, nil + } + + log := log.FromContext(ctx) + log.Info("render requested", "requested", requested) + r.updateRenderStatus(ctx, pr, requested, "", + renderedCondition(pr.Generation, metav1.ConditionUnknown, porchv1alpha2.ReasonPending, "rendering"), + ) + + result, err := r.executeRender(ctx, pr, repoKey) + if err != nil { + return nil, err + } + if result != nil { + return result, nil + } + + observed := observedVersionAfterRender(requested, pr.Annotations) + r.updateRenderStatus(ctx, pr, "", observed, + renderedCondition(pr.Generation, metav1.ConditionTrue, porchv1alpha2.ReasonRendered, ""), + ) + return nil, nil +} + +func (r *PackageRevisionReconciler) Name() string { return reconcilerName } + +func (r *PackageRevisionReconciler) SetupWithManager(mgr ctrl.Manager) error { + log := ctrl.Log.WithName(r.Name()) + + r.Client = mgr.GetClient() + r.apiReader = mgr.GetAPIReader() + + if r.MaxConcurrentRenders > 0 { + r.renderLimiter = make(chan struct{}, r.MaxConcurrentRenders) + } + + if err := setupFieldIndexes(mgr); err != nil { + return fmt.Errorf("failed to setup field indexes: %w", err) + } + + err := ctrl.NewControllerManagedBy(mgr). + For(&porchv1alpha2.PackageRevision{}). + WithEventFilter(predicate.Or( + predicate.GenerationChangedPredicate{}, + renderRequestChanged(), + )). + WithOptions(controller.Options{ + MaxConcurrentReconciles: r.MaxConcurrentReconciles, + }). + Named("packagerevision"). + Complete(r) + + if err == nil { + log.V(1).Info("PackageRevision controller successfully registered") + } + return err +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/packagerevision_controller_test.go b/controllers/packagerevisions/pkg/controllers/packagerevision/packagerevision_controller_test.go new file mode 100644 index 000000000..28a31480b --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/packagerevision_controller_test.go @@ -0,0 +1,1770 @@ +package packagerevision + +import ( + "context" + "errors" + "testing" + "time" + + "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" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + configapi "github.com/nephio-project/porch/api/porchconfig/v1alpha1" + "github.com/nephio-project/porch/pkg/repository" + mockclient "github.com/nephio-project/porch/test/mockery/mocks/external/sigs.k8s.io/controller-runtime/pkg/client" + mockrepository "github.com/nephio-project/porch/test/mockery/mocks/porch/pkg/repository" +) + +// setupMockContentDefaults adds common Maybe() expectations for PackageContent methods +// that updateStatus may call. +func setupMockContentDefaults(m *mockrepository.MockPackageContent) { + m.EXPECT().Key().Return(repository.PackageRevisionKey{}).Maybe() + 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() +} + +func newTestReconciler(mockClient *mockclient.MockClient, cache *mockrepository.MockContentCache) *PackageRevisionReconciler { + return &PackageRevisionReconciler{ + Client: mockClient, + ContentCache: cache, + } +} + +// readyObjectMeta returns a standard ObjectMeta with finalizer and ownerReference already set, +// representing a PR that has already been reconciled at least once. +func readyObjectMeta(name, namespace, repoName string) metav1.ObjectMeta { + return metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Finalizers: []string{porchv1alpha2.PackageRevisionFinalizer}, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: configapi.GroupVersion.Identifier(), + Kind: configapi.TypeRepository.Kind, + Name: repoName, + UID: "repo-uid", + }}, + } +} + +func TestReconcileNotFound(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "missing-pr", Namespace: "default"}} + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, &porchv1alpha2.PackageRevision{}). + Return(apierrors.NewNotFound(schema.GroupResource{}, "missing-pr")) + + r := newTestReconciler(mockClient, mockrepository.NewMockContentCache(t)) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) +} + +func TestReconcileGetError(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, &porchv1alpha2.PackageRevision{}). + Return(errors.New("api server unavailable")) + + r := newTestReconciler(mockClient, mockrepository.NewMockContentCache(t)) + _, err := r.Reconcile(ctx, req) + + assert.Error(t, err) +} + +func TestReconcileFinalizerAddedWhenMissing(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pr", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + RepositoryName: "my-repo", + }, + } + + repo := &configapi.Repository{ + ObjectMeta: metav1.ObjectMeta{Name: "my-repo", Namespace: "default", UID: "repo-uid-123"}, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + mockClient.EXPECT().Get(mock.Anything, types.NamespacedName{Namespace: "default", Name: "my-repo"}, mock.AnythingOfType("*v1alpha1.Repository")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*configapi.Repository) = *repo + }).Return(nil) + mockClient.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.PatchOption) { + pr := obj.(*porchv1alpha2.PackageRevision) + assert.Contains(t, pr.GetFinalizers(), porchv1alpha2.PackageRevisionFinalizer) + require.Len(t, pr.OwnerReferences, 1) + assert.Equal(t, "my-repo", pr.OwnerReferences[0].Name) + assert.Equal(t, types.UID("repo-uid-123"), pr.OwnerReferences[0].UID) + assert.Equal(t, configapi.TypeRepository.Kind, pr.OwnerReferences[0].Kind) + }).Return(nil) + + r := newTestReconciler(mockClient, mockrepository.NewMockContentCache(t)) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) +} + +func TestReconcileFinalizerAddPatchFails(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pr", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + RepositoryName: "my-repo", + }, + } + + repo := &configapi.Repository{ + ObjectMeta: metav1.ObjectMeta{Name: "my-repo", Namespace: "default", UID: "repo-uid-123"}, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + mockClient.EXPECT().Get(mock.Anything, types.NamespacedName{Namespace: "default", Name: "my-repo"}, mock.AnythingOfType("*v1alpha1.Repository")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*configapi.Repository) = *repo + }).Return(nil) + mockClient.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything).Return(errors.New("conflict")) + + r := newTestReconciler(mockClient, mockrepository.NewMockContentCache(t)) + _, err := r.Reconcile(ctx, req) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to patch finalizer/ownerReference") +} + +func TestReconcileDeletionBlockedWhenPublishedAndRepoExists(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + now := metav1.Now() + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + DeletionTimestamp: &now, + Finalizers: []string{porchv1alpha2.PackageRevisionFinalizer}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished, + RepositoryName: "my-repo", + }, + } + + repo := &configapi.Repository{ + ObjectMeta: metav1.ObjectMeta{Name: "my-repo", Namespace: "default", UID: "repo-uid-123"}, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + mockClient.EXPECT().Get(mock.Anything, types.NamespacedName{Namespace: "default", Name: "my-repo"}, mock.AnythingOfType("*v1alpha1.Repository")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*configapi.Repository) = *repo + }).Return(nil) + // No Patch expected — finalizer must NOT be removed. + + r := newTestReconciler(mockClient, mockrepository.NewMockContentCache(t)) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) +} + +func TestReconcileDeletionAllowedWhenPublishedButRepoGone(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + now := metav1.Now() + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + DeletionTimestamp: &now, + Finalizers: []string{porchv1alpha2.PackageRevisionFinalizer}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished, + RepositoryName: "deleted-repo", + }, + } + + var patched bool + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + mockClient.EXPECT().Get(mock.Anything, types.NamespacedName{Namespace: "default", Name: "deleted-repo"}, mock.AnythingOfType("*v1alpha1.Repository")). + Return(apierrors.NewNotFound(schema.GroupResource{}, "deleted-repo")) + mockClient.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.PatchOption) { + patched = true + assert.NotContains(t, obj.GetFinalizers(), porchv1alpha2.PackageRevisionFinalizer) + }).Return(nil) + // No List expected — updateLatestRevisionLabels is skipped when repo is gone. + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().DeletePackage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) + assert.True(t, patched, "finalizer should be removed when repo is gone") +} + +func TestReconcileDeletionAllowedForDraft(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + now := metav1.Now() + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + DeletionTimestamp: &now, + Finalizers: []string{porchv1alpha2.PackageRevisionFinalizer}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + }, + } + + var patched bool + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + mockClient.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.PatchOption) { + patched = true + assert.NotContains(t, obj.GetFinalizers(), porchv1alpha2.PackageRevisionFinalizer) + }).Return(nil) + // updateLatestRevisionLabels is called after finalizer removal + mockClient.EXPECT().List(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevisionList"), mock.Anything, mock.Anything).Return(nil) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().DeletePackage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) + assert.True(t, patched, "finalizer should have been removed for Draft package") +} + +func TestReconcileDeletionAllowedWhenDeletionProposed(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + now := metav1.Now() + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + DeletionTimestamp: &now, + Finalizers: []string{porchv1alpha2.PackageRevisionFinalizer}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDeletionProposed, + }, + } + + var patched bool + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + mockClient.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.PatchOption) { + patched = true + assert.NotContains(t, obj.GetFinalizers(), porchv1alpha2.PackageRevisionFinalizer) + }).Return(nil) + // updateLatestRevisionLabels is called after finalizer removal + mockClient.EXPECT().List(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevisionList"), mock.Anything, mock.Anything).Return(nil) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().DeletePackage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) + assert.True(t, patched, "finalizer should have been removed via Patch") +} + +func TestReconcileDeletionRemoveFinalizerPatchFails(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + now := metav1.Now() + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + DeletionTimestamp: &now, + Finalizers: []string{porchv1alpha2.PackageRevisionFinalizer}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDeletionProposed, + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + mockClient.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything).Return(errors.New("conflict")) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().DeletePackage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + + r := newTestReconciler(mockClient, mockCache) + _, err := r.Reconcile(ctx, req) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to remove finalizer") +} + +func TestReconcileDeletionProposedNoFinalizer(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + now := metav1.Now() + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + DeletionTimestamp: &now, + Finalizers: []string{}, // already removed + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDeletionProposed, + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *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) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().DeletePackage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) +} + +func TestReconcileDeletionGitCleanupFailsBlocksFinalizer(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + now := metav1.Now() + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + DeletionTimestamp: &now, + Finalizers: []string{porchv1alpha2.PackageRevisionFinalizer}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDeletionProposed, + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + // No Patch expected — git cleanup fails so finalizer must NOT be removed. + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().DeletePackage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("git push failed")) + + r := newTestReconciler(mockClient, mockCache) + _, err := r.Reconcile(ctx, req) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to delete package from git") +} + +func TestReconcileDeletionGitCleanupFailsBestEffortForPublishedRepoGone(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + now := metav1.Now() + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + DeletionTimestamp: &now, + Finalizers: []string{porchv1alpha2.PackageRevisionFinalizer}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished, + RepositoryName: "deleted-repo", + }, + } + + var patched bool + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + mockClient.EXPECT().Get(mock.Anything, types.NamespacedName{Namespace: "default", Name: "deleted-repo"}, mock.AnythingOfType("*v1alpha1.Repository")). + Return(apierrors.NewNotFound(schema.GroupResource{}, "deleted-repo")) + mockClient.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.PatchOption) { + patched = true + assert.NotContains(t, obj.GetFinalizers(), porchv1alpha2.PackageRevisionFinalizer) + }).Return(nil) + + mockCache := mockrepository.NewMockContentCache(t) + // Git cleanup fails, but since repo is gone this is best-effort — finalizer should still be removed. + mockCache.EXPECT().DeletePackage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("repo not in cache")) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) + assert.True(t, patched, "finalizer should be removed even when best-effort git cleanup fails") +} + +func TestReconcileDeletionAllowedForProposed(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + now := metav1.Now() + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + DeletionTimestamp: &now, + Finalizers: []string{porchv1alpha2.PackageRevisionFinalizer}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecycleProposed, + }, + } + + var patched bool + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + mockClient.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.PatchOption) { + patched = true + assert.NotContains(t, obj.GetFinalizers(), porchv1alpha2.PackageRevisionFinalizer) + }).Return(nil) + mockClient.EXPECT().List(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevisionList"), mock.Anything, mock.Anything).Return(nil) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().DeletePackage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) + assert.True(t, patched, "finalizer should have been removed for Proposed package") +} + +func TestReconcileDeletionPassesCorrectRepoKey(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "my-ns"}} + + now := metav1.Now() + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "my-ns", + DeletionTimestamp: &now, + Finalizers: []string{porchv1alpha2.PackageRevisionFinalizer}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDeletionProposed, + RepositoryName: "my-repo", + PackageName: "my-pkg", + WorkspaceName: "ws-v1", + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + mockClient.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything).Return(nil) + mockClient.EXPECT().List(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevisionList"), mock.Anything, mock.Anything).Return(nil) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().DeletePackage(mock.Anything, + repository.RepositoryKey{Namespace: "my-ns", Name: "my-repo"}, + "my-pkg", "ws-v1", + ).Return(nil) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) +} + +func TestReconcileDeletionPRAlreadyGoneOnPatch(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + now := metav1.Now() + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + DeletionTimestamp: &now, + Finalizers: []string{porchv1alpha2.PackageRevisionFinalizer}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDeletionProposed, + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + // PR disappeared between DeletePackage and Patch — NotFound should be silently ignored. + mockClient.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything). + Return(apierrors.NewNotFound(schema.GroupResource{}, "test-pr")) + + // updateLatestRevisionLabels is called after finalizer removal + mockClient.EXPECT().List(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevisionList"), mock.Anything, mock.Anything).Return(nil) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().DeletePackage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err, "NotFound on Patch should be silently ignored") + assert.Equal(t, ctrl.Result{}, result) +} + +func TestReconcileDeletionPublishedTransientAPIError(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + now := metav1.Now() + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + DeletionTimestamp: &now, + Finalizers: []string{porchv1alpha2.PackageRevisionFinalizer}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished, + RepositoryName: "my-repo", + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + // Repo lookup returns a transient error — should NOT fall into best-effort path. + mockClient.EXPECT().Get(mock.Anything, types.NamespacedName{Namespace: "default", Name: "my-repo"}, mock.AnythingOfType("*v1alpha1.Repository")). + Return(errors.New("api server timeout")) + // No Patch, no DeletePackage — error should propagate. + + r := newTestReconciler(mockClient, mockrepository.NewMockContentCache(t)) + _, err := r.Reconcile(ctx, req) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to check owner repository") +} + +func TestReconcileDeletionGitPackageNotFoundIsNoop(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + now := metav1.Now() + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + DeletionTimestamp: &now, + Finalizers: []string{porchv1alpha2.PackageRevisionFinalizer}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + }, + } + + var patched bool + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + mockClient.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.PatchOption) { + patched = true + assert.NotContains(t, obj.GetFinalizers(), porchv1alpha2.PackageRevisionFinalizer) + }).Return(nil) + mockClient.EXPECT().List(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevisionList"), mock.Anything, mock.Anything).Return(nil) + + mockCache := mockrepository.NewMockContentCache(t) + // Package never existed in git — "not found" should be treated as success. + mockCache.EXPECT().DeletePackage(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(errors.New("package revision / not found in repository default/")) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) + assert.True(t, patched, "finalizer should be removed when package doesn't exist in git") +} + +func TestReconcileOwnerRefAlreadySet(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + Finalizers: []string{porchv1alpha2.PackageRevisionFinalizer}, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: configapi.GroupVersion.Identifier(), + Kind: configapi.TypeRepository.Kind, + Name: "my-repo", + UID: "repo-uid-123", + }}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{RepositoryName: "my-repo"}, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + // No Patch, no repo Get — both finalizer and ownerRef already present. + + r := newTestReconciler(mockClient, mockrepository.NewMockContentCache(t)) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) +} + +func TestReconcileOwnerRefRepoLookupFails(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pr", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{RepositoryName: "missing-repo"}, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + mockClient.EXPECT().Get(mock.Anything, types.NamespacedName{Namespace: "default", Name: "missing-repo"}, mock.AnythingOfType("*v1alpha1.Repository")). + Return(apierrors.NewNotFound(schema.GroupResource{}, "missing-repo")) + // Finalizer still needs to be added even if ownerRef lookup fails. + mockClient.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.PatchOption) { + pr := obj.(*porchv1alpha2.PackageRevision) + assert.Contains(t, pr.GetFinalizers(), porchv1alpha2.PackageRevisionFinalizer) + assert.Empty(t, pr.OwnerReferences, "ownerRef should not be set when repo lookup fails") + }).Return(nil) + + r := newTestReconciler(mockClient, mockrepository.NewMockContentCache(t)) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) +} + +func TestReconcileEmptyLifecycle(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", Namespace: "default", + Finalizers: []string{porchv1alpha2.PackageRevisionFinalizer}, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: configapi.GroupVersion.Identifier(), + Kind: configapi.TypeRepository.Kind, + Name: "my-repo", + UID: "repo-uid", + }}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{RepositoryName: "my-repo"}, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + + r := newTestReconciler(mockClient, mockrepository.NewMockContentCache(t)) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) +} + +func TestReconcileNoChange(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: readyObjectMeta("test-pr", "default", "my-repo"), + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "ws-1", + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().Lifecycle(mock.Anything).Return("Draft") + setupMockContentDefaults(mockContent) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(mock.Anything, mock.Anything, "my-pkg", "ws-1").Return(mockContent, nil) + + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) +} + +func TestReconcileLifecycleTransition(t *testing.T) { + tests := []struct { + name string + current string + desired porchv1alpha2.PackageRevisionLifecycle + }{ + {"Draft to Proposed", "Draft", porchv1alpha2.PackageRevisionLifecycleProposed}, + {"Proposed to Draft", "Proposed", porchv1alpha2.PackageRevisionLifecycleDraft}, + {"Proposed to Published", "Proposed", porchv1alpha2.PackageRevisionLifecyclePublished}, + {"Published to DeletionProposed", "Published", porchv1alpha2.PackageRevisionLifecycleDeletionProposed}, + {"DeletionProposed to Published", "DeletionProposed", porchv1alpha2.PackageRevisionLifecyclePublished}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: readyObjectMeta("test-pr", "default", "my-repo"), + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "ws-1", + Lifecycle: tt.desired, + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().Lifecycle(mock.Anything).Return(tt.current) + + updatedContent := mockrepository.NewMockPackageContent(t) + updatedContent.EXPECT().Lifecycle(mock.Anything).Return(string(tt.desired)).Maybe() + setupMockContentDefaults(updatedContent) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(mock.Anything, mock.Anything, "my-pkg", "ws-1").Return(mockContent, nil) + mockCache.EXPECT().UpdateLifecycle(mock.Anything, mock.Anything, "my-pkg", "ws-1", string(tt.desired)).Return(updatedContent, nil) + + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + if porchv1alpha2.LifecycleIsPublished(tt.desired) { + assert.Equal(t, ctrl.Result{Requeue: true}, result) + } else { + assert.Equal(t, ctrl.Result{}, result) + } + }) + } +} + +func TestReconcileLifecycleTransitionFailure(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: readyObjectMeta("test-pr", "default", "my-repo"), + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "ws-1", + Lifecycle: porchv1alpha2.PackageRevisionLifecycleProposed, + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().Lifecycle(mock.Anything).Return("Draft") + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(mock.Anything, mock.Anything, "my-pkg", "ws-1").Return(mockContent, nil) + mockCache.EXPECT().UpdateLifecycle(mock.Anything, mock.Anything, "my-pkg", "ws-1", "Proposed").Return(nil, errors.New("git push failed")) + + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.SubResourcePatchOption) { + pr := obj.(*porchv1alpha2.PackageRevision) + assert.Len(t, pr.Status.Conditions, 1) + assert.Equal(t, porchv1alpha2.ConditionReady, pr.Status.Conditions[0].Type) + assert.Equal(t, metav1.ConditionFalse, pr.Status.Conditions[0].Status) + assert.Equal(t, porchv1alpha2.ReasonFailed, pr.Status.Conditions[0].Reason) + assert.Contains(t, pr.Status.Conditions[0].Message, "git push failed") + }).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + // Controller returns nil error (failure is recorded in status) + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) +} + +func TestReconcileGetPackageContentFailure(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: readyObjectMeta("test-pr", "default", "my-repo"), + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "ws-1", + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(mock.Anything, mock.Anything, "my-pkg", "ws-1").Return(nil, errors.New("repository not found")) + + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.SubResourcePatchOption) { + pr := obj.(*porchv1alpha2.PackageRevision) + assert.Len(t, pr.Status.Conditions, 1) + assert.Equal(t, metav1.ConditionFalse, pr.Status.Conditions[0].Status) + assert.Equal(t, porchv1alpha2.ReasonFailed, pr.Status.Conditions[0].Reason) + }).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) +} + +// --- Source execution tests --- + +// fakeDraftSlim is a simple test double for PackageRevisionDraftSlim. +type fakeDraftSlim struct { + resources map[string]string + commitMsg string + lifecycle string + updateErr error +} + +func (f *fakeDraftSlim) Key() repository.PackageRevisionKey { + return repository.PackageRevisionKey{} +} + +func (f *fakeDraftSlim) UpdateResources(_ context.Context, resources map[string]string, commitMsg string) error { + if f.updateErr != nil { + return f.updateErr + } + f.resources = resources + f.commitMsg = commitMsg + return nil +} + +func (f *fakeDraftSlim) UpdateLifecycle(_ context.Context, lifecycle string) error { + f.lifecycle = lifecycle + return nil +} + +func TestReconcileInitSource(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: readyObjectMeta("test-pr", "default", "my-repo"), + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "ws-1", + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + Source: &porchv1alpha2.PackageSource{ + Init: &porchv1alpha2.PackageInitSpec{ + Description: "test package", + }, + }, + }, + // No CreationSource — triggers source execution + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + + mockDraft := &fakeDraftSlim{} + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().CreateNewDraft(mock.Anything, repository.RepositoryKey{Namespace: "default", Name: "my-repo"}, "my-pkg", "ws-1", "Draft").Return(mockDraft, nil) + mockCache.EXPECT().CloseDraft(mock.Anything, repository.RepositoryKey{Namespace: "default", Name: "my-repo"}, mockDraft, 0).Return(nil) + + // After CloseDraft, reconcileSource reads back the content for lock info + mockContentAfterInit := mockrepository.NewMockPackageContent(t) + mockContentAfterInit.EXPECT().Lifecycle(mock.Anything).Return("Draft").Maybe() + setupMockContentDefaults(mockContentAfterInit) + mockCache.EXPECT().GetPackageContent(mock.Anything, repository.RepositoryKey{Namespace: "default", Name: "my-repo"}, "my-pkg", "ws-1").Return(mockContentAfterInit, nil) + + // Expect SSA status patches: updateStatus (Ready) then updateRenderStatus (Rendered) + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + var statusPatches []porchv1alpha2.PackageRevisionStatus + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.SubResourcePatchOption) { + statusPatches = append(statusPatches, obj.(*porchv1alpha2.PackageRevision).Status) + }).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + // Expect merge patch for latest-revision label + mockClient.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything).Return(nil) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{Requeue: true}, result) + + // Verify init created a Kptfile + assert.Contains(t, mockDraft.resources, "Kptfile") + assert.Equal(t, "init", mockDraft.commitMsg) + + // First patch: updateStatus with Ready=False/Pending and creationSource + require.GreaterOrEqual(t, len(statusPatches), 1) + assert.Equal(t, "init", statusPatches[0].CreationSource) + var readyCond metav1.Condition + for _, c := range statusPatches[0].Conditions { + if c.Type == porchv1alpha2.ConditionReady { + readyCond = c + } + } + assert.Equal(t, metav1.ConditionFalse, readyCond.Status) + assert.Equal(t, porchv1alpha2.ReasonPending, readyCond.Reason) + + // Second patch: updateRenderStatus with Rendered=Unknown/Pending + require.GreaterOrEqual(t, len(statusPatches), 2) + var renderedCond metav1.Condition + for _, c := range statusPatches[1].Conditions { + if c.Type == porchv1alpha2.ConditionRendered { + renderedCond = c + } + } + assert.Equal(t, metav1.ConditionUnknown, renderedCond.Status) + assert.Equal(t, porchv1alpha2.ReasonPending, renderedCond.Reason) +} + +func TestReconcileInitSourceAlreadyCreated(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + // CreationSource already set — source execution should be skipped + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: readyObjectMeta("test-pr", "default", "my-repo"), + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "ws-1", + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + Source: &porchv1alpha2.PackageSource{ + Init: &porchv1alpha2.PackageInitSpec{Description: "test"}, + }, + }, + Status: porchv1alpha2.PackageRevisionStatus{ + CreationSource: "init", + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().Lifecycle(mock.Anything).Return("Draft") + setupMockContentDefaults(mockContent) + + mockCache := mockrepository.NewMockContentCache(t) + // No CreateNewDraft expected — source already applied + mockCache.EXPECT().GetPackageContent(mock.Anything, mock.Anything, "my-pkg", "ws-1").Return(mockContent, nil) + + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) +} + +func TestReconcileInitSourceCreateDraftFails(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: readyObjectMeta("test-pr", "default", "my-repo"), + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "ws-1", + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + Source: &porchv1alpha2.PackageSource{ + Init: &porchv1alpha2.PackageInitSpec{Description: "test"}, + }, + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().CreateNewDraft(mock.Anything, mock.Anything, "my-pkg", "ws-1", "Draft"). + Return(nil, errors.New("repo not found")) + + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.SubResourcePatchOption) { + pr := obj.(*porchv1alpha2.PackageRevision) + // Should have both Ready=False and Rendered=False + assert.Len(t, pr.Status.Conditions, 2) + for _, c := range pr.Status.Conditions { + assert.Equal(t, metav1.ConditionFalse, c.Status) + assert.Equal(t, porchv1alpha2.ReasonFailed, c.Reason) + assert.Contains(t, c.Message, "repo not found") + } + }).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) +} + +func TestReconcileNoSource(t *testing.T) { + // PR with no Source and no CreationSource — discovered from git by repo controller. + // Should skip source execution and go straight to lifecycle. + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: readyObjectMeta("test-pr", "default", "my-repo"), + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "ws-1", + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + // No Source — git-discovered package + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().Lifecycle(mock.Anything).Return("Draft") + setupMockContentDefaults(mockContent) + + mockCache := mockrepository.NewMockContentCache(t) + // No CreateNewDraft expected + mockCache.EXPECT().GetPackageContent(mock.Anything, mock.Anything, "my-pkg", "ws-1").Return(mockContent, nil) + + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) +} + + +// mockRenderer is a test double for the renderer interface. +type mockRenderer struct { + resources map[string]string + err error // infrastructure error + pipelineErr error // pipeline failure (returned via renderResult.err) +} + +func (m *mockRenderer) Render(_ context.Context, _ map[string]string) (*renderResult, error) { + if m.err != nil { + return nil, m.err + } + return &renderResult{ + resources: m.resources, + err: m.pipelineErr, + }, nil +} + +// helper to build a PR for render tests. +func renderTestPR(annotation, observed, rendering, creationSource string, renderedStatus metav1.ConditionStatus) *porchv1alpha2.PackageRevision { + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + Annotations: map[string]string{}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "ws-1", + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + }, + Status: porchv1alpha2.PackageRevisionStatus{ + ObservedPrrResourceVersion: observed, + RenderingPrrResourceVersion: rendering, + CreationSource: creationSource, + }, + } + if annotation != "" { + pr.Annotations[porchv1alpha2.AnnotationRenderRequest] = annotation + } + if renderedStatus != "" { + pr.Status.Conditions = []metav1.Condition{ + {Type: porchv1alpha2.ConditionRendered, Status: renderedStatus}, + } + } + return pr +} + +var testRepoKey = repository.RepositoryKey{Namespace: "default", Name: "my-repo"} + +func TestReconcileRenderNoRenderer(t *testing.T) { + r := &PackageRevisionReconciler{Renderer: nil} + pr := renderTestPR("v1", "", "", "init", metav1.ConditionUnknown) + result, err := r.reconcileRender(t.Context(), pr, testRepoKey) + assert.NoError(t, err) + assert.Nil(t, result) +} + +func TestReconcileRenderNoTrigger(t *testing.T) { + r := &PackageRevisionReconciler{Renderer: &mockRenderer{}} + pr := renderTestPR("", "", "", "", "") + result, err := r.reconcileRender(t.Context(), pr, testRepoKey) + assert.NoError(t, err) + assert.Nil(t, result) +} + +func TestReconcileRenderAlreadyRendered(t *testing.T) { + r := &PackageRevisionReconciler{Renderer: &mockRenderer{}} + pr := renderTestPR("v1", "v1", "", "init", metav1.ConditionTrue) + result, err := r.reconcileRender(t.Context(), pr, testRepoKey) + assert.NoError(t, err) + assert.Nil(t, result) +} + + +func TestReconcileRenderSourceTrigger(t *testing.T) { + ctx := t.Context() + rendered := map[string]string{"Kptfile": "rendered"} + + mockCache := mockrepository.NewMockContentCache(t) + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(mock.Anything).Return(map[string]string{"Kptfile": "original"}, nil) + mockCache.EXPECT().GetPackageContent(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(mockContent, nil) + + mockDraft := &fakeDraftSlim{} + mockCache.EXPECT().CreateDraftFromExisting(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(mockDraft, nil) + mockCache.EXPECT().CloseDraft(mock.Anything, testRepoKey, mockDraft, 0).Return(nil) + + mockClient := mockclient.NewMockClient(t) + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := &PackageRevisionReconciler{ + Client: mockClient, + ContentCache: mockCache, + Renderer: &mockRenderer{resources: rendered}, + } + + pr := renderTestPR("", "", "", "init", metav1.ConditionUnknown) + result, err := r.reconcileRender(ctx, pr, testRepoKey) + assert.NoError(t, err) + assert.Nil(t, result) + assert.Equal(t, "rendered", mockDraft.resources["Kptfile"]) +} + +func TestReconcileRenderAnnotationTrigger(t *testing.T) { + ctx := t.Context() + rendered := map[string]string{"Kptfile": "rendered"} + + mockCache := mockrepository.NewMockContentCache(t) + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(mock.Anything).Return(map[string]string{"Kptfile": "original"}, nil) + mockCache.EXPECT().GetPackageContent(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(mockContent, nil) + + mockDraft := &fakeDraftSlim{} + mockCache.EXPECT().CreateDraftFromExisting(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(mockDraft, nil) + mockCache.EXPECT().CloseDraft(mock.Anything, testRepoKey, mockDraft, 0).Return(nil) + + // Re-read for stale check returns same annotation (via apiReader, bypasses informer cache). + prAfterRender := renderTestPR("v1", "", "", "init", metav1.ConditionTrue) + mockReader := mockclient.NewMockReader(t) + mockReader.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "test-pr"}, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *prAfterRender + }).Return(nil) + + mockClient := mockclient.NewMockClient(t) + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := &PackageRevisionReconciler{ + Client: mockClient, + ContentCache: mockCache, + Renderer: &mockRenderer{resources: rendered}, + apiReader: mockReader, + } + + pr := renderTestPR("v1", "", "", "init", metav1.ConditionUnknown) + result, err := r.reconcileRender(ctx, pr, testRepoKey) + assert.NoError(t, err) + assert.Nil(t, result) +} + +func TestReconcileRenderStale(t *testing.T) { + ctx := t.Context() + + mockCache := mockrepository.NewMockContentCache(t) + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(mock.Anything).Return(map[string]string{"Kptfile": "original"}, nil) + mockCache.EXPECT().GetPackageContent(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(mockContent, nil) + + // Re-read returns different annotation — stale. + prAfterRender := renderTestPR("v2", "", "", "", "") + mockReader := mockclient.NewMockReader(t) + mockReader.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "test-pr"}, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *prAfterRender + }).Return(nil) + + mockClient := mockclient.NewMockClient(t) + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := &PackageRevisionReconciler{ + Client: mockClient, + ContentCache: mockCache, + Renderer: &mockRenderer{resources: map[string]string{"Kptfile": "rendered"}}, + apiReader: mockReader, + } + + pr := renderTestPR("v1", "", "", "", metav1.ConditionUnknown) + pr.Status.CreationSource = "init" + result, err := r.reconcileRender(ctx, pr, testRepoKey) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.Requeue) +} + +func TestReconcileRenderFailure(t *testing.T) { + ctx := t.Context() + + mockCache := mockrepository.NewMockContentCache(t) + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(mock.Anything).Return(map[string]string{"Kptfile": "original"}, nil) + mockCache.EXPECT().GetPackageContent(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(mockContent, nil) + + mockClient := mockclient.NewMockClient(t) + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Times(3) + mockClient.EXPECT().Status().Return(mockStatusWriter).Times(3) + + r := &PackageRevisionReconciler{ + Client: mockClient, + ContentCache: mockCache, + Renderer: &mockRenderer{err: errors.New("fn-runner unavailable")}, + } + + pr := renderTestPR("", "", "", "init", metav1.ConditionUnknown) + result, err := r.reconcileRender(ctx, pr, testRepoKey) + assert.Error(t, err) + assert.Contains(t, err.Error(), "render failed") + assert.Nil(t, result) +} + +func TestReconcileRenderReadContentFails(t *testing.T) { + ctx := t.Context() + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(nil, errors.New("repo not found")) + + mockClient := mockclient.NewMockClient(t) + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := &PackageRevisionReconciler{ + Client: mockClient, + ContentCache: mockCache, + Renderer: &mockRenderer{}, + } + + pr := renderTestPR("", "", "", "init", metav1.ConditionUnknown) + result, err := r.reconcileRender(ctx, pr, testRepoKey) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "get content for render") +} + +func TestReconcileRenderWriteFails(t *testing.T) { + ctx := t.Context() + + mockCache := mockrepository.NewMockContentCache(t) + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(mock.Anything).Return(map[string]string{"Kptfile": "original"}, nil) + mockCache.EXPECT().GetPackageContent(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(mockContent, nil) + mockCache.EXPECT().CreateDraftFromExisting(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(nil, errors.New("draft failed")) + + mockClient := mockclient.NewMockClient(t) + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := &PackageRevisionReconciler{ + Client: mockClient, + ContentCache: mockCache, + Renderer: &mockRenderer{resources: map[string]string{"Kptfile": "rendered"}}, + } + + pr := renderTestPR("", "", "", "init", metav1.ConditionUnknown) + result, err := r.reconcileRender(ctx, pr, testRepoKey) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "create draft for render") +} + +func TestReconcileSourceUpdateResourcesFails(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: readyObjectMeta("test-pr", "default", "my-repo"), + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "ws-1", + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + Source: &porchv1alpha2.PackageSource{Init: &porchv1alpha2.PackageInitSpec{}}, + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + + badDraft := &fakeDraftSlim{updateErr: errors.New("write failed")} + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().CreateNewDraft(mock.Anything, mock.Anything, "my-pkg", "ws-1", "Draft").Return(badDraft, nil) + + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) // error handled internally, status updated + assert.Equal(t, ctrl.Result{}, result) +} + +func TestReconcileSourceCloseDraftFails(t *testing.T) { + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: readyObjectMeta("test-pr", "default", "my-repo"), + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "ws-1", + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + Source: &porchv1alpha2.PackageSource{Init: &porchv1alpha2.PackageInitSpec{}}, + }, + } + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + + mockDraft := &fakeDraftSlim{} + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().CreateNewDraft(mock.Anything, mock.Anything, "my-pkg", "ws-1", "Draft").Return(mockDraft, nil) + mockCache.EXPECT().CloseDraft(mock.Anything, mock.Anything, mockDraft, 0).Return(errors.New("git push failed")) + + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := newTestReconciler(mockClient, mockCache) + result, err := r.Reconcile(ctx, req) + + assert.NoError(t, err) + 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). + ctx := t.Context() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-pr", Namespace: "default"}} + + pr := renderTestPR("", "", "", "init", metav1.ConditionUnknown) + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecycleDraft + pr.Spec.RepositoryName = "my-repo" + pr.Finalizers = []string{porchv1alpha2.PackageRevisionFinalizer} + pr.OwnerReferences = []metav1.OwnerReference{{ + APIVersion: configapi.GroupVersion.Identifier(), + Kind: configapi.TypeRepository.Kind, + Name: "my-repo", + UID: "repo-uid", + }} + + mockClient := mockclient.NewMockClient(t) + mockClient.EXPECT().Get(mock.Anything, req.NamespacedName, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Run(func(_ context.Context, _ types.NamespacedName, obj client.Object, _ ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *pr + }).Return(nil) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(mock.Anything, testRepoKey, "my-pkg", "ws-1"). + Return(nil, errors.New("cache down")) + + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := &PackageRevisionReconciler{ + Client: mockClient, + ContentCache: mockCache, + Renderer: &mockRenderer{}, + } + + result, err := r.Reconcile(ctx, req) + assert.NoError(t, err) // error handled internally + assert.Equal(t, ctrl.Result{}, result) +} + +func TestReconcileRenderStaleReReadFails(t *testing.T) { + ctx := t.Context() + + mockCache := mockrepository.NewMockContentCache(t) + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(mock.Anything).Return(map[string]string{"Kptfile": "x"}, nil) + mockCache.EXPECT().GetPackageContent(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(mockContent, nil) + + mockReader := mockclient.NewMockReader(t) + // Re-read for stale check fails. + mockReader.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "test-pr"}, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(errors.New("api server gone")) + + mockClient := mockclient.NewMockClient(t) + + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := &PackageRevisionReconciler{ + Client: mockClient, + ContentCache: mockCache, + Renderer: &mockRenderer{resources: map[string]string{"Kptfile": "rendered"}}, + apiReader: mockReader, + } + + pr := renderTestPR("v1", "", "", "", metav1.ConditionUnknown) + pr.Status.CreationSource = "init" + result, err := r.reconcileRender(ctx, pr, testRepoKey) + assert.Error(t, err) + assert.Contains(t, err.Error(), "re-read PR after render") + assert.Nil(t, result) +} + +func TestReadPackageResourcesGetContentsFails(t *testing.T) { + mockCache := mockrepository.NewMockContentCache(t) + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(mock.Anything).Return(nil, errors.New("corrupt")) + mockCache.EXPECT().GetPackageContent(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(mockContent, nil) + + r := &PackageRevisionReconciler{ContentCache: mockCache} + _, err := r.readPackageResources(t.Context(), testRepoKey, "my-pkg", "ws-1") + assert.Error(t, err) + assert.Contains(t, err.Error(), "read resources for render") +} + +func TestWriteRenderedResourcesUpdateFails(t *testing.T) { + mockDraft := &fakeDraftSlim{updateErr: errors.New("write failed")} + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().CreateDraftFromExisting(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(mockDraft, nil) + + r := &PackageRevisionReconciler{ContentCache: mockCache} + err := r.writeRenderedResources(t.Context(), testRepoKey, "my-pkg", "ws-1", map[string]string{"Kptfile": "x"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "write rendered resources") +} + +func TestWriteRenderedResourcesCloseDraftFails(t *testing.T) { + mockDraft := &fakeDraftSlim{} + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().CreateDraftFromExisting(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(mockDraft, nil) + mockCache.EXPECT().CloseDraft(mock.Anything, testRepoKey, mockDraft, 0).Return(errors.New("git push failed")) + + r := &PackageRevisionReconciler{ContentCache: mockCache} + err := r.writeRenderedResources(t.Context(), testRepoKey, "my-pkg", "ws-1", map[string]string{"Kptfile": "x"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "close draft after render") +} + + +func TestReconcileRenderPipelineFailureNoPush(t *testing.T) { + ctx := t.Context() + + mockCache := mockrepository.NewMockContentCache(t) + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(mock.Anything).Return(map[string]string{"Kptfile": "original"}, nil) + mockCache.EXPECT().GetPackageContent(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(mockContent, nil) + + // No CreateDraftFromExisting expected — resources should NOT be written. + + mockClient := mockclient.NewMockClient(t) + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Times(3) + mockClient.EXPECT().Status().Return(mockStatusWriter).Times(3) + + r := &PackageRevisionReconciler{ + Client: mockClient, + ContentCache: mockCache, + Renderer: &mockRenderer{resources: map[string]string{"Kptfile": "partial"}, pipelineErr: errors.New("function failed")}, + } + + pr := renderTestPR("", "", "", "init", metav1.ConditionUnknown) + result, err := r.reconcileRender(ctx, pr, testRepoKey) + assert.Error(t, err) + assert.Contains(t, err.Error(), "render pipeline failed") + assert.Nil(t, result) +} + +func TestReconcileRenderPipelineFailureWithPush(t *testing.T) { + ctx := t.Context() + + mockCache := mockrepository.NewMockContentCache(t) + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(mock.Anything).Return(map[string]string{"Kptfile": "original"}, nil) + mockCache.EXPECT().GetPackageContent(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(mockContent, nil) + + // Resources SHOULD be written when push-on-render-failure is set. + mockDraft := &fakeDraftSlim{} + mockCache.EXPECT().CreateDraftFromExisting(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(mockDraft, nil) + mockCache.EXPECT().CloseDraft(mock.Anything, testRepoKey, mockDraft, 0).Return(nil) + + mockClient := mockclient.NewMockClient(t) + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Times(3) + mockClient.EXPECT().Status().Return(mockStatusWriter).Times(3) + + r := &PackageRevisionReconciler{ + Client: mockClient, + ContentCache: mockCache, + Renderer: &mockRenderer{resources: map[string]string{"Kptfile": "partial"}, pipelineErr: errors.New("function failed")}, + } + + pr := renderTestPR("", "", "", "init", metav1.ConditionUnknown) + pr.Annotations[porchv1alpha2.PushOnFnRenderFailureKey] = porchv1alpha2.PushOnFnRenderFailureValue + result, err := r.reconcileRender(ctx, pr, testRepoKey) + assert.Error(t, err) + assert.Contains(t, err.Error(), "render pipeline failed") + assert.Nil(t, result) + assert.Equal(t, "partial", mockDraft.resources["Kptfile"], "partial resources should be written") +} + +func TestReconcileRenderPipelineFailureWithPushWriteFails(t *testing.T) { + ctx := t.Context() + + mockCache := mockrepository.NewMockContentCache(t) + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(mock.Anything).Return(map[string]string{"Kptfile": "original"}, nil) + mockCache.EXPECT().GetPackageContent(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(mockContent, nil) + + // Write fails — should still set Rendered=False, not crash. + mockCache.EXPECT().CreateDraftFromExisting(mock.Anything, testRepoKey, "my-pkg", "ws-1").Return(nil, errors.New("draft failed")) + + mockClient := mockclient.NewMockClient(t) + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Times(3) + mockClient.EXPECT().Status().Return(mockStatusWriter).Times(3) + + r := &PackageRevisionReconciler{ + Client: mockClient, + ContentCache: mockCache, + Renderer: &mockRenderer{resources: map[string]string{"Kptfile": "partial"}, pipelineErr: errors.New("function failed")}, + } + + pr := renderTestPR("", "", "", "init", metav1.ConditionUnknown) + pr.Annotations[porchv1alpha2.PushOnFnRenderFailureKey] = porchv1alpha2.PushOnFnRenderFailureValue + result, err := r.reconcileRender(ctx, pr, testRepoKey) + assert.Error(t, err) + assert.Contains(t, err.Error(), "render pipeline failed") + assert.Nil(t, result) +} + + +func TestRenderWithConcurrencyLimitRequeues(t *testing.T) { + limiter := make(chan struct{}, 1) + limiter <- struct{}{} // fill the limiter + + r := &PackageRevisionReconciler{ + Renderer: &mockRenderer{resources: map[string]string{"Kptfile": "x"}}, + renderLimiter: limiter, + RenderRequeueDelay: 2 * time.Second, + } + + result, requeue, err := r.renderWithConcurrencyLimit(t.Context(), nil) + assert.NoError(t, err) + assert.Nil(t, result) + assert.NotNil(t, requeue) + assert.Equal(t, 2*time.Second, requeue.RequeueAfter) +} + +func TestSyncKptfileFieldsParseError(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + r := &PackageRevisionReconciler{Client: mockClient} + pr := renderTestPR("", "", "", "", "") + + // Malformed Kptfile — should log error, not panic. + r.syncKptfileFields(t.Context(), pr, map[string]string{"Kptfile": "not: valid: yaml: ["}) +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/render.go b/controllers/packagerevisions/pkg/controllers/packagerevision/render.go new file mode 100644 index 000000000..6310ae0f9 --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/render.go @@ -0,0 +1,324 @@ +// Copyright 2026 The kpt and Nephio 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" + "path" + "strings" + + fnresult "github.com/kptdev/kpt/pkg/api/fnresult/v1" + kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" + "github.com/kptdev/kpt/pkg/fn" + "github.com/kptdev/kpt/pkg/kptfile/kptfileutil" + "github.com/kptdev/kpt/pkg/lib/kptops" + "github.com/kptdev/kpt/pkg/lib/runneroptions" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/pkg/repository" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/kustomize/kyaml/filesys" +) + +// renderResult holds the output of a render operation. +type renderResult struct { + resources map[string]string + results *fnresult.ResultList // per-function execution results; nil if no pipeline ran + err error // pipeline failure; nil on success +} + +// renderer renders a package's KRM function pipeline. +// If nil on the reconciler, rendering is skipped. +// The returned error is an infrastructure failure (cannot read/write FS). +// A pipeline failure is reported via renderResult.err. +type renderer interface { + Render(ctx context.Context, resources map[string]string) (*renderResult, error) +} + +// kptRenderer implements renderer using kpt's render library. +type kptRenderer struct { + runtime fn.FunctionRuntime + runnerOptions runneroptions.RunnerOptions +} + +// newKptRenderer creates a renderer backed by a kpt FunctionRuntime. +func newKptRenderer(runtime fn.FunctionRuntime, opts runneroptions.RunnerOptions) renderer { + return &kptRenderer{runtime: runtime, runnerOptions: opts} +} + +func (r *kptRenderer) Render(ctx context.Context, resources map[string]string) (*renderResult, error) { + fs := filesys.MakeFsInMemory() + pkgPath, err := writeResourcesToFS(fs, resources) + if err != nil { + return nil, fmt.Errorf("failed to write resources for render: %w", err) + } + + if pkgPath == "" { + return &renderResult{resources: resources}, nil + } + + renderer := kptops.NewRenderer(r.runnerOptions) + resultList, renderErr := renderer.Render(ctx, fs, fn.RenderOptions{ + PkgPath: pkgPath, + Runtime: r.runtime, + }) + + // Read back from FS — honours save-on-render-failure on failure, + // picks up rendered resources + kpt status conditions on success. + fsResources, readErr := readResourcesFromFS(fs) + if readErr != nil { + return nil, fmt.Errorf("failed to read resources after render: %w", readErr) + } + + return &renderResult{ + resources: fsResources, + results: resultList, + err: renderErr, + }, nil +} + +func writeResourcesToFS(fs filesys.FileSystem, resources map[string]string) (string, error) { + var packageDir string + for k, v := range resources { + dir := path.Dir(k) + if dir == "." { + dir = "/" + } + if err := fs.MkdirAll(dir); err != nil { + return "", err + } + if err := fs.WriteFile(path.Join(dir, path.Base(k)), []byte(v)); err != nil { + return "", err + } + if path.Base(k) == "Kptfile" { + if packageDir == "" || dir == "/" || strings.HasPrefix(packageDir, dir+"/") { + packageDir = dir + } + } + } + return packageDir, nil +} + +func readResourcesFromFS(fs filesys.FileSystem) (map[string]string, error) { + contents := map[string]string{} + if err := fs.Walk("/", func(p string, info iofs.FileInfo, err error) error { + if err != nil { + return err + } + if info.Mode().IsRegular() { + data, err := fs.ReadFile(p) + if err != nil { + return err + } + contents[strings.TrimPrefix(p, "/")] = string(data) + } + return nil + }); err != nil { + return nil, err + } + return contents, nil +} + +// renderTrigger determines if rendering is needed. +// Render only applies to Draft packages +// Returns the annotation value (requested), and which trigger fired. +func renderTrigger(pr *porchv1alpha2.PackageRevision) (requested string, annotation, source bool) { + if pr.Spec.Lifecycle != porchv1alpha2.PackageRevisionLifecycleDraft { + return "", false, false + } + requested = pr.Annotations[porchv1alpha2.AnnotationRenderRequest] + annotation = requested != "" && requested != pr.Status.ObservedPrrResourceVersion + source = pr.Status.CreationSource != "" && !isRenderedTrue(pr) + return +} + + +// isRenderStale returns true if the annotation changed during render. +func isRenderStale(currentAnnotation, rendered string) bool { + return currentAnnotation != rendered +} + +// observedVersionAfterRender returns the value to set as observedPrrResourceVersion. +// Uses the annotation value if available, so the annotation trigger won't re-fire. +func observedVersionAfterRender(requested string, annotations map[string]string) string { + if requested != "" { + return requested + } + return annotations[porchv1alpha2.AnnotationRenderRequest] +} + +// kptfileFromResources parses the Kptfile from an in-memory resource map. +// Returns a zero-value KptFile if no Kptfile is present. +func kptfileFromResources(resources map[string]string) (kptfilev1.KptFile, error) { + kfString, ok := resources[kptfilev1.KptFileName] + if !ok || kfString == "" { + return kptfilev1.KptFile{}, nil + } + kf, err := kptfileutil.DecodeKptfile(strings.NewReader(kfString)) + if err != nil { + return kptfilev1.KptFile{}, fmt.Errorf("decode Kptfile: %w", err) + } + return *kf, nil +} + +func isRenderedTrue(pr *porchv1alpha2.PackageRevision) bool { + for _, c := range pr.Status.Conditions { + if c.Type == porchv1alpha2.ConditionRendered { + return c.Status == metav1.ConditionTrue + } + } + return false +} + +// isPushOnRenderFailure returns true if the PR opts into persisting resources on render failure. +func isPushOnRenderFailure(pr *porchv1alpha2.PackageRevision) bool { + return pr.Annotations[porchv1alpha2.PushOnFnRenderFailureKey] == porchv1alpha2.PushOnFnRenderFailureValue +} + +// executeRender performs the render, handles failure/stale, and writes results. +func (r *PackageRevisionReconciler) executeRender(ctx context.Context, pr *porchv1alpha2.PackageRevision, repoKey repository.RepositoryKey) (*ctrl.Result, error) { + log := log.FromContext(ctx) + + resources, err := r.readPackageResources(ctx, repoKey, pr.Spec.PackageName, pr.Spec.WorkspaceName) + if err != nil { + r.setRenderFailed(ctx, pr, err) + return nil, err + } + log.V(1).Info("read package resources", "count", len(resources)) + + result, requeueResult, err := r.renderWithConcurrencyLimit(ctx, resources) + if requeueResult != nil { + log.Info("render concurrency limit reached, requeuing") + return requeueResult, nil + } + if err != nil { + r.setRenderFailed(ctx, pr, err) + return nil, fmt.Errorf("render failed: %w", err) + } + + requested := pr.Annotations[porchv1alpha2.AnnotationRenderRequest] + if requested != "" { + if stale, err := r.checkRenderStale(ctx, pr, requested); err != nil { + return nil, err + } else if stale != nil { + return stale, nil + } + } + + if result.err != nil { + log.Error(result.err, "render pipeline failed", "fnResults", result.results) + if isPushOnRenderFailure(pr) { + log.Info("persisting partial resources (push-on-render-failure)") + r.persistAndSyncKptfile(ctx, pr, repoKey, result.resources) + } + r.setRenderFailed(ctx, pr, result.err) + return nil, fmt.Errorf("render pipeline failed: %w", result.err) + } + + if err := r.writeRenderedResources(ctx, repoKey, pr.Spec.PackageName, pr.Spec.WorkspaceName, result.resources); err != nil { + r.setRenderFailed(ctx, pr, err) + return nil, err + } + log.V(1).Info("rendered resources written") + r.syncKptfileFields(ctx, pr, result.resources) + log.Info("render complete") + return nil, nil +} + +func (r *PackageRevisionReconciler) renderWithConcurrencyLimit(ctx context.Context, resources map[string]string) (*renderResult, *ctrl.Result, error) { + if r.renderLimiter != nil { + select { + case r.renderLimiter <- struct{}{}: + defer func() { <-r.renderLimiter }() + default: + return nil, &ctrl.Result{RequeueAfter: r.RenderRequeueDelay}, nil + } + } + result, err := r.Renderer.Render(ctx, resources) + return result, nil, err +} + +func (r *PackageRevisionReconciler) checkRenderStale(ctx context.Context, pr *porchv1alpha2.PackageRevision, rendered string) (*ctrl.Result, error) { + var current porchv1alpha2.PackageRevision + if err := r.apiReader.Get(ctx, client.ObjectKeyFromObject(pr), ¤t); err != nil { + return nil, fmt.Errorf("re-read PR after render: %w", err) + } + if isRenderStale(current.Annotations[porchv1alpha2.AnnotationRenderRequest], rendered) { + log.FromContext(ctx).Info("render stale, requeuing", "rendered", rendered, + "current", current.Annotations[porchv1alpha2.AnnotationRenderRequest]) + return &ctrl.Result{Requeue: true}, nil + } + return nil, nil +} + +func (r *PackageRevisionReconciler) persistAndSyncKptfile(ctx context.Context, pr *porchv1alpha2.PackageRevision, repoKey repository.RepositoryKey, resources map[string]string) { + if err := r.writeRenderedResources(ctx, repoKey, pr.Spec.PackageName, pr.Spec.WorkspaceName, resources); err != nil { + log.FromContext(ctx).Error(err, "failed to write resources") + return + } + r.syncKptfileFields(ctx, pr, resources) +} + +func (r *PackageRevisionReconciler) syncKptfileFields(ctx context.Context, pr *porchv1alpha2.PackageRevision, resources map[string]string) { + kf, err := kptfileFromResources(resources) + if err != nil { + log.FromContext(ctx).Error(err, "failed to parse Kptfile for CRD sync") + return + } + r.updateKptfileFields(ctx, pr, kf) +} + +func (r *PackageRevisionReconciler) readPackageResources(ctx context.Context, repoKey repository.RepositoryKey, pkg, workspace string) (map[string]string, error) { + content, err := r.ContentCache.GetPackageContent(ctx, repoKey, pkg, workspace) + if err != nil { + return nil, fmt.Errorf("get content for render: %w", err) + } + resources, err := content.GetResourceContents(ctx) + if err != nil { + return nil, fmt.Errorf("read resources for render: %w", err) + } + return resources, nil +} + +func (r *PackageRevisionReconciler) writeRenderedResources(ctx context.Context, repoKey repository.RepositoryKey, pkg, workspace string, resources map[string]string) error { + draft, err := r.ContentCache.CreateDraftFromExisting(ctx, repoKey, pkg, workspace) + if err != nil { + return fmt.Errorf("create draft for render: %w", err) + } + if err := draft.UpdateResources(ctx, resources, "rendered"); err != nil { + return fmt.Errorf("write rendered resources: %w", err) + } + if err := r.ContentCache.CloseDraft(ctx, repoKey, draft, 0); err != nil { + return fmt.Errorf("close draft after render: %w", err) + } + return nil +} + +// renderRequestChanged fires when the render-request annotation changes. +func renderRequestChanged() predicate.Predicate { + return predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + oldVal := e.ObjectOld.GetAnnotations()[porchv1alpha2.AnnotationRenderRequest] + newVal := e.ObjectNew.GetAnnotations()[porchv1alpha2.AnnotationRenderRequest] + return oldVal != newVal + }, + } +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/render_test.go b/controllers/packagerevisions/pkg/controllers/packagerevision/render_test.go new file mode 100644 index 000000000..cae35c591 --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/render_test.go @@ -0,0 +1,301 @@ +package packagerevision + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +func prWithRenderState(annotation, observed, rendering, creationSource string, renderedStatus metav1.ConditionStatus) *porchv1alpha2.PackageRevision { + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + }, + Status: porchv1alpha2.PackageRevisionStatus{ + ObservedPrrResourceVersion: observed, + RenderingPrrResourceVersion: rendering, + CreationSource: creationSource, + }, + } + if annotation != "" { + pr.Annotations[porchv1alpha2.AnnotationRenderRequest] = annotation + } + if renderedStatus != "" { + pr.Status.Conditions = []metav1.Condition{ + {Type: porchv1alpha2.ConditionRendered, Status: renderedStatus}, + } + } + return pr +} + +func TestRenderTrigger(t *testing.T) { + tests := []struct { + name string + pr *porchv1alpha2.PackageRevision + wantReq string + wantAnno bool + wantSource bool + }{ + { + name: "no triggers", + pr: prWithRenderState("", "", "", "", ""), + wantAnno: false, wantSource: false, + }, + { + name: "no trigger — not Draft", + pr: func() *porchv1alpha2.PackageRevision { + pr := prWithRenderState("v1", "", "", "init", metav1.ConditionUnknown) + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return pr + }(), + wantAnno: false, wantSource: false, + }, + { + name: "annotation trigger — new annotation", + pr: prWithRenderState("v1", "", "", "", ""), + wantReq: "v1", wantAnno: true, wantSource: false, + }, + { + name: "annotation trigger — annotation differs from observed", + pr: prWithRenderState("v2", "v1", "", "", metav1.ConditionTrue), + wantReq: "v2", wantAnno: true, wantSource: false, + }, + { + name: "no annotation trigger — annotation matches observed", + pr: prWithRenderState("v1", "v1", "", "", metav1.ConditionTrue), + wantReq: "v1", wantAnno: false, wantSource: false, + }, + { + name: "source trigger — creationSource set, not rendered", + pr: prWithRenderState("", "", "", "init", metav1.ConditionUnknown), + wantAnno: false, wantSource: true, + }, + { + name: "source trigger — creationSource set, no conditions", + pr: prWithRenderState("", "", "", "clone", ""), + wantAnno: false, wantSource: true, + }, + { + name: "no source trigger — already rendered", + pr: prWithRenderState("", "", "", "init", metav1.ConditionTrue), + wantAnno: false, wantSource: false, + }, + { + name: "both triggers", + pr: prWithRenderState("v1", "", "", "init", metav1.ConditionUnknown), + wantReq: "v1", wantAnno: true, wantSource: true, + }, + { + name: "source trigger on render failure", + pr: prWithRenderState("", "", "", "clone", metav1.ConditionFalse), + wantAnno: false, wantSource: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, anno, source := renderTrigger(tt.pr) + assert.Equal(t, tt.wantReq, req) + assert.Equal(t, tt.wantAnno, anno) + assert.Equal(t, tt.wantSource, source) + }) + } +} + + +func TestIsRenderStale(t *testing.T) { + assert.True(t, isRenderStale("v2", "v1")) + assert.False(t, isRenderStale("v1", "v1")) + assert.True(t, isRenderStale("", "v1")) +} + +func TestObservedVersionAfterRender(t *testing.T) { + // Annotation trigger: use requested value. + assert.Equal(t, "v1", observedVersionAfterRender("v1", nil)) + + // Source trigger: use annotation if present. + annos := map[string]string{porchv1alpha2.AnnotationRenderRequest: "v2"} + assert.Equal(t, "v2", observedVersionAfterRender("", annos)) + + // Source trigger: no annotation. + assert.Equal(t, "", observedVersionAfterRender("", nil)) +} + +func TestIsRenderedTrue(t *testing.T) { + assert.True(t, isRenderedTrue(prWithRenderState("", "", "", "", metav1.ConditionTrue))) + assert.False(t, isRenderedTrue(prWithRenderState("", "", "", "", metav1.ConditionFalse))) + assert.False(t, isRenderedTrue(prWithRenderState("", "", "", "", metav1.ConditionUnknown))) + assert.False(t, isRenderedTrue(prWithRenderState("", "", "", "", ""))) +} + +func TestReadyCondition(t *testing.T) { + c := readyCondition(5, metav1.ConditionTrue, porchv1alpha2.ReasonReady, "") + assert.Equal(t, porchv1alpha2.ConditionReady, c.Type) + assert.Equal(t, metav1.ConditionTrue, c.Status) + assert.Equal(t, porchv1alpha2.ReasonReady, c.Reason) + assert.Equal(t, int64(5), c.ObservedGeneration) +} + +func TestRenderedCondition(t *testing.T) { + c := renderedCondition(3, metav1.ConditionFalse, porchv1alpha2.ReasonRenderFailed, "boom") + assert.Equal(t, porchv1alpha2.ConditionRendered, c.Type) + assert.Equal(t, metav1.ConditionFalse, c.Status) + assert.Equal(t, porchv1alpha2.ReasonRenderFailed, c.Reason) + assert.Equal(t, "boom", c.Message) + assert.Equal(t, int64(3), c.ObservedGeneration) +} + +func TestRenderRequestChanged(t *testing.T) { + pred := renderRequestChanged() + + tests := []struct { + name string + oldVal string + newVal string + want bool + }{ + {"changed", "v1", "v2", true}, + {"unchanged", "v1", "v1", false}, + {"added", "", "v1", true}, + {"removed", "v1", "", true}, + {"both empty", "", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oldObj := &porchv1alpha2.PackageRevision{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}} + newObj := &porchv1alpha2.PackageRevision{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}} + if tt.oldVal != "" { + oldObj.Annotations[porchv1alpha2.AnnotationRenderRequest] = tt.oldVal + } + if tt.newVal != "" { + newObj.Annotations[porchv1alpha2.AnnotationRenderRequest] = tt.newVal + } + got := pred.Update(event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObj}) + assert.Equal(t, tt.want, got) + }) + } +} + + +func TestKptfileFromResources(t *testing.T) { + tests := []struct { + name string + resources map[string]string + wantName string + wantErr bool + }{ + { + name: "no Kptfile", + resources: map[string]string{"foo.yaml": "bar"}, + wantName: "", + }, + { + name: "empty map", + resources: map[string]string{}, + wantName: "", + }, + { + name: "empty Kptfile string", + resources: map[string]string{"Kptfile": ""}, + wantName: "", + }, + { + name: "valid Kptfile", + resources: map[string]string{ + "Kptfile": `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: my-pkg +info: + readinessGates: + - conditionType: Ready +`, + }, + wantName: "my-pkg", + }, + { + name: "malformed Kptfile", + resources: map[string]string{"Kptfile": "not: valid: yaml: ["}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + kf, err := kptfileFromResources(tt.resources) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + if tt.wantName != "" { + assert.Equal(t, tt.wantName, kf.Name) + assert.Len(t, kf.Info.ReadinessGates, 1) + } + }) + } +} + + +func TestKptRendererSuccess(t *testing.T) { + r := &kptRenderer{} + resources := map[string]string{ + "Kptfile": `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: test-pkg +`, + "cm.yaml": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +`, + } + + result, err := r.Render(t.Context(), resources) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.NoError(t, result.err) + assert.Contains(t, result.resources, "Kptfile") + assert.Contains(t, result.resources, "cm.yaml") +} + +func TestKptRendererNoKptfile(t *testing.T) { + r := &kptRenderer{} + resources := map[string]string{"cm.yaml": "apiVersion: v1\nkind: ConfigMap\n"} + + result, err := r.Render(t.Context(), resources) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.NoError(t, result.err) + assert.Equal(t, resources, result.resources) + assert.Nil(t, result.results) +} + + +func TestMockRendererPipelineErr(t *testing.T) { + m := &mockRenderer{ + resources: map[string]string{"Kptfile": "rendered"}, + pipelineErr: errors.New("function failed"), + } + result, err := m.Render(t.Context(), nil) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "rendered", result.resources["Kptfile"]) + assert.EqualError(t, result.err, "function failed") +} + +func TestMockRendererInfraErr(t *testing.T) { + m := &mockRenderer{err: errors.New("cannot write FS")} + result, err := m.Render(t.Context(), nil) + assert.Error(t, err) + assert.Nil(t, result) +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/source.go b/controllers/packagerevisions/pkg/controllers/packagerevision/source.go new file mode 100644 index 000000000..67f7d7790 --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/source.go @@ -0,0 +1,358 @@ +// Copyright 2026 The kpt and Nephio 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/pkg/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/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/pkg/repository" + "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. +// Returns nil, nil if no source needs to be applied (package already created). +func (r *PackageRevisionReconciler) applySource(ctx context.Context, pr *porchv1alpha2.PackageRevision) (map[string]string, string, error) { + if pr.Status.CreationSource != "" { + return nil, "", nil + } + if pr.Spec.Source == nil { + return nil, "", nil + } + + switch { + case pr.Spec.Source.Init != nil: + resources, err := initPackage(ctx, pr.Spec.PackageName, pr.Spec.Source.Init) + return resources, "init", err + case pr.Spec.Source.CloneFrom != nil: + resources, err := r.clonePackage(ctx, pr) + return resources, "clone", err + case pr.Spec.Source.CopyFrom != nil: + resources, err := r.copyPackage(ctx, pr) + return resources, "copy", err + case pr.Spec.Source.Upgrade != nil: + resources, err := r.upgradePackage(ctx, pr) + return resources, "upgrade", err + default: + return nil, "", fmt.Errorf("source has no fields set") + } +} + +func initPackage(ctx context.Context, pkgName string, spec *porchv1alpha2.PackageInitSpec) (map[string]string, error) { + fs := filesys.MakeFsInMemory() + pkgPath := "/" + + if spec.Subpackage != "" { + pkgPath = "/" + spec.Subpackage + } + if err := fs.Mkdir(pkgPath); err != nil { + return nil, err + } + + init := kptpkg.DefaultInitializer{} + if err := init.Initialize(printer.WithContext(ctx, &fake.Printer{}), fs, kptpkg.InitOptions{ + PkgPath: pkgPath, + PkgName: pkgName, + Desc: spec.Description, + Keywords: spec.Keywords, + Site: spec.Site, + }); err != nil { + return nil, fmt.Errorf("failed to initialize pkg %q: %w", pkgName, err) + } + + return readFsToMap(fs) +} + +// copyPackage reads the source package referenced by CopyFrom and returns its resources. +// Validates the source is from the same repository and is published. +func (r *PackageRevisionReconciler) copyPackage(ctx context.Context, pr *porchv1alpha2.PackageRevision) (map[string]string, error) { + log := log.FromContext(ctx) + sourceRef := pr.Spec.Source.CopyFrom + + var sourcePR porchv1alpha2.PackageRevision + if err := r.Get(ctx, client.ObjectKey{Namespace: pr.Namespace, Name: sourceRef.Name}, &sourcePR); err != nil { + return nil, fmt.Errorf("failed to get source package %q: %w", sourceRef.Name, err) + } + + if sourcePR.Spec.RepositoryName != pr.Spec.RepositoryName { + return nil, fmt.Errorf("source package must be from same repository %q, got %q", pr.Spec.RepositoryName, sourcePR.Spec.RepositoryName) + } + if sourcePR.Spec.PackageName != pr.Spec.PackageName { + return nil, fmt.Errorf("source package must be same package %q, got %q", pr.Spec.PackageName, sourcePR.Spec.PackageName) + } + if !porchv1alpha2.LifecycleIsPublished(sourcePR.Spec.Lifecycle) { + return nil, fmt.Errorf("source package %q must be published", sourceRef.Name) + } + + log.V(1).Info("copying from source", "source", sourceRef.Name) + repoKey := repository.RepositoryKey{Namespace: pr.Namespace, Name: pr.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 source package content: %w", err) + } + + 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/source_test.go b/controllers/packagerevisions/pkg/controllers/packagerevision/source_test.go new file mode 100644 index 000000000..c08c606f5 --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/source_test.go @@ -0,0 +1,1043 @@ +// Copyright 2026 The kpt and Nephio 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" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/pkg/repository" + mockclient "github.com/nephio-project/porch/test/mockery/mocks/external/sigs.k8s.io/controller-runtime/pkg/client" + mockrepository "github.com/nephio-project/porch/test/mockery/mocks/porch/pkg/repository" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestApplySourceInit(t *testing.T) { + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "test-pkg", + Source: &porchv1alpha2.PackageSource{ + Init: &porchv1alpha2.PackageInitSpec{ + Description: "a test package", + }, + }, + }, + } + + resources, source, err := r.applySource(context.Background(), pr) + require.NoError(t, err) + assert.Equal(t, "init", source) + assert.Contains(t, resources, "Kptfile") + assert.Contains(t, resources["Kptfile"], "test-pkg") + assert.Contains(t, resources["Kptfile"], "a test package") +} + +func TestApplySourceInitWithSubpackage(t *testing.T) { + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "test-pkg", + Source: &porchv1alpha2.PackageSource{ + Init: &porchv1alpha2.PackageInitSpec{ + Subpackage: "sub/dir", + Description: "subpkg", + }, + }, + }, + } + + resources, source, err := r.applySource(context.Background(), pr) + require.NoError(t, err) + assert.Equal(t, "init", source) + assert.Contains(t, resources, "sub/dir/Kptfile") +} + +func TestApplySourceSkipsWhenAlreadyCreated(t *testing.T) { + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "test-pkg", + Source: &porchv1alpha2.PackageSource{ + Init: &porchv1alpha2.PackageInitSpec{}, + }, + }, + Status: porchv1alpha2.PackageRevisionStatus{ + CreationSource: "init", + }, + } + + resources, source, err := r.applySource(context.Background(), pr) + assert.NoError(t, err) + assert.Nil(t, resources) + assert.Empty(t, source) +} + +func TestApplySourceSkipsWhenNoSource(t *testing.T) { + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "test-pkg", + }, + } + + resources, source, err := r.applySource(context.Background(), pr) + assert.NoError(t, err) + assert.Nil(t, resources) + assert.Empty(t, source) +} + +func TestInitPackage(t *testing.T) { + resources, err := initPackage(context.Background(), "my-pkg", &porchv1alpha2.PackageInitSpec{ + Description: "my description", + Keywords: []string{"kw1", "kw2"}, + Site: "https://example.com", + }) + require.NoError(t, err) + + kptfile, ok := resources["Kptfile"] + require.True(t, ok, "Kptfile should exist") + assert.Contains(t, kptfile, "my-pkg") + assert.Contains(t, kptfile, "my description") +} + +func TestInitPackageEmpty(t *testing.T) { + resources, err := initPackage(context.Background(), "empty-pkg", &porchv1alpha2.PackageInitSpec{}) + require.NoError(t, err) + + _, ok := resources["Kptfile"] + assert.True(t, ok, "Kptfile should exist even with empty spec") +} + +func TestApplySourceEmptySourceStruct(t *testing.T) { + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "test-pkg", + Source: &porchv1alpha2.PackageSource{}, + }, + } + + _, _, err := r.applySource(context.Background(), pr) + assert.ErrorContains(t, err, "source has no fields set") +} + +func TestApplySourceCopy(t *testing.T) { + ctx := context.Background() + + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "repo.pkg.v1"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + src := obj.(*porchv1alpha2.PackageRevision) + src.Spec.PackageName = "pkg" + src.Spec.RepositoryName = "repo" + src.Spec.WorkspaceName = "v1" + src.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(ctx).Return(map[string]string{"Kptfile": "test-content"}, nil) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(ctx, + repository.RepositoryKey{Namespace: "default", Name: "repo"}, "pkg", "v1", + ).Return(mockContent, nil) + + r := &PackageRevisionReconciler{Client: mc, ContentCache: mockCache} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "repo.pkg.v2", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "repo", + WorkspaceName: "v2", + Source: &porchv1alpha2.PackageSource{ + CopyFrom: &porchv1alpha2.PackageRevisionRef{Name: "repo.pkg.v1"}, + }, + }, + } + + resources, source, err := r.applySource(ctx, pr) + require.NoError(t, err) + assert.Equal(t, "copy", source) + assert.Equal(t, map[string]string{"Kptfile": "test-content"}, resources) +} + +func TestApplySourceCopyDifferentRepo(t *testing.T) { + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "other-repo.pkg.v1"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + src := obj.(*porchv1alpha2.PackageRevision) + src.Spec.RepositoryName = "other-repo" + src.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + + r := &PackageRevisionReconciler{Client: mc} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "repo.pkg.v2", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "repo", + WorkspaceName: "v2", + Source: &porchv1alpha2.PackageSource{ + CopyFrom: &porchv1alpha2.PackageRevisionRef{Name: "other-repo.pkg.v1"}, + }, + }, + } + + _, _, err := r.applySource(context.Background(), pr) + assert.ErrorContains(t, err, "same repository") +} + +func TestApplySourceCopyNotPublished(t *testing.T) { + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "repo.pkg.v1"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + src := obj.(*porchv1alpha2.PackageRevision) + src.Spec.PackageName = "pkg" + src.Spec.RepositoryName = "repo" + src.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecycleDraft + return nil + }) + + r := &PackageRevisionReconciler{Client: mc} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "repo.pkg.v2", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "repo", + WorkspaceName: "v2", + Source: &porchv1alpha2.PackageSource{ + CopyFrom: &porchv1alpha2.PackageRevisionRef{Name: "repo.pkg.v1"}, + }, + }, + } + + _, _, err := r.applySource(context.Background(), pr) + assert.ErrorContains(t, err, "must be published") +} + +func TestApplySourceCopyNotFound(t *testing.T) { + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "repo.pkg.v1"}, &porchv1alpha2.PackageRevision{}). + Return(assert.AnError) + + r := &PackageRevisionReconciler{Client: mc} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "repo.pkg.v2", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "repo", + WorkspaceName: "v2", + Source: &porchv1alpha2.PackageSource{ + CopyFrom: &porchv1alpha2.PackageRevisionRef{Name: "repo.pkg.v1"}, + }, + }, + } + + _, _, err := r.applySource(context.Background(), pr) + assert.ErrorContains(t, err, "failed to get source package") +} + +func TestApplySourceCopyDifferentPackageName(t *testing.T) { + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "repo.other-pkg.v1"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + src := obj.(*porchv1alpha2.PackageRevision) + src.Spec.PackageName = "other-pkg" + src.Spec.RepositoryName = "repo" + src.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + + r := &PackageRevisionReconciler{Client: mc} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "repo.pkg.v2", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "repo", + WorkspaceName: "v2", + Source: &porchv1alpha2.PackageSource{ + CopyFrom: &porchv1alpha2.PackageRevisionRef{Name: "repo.other-pkg.v1"}, + }, + }, + } + + _, _, err := r.applySource(context.Background(), pr) + assert.ErrorContains(t, err, "same package") +} + +func TestApplySourceCloneUpstreamRef(t *testing.T) { + ctx := context.Background() + + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "upstream-repo.upstream-pkg.v1"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + src := obj.(*porchv1alpha2.PackageRevision) + src.Spec.PackageName = "upstream-pkg" + src.Spec.RepositoryName = "upstream-repo" + src.Spec.WorkspaceName = "v1" + src.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(ctx).Return(map[string]string{ + "Kptfile": "apiVersion: kpt.dev/v1\nkind: Kptfile\nmetadata:\n name: upstream-pkg\n", + }, nil) + mockContent.EXPECT().GetLock(ctx).Return( + kptfilev1.Upstream{Type: kptfilev1.GitOrigin, Git: &kptfilev1.Git{Repo: "https://example.com/repo.git", Directory: "/upstream-pkg", Ref: "v1"}}, + kptfilev1.Locator{Type: kptfilev1.GitOrigin, Git: &kptfilev1.GitLock{Repo: "https://example.com/repo.git", Directory: "/upstream-pkg", Ref: "v1", Commit: "abc123"}}, + nil, + ) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(ctx, + repository.RepositoryKey{Namespace: "default", Name: "upstream-repo"}, "upstream-pkg", "v1", + ).Return(mockContent, nil) + + r := &PackageRevisionReconciler{Client: mc, ContentCache: mockCache} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "my-repo.my-pkg.v1", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "v1", + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "upstream-repo.upstream-pkg.v1"}, + }, + }, + }, + } + + resources, source, err := r.applySource(ctx, pr) + require.NoError(t, err) + assert.Equal(t, "clone", source) + assert.Contains(t, resources, "Kptfile") + // Kptfile should have been updated with upstream info and renamed to my-pkg + assert.Contains(t, resources["Kptfile"], "my-pkg") +} + +func TestApplySourceCloneUpstreamRefNotPublished(t *testing.T) { + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "upstream-repo.pkg.v1"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + src := obj.(*porchv1alpha2.PackageRevision) + src.Spec.PackageName = "pkg" + src.Spec.RepositoryName = "upstream-repo" + src.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecycleDraft + return nil + }) + + r := &PackageRevisionReconciler{Client: mc} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "my-repo.pkg.v1", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "my-repo", + WorkspaceName: "v1", + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "upstream-repo.pkg.v1"}, + }, + }, + }, + } + + _, _, err := r.applySource(context.Background(), pr) + assert.ErrorContains(t, err, "must be published") +} + +func TestApplySourceCloneUpstreamRefNotFound(t *testing.T) { + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "upstream-repo.pkg.v1"}, &porchv1alpha2.PackageRevision{}). + Return(assert.AnError) + + r := &PackageRevisionReconciler{Client: mc} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "my-repo.pkg.v1", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "my-repo", + WorkspaceName: "v1", + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "upstream-repo.pkg.v1"}, + }, + }, + }, + } + + _, _, err := r.applySource(context.Background(), pr) + assert.ErrorContains(t, err, "failed to get upstream package") +} + +func TestApplySourceCloneGit(t *testing.T) { + ctx := context.Background() + + gitSpec := &porchv1alpha2.GitPackage{ + Repo: "https://example.com/repo.git", + Ref: "v1", + Directory: "/pkg", + } + + mockFetcher := mockrepository.NewMockExternalPackageFetcher(t) + mockFetcher.EXPECT().FetchExternalGitPackage(ctx, gitSpec, "default").Return( + map[string]string{"Kptfile": "apiVersion: kpt.dev/v1\nkind: Kptfile\nmetadata:\n name: pkg\n"}, + kptfilev1.GitLock{Repo: "https://example.com/repo.git", Directory: "/pkg", Ref: "v1", Commit: "abc123"}, + nil, + ) + + r := &PackageRevisionReconciler{ExternalPackageFetcher: mockFetcher} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "my-repo.my-pkg.v1", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "my-pkg", + RepositoryName: "my-repo", + WorkspaceName: "v1", + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{ + Git: gitSpec, + }, + }, + }, + } + + resources, source, err := r.applySource(ctx, pr) + require.NoError(t, err) + assert.Equal(t, "clone", source) + assert.Contains(t, resources, "Kptfile") + assert.Contains(t, resources["Kptfile"], "my-pkg") +} + +func TestApplySourceCloneNoSourceSpecified(t *testing.T) { + r := &PackageRevisionReconciler{} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "my-repo.pkg.v1", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "my-repo", + WorkspaceName: "v1", + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{}, + }, + }, + } + + _, _, err := r.applySource(context.Background(), pr) + assert.ErrorContains(t, err, "must specify either upstreamRef or git") +} + +func TestApplySourceCloneUpstreamRefGetContentError(t *testing.T) { + ctx := context.Background() + + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "upstream-repo.pkg.v1"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + src := obj.(*porchv1alpha2.PackageRevision) + src.Spec.PackageName = "pkg" + src.Spec.RepositoryName = "upstream-repo" + src.Spec.WorkspaceName = "v1" + src.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(ctx, + repository.RepositoryKey{Namespace: "default", Name: "upstream-repo"}, "pkg", "v1", + ).Return(nil, assert.AnError) + + r := &PackageRevisionReconciler{Client: mc, ContentCache: mockCache} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "my-repo.pkg.v1", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "my-repo", + WorkspaceName: "v1", + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "upstream-repo.pkg.v1"}, + }, + }, + }, + } + + _, _, err := r.applySource(ctx, pr) + assert.ErrorContains(t, err, "failed to get upstream package content") +} + +func TestApplySourceCloneUpstreamRefGetLockError(t *testing.T) { + ctx := context.Background() + + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "upstream-repo.pkg.v1"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + src := obj.(*porchv1alpha2.PackageRevision) + src.Spec.PackageName = "pkg" + src.Spec.RepositoryName = "upstream-repo" + src.Spec.WorkspaceName = "v1" + src.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(ctx).Return(map[string]string{"Kptfile": "test"}, nil) + mockContent.EXPECT().GetLock(ctx).Return(kptfilev1.Upstream{}, kptfilev1.Locator{}, assert.AnError) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(ctx, + repository.RepositoryKey{Namespace: "default", Name: "upstream-repo"}, "pkg", "v1", + ).Return(mockContent, nil) + + r := &PackageRevisionReconciler{Client: mc, ContentCache: mockCache} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "my-repo.pkg.v1", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "my-repo", + WorkspaceName: "v1", + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "upstream-repo.pkg.v1"}, + }, + }, + }, + } + + _, _, err := r.applySource(ctx, pr) + assert.ErrorContains(t, err, "failed to get upstream lock") +} + +func TestApplySourceCloneGitFetchError(t *testing.T) { + ctx := context.Background() + + gitSpec := &porchv1alpha2.GitPackage{ + Repo: "https://example.com/repo.git", + Ref: "v1", + Directory: "/pkg", + } + + mockFetcher := mockrepository.NewMockExternalPackageFetcher(t) + mockFetcher.EXPECT().FetchExternalGitPackage(ctx, gitSpec, "default"). + Return(nil, kptfilev1.GitLock{}, assert.AnError) + + r := &PackageRevisionReconciler{ExternalPackageFetcher: mockFetcher} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "my-repo.pkg.v1", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "my-repo", + WorkspaceName: "v1", + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{ + Git: gitSpec, + }, + }, + }, + } + + _, _, err := r.applySource(ctx, pr) + assert.ErrorContains(t, err, "failed to fetch from git") +} + +func TestApplySourceCloneUpstreamRefGetResourcesError(t *testing.T) { + ctx := context.Background() + + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "upstream-repo.pkg.v1"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + src := obj.(*porchv1alpha2.PackageRevision) + src.Spec.PackageName = "pkg" + src.Spec.RepositoryName = "upstream-repo" + src.Spec.WorkspaceName = "v1" + src.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + + mockContent := mockrepository.NewMockPackageContent(t) + mockContent.EXPECT().GetResourceContents(ctx).Return(nil, assert.AnError) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(ctx, + repository.RepositoryKey{Namespace: "default", Name: "upstream-repo"}, "pkg", "v1", + ).Return(mockContent, nil) + + r := &PackageRevisionReconciler{Client: mc, ContentCache: mockCache} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "my-repo.pkg.v1", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "my-repo", + WorkspaceName: "v1", + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "upstream-repo.pkg.v1"}, + }, + }, + }, + } + + _, _, err := r.applySource(ctx, pr) + assert.ErrorContains(t, err, "failed to read upstream resources") +} + +func TestApplySourceCloneIdempotent(t *testing.T) { + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "upstream-repo.pkg.v1"}, + }, + }, + }, + Status: porchv1alpha2.PackageRevisionStatus{ + CreationSource: "clone", + }, + } + + resources, source, err := r.applySource(context.Background(), pr) + assert.NoError(t, err) + assert.Nil(t, resources) + assert.Empty(t, source) +} + + +func TestApplySourceUpgrade(t *testing.T) { + ctx := context.Background() + + mc := mockclient.NewMockClient(t) + // oldUpstream + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "upstream.pkg.v1"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + pr := obj.(*porchv1alpha2.PackageRevision) + pr.Namespace = "default" + pr.Spec.PackageName = "pkg" + pr.Spec.RepositoryName = "upstream" + pr.Spec.WorkspaceName = "v1" + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + // newUpstream + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "upstream.pkg.v2"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + pr := obj.(*porchv1alpha2.PackageRevision) + pr.Namespace = "default" + pr.Spec.PackageName = "pkg" + pr.Spec.RepositoryName = "upstream" + pr.Spec.WorkspaceName = "v2" + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + // currentPackage + 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 { + pr := obj.(*porchv1alpha2.PackageRevision) + pr.Namespace = "default" + pr.Spec.PackageName = "pkg" + pr.Spec.RepositoryName = "downstream" + pr.Spec.WorkspaceName = "v1" + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + + kptfileContent := "apiVersion: kpt.dev/v1\nkind: Kptfile\nmetadata:\n name: pkg\n" + + // oldUpstream content + oldContent := mockrepository.NewMockPackageContent(t) + oldContent.EXPECT().GetResourceContents(ctx).Return(map[string]string{"Kptfile": kptfileContent}, nil) + + // newUpstream content — used for both resources and lock + newContent := mockrepository.NewMockPackageContent(t) + newContent.EXPECT().GetResourceContents(ctx).Return(map[string]string{ + "Kptfile": kptfileContent, + "new.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: new\n", + }, nil) + newContent.EXPECT().GetLock(ctx).Return( + kptfilev1.Upstream{Type: kptfilev1.GitOrigin, Git: &kptfilev1.Git{Repo: "https://example.com/upstream.git", Directory: "/pkg", Ref: "v2"}}, + kptfilev1.Locator{Type: kptfilev1.GitOrigin, Git: &kptfilev1.GitLock{Repo: "https://example.com/upstream.git", Directory: "/pkg", Ref: "v2", Commit: "def456"}}, + nil, + ) + + // currentPackage content + currentContent := mockrepository.NewMockPackageContent(t) + currentContent.EXPECT().GetResourceContents(ctx).Return(map[string]string{ + "Kptfile": kptfileContent, + "local.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: local\n", + }, nil) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(ctx, + repository.RepositoryKey{Namespace: "default", Name: "upstream"}, "pkg", "v1", + ).Return(oldContent, nil) + mockCache.EXPECT().GetPackageContent(ctx, + repository.RepositoryKey{Namespace: "default", Name: "upstream"}, "pkg", "v2", + ).Return(newContent, nil) + mockCache.EXPECT().GetPackageContent(ctx, + repository.RepositoryKey{Namespace: "default", Name: "downstream"}, "pkg", "v1", + ).Return(currentContent, nil) + + r := &PackageRevisionReconciler{Client: mc, ContentCache: mockCache} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "downstream.pkg.v2", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "downstream", + WorkspaceName: "v2", + Source: &porchv1alpha2.PackageSource{ + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + OldUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v1"}, + NewUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v2"}, + CurrentPackage: porchv1alpha2.PackageRevisionRef{Name: "downstream.pkg.v1"}, + }, + }, + }, + } + + resources, source, err := r.applySource(ctx, pr) + require.NoError(t, err) + assert.Equal(t, "upgrade", source) + // Should contain both local and new upstream resources after merge. + assert.Contains(t, resources, "local.yaml") + assert.Contains(t, resources, "new.yaml") + assert.Contains(t, resources, "Kptfile") + // Kptfile should reference new upstream. + assert.Contains(t, resources["Kptfile"], "v2") +} + +func TestApplySourceUpgradeOldUpstreamNotFound(t *testing.T) { + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "upstream.pkg.v1"}, &porchv1alpha2.PackageRevision{}). + Return(assert.AnError) + + r := &PackageRevisionReconciler{Client: mc} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "downstream.pkg.v2", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "downstream", + Source: &porchv1alpha2.PackageSource{ + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + OldUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v1"}, + NewUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v2"}, + CurrentPackage: porchv1alpha2.PackageRevisionRef{Name: "downstream.pkg.v1"}, + }, + }, + }, + } + + _, _, err := r.applySource(context.Background(), pr) + assert.ErrorContains(t, err, "old upstream") + assert.ErrorContains(t, err, "failed to get package") +} + +func TestApplySourceUpgradeNewUpstreamNotPublished(t *testing.T) { + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "upstream.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 + }) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "upstream.pkg.v2"}, &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{Name: "downstream.pkg.v2", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "downstream", + Source: &porchv1alpha2.PackageSource{ + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + OldUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v1"}, + NewUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v2"}, + CurrentPackage: porchv1alpha2.PackageRevisionRef{Name: "downstream.pkg.v1"}, + }, + }, + }, + } + + _, _, err := r.applySource(context.Background(), pr) + assert.ErrorContains(t, err, "new upstream") + assert.ErrorContains(t, err, "must be published") +} + +func TestApplySourceUpgradeCurrentPackageNotFound(t *testing.T) { + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "upstream.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 + }) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "upstream.pkg.v2"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + obj.(*porchv1alpha2.PackageRevision).Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "downstream.pkg.v1"}, &porchv1alpha2.PackageRevision{}). + Return(assert.AnError) + + r := &PackageRevisionReconciler{Client: mc} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "downstream.pkg.v2", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "downstream", + Source: &porchv1alpha2.PackageSource{ + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + OldUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v1"}, + NewUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v2"}, + CurrentPackage: porchv1alpha2.PackageRevisionRef{Name: "downstream.pkg.v1"}, + }, + }, + }, + } + + _, _, err := r.applySource(context.Background(), pr) + assert.ErrorContains(t, err, "current package") + assert.ErrorContains(t, err, "failed to get package") +} + +func TestApplySourceUpgradeReadResourcesError(t *testing.T) { + ctx := context.Background() + + mc := mockclient.NewMockClient(t) + for _, name := range []string{"upstream.pkg.v1", "upstream.pkg.v2", "downstream.pkg.v1"} { + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: name}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + pr := obj.(*porchv1alpha2.PackageRevision) + pr.Namespace = "default" + pr.Spec.RepositoryName = "upstream" + pr.Spec.PackageName = "pkg" + pr.Spec.WorkspaceName = "v1" + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + } + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(ctx, + repository.RepositoryKey{Namespace: "default", Name: "upstream"}, "pkg", "v1", + ).Return(nil, assert.AnError) + + r := &PackageRevisionReconciler{Client: mc, ContentCache: mockCache} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "downstream.pkg.v2", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "downstream", + Source: &porchv1alpha2.PackageSource{ + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + OldUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v1"}, + NewUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v2"}, + CurrentPackage: porchv1alpha2.PackageRevisionRef{Name: "downstream.pkg.v1"}, + }, + }, + }, + } + + _, _, err := r.applySource(ctx, pr) + assert.ErrorContains(t, err, "failed to read old upstream resources") +} + +func TestApplySourceUpgradeGetLockError(t *testing.T) { + ctx := context.Background() + + mc := mockclient.NewMockClient(t) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "upstream.pkg.v1"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + pr := obj.(*porchv1alpha2.PackageRevision) + pr.Namespace = "default" + pr.Spec.PackageName = "pkg" + pr.Spec.RepositoryName = "upstream" + pr.Spec.WorkspaceName = "v1" + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + mc.EXPECT().Get(mock.Anything, client.ObjectKey{Namespace: "default", Name: "upstream.pkg.v2"}, &porchv1alpha2.PackageRevision{}). + RunAndReturn(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + pr := obj.(*porchv1alpha2.PackageRevision) + pr.Namespace = "default" + pr.Spec.PackageName = "pkg" + pr.Spec.RepositoryName = "upstream" + pr.Spec.WorkspaceName = "v2" + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + 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 { + pr := obj.(*porchv1alpha2.PackageRevision) + pr.Namespace = "default" + pr.Spec.PackageName = "pkg" + pr.Spec.RepositoryName = "downstream" + pr.Spec.WorkspaceName = "v1" + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + return nil + }) + + kptfileContent := "apiVersion: kpt.dev/v1\nkind: Kptfile\nmetadata:\n name: pkg\n" + + oldContent := mockrepository.NewMockPackageContent(t) + oldContent.EXPECT().GetResourceContents(ctx).Return(map[string]string{"Kptfile": kptfileContent}, nil) + + // newUpstream content — GetResourceContents succeeds, GetLock fails. + newContent := mockrepository.NewMockPackageContent(t) + newContent.EXPECT().GetResourceContents(ctx).Return(map[string]string{"Kptfile": kptfileContent}, nil) + newContent.EXPECT().GetLock(ctx).Return(kptfilev1.Upstream{}, kptfilev1.Locator{}, assert.AnError) + + currentContent := mockrepository.NewMockPackageContent(t) + currentContent.EXPECT().GetResourceContents(ctx).Return(map[string]string{"Kptfile": kptfileContent}, nil) + + mockCache := mockrepository.NewMockContentCache(t) + mockCache.EXPECT().GetPackageContent(ctx, + repository.RepositoryKey{Namespace: "default", Name: "upstream"}, "pkg", "v1", + ).Return(oldContent, nil) + mockCache.EXPECT().GetPackageContent(ctx, + repository.RepositoryKey{Namespace: "default", Name: "upstream"}, "pkg", "v2", + ).Return(newContent, nil) + mockCache.EXPECT().GetPackageContent(ctx, + repository.RepositoryKey{Namespace: "default", Name: "downstream"}, "pkg", "v1", + ).Return(currentContent, nil) + + r := &PackageRevisionReconciler{Client: mc, ContentCache: mockCache} + + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "downstream.pkg.v2", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + RepositoryName: "downstream", + WorkspaceName: "v2", + Source: &porchv1alpha2.PackageSource{ + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + OldUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v1"}, + NewUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v2"}, + CurrentPackage: porchv1alpha2.PackageRevisionRef{Name: "downstream.pkg.v1"}, + }, + }, + }, + } + + _, _, err := r.applySource(ctx, pr) + assert.ErrorContains(t, err, "failed to get new upstream lock") +} + +func TestApplySourceUpgradeIdempotent(t *testing.T) { + r := &PackageRevisionReconciler{} + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg", + Source: &porchv1alpha2.PackageSource{ + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + OldUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v1"}, + NewUpstream: porchv1alpha2.PackageRevisionRef{Name: "upstream.pkg.v2"}, + CurrentPackage: porchv1alpha2.PackageRevisionRef{Name: "downstream.pkg.v1"}, + }, + }, + }, + Status: porchv1alpha2.PackageRevisionStatus{ + CreationSource: "upgrade", + }, + } + + resources, source, err := r.applySource(context.Background(), pr) + assert.NoError(t, err) + assert.Nil(t, resources) + assert.Empty(t, source) +} + +func TestStripKptfileStatus(t *testing.T) { + kfWithStatus := `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: pkg +status: + conditions: + - type: Rendered + status: "True" + reason: Rendered +` + resources := map[string]string{"Kptfile": kfWithStatus, "other.yaml": "data"} + stripKptfileStatus(resources) + + assert.NotContains(t, resources["Kptfile"], "status:") + assert.NotContains(t, resources["Kptfile"], "Rendered") + assert.Contains(t, resources["Kptfile"], "name: pkg") + assert.Equal(t, "data", resources["other.yaml"]) +} + +func TestStripKptfileStatusNoKptfile(t *testing.T) { + resources := map[string]string{"other.yaml": "data"} + stripKptfileStatus(resources) + assert.Equal(t, "data", resources["other.yaml"]) +} + +func TestStripKptfileStatusNoStatus(t *testing.T) { + kf := `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: pkg +` + resources := map[string]string{"Kptfile": kf} + stripKptfileStatus(resources) + assert.Equal(t, kf, resources["Kptfile"]) +} + +func TestStripKptfileStatusMalformed(t *testing.T) { + resources := map[string]string{"Kptfile": "not: valid: yaml: ["} + stripKptfileStatus(resources) + assert.Equal(t, "not: valid: yaml: [", resources["Kptfile"]) +} + +func TestCopyResources(t *testing.T) { + src := map[string]string{"a": "1", "b": "2"} + dst := copyResources(src) + + assert.Equal(t, src, dst) + dst["a"] = "modified" + assert.Equal(t, "1", src["a"], "original should not be modified") +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/status.go b/controllers/packagerevisions/pkg/controllers/packagerevision/status.go new file mode 100644 index 000000000..24df4a849 --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/status.go @@ -0,0 +1,212 @@ +// Copyright 2026 The kpt and Nephio 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" + + kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/pkg/repository" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + fieldManagerPRController = "packagerev-controller" + fieldManagerPRControllerRender = "packagerev-controller-render" + fieldManagerPRControllerKptfile = "packagerev-controller-kptfile" +) + +// updateStatus applies the PR-controller-owned status fields via SSA. +// When content is non-nil and represents a published package, publish metadata +// (revision, publishedBy, publishedAt) is included in the apply. +func (r *PackageRevisionReconciler) updateStatus(ctx context.Context, pr *porchv1alpha2.PackageRevision, content repository.PackageContent, creationSource string, conditions ...metav1.Condition) { + if creationSource == "" { + creationSource = pr.Status.CreationSource + } + + status := porchv1alpha2.PackageRevisionStatus{ + ObservedGeneration: pr.Generation, + Conditions: conditions, + CreationSource: creationSource, + } + + if content != nil { + if porchv1alpha2.LifecycleIsPublished(porchv1alpha2.PackageRevisionLifecycle(content.Lifecycle(ctx))) { + status.Revision = content.Key().Revision + commitTime, commitAuthor := content.GetCommitInfo() + status.PublishedBy = commitAuthor + if !commitTime.IsZero() { + t := metav1.NewTime(commitTime) + status.PublishedAt = &t + } + } + if _, selfLock, err := content.GetLock(ctx); err == nil { + status.SelfLock = porchv1alpha2.KptLocatorToLocator(selfLock) + } + if _, upstreamLock, err := content.GetUpstreamLock(ctx); err == nil { + status.UpstreamLock = porchv1alpha2.KptLocatorToLocator(upstreamLock) + } + } + + applyObj := &porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: pr.Name, + Namespace: pr.Namespace, + }, + Status: status, + } + + if err := r.Status().Patch(ctx, applyObj, client.Apply, client.FieldOwner(fieldManagerPRController), client.ForceOwnership); err != nil { + log.FromContext(ctx).Error(err, "failed to apply status") + } +} + +// updateRenderStatus patches render tracking fields and Rendered condition via SSA. +// Uses a separate field manager to avoid stomping fields owned by updateStatus. +func (r *PackageRevisionReconciler) updateRenderStatus(ctx context.Context, pr *porchv1alpha2.PackageRevision, renderingVersion, observedVersion string, conditions ...metav1.Condition) { + status := porchv1alpha2.PackageRevisionStatus{ + RenderingPrrResourceVersion: renderingVersion, + ObservedPrrResourceVersion: observedVersion, + Conditions: conditions, + } + + if observedVersion == "" { + status.ObservedPrrResourceVersion = pr.Status.ObservedPrrResourceVersion + } + + applyObj := &porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: pr.Name, + Namespace: pr.Namespace, + }, + Status: status, + } + + if err := r.Status().Patch(ctx, applyObj, client.Apply, client.FieldOwner(fieldManagerPRControllerRender), client.ForceOwnership); err != nil { + log.FromContext(ctx).Error(err, "failed to update render status") + } +} + +// setSourceFailed logs the error and sets Ready=False and Rendered=False. +// Rendered is set even though rendering was never attempted — the package +// content didn't land successfully, so "not rendered" is accurate. +func (r *PackageRevisionReconciler) setSourceFailed(ctx context.Context, pr *porchv1alpha2.PackageRevision, err error) error { + log.FromContext(ctx).Error(err, "source execution failed") + r.updateStatus(ctx, pr, nil, "", + readyCondition(pr.Generation, metav1.ConditionFalse, porchv1alpha2.ReasonFailed, err.Error()), + renderedCondition(pr.Generation, metav1.ConditionFalse, porchv1alpha2.ReasonFailed, err.Error()), + ) + return err +} + +func (r *PackageRevisionReconciler) setRenderFailed(ctx context.Context, pr *porchv1alpha2.PackageRevision, err error) { + // Pass the render-request annotation as observedVersion so the controller + // records that this request was processed (even though it failed). + // Without this, the render trigger keeps re-firing indefinitely. + observed := pr.Annotations[porchv1alpha2.AnnotationRenderRequest] + r.updateRenderStatus(ctx, pr, "", observed, + renderedCondition(pr.Generation, metav1.ConditionFalse, porchv1alpha2.ReasonRenderFailed, err.Error()), + ) + // Also set Ready=False — a failed render means the package is not ready. + r.updateStatus(ctx, pr, nil, "", + readyCondition(pr.Generation, metav1.ConditionFalse, porchv1alpha2.ReasonRenderFailed, "render failed"), + ) +} + +func readyCondition(generation int64, status metav1.ConditionStatus, reason, message string) metav1.Condition { + return metav1.Condition{ + Type: porchv1alpha2.ConditionReady, + Status: status, + Reason: reason, + Message: message, + LastTransitionTime: metav1.Now(), + ObservedGeneration: generation, + } +} + +func renderedCondition(generation int64, status metav1.ConditionStatus, reason, message string) metav1.Condition { + return metav1.Condition{ + Type: porchv1alpha2.ConditionRendered, + Status: status, + Reason: reason, + Message: message, + LastTransitionTime: metav1.Now(), + ObservedGeneration: generation, + } +} + +// updateKptfileFields updates the CRD with Kptfile-derived fields after render. +// Uses the main PR controller field manager with ForceOwnership to take +// ownership from the repo controller (which sets these on create). +func (r *PackageRevisionReconciler) updateKptfileFields(ctx context.Context, pr *porchv1alpha2.PackageRevision, kf kptfilev1.KptFile) { + gates := porchv1alpha2.KptfileToReadinessGates(kf) + meta := porchv1alpha2.KptfileToPackageMetadata(kf) + conds := porchv1alpha2.KptfileToPackageConditions(kf) + + // Skip if there's nothing to sync — avoids SSA taking ownership of empty fields. + if len(gates) == 0 && meta == nil && len(conds) == 0 { + return + } + + if len(gates) > 0 || meta != nil { + specObj := &porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: pr.Name, + Namespace: pr.Namespace, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + ReadinessGates: gates, + PackageMetadata: meta, + }, + } + if err := r.Patch(ctx, specObj, client.Apply, client.FieldOwner(fieldManagerPRControllerKptfile), client.ForceOwnership); err != nil { + log.FromContext(ctx).Error(err, "failed to update Kptfile-derived spec fields") + } + } + + if len(conds) > 0 { + statusObj := &porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: pr.Name, + Namespace: pr.Namespace, + }, + Status: porchv1alpha2.PackageRevisionStatus{ + PackageConditions: conds, + }, + } + if err := r.Status().Patch(ctx, statusObj, client.Apply, client.FieldOwner(fieldManagerPRControllerKptfile), client.ForceOwnership); err != nil { + log.FromContext(ctx).Error(err, "failed to update Kptfile-derived status fields") + } + } +} diff --git a/controllers/packagerevisions/pkg/controllers/packagerevision/status_test.go b/controllers/packagerevisions/pkg/controllers/packagerevision/status_test.go new file mode 100644 index 000000000..0b3ae7ebf --- /dev/null +++ b/controllers/packagerevisions/pkg/controllers/packagerevision/status_test.go @@ -0,0 +1,287 @@ +package packagerevision + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/pkg/repository" + mockclient "github.com/nephio-project/porch/test/mockery/mocks/external/sigs.k8s.io/controller-runtime/pkg/client" + mockrepository "github.com/nephio-project/porch/test/mockery/mocks/porch/pkg/repository" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func captureStatusPatch(t *testing.T, mockClient *mockclient.MockClient) *porchv1alpha2.PackageRevisionStatus { + t.Helper() + var captured porchv1alpha2.PackageRevisionStatus + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.SubResourcePatchOption) { + captured = obj.(*porchv1alpha2.PackageRevision).Status + }).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + return &captured +} + +func basePR() *porchv1alpha2.PackageRevision { + return &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pr", + Namespace: "default", + Generation: 3, + }, + Status: porchv1alpha2.PackageRevisionStatus{ + CreationSource: "init", + }, + } +} + +func TestUpdateStatusBasic(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + captured := captureStatusPatch(t, mockClient) + + r := &PackageRevisionReconciler{Client: mockClient} + pr := basePR() + + r.updateStatus(t.Context(), pr, nil, "clone", + readyCondition(pr.Generation, metav1.ConditionTrue, porchv1alpha2.ReasonReady, ""), + ) + + assert.Equal(t, int64(3), captured.ObservedGeneration) + assert.Equal(t, "clone", captured.CreationSource) + assert.Len(t, captured.Conditions, 1) + assert.Equal(t, porchv1alpha2.ConditionReady, captured.Conditions[0].Type) +} + +func TestUpdateStatusPreservesCreationSource(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + captured := captureStatusPatch(t, mockClient) + + r := &PackageRevisionReconciler{Client: mockClient} + pr := basePR() + + r.updateStatus(t.Context(), pr, nil, "", + readyCondition(pr.Generation, metav1.ConditionTrue, porchv1alpha2.ReasonReady, ""), + ) + + assert.Equal(t, "init", captured.CreationSource) +} + +func TestUpdateStatusWithPublishedContent(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + captured := captureStatusPatch(t, mockClient) + + content := mockrepository.NewMockPackageContent(t) + content.EXPECT().Lifecycle(mock.Anything).Return("Published") + content.EXPECT().Key().Return(repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{}, + WorkspaceName: "ws", + Revision: 5, + }) + commitTime := time.Date(2025, 7, 1, 12, 0, 0, 0, time.UTC) + 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) + + r := &PackageRevisionReconciler{Client: mockClient} + pr := basePR() + + r.updateStatus(t.Context(), pr, content, "", + readyCondition(pr.Generation, metav1.ConditionTrue, porchv1alpha2.ReasonReady, ""), + ) + + assert.Equal(t, 5, captured.Revision) + assert.Equal(t, "user@example.com", captured.PublishedBy) + assert.NotNil(t, captured.PublishedAt) +} + +func TestUpdateStatusWithDraftContent(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + captured := captureStatusPatch(t, mockClient) + + content := mockrepository.NewMockPackageContent(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) + + r := &PackageRevisionReconciler{Client: mockClient} + pr := basePR() + + r.updateStatus(t.Context(), pr, content, "") + + assert.Equal(t, 0, captured.Revision) + assert.Empty(t, captured.PublishedBy) + assert.Nil(t, captured.PublishedAt) +} + +func TestUpdateRenderStatusInProgress(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + captured := captureStatusPatch(t, mockClient) + + r := &PackageRevisionReconciler{Client: mockClient} + pr := basePR() + pr.Status.ObservedPrrResourceVersion = "old-version" + + r.updateRenderStatus(t.Context(), pr, "v1", "", + renderedCondition(pr.Generation, metav1.ConditionUnknown, porchv1alpha2.ReasonPending, "rendering"), + ) + + assert.Equal(t, "v1", captured.RenderingPrrResourceVersion) + assert.Equal(t, "old-version", captured.ObservedPrrResourceVersion) // preserved + assert.Len(t, captured.Conditions, 1) + assert.Equal(t, metav1.ConditionUnknown, captured.Conditions[0].Status) +} + +func TestUpdateRenderStatusComplete(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + captured := captureStatusPatch(t, mockClient) + + r := &PackageRevisionReconciler{Client: mockClient} + pr := basePR() + + r.updateRenderStatus(t.Context(), pr, "", "v1", + renderedCondition(pr.Generation, metav1.ConditionTrue, porchv1alpha2.ReasonRendered, ""), + ) + + assert.Empty(t, captured.RenderingPrrResourceVersion) + assert.Equal(t, "v1", captured.ObservedPrrResourceVersion) + assert.Equal(t, metav1.ConditionTrue, captured.Conditions[0].Status) +} + +func TestSetRenderFailed(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + + // setRenderFailed now makes two status patches: Rendered=False then Ready=False + var renderPatch porchv1alpha2.PackageRevisionStatus + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.SubResourcePatchOption) { + renderPatch = obj.(*porchv1alpha2.PackageRevision).Status + }).Return(nil).Once() + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything). + Return(nil).Once() + mockClient.EXPECT().Status().Return(mockStatusWriter).Times(2) + + r := &PackageRevisionReconciler{Client: mockClient} + pr := basePR() + pr.Status.ObservedPrrResourceVersion = "prev" + + r.setRenderFailed(t.Context(), pr, assert.AnError) + + assert.Empty(t, renderPatch.RenderingPrrResourceVersion) + assert.Equal(t, "prev", renderPatch.ObservedPrrResourceVersion) // preserved + assert.Equal(t, metav1.ConditionFalse, renderPatch.Conditions[0].Status) + assert.Equal(t, porchv1alpha2.ReasonRenderFailed, renderPatch.Conditions[0].Reason) +} + + +func TestUpdateKptfileFields(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + + var specPatch porchv1alpha2.PackageRevisionSpec + var statusPatch porchv1alpha2.PackageRevisionStatus + + // Spec apply (Patch on the object) + 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) { + specPatch = obj.(*porchv1alpha2.PackageRevision).Spec + }).Return(nil) + + // Status apply + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.SubResourcePatchOption) { + statusPatch = obj.(*porchv1alpha2.PackageRevision).Status + }).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := &PackageRevisionReconciler{Client: mockClient} + pr := basePR() + + kf := kptfilev1.KptFile{ + Info: &kptfilev1.PackageInfo{ + ReadinessGates: []kptfilev1.ReadinessGate{{ConditionType: "Ready"}}, + }, + Status: &kptfilev1.Status{ + Conditions: []kptfilev1.Condition{ + {Type: "Ready", Status: kptfilev1.ConditionTrue, Reason: "AllGood"}, + }, + }, + } + + r.updateKptfileFields(t.Context(), pr, kf) + + assert.Len(t, specPatch.ReadinessGates, 1) + assert.Equal(t, "Ready", specPatch.ReadinessGates[0].ConditionType) + assert.Nil(t, specPatch.PackageMetadata) // no labels/annotations set on KptFile + assert.Len(t, statusPatch.PackageConditions, 1) + assert.Equal(t, "Ready", statusPatch.PackageConditions[0].Type) +} + +func TestUpdateKptfileFieldsSkipsWhenEmpty(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + // No Patch or Status calls expected — should be a no-op. + + r := &PackageRevisionReconciler{Client: mockClient} + pr := basePR() + + kf := kptfilev1.KptFile{} + r.updateKptfileFields(t.Context(), pr, kf) +} + +func TestUpdateKptfileFieldsConditionsOnly(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + + // Only status patch expected (no spec patch since no gates/meta). + mockStatusWriter := mockclient.NewMockSubResourceWriter(t) + var statusPatch porchv1alpha2.PackageRevisionStatus + mockStatusWriter.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.SubResourcePatchOption) { + statusPatch = obj.(*porchv1alpha2.PackageRevision).Status + }).Return(nil) + mockClient.EXPECT().Status().Return(mockStatusWriter) + + r := &PackageRevisionReconciler{Client: mockClient} + pr := basePR() + + kf := kptfilev1.KptFile{ + Status: &kptfilev1.Status{ + Conditions: []kptfilev1.Condition{ + {Type: "MyGate", Status: kptfilev1.ConditionTrue, Reason: "OK"}, + }, + }, + } + + r.updateKptfileFields(t.Context(), pr, kf) + assert.Len(t, statusPatch.PackageConditions, 1) + assert.Equal(t, "MyGate", statusPatch.PackageConditions[0].Type) +} + +func TestUpdateKptfileFieldsGatesOnly(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + + // Only spec patch expected (no status patch since no conditions). + var specPatch porchv1alpha2.PackageRevisionSpec + 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) { + specPatch = obj.(*porchv1alpha2.PackageRevision).Spec + }).Return(nil) + + r := &PackageRevisionReconciler{Client: mockClient} + pr := basePR() + + kf := kptfilev1.KptFile{ + Info: &kptfilev1.PackageInfo{ + ReadinessGates: []kptfilev1.ReadinessGate{{ConditionType: "Ready"}}, + }, + } + + r.updateKptfileFields(t.Context(), pr, kf) + assert.Len(t, specPatch.ReadinessGates, 1) +} diff --git a/controllers/packagevariants/pkg/controllers/packagevariant/packagevariant_controller.go b/controllers/packagevariants/pkg/controllers/packagevariant/packagevariant_controller.go index 0ee385ef6..be64b3b80 100644 --- a/controllers/packagevariants/pkg/controllers/packagevariant/packagevariant_controller.go +++ b/controllers/packagevariants/pkg/controllers/packagevariant/packagevariant_controller.go @@ -53,20 +53,17 @@ type PackageVariantReconciler struct { client.Client client.Reader Options - loggerName string } const ( + reconcilerName = "packagevariants" workspaceNamePrefix = "packagevariant-" ConditionTypeStalled = "Stalled" // whether or not the packagevariant object is making progress or not ConditionTypeReady = "Ready" // whether or not the reconciliation succeeded ) -// SetLogger sets the logger name for this reconciler -func (r *PackageVariantReconciler) SetLogger(name string) { - r.loggerName = name -} +func (r *PackageVariantReconciler) Name() string { return reconcilerName } //go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen@v0.19.0 rbac:headerFile=../../../../../scripts/boilerplate.yaml.txt,roleName=porch-controllers-packagevariants,year=$YEAR_GEN webhook paths="." output:rbac:artifacts:config=../../../config/rbac diff --git a/controllers/packagevariantsets/pkg/controllers/packagevariantset/packagevariantset_controller.go b/controllers/packagevariantsets/pkg/controllers/packagevariantset/packagevariantset_controller.go index 2fdf5912d..a0b956a94 100644 --- a/controllers/packagevariantsets/pkg/controllers/packagevariantset/packagevariantset_controller.go +++ b/controllers/packagevariantsets/pkg/controllers/packagevariantset/packagevariantset_controller.go @@ -56,10 +56,11 @@ type PackageVariantSetReconciler struct { Options serializer *json.Serializer - loggerName string } const ( + reconcilerName = "packagevariantsets" + PackageVariantSetOwnerLabel = "config.porch.kpt.dev/packagevariantset" ConditionTypeStalled = "Stalled" // whether or not the resource reconciliation is making progress or not @@ -69,10 +70,7 @@ const ( PackageVariantNameHashLength = 8 ) -// SetLogger sets the logger name for this reconciler -func (r *PackageVariantSetReconciler) SetLogger(name string) { - r.loggerName = name -} +func (r *PackageVariantSetReconciler) Name() string { return reconcilerName } //go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen@v0.19.0 rbac:headerFile=../../../../../scripts/boilerplate.yaml.txt,roleName=porch-controllers-packagevariantsets,year=$YEAR_GEN webhook paths="." output:rbac:artifacts:config=../../../config/rbac diff --git a/controllers/repositories/config/rbac/role.yaml b/controllers/repositories/config/rbac/role.yaml index fb637effa..d5f0e2323 100644 --- a/controllers/repositories/config/rbac/role.yaml +++ b/controllers/repositories/config/rbac/role.yaml @@ -53,3 +53,23 @@ rules: - get - patch - update +- apiGroups: + - porch.kpt.dev + resources: + - packagerevisions + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - porch.kpt.dev + resources: + - packagerevisions/status + verbs: + - get + - patch + - update diff --git a/controllers/repositories/pkg/controllers/repository/cache.go b/controllers/repositories/pkg/controllers/repository/cache.go index 7c1a2728d..534bbf93d 100644 --- a/controllers/repositories/pkg/controllers/repository/cache.go +++ b/controllers/repositories/pkg/controllers/repository/cache.go @@ -35,7 +35,7 @@ import ( ) func (r *RepositoryReconciler) createCacheFromEnv(ctx context.Context, mgr ctrl.Manager) error { - log := ctrl.Log.WithName(r.loggerName) + log := ctrl.Log.WithName(r.Name()) if err := r.validateCacheType(); err != nil { return err } @@ -97,7 +97,7 @@ func (r *RepositoryReconciler) validateCacheType() error { // Priority: 1. Flag value, 2. GIT_CACHE_DIR env var, 3. User cache dir, 4. Temp dir // Matches porch-server behavior for consistency func (r *RepositoryReconciler) determineCacheDirectory() string { - log := ctrl.Log.WithName(r.loggerName) + log := ctrl.Log.WithName(r.Name()) // Priority 1: Use flag value if set if r.cacheDirectory != "" { @@ -155,11 +155,12 @@ func (r *RepositoryReconciler) buildCacheOptions( RepoOperationRetryAttempts: r.RepoOperationRetryAttempts, }, RepoPRChangeNotifier: cachetypes.NewNoOpRepoPRChangeNotifier(), + DbPushDraftsToGit: r.PushDraftsToGit, } } func (r *RepositoryReconciler) setupDBCacheOptionsFromEnv() (cachetypes.DBCacheOptions, error) { - log := ctrl.Log.WithName(r.loggerName) + log := ctrl.Log.WithName(r.Name()) dbDriver := os.Getenv("DB_DRIVER") dbHost := os.Getenv("DB_HOST") dbPort := os.Getenv("DB_PORT") diff --git a/controllers/repositories/pkg/controllers/repository/config.go b/controllers/repositories/pkg/controllers/repository/config.go index ff767a334..ab4de6046 100644 --- a/controllers/repositories/pkg/controllers/repository/config.go +++ b/controllers/repositories/pkg/controllers/repository/config.go @@ -55,6 +55,8 @@ func (r *RepositoryReconciler) BindFlags(prefix string, flags *flag.FlagSet) { flags.DurationVar(&r.SyncStaleTimeout, prefix+"sync-stale-timeout", defaultSyncStaleTimeout, "Timeout for considering a sync stale") flags.IntVar(&r.RepoOperationRetryAttempts, prefix+"repo-operation-retry-attempts", defaultRepoOperationRetryAttempts, "Number of retry attempts for git operations") flags.BoolVar(&r.useUserDefinedCaBundle, prefix+"use-user-defined-ca-bundle", false, "Enable custom CA bundle support from secrets") + flags.BoolVar(&r.CreateV1Alpha2Rpkg, prefix+"create-v1alpha2-rpkg", false, "Create v1alpha2 PackageRevision resources during repository sync") + flags.BoolVar(&r.PushDraftsToGit, prefix+"push-drafts-to-git", false, "Push draft and proposed branches to git when using DB cache") } // validateConfig ensures configuration values are valid @@ -91,7 +93,9 @@ func (r *RepositoryReconciler) LogConfig(log interface { "syncStaleTimeout", r.SyncStaleTimeout, "repoOperationRetryAttempts", r.RepoOperationRetryAttempts, "cacheType", r.cacheType, - "cacheDirectory", r.cacheDirectory) + "cacheDirectory", r.cacheDirectory, + "createV1Alpha2Rpkg", r.CreateV1Alpha2Rpkg, + "pushDraftsToGit", r.PushDraftsToGit) if r.HealthCheckFrequency < defaultHealthCheckFrequency { log.Info("Health check frequency is lower than recommended default", diff --git a/controllers/repositories/pkg/controllers/repository/pkgrevsync.go b/controllers/repositories/pkg/controllers/repository/pkgrevsync.go new file mode 100644 index 000000000..21f465e8c --- /dev/null +++ b/controllers/repositories/pkg/controllers/repository/pkgrevsync.go @@ -0,0 +1,272 @@ +// Copyright 2026 The kpt and Nephio 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 repository + +import ( + "context" + "fmt" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + configapi "github.com/nephio-project/porch/api/porchconfig/v1alpha1" + "github.com/nephio-project/porch/pkg/repository" + "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + fieldManagerRepoController = "repository-controller" + fieldManagerRepoControllerSeed = "repository-controller-seed" + RepositoryLabel = "porch.kpt.dev/repository" +) + +// syncPackageRevisions creates, updates, or deletes PackageRevision resources +// to match the packages discovered by ListPackageRevisions. +// +// Individual apply/delete failures are logged but do not fail the sync. +// The repo is considered healthy if git is reachable — etcd plumbing errors +// (conflicts, quota, etc.) are transient and resolve on the next sync. +// +// TODO: When the system is fully async (PR controller active), introduce a +// degraded condition on the Repository CR (e.g. PackageRevisionSyncComplete=False) +// to surface partial sync failures to users without marking the repo as unhealthy. +func (r *RepositoryReconciler) syncPackageRevisions(ctx context.Context, repo *configapi.Repository, pkgRevs []repository.PackageRevision) error { + existingByName, err := r.listExistingPackageRevisions(ctx, repo) + if err != nil { + return err + } + + desiredNames := r.applyDesiredPackageRevisions(ctx, repo, pkgRevs, existingByName) + r.deleteStalePackageRevisions(ctx, existingByName, desiredNames) + + return nil +} + +func (r *RepositoryReconciler) listExistingPackageRevisions(ctx context.Context, repo *configapi.Repository) (map[string]*porchv1alpha2.PackageRevision, error) { + existing := &porchv1alpha2.PackageRevisionList{} + if err := r.List(ctx, existing, + client.InNamespace(repo.Namespace), + client.MatchingLabels{RepositoryLabel: repo.Name}, + ); err != nil { + return nil, fmt.Errorf("failed to list existing PackageRevisions: %w", err) + } + result := make(map[string]*porchv1alpha2.PackageRevision, len(existing.Items)) + for i := range existing.Items { + result[existing.Items[i].Name] = &existing.Items[i] + } + return result, nil +} + +func (r *RepositoryReconciler) applyDesiredPackageRevisions(ctx context.Context, repo *configapi.Repository, pkgRevs []repository.PackageRevision, existingByName map[string]*porchv1alpha2.PackageRevision) map[string]bool { + log := log.FromContext(ctx) + desiredNames := make(map[string]bool, len(pkgRevs)) + + for _, pkgRev := range pkgRevs { + name := pkgRev.KubeObjectName() + ex, isUpdate := existingByName[name] + + desired, err := buildPackageRevision(ctx, repo, pkgRev) + if err != nil { + log.Error(err, "Failed to build PackageRevision", "key", pkgRev.Key()) + continue + } + desiredNames[desired.Name] = true + + if isUpdate && packageRevisionUpToDate(ex, desired) { + log.V(5).Info("PackageRevision unchanged, skipping", "name", desired.Name) + continue + } + + if err := r.applyPackageRevision(ctx, desired); err != nil { + continue + } + if !isUpdate { + r.applySeedFields(ctx, repo, pkgRev, desired) + } + if isUpdate { + log.V(3).Info("Updated PackageRevision", "name", desired.Name) + } else { + log.V(3).Info("Created PackageRevision", "name", desired.Name) + } + } + + return desiredNames +} + +func (r *RepositoryReconciler) deleteStalePackageRevisions(ctx context.Context, existingByName map[string]*porchv1alpha2.PackageRevision, desiredNames map[string]bool) { + log := log.FromContext(ctx) + for name, ex := range existingByName { + if !desiredNames[name] { + if err := r.Delete(ctx, ex); err != nil { + log.Error(err, "Failed to delete stale PackageRevision", "name", name) + } + } + } +} + +// applyPackageRevision applies repo-controller-owned spec and status fields +// via SSA with ForceOwnership. Only includes fields the repo controller +// permanently owns: identity, labels, ownerRef, locks, deployment. +func (r *RepositoryReconciler) applyPackageRevision(ctx context.Context, pr *porchv1alpha2.PackageRevision) error { + log := log.FromContext(ctx) + + savedStatus := pr.Status + + opts := []client.PatchOption{client.FieldOwner(fieldManagerRepoController), client.ForceOwnership} + if err := r.Patch(ctx, pr, client.Apply, opts...); err != nil { + log.Error(err, "Failed to apply PackageRevision", "name", pr.Name) + return err + } + + statusObj := &porchv1alpha2.PackageRevision{ + TypeMeta: pr.TypeMeta, + ObjectMeta: metav1.ObjectMeta{Name: pr.Name, Namespace: pr.Namespace}, + Status: savedStatus, + } + statusOpts := []client.SubResourcePatchOption{client.FieldOwner(fieldManagerRepoController), client.ForceOwnership} + if err := r.Status().Patch(ctx, statusObj, client.Apply, statusOpts...); err != nil { + log.Error(err, "Failed to apply PackageRevision status", "name", pr.Name) + return err + } + + return nil +} + +// applySeedFields applies non-repo-controller-owned fields (lifecycle, revision, +// Kptfile-derived, publish metadata) on initial creation only. Uses a +// separate field manager without ForceOwnership so these fields seed the value +// for discovered packages but never overwrite values already set by the user +// or the PR controller. +func (r *RepositoryReconciler) applySeedFields(ctx context.Context, repo *configapi.Repository, pkgRev repository.PackageRevision, crd *porchv1alpha2.PackageRevision) { + log := log.FromContext(ctx) + + lifecycle := porchv1alpha2.PackageRevisionLifecycle(pkgRev.Lifecycle(ctx)) + kf, _ := pkgRev.GetKptfile(ctx) + + seedSpec := &porchv1alpha2.PackageRevision{ + TypeMeta: crd.TypeMeta, + ObjectMeta: metav1.ObjectMeta{Name: crd.Name, Namespace: crd.Namespace}, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: lifecycle, + ReadinessGates: porchv1alpha2.KptfileToReadinessGates(kf), + PackageMetadata: porchv1alpha2.KptfileToPackageMetadata(kf), + }, + } + if err := r.Patch(ctx, seedSpec, client.Apply, client.FieldOwner(fieldManagerRepoControllerSeed)); err != nil { + log.V(3).Info("Seed spec apply skipped (fields likely already owned)", "name", crd.Name, "err", err) + } + + seedStatus := porchv1alpha2.PackageRevisionStatus{ + PackageConditions: porchv1alpha2.KptfileToPackageConditions(kf), + } + if porchv1alpha2.LifecycleIsPublished(lifecycle) { + key := pkgRev.Key() + seedStatus.Revision = key.Revision + commitTime, commitAuthor := pkgRev.GetCommitInfo() + seedStatus.PublishedBy = commitAuthor + if !commitTime.IsZero() { + t := metav1.NewTime(commitTime) + seedStatus.PublishedAt = &t + } + } + statusObj := &porchv1alpha2.PackageRevision{ + TypeMeta: crd.TypeMeta, + ObjectMeta: metav1.ObjectMeta{Name: crd.Name, Namespace: crd.Namespace}, + Status: seedStatus, + } + if err := r.Status().Patch(ctx, statusObj, client.Apply, client.FieldOwner(fieldManagerRepoControllerSeed)); err != nil { + log.V(3).Info("Seed status apply skipped (fields likely already owned)", "name", crd.Name, "err", err) + } +} + +// packageRevisionUpToDate returns true if the repo-controller-owned fields +// in the existing resource match the desired state. Skips immutable identity +// fields (package name, repo, workspace) and fields owned by other controllers +// (lifecycle, conditions, publish metadata). +func packageRevisionUpToDate(existing, desired *porchv1alpha2.PackageRevision) bool { + return equality.Semantic.DeepEqual(existing.Labels, desired.Labels) && + existing.Status.Deployment == desired.Status.Deployment && + equality.Semantic.DeepEqual(existing.Status.UpstreamLock, desired.Status.UpstreamLock) && + equality.Semantic.DeepEqual(existing.Status.SelfLock, desired.Status.SelfLock) +} + +// buildPackageRevision constructs a PackageRevision resource containing only +// repo-controller-owned fields: identity, labels, ownerRef, locks, deployment. +// Seed fields (lifecycle, publish metadata, Kptfile-derived) are applied +// separately via applySeedFields on create. +func buildPackageRevision(ctx context.Context, repo *configapi.Repository, pkgRev repository.PackageRevision) (*porchv1alpha2.PackageRevision, error) { + key := pkgRev.Key() + _, upstreamLock, _ := pkgRev.GetUpstreamLock(ctx) + _, selfLock, _ := pkgRev.GetLock(ctx) + + status := porchv1alpha2.PackageRevisionStatus{ + UpstreamLock: porchv1alpha2.KptLocatorToLocator(upstreamLock), + SelfLock: porchv1alpha2.KptLocatorToLocator(selfLock), + Deployment: repo.Spec.Deployment, + // PackageConditions omitted — PR controller owns after first render. + } + + crd := &porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: pkgRev.KubeObjectName(), + Namespace: pkgRev.KubeObjectNamespace(), + Labels: packageRevisionLabelsForUpdate(repo.Name), + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: configapi.GroupVersion.Identifier(), + Kind: configapi.TypeRepository.Kind, + Name: repo.Name, + UID: repo.UID, + }, + }, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: key.PkgKey.ToPkgPathname(), + RepositoryName: key.RKey().Name, + WorkspaceName: key.WorkspaceName, + // ReadinessGates and PackageMetadata omitted — PR controller owns after first render. + }, + Status: status, + } + + return crd, nil +} + +// packageRevisionLabels returns the standard labels for a PackageRevision, +// including the repository label and the latest-revision indicator. +func packageRevisionLabels(repoName string, pkgRev repository.PackageRevision) map[string]string { + labels := map[string]string{ + RepositoryLabel: repoName, + } + if pkgRev.IsLatestRevision() { + labels[porchv1alpha2.LatestPackageRevisionKey] = porchv1alpha2.LatestPackageRevisionValue + } else { + labels[porchv1alpha2.LatestPackageRevisionKey] = "false" + } + return labels +} + +// packageRevisionLabelsForUpdate returns labels for the update path. +// Omits latest-revision — that's PR-controller-owned after initial creation. +func packageRevisionLabelsForUpdate(repoName string) map[string]string { + return map[string]string{ + RepositoryLabel: repoName, + } +} diff --git a/controllers/repositories/pkg/controllers/repository/pkgrevsync_test.go b/controllers/repositories/pkg/controllers/repository/pkgrevsync_test.go new file mode 100644 index 000000000..a5da5496d --- /dev/null +++ b/controllers/repositories/pkg/controllers/repository/pkgrevsync_test.go @@ -0,0 +1,607 @@ +// Copyright 2026 The kpt and Nephio 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 repository + +import ( + "context" + "fmt" + "testing" + "time" + + kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" + porchv1alpha1 "github.com/nephio-project/porch/api/porch/v1alpha1" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + configapi "github.com/nephio-project/porch/api/porchconfig/v1alpha1" + "github.com/nephio-project/porch/pkg/repository" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + mockclient "github.com/nephio-project/porch/test/mockery/mocks/external/sigs.k8s.io/controller-runtime/pkg/client" +) + +// --- Test helpers --- + +func newTestRepo() *configapi.Repository { + return &configapi.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-repo", + Namespace: "default", + UID: types.UID("repo-uid"), + }, + Spec: configapi.RepositorySpec{Deployment: true}, + } +} + +func newFakePkgRev(pkg, workspace string, lifecycle porchv1alpha2.PackageRevisionLifecycle) *fakePackageRevision { + return &fakePackageRevision{ + key: repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{ + RepoKey: repository.RepositoryKey{Namespace: "default", Name: "my-repo"}, + Package: pkg, + }, + WorkspaceName: workspace, + }, + lifecycle: porchv1alpha1.PackageRevisionLifecycle(lifecycle), + kptfile: kptfilev1.KptFile{}, + } +} + +// mockListReturning sets up a mock List that populates the result with the given PackageRevisions. +func mockListReturning(m *mockclient.MockClient, items []porchv1alpha2.PackageRevision) { + m.EXPECT().List(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevisionList"), mock.Anything, mock.Anything). + Run(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) { + list.(*porchv1alpha2.PackageRevisionList).Items = items + }).Return(nil) +} + +// mockApplySuccess sets up mock expectations for a successful SSA apply (Patch + Status().Patch). +func mockApplySuccess(t *testing.T, m *mockclient.MockClient) { + t.Helper() + m.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything).Return(nil) + sw := mockclient.NewMockSubResourceWriter(t) + m.EXPECT().Status().Return(sw) + sw.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything).Return(nil) +} + +// --- fakePackageRevision --- + +// fakePackageRevision implements repository.PackageRevision and the optional +// GetCommitInfo/IsLatestRevision interfaces for testing. +type fakePackageRevision struct { + key repository.PackageRevisionKey + lifecycle porchv1alpha1.PackageRevisionLifecycle + kptfile kptfilev1.KptFile + upstreamLock kptfilev1.Locator + selfLock kptfilev1.Locator + commitTime time.Time + commitAuthor string + isLatest bool +} + +func (f *fakePackageRevision) KubeObjectNamespace() string { return f.key.RKey().Namespace } +func (f *fakePackageRevision) KubeObjectName() string { return repository.ComposePkgRevObjName(f.key) } +func (f *fakePackageRevision) Key() repository.PackageRevisionKey { return f.key } +func (f *fakePackageRevision) UID() types.UID { return "" } +func (f *fakePackageRevision) ResourceVersion() string { return "" } +func (f *fakePackageRevision) GetMeta() metav1.ObjectMeta { return metav1.ObjectMeta{} } +func (f *fakePackageRevision) SetMeta(_ context.Context, _ metav1.ObjectMeta) error { return nil } +func (f *fakePackageRevision) Lifecycle(_ context.Context) porchv1alpha1.PackageRevisionLifecycle { + return f.lifecycle +} +func (f *fakePackageRevision) UpdateLifecycle(_ context.Context, _ porchv1alpha1.PackageRevisionLifecycle) error { + return nil +} +func (f *fakePackageRevision) GetPackageRevision(_ context.Context) (*porchv1alpha1.PackageRevision, error) { + return nil, nil +} +func (f *fakePackageRevision) GetResources(_ context.Context) (*porchv1alpha1.PackageRevisionResources, error) { + return nil, nil +} +func (f *fakePackageRevision) GetUpstreamLock(_ context.Context) (kptfilev1.Upstream, kptfilev1.Locator, error) { + return kptfilev1.Upstream{}, f.upstreamLock, nil +} +func (f *fakePackageRevision) GetKptfile(_ context.Context) (kptfilev1.KptFile, error) { + return f.kptfile, nil +} +func (f *fakePackageRevision) GetLock(_ context.Context) (kptfilev1.Upstream, kptfilev1.Locator, error) { + return kptfilev1.Upstream{}, f.selfLock, nil +} +func (f *fakePackageRevision) ToMainPackageRevision(_ context.Context) repository.PackageRevision { + return nil +} +func (f *fakePackageRevision) GetCommitInfo() (time.Time, string) { + return f.commitTime, f.commitAuthor +} +func (f *fakePackageRevision) IsLatestRevision() bool { return f.isLatest } + +// --- Tests: buildPackageRevision --- + +func TestBuildPackageRevision(t *testing.T) { + ctx := context.Background() + repo := newTestRepo() + + t.Run("published with full metadata", func(t *testing.T) { + pkgRev := newFakePkgRev("my-pkg", "v3", porchv1alpha2.PackageRevisionLifecyclePublished) + pkgRev.key.PkgKey.Path = "path/to" + pkgRev.key.Revision = 3 + pkgRev.commitTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) + pkgRev.commitAuthor = "user@example.com" + pkgRev.kptfile = kptfilev1.KptFile{ + Info: &kptfilev1.PackageInfo{ + ReadinessGates: []kptfilev1.ReadinessGate{{ConditionType: "Ready"}}, + }, + Status: &kptfilev1.Status{ + Conditions: []kptfilev1.Condition{ + {Type: "Ready", Status: kptfilev1.ConditionTrue, Reason: "AllGood"}, + }, + }, + } + pkgRev.upstreamLock = kptfilev1.Locator{ + Type: kptfilev1.GitOrigin, + Git: &kptfilev1.GitLock{Repo: "https://github.com/upstream.git", Ref: "v1.0", Directory: "/", Commit: "abc"}, + } + pkgRev.selfLock = kptfilev1.Locator{ + Type: kptfilev1.GitOrigin, + Git: &kptfilev1.GitLock{Repo: "https://github.com/self.git", Ref: "main", Directory: "path/to/my-pkg", Commit: "def"}, + } + + crd, err := buildPackageRevision(ctx, repo, pkgRev) + assert.NoError(t, err) + + // Metadata + assert.Equal(t, "PackageRevision", crd.Kind) + assert.Equal(t, porchv1alpha2.SchemeGroupVersion.Identifier(), crd.APIVersion) + assert.Equal(t, "default", crd.Namespace) + assert.Equal(t, repo.Name, crd.OwnerReferences[0].Name) + assert.Equal(t, repo.UID, crd.OwnerReferences[0].UID) + + // Spec — repo-owned identity fields + assert.Equal(t, "path/to/my-pkg", crd.Spec.PackageName) + assert.Equal(t, "my-repo", crd.Spec.RepositoryName) + assert.Equal(t, "v3", crd.Spec.WorkspaceName) + + // Spec — seed fields NOT included (applied separately via applySeedFields) + assert.Empty(t, crd.Spec.Lifecycle) + assert.Nil(t, crd.Spec.ReadinessGates) + + // Status — repo-owned fields + assert.True(t, crd.Status.Deployment) + assert.Equal(t, "https://github.com/upstream.git", crd.Status.UpstreamLock.Git.Repo) + assert.Equal(t, "https://github.com/self.git", crd.Status.SelfLock.Git.Repo) + + // Status — seed fields NOT included + assert.Equal(t, 0, crd.Status.Revision) + assert.Empty(t, crd.Status.PublishedBy) + assert.Nil(t, crd.Status.PublishedAt) + assert.Nil(t, crd.Status.PackageConditions) + + // Labels + assert.Equal(t, "my-repo", crd.Labels[RepositoryLabel]) + }) + + t.Run("draft has no publish metadata", func(t *testing.T) { + pkgRev := newFakePkgRev("draft-pkg", "ws1", porchv1alpha2.PackageRevisionLifecycleDraft) + + crd, err := buildPackageRevision(ctx, repo, pkgRev) + assert.NoError(t, err) + + // buildPackageRevision never includes seed fields + assert.Equal(t, 0, crd.Status.Revision) + assert.Empty(t, crd.Status.PublishedBy) + assert.Nil(t, crd.Status.PublishedAt) + assert.Empty(t, crd.Spec.Lifecycle) + }) + + t.Run("empty kptfile yields nil optional fields", func(t *testing.T) { + pkgRev := newFakePkgRev("bare-pkg", "ws1", porchv1alpha2.PackageRevisionLifecycleDraft) + + crd, err := buildPackageRevision(ctx, repo, pkgRev) + assert.NoError(t, err) + + assert.Nil(t, crd.Spec.ReadinessGates) + assert.Nil(t, crd.Spec.PackageMetadata) + assert.Nil(t, crd.Status.PackageConditions) + assert.Nil(t, crd.Status.UpstreamLock) + assert.Nil(t, crd.Status.SelfLock) + }) +} + +// --- Tests: packageRevisionUpToDate --- + +func TestPackageRevisionUpToDate(t *testing.T) { + base := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{RepositoryLabel: "repo1"}, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: "pkg1", + Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished, + }, + Status: porchv1alpha2.PackageRevisionStatus{Revision: 1}, + } + + tests := []struct { + name string + modify func(*porchv1alpha2.PackageRevision) + expected bool + }{ + {name: "identical", modify: nil, expected: true}, + {name: "lifecycle changed - still up to date (client-owned)", modify: func(pr *porchv1alpha2.PackageRevision) { + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecycleDraft + }, expected: true}, + {name: "status changed (packageConditions — PR controller owned)", modify: func(pr *porchv1alpha2.PackageRevision) { + pr.Status.PackageConditions = []porchv1alpha2.PackageCondition{{Type: "new"}} + }, expected: true}, + {name: "labels changed", modify: func(pr *porchv1alpha2.PackageRevision) { + pr.Labels[porchv1alpha2.LatestPackageRevisionKey] = porchv1alpha2.LatestPackageRevisionValue + }, expected: false}, + {name: "annotations differ - still up to date", modify: func(pr *porchv1alpha2.PackageRevision) { + pr.Annotations = map[string]string{"foo": "bar"} + }, expected: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + desired := base.DeepCopy() + if tt.modify != nil { + tt.modify(desired) + } + assert.Equal(t, tt.expected, packageRevisionUpToDate(base, desired)) + }) + } +} + +// --- Tests: packageRevisionLabels --- + +func TestPackageRevisionLabels(t *testing.T) { + tests := []struct { + name string + isLatest bool + wantLatest bool + }{ + {name: "non-latest", isLatest: false, wantLatest: false}, + {name: "latest", isLatest: true, wantLatest: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgRev := &fakePackageRevision{isLatest: tt.isLatest} + labels := packageRevisionLabels("my-repo", pkgRev) + assert.Equal(t, "my-repo", labels[RepositoryLabel]) + if tt.wantLatest { + assert.Equal(t, porchv1alpha2.LatestPackageRevisionValue, labels[porchv1alpha2.LatestPackageRevisionKey]) + } else { + assert.Equal(t, "false", labels[porchv1alpha2.LatestPackageRevisionKey]) + } + }) + } +} + +// --- Tests: syncPackageRevisions --- + +func TestSyncPackageRevisions(t *testing.T) { + ctx := context.Background() + repo := newTestRepo() + draftPkgRev := newFakePkgRev("pkg1", "ws1", porchv1alpha2.PackageRevisionLifecycleDraft) + + stalePR := porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "stale-pr", + Namespace: "default", + Labels: map[string]string{RepositoryLabel: "my-repo"}, + }, + } + + // Pre-build the "up to date" resource for the skip test + upToDatePR, _ := buildPackageRevision(ctx, repo, draftPkgRev) + + tests := []struct { + name string + pkgRevs []repository.PackageRevision + setupMocks func(t *testing.T, m *mockclient.MockClient) + expectError string + }{ + { + name: "creates new PackageRevision with seed fields", + pkgRevs: []repository.PackageRevision{draftPkgRev}, + setupMocks: func(t *testing.T, m *mockclient.MockClient) { + mockListReturning(m, nil) + // applyPackageRevision: spec Patch + status Patch + m.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + sw := mockclient.NewMockSubResourceWriter(t) + sw.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + // applySeedFields: spec Patch (no ForceOwnership, so 1 fewer option) + status Patch + m.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything).Return(nil).Once() + sw.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything).Return(nil).Once() + m.EXPECT().Status().Return(sw) + }, + }, + { + name: "skips up-to-date PackageRevision", + pkgRevs: []repository.PackageRevision{draftPkgRev}, + setupMocks: func(t *testing.T, m *mockclient.MockClient) { + mockListReturning(m, []porchv1alpha2.PackageRevision{*upToDatePR}) + }, + }, + { + name: "deletes stale PackageRevision", + pkgRevs: nil, + setupMocks: func(t *testing.T, m *mockclient.MockClient) { + mockListReturning(m, []porchv1alpha2.PackageRevision{stalePR}) + m.EXPECT().Delete(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision")).Return(nil) + }, + }, + { + name: "deletes only stale PackageRevisions when mixed with current", + pkgRevs: []repository.PackageRevision{draftPkgRev}, + setupMocks: func(t *testing.T, m *mockclient.MockClient) { + // List returns both the current resource (matching draftPkgRev) and a stale one + mockListReturning(m, []porchv1alpha2.PackageRevision{*upToDatePR, stalePR}) + // Only the stale one should be deleted + m.EXPECT().Delete(mock.Anything, mock.MatchedBy(func(obj client.Object) bool { + return obj.GetName() == "stale-pr" + })).Return(nil) + }, + }, + { + name: "delete error logged but sync succeeds", + pkgRevs: nil, + setupMocks: func(t *testing.T, m *mockclient.MockClient) { + mockListReturning(m, []porchv1alpha2.PackageRevision{stalePR}) + m.EXPECT().Delete(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision")).Return(fmt.Errorf("delete failed")) + }, + }, + { + name: "list error", + setupMocks: func(t *testing.T, m *mockclient.MockClient) { + m.EXPECT().List(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevisionList"), mock.Anything, mock.Anything). + Return(fmt.Errorf("list failed")) + }, + expectError: "list failed", + }, + { + name: "apply error logged but sync succeeds", + pkgRevs: []repository.PackageRevision{draftPkgRev}, + setupMocks: func(t *testing.T, m *mockclient.MockClient) { + mockListReturning(m, nil) + m.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything). + Return(fmt.Errorf("apply failed")) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + tt.setupMocks(t, mockClient) + + r := &RepositoryReconciler{Client: mockClient} + err := r.syncPackageRevisions(ctx, repo, tt.pkgRevs) + + if tt.expectError != "" { + assert.ErrorContains(t, err, tt.expectError) + } else { + assert.NoError(t, err) + } + }) + } +} + +// --- Tests: applyPackageRevision --- + +func TestApplyPackageRevision(t *testing.T) { + ctx := context.Background() + + pr := &porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{Kind: "PackageRevision", APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier()}, + ObjectMeta: metav1.ObjectMeta{Name: "test-pr", Namespace: "default"}, + Spec: porchv1alpha2.PackageRevisionSpec{PackageName: "pkg1"}, + Status: porchv1alpha2.PackageRevisionStatus{Revision: 1}, + } + + tests := []struct { + name string + setupMocks func(t *testing.T, m *mockclient.MockClient) + expectError string + }{ + { + name: "success", + setupMocks: func(t *testing.T, m *mockclient.MockClient) { + mockApplySuccess(t, m) + }, + }, + { + name: "spec patch fails", + setupMocks: func(t *testing.T, m *mockclient.MockClient) { + m.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything). + Return(fmt.Errorf("spec patch failed")) + }, + expectError: "spec patch failed", + }, + { + name: "status patch fails", + setupMocks: func(t *testing.T, m *mockclient.MockClient) { + m.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything).Return(nil) + sw := mockclient.NewMockSubResourceWriter(t) + m.EXPECT().Status().Return(sw) + sw.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything). + Return(fmt.Errorf("status patch failed")) + }, + expectError: "status patch failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := mockclient.NewMockClient(t) + tt.setupMocks(t, mockClient) + + r := &RepositoryReconciler{Client: mockClient} + err := r.applyPackageRevision(ctx, pr.DeepCopy()) + + if tt.expectError != "" { + assert.ErrorContains(t, err, tt.expectError) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestBuildPackageRevisionOmitsNonOwnedFields(t *testing.T) { + ctx := context.Background() + repo := &configapi.Repository{ + ObjectMeta: metav1.ObjectMeta{Name: "my-repo", Namespace: "default", UID: "repo-uid"}, + Spec: configapi.RepositorySpec{Deployment: true}, + } + + pkgRev := &fakePackageRevision{} + pkgRev.key = repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{RepoKey: repository.RepositoryKey{Namespace: "default", Name: "my-repo"}, Package: "my-pkg"}, + WorkspaceName: "v1", + } + pkgRev.kptfile = kptfilev1.KptFile{ + Info: &kptfilev1.PackageInfo{ + ReadinessGates: []kptfilev1.ReadinessGate{{ConditionType: "Ready"}}, + }, + Status: &kptfilev1.Status{ + Conditions: []kptfilev1.Condition{ + {Type: "Ready", Status: kptfilev1.ConditionTrue}, + }, + }, + } + + crd, err := buildPackageRevision(ctx, repo, pkgRev) + assert.NoError(t, err) + + // Verify identity fields are present. + assert.Equal(t, "my-pkg", crd.Spec.PackageName) + assert.Equal(t, "my-repo", crd.Spec.RepositoryName) + assert.Equal(t, "v1", crd.Spec.WorkspaceName) + assert.True(t, crd.Status.Deployment) + + // Verify Kptfile-derived fields are omitted (PR controller owns them). + assert.Nil(t, crd.Spec.ReadinessGates) + assert.Nil(t, crd.Spec.PackageMetadata) + assert.Nil(t, crd.Status.PackageConditions) +} + +// --- Tests: re-sync and race scenarios --- + +// TestResyncDoesNotStripNonOwnedStatusFields verifies that when the repo +// controller re-syncs an existing resource (update path), the SSA apply object +// does not include status.revision, status.publishedBy, or status.publishedAt. +// This prevents SSA from removing those fields on re-sync, which was the +// root cause of status.revision being reset to 0 after repo re-sync. +func TestResyncDoesNotStripNonOwnedStatusFields(t *testing.T) { + ctx := context.Background() + repo := newTestRepo() + + pkgRev := newFakePkgRev("resync-pkg", "v1", porchv1alpha2.PackageRevisionLifecyclePublished) + pkgRev.key.Revision = 1 + pkgRev.commitTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) + pkgRev.commitAuthor = "user@example.com" + + // Build the resource as the repo controller would for the update path. + crd, err := buildPackageRevision(ctx, repo, pkgRev) + assert.NoError(t, err) + + // The resource must NOT contain publish metadata — those are PR-controller-owned. + assert.Equal(t, 0, crd.Status.Revision, "status.revision must not be set by repo controller") + assert.Empty(t, crd.Status.PublishedBy, "status.publishedBy must not be set by repo controller") + assert.Nil(t, crd.Status.PublishedAt, "status.publishedAt must not be set by repo controller") + assert.Empty(t, crd.Spec.Lifecycle, "spec.lifecycle must not be set by repo controller") +} + +// TestSyncUpdatePathDoesNotCallSeedFields verifies that when the informer +// cache correctly identifies an existing resource (isUpdate=true), applySeedFields +// is NOT called — only applyPackageRevision runs. +func TestSyncUpdatePathDoesNotCallSeedFields(t *testing.T) { + ctx := context.Background() + repo := newTestRepo() + + pkgRev := newFakePkgRev("existing-pkg", "v1", porchv1alpha2.PackageRevisionLifecyclePublished) + pkgRev.selfLock = kptfilev1.Locator{ + Type: kptfilev1.GitOrigin, + Git: &kptfilev1.GitLock{Repo: "https://example.com/repo.git", Ref: "main", Directory: "existing-pkg", Commit: "new-commit"}, + } + + // Existing resource has an old selfLock so it's NOT up-to-date. + existingPR, _ := buildPackageRevision(ctx, repo, pkgRev) + existingPR.Status.SelfLock = nil // different from desired → triggers update + + mockClient := mockclient.NewMockClient(t) + mockListReturning(mockClient, []porchv1alpha2.PackageRevision{*existingPR}) + + // Expect exactly 1 spec Patch + 1 status Patch (applyPackageRevision only). + // No additional Patch calls from applySeedFields. + var specPatchObj *porchv1alpha2.PackageRevision + 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) { + specPatchObj = obj.(*porchv1alpha2.PackageRevision) + }).Return(nil).Once() + sw := mockclient.NewMockSubResourceWriter(t) + mockClient.EXPECT().Status().Return(sw).Once() + var statusPatchObj *porchv1alpha2.PackageRevision + sw.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything). + Run(func(_ context.Context, obj client.Object, _ client.Patch, _ ...client.SubResourcePatchOption) { + statusPatchObj = obj.(*porchv1alpha2.PackageRevision) + }).Return(nil).Once() + + r := &RepositoryReconciler{Client: mockClient} + err := r.syncPackageRevisions(ctx, repo, []repository.PackageRevision{pkgRev}) + assert.NoError(t, err) + + // Verify the spec apply does not include lifecycle. + assert.Empty(t, specPatchObj.Spec.Lifecycle, "update path must not set spec.lifecycle") + + // Verify the status apply does not include revision or publish metadata. + assert.Equal(t, 0, statusPatchObj.Status.Revision, "update path must not set status.revision") + assert.Empty(t, statusPatchObj.Status.PublishedBy, "update path must not set status.publishedBy") + assert.Nil(t, statusPatchObj.Status.PublishedAt, "update path must not set status.publishedAt") +} + +// TestSeedFieldsNotCalledOnUpdate verifies that applySeedFields is only +// called on the create path (!isUpdate), not on the update path. +func TestSeedFieldsNotCalledOnUpdate(t *testing.T) { + ctx := context.Background() + repo := newTestRepo() + + pkgRev := newFakePkgRev("pkg1", "v1", porchv1alpha2.PackageRevisionLifecyclePublished) + pkgRev.key.Revision = 1 + + // Simulate existing resource with different labels to force an update. + existingPR, _ := buildPackageRevision(ctx, repo, pkgRev) + existingPR.Labels["extra"] = "label" + + mockClient := mockclient.NewMockClient(t) + mockListReturning(mockClient, []porchv1alpha2.PackageRevision{*existingPR}) + + // Only applyPackageRevision calls expected (1 spec Patch + 1 status Patch). + mockClient.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + sw := mockclient.NewMockSubResourceWriter(t) + mockClient.EXPECT().Status().Return(sw).Once() + sw.EXPECT().Patch(mock.Anything, mock.AnythingOfType("*v1alpha2.PackageRevision"), mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + + // If applySeedFields were called, there would be additional Patch calls + // which would cause the mock to fail with unexpected calls. + + r := &RepositoryReconciler{Client: mockClient} + err := r.syncPackageRevisions(ctx, repo, []repository.PackageRevision{pkgRev}) + assert.NoError(t, err) +} diff --git a/controllers/repositories/pkg/controllers/repository/repository_controller.go b/controllers/repositories/pkg/controllers/repository/repository_controller.go index 545f9860d..0f402dec9 100644 --- a/controllers/repositories/pkg/controllers/repository/repository_controller.go +++ b/controllers/repositories/pkg/controllers/repository/repository_controller.go @@ -18,6 +18,7 @@ import ( "context" "fmt" "runtime/debug" + "sync" "time" porchcontext "github.com/nephio-project/porch/pkg/util/context" @@ -37,6 +38,7 @@ import ( ) const ( + reconcilerName = "repositories" RepositoryFinalizer = "config.porch.kpt.dev/repository" ) @@ -54,14 +56,18 @@ type RepositoryReconciler struct { SyncStaleTimeout time.Duration // How long before sync is considered stale RepoOperationRetryAttempts int // Git operation retry attempts + // Feature flags + CreateV1Alpha2Rpkg bool // Create v1alpha2 PackageRevision resources during repo sync + PushDraftsToGit bool // Push draft/proposed branches to git (DB cache only) + // Configuration (set via flags or defaults) cacheType string // Cache type (DB or CR) cacheDirectory string // Directory for git repository cache useUserDefinedCaBundle bool // Whether to use custom CA bundles from secrets // Private implementation details - syncLimiter chan struct{} // Semaphore for sync concurrency - loggerName string // Logger name for this reconciler + syncLimiter chan struct{} // Semaphore for sync concurrency + coldStartRepos sync.Map // Tracks repos that have synced since startup } //go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen@v0.19.0 rbac:headerFile=../../../../../scripts/boilerplate.yaml.txt,roleName=porch-controllers-repositories,year=$YEAR_GEN webhook paths="." output:rbac:artifacts:config=../../../config/rbac @@ -70,6 +76,8 @@ type RepositoryReconciler struct { //+kubebuilder:rbac:groups=config.porch.kpt.dev,resources=repositories/status,verbs=get;update;patch //+kubebuilder:rbac:groups=config.porch.kpt.dev,resources=repositories/finalizers,verbs=update //+kubebuilder:rbac:groups=config.porch.kpt.dev,resources=packagerevs,verbs=create;get;list;watch;update;patch;delete +//+kubebuilder:rbac:groups=porch.kpt.dev,resources=packagerevisions,verbs=create;get;list;watch;update;patch;delete +//+kubebuilder:rbac:groups=porch.kpt.dev,resources=packagerevisions/status,verbs=get;update;patch //+kubebuilder:rbac:groups="",resources=events,verbs=get;list;watch //+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch @@ -252,10 +260,7 @@ func (r *RepositoryReconciler) performFullSync(ctx context.Context, repo *api.Re return ctrl.Result{RequeueAfter: r.HealthCheckFrequency}, nil } -// SetLogger sets the logger name for this reconciler -func (r *RepositoryReconciler) SetLogger(name string) { - r.loggerName = name -} +func (r *RepositoryReconciler) Name() string { return reconcilerName } // ensureFinalizer adds finalizer if missing using patch to avoid generation increment func (r *RepositoryReconciler) ensureFinalizer(ctx context.Context, repo *api.Repository) (bool, error) { @@ -284,7 +289,7 @@ func getRepoFields(repo *api.Repository) (repoURL, branch, directory string) { // SetupWithManager sets up the controller with the Manager func (r *RepositoryReconciler) SetupWithManager(mgr ctrl.Manager) error { - log := ctrl.Log.WithName(r.loggerName) + log := ctrl.Log.WithName(r.Name()) log.Info("SetupWithManager called", "reconcilerPtr", fmt.Sprintf("%p", r), "cacheIsNil", r.Cache == nil) // Log controller configuration diff --git a/controllers/repositories/pkg/controllers/repository/sync.go b/controllers/repositories/pkg/controllers/repository/sync.go index 2dcaf6b4d..45fce1f09 100644 --- a/controllers/repositories/pkg/controllers/repository/sync.go +++ b/controllers/repositories/pkg/controllers/repository/sync.go @@ -80,6 +80,10 @@ func (s *Sync) HandleSync(ctx context.Context, repo *api.Repository) SyncResult } log.Info("Repository full sync completed successfully", "trigger", syncReason) + // Mark this repo as synced since startup (cold start complete) + repoKey := repo.Namespace + "/" + repo.Name + s.reconciler.coldStartRepos.Store(repoKey, true) + // Update status fields now := metav1.Now() repo.Status.LastFullSyncTime = &now @@ -144,6 +148,13 @@ func (r *RepositoryReconciler) syncRepository(ctx context.Context, repo *api.Rep return 0, "", err } + if r.CreateV1Alpha2Rpkg && repo.Annotations[api.AnnotationKeyV1Alpha2Migration] == api.AnnotationValueMigrationEnabled { + if err := r.syncPackageRevisions(ctx, repo, pkgRevs); err != nil { + log.Error(err, "Failed to sync v1alpha2 PackageRevisions", "repo", repo.Name) + // Non-fatal: don't fail the entire sync for v1alpha2 creation failures + } + } + // Get commit hash (best effort - don't fail sync if this fails) commitHash, _ = repoHandle.BranchCommitHash(ctx) @@ -158,6 +169,17 @@ func (r *RepositoryReconciler) determineSyncDecision(ctx context.Context, repo * return SyncDecision{Type: OperationHealthCheck, SyncNecessary: false, UseExponentialBackoff: true} } + // After a controller restart the git cache is cold but LastFullSyncTime + // may still be recent (persisted in etcd by the previous pod). Force a + // full sync for each repo on the first reconcile to re-warm the cache. + // Note: with many repos (1000+), the syncLimiter bounds concurrency + // (MaxConcurrentSyncs) so repos sync in batches, not all at once. + repoKey := repo.Namespace + "/" + repo.Name + if _, synced := r.coldStartRepos.Load(repoKey); !synced { + log.FromContext(ctx).Info("Cold start: forcing full sync to re-warm cache") + return SyncDecision{Type: OperationFullSync, SyncNecessary: true, DelayBeforeNextSync: 0} + } + switch { case r.isOneTimeSyncDue(repo): // 1. One-time sync due → Full sync diff --git a/controllers/repositories/pkg/controllers/repository/sync_test.go b/controllers/repositories/pkg/controllers/repository/sync_test.go index 5ce08a8b5..0306281b9 100644 --- a/controllers/repositories/pkg/controllers/repository/sync_test.go +++ b/controllers/repositories/pkg/controllers/repository/sync_test.go @@ -836,7 +836,7 @@ func TestDetermineSyncDecision(t *testing.T) { HealthCheckFrequency: 5 * time.Minute, FullSyncFrequency: 1 * time.Hour, } - decision := r.determineSyncDecision(ctx, tt.repo) + r.coldStartRepos.Store(tt.repo.Namespace+"/"+tt.repo.Name, true); decision := r.determineSyncDecision(ctx, tt.repo) assert.Equal(t, tt.expectedType, decision.Type) assert.Equal(t, tt.expectedNeed, decision.SyncNecessary) }) @@ -1157,7 +1157,7 @@ func TestDetermineSyncDecisionExtended(t *testing.T) { HealthCheckFrequency: 5 * time.Minute, FullSyncFrequency: 1 * time.Hour, } - decision := r.determineSyncDecision(ctx, tt.repo) + r.coldStartRepos.Store(tt.repo.Namespace+"/"+tt.repo.Name, true); decision := r.determineSyncDecision(ctx, tt.repo) assert.Equal(t, tt.expectedType, decision.Type) assert.Equal(t, tt.expectedNeed, decision.SyncNecessary) }) @@ -1352,7 +1352,7 @@ func TestDetermineSyncDecision_RunOnceAtInteractions(t *testing.T) { HealthCheckFrequency: 5 * time.Minute, FullSyncFrequency: 1 * time.Hour, } - decision := r.determineSyncDecision(ctx, tt.repo) + r.coldStartRepos.Store(tt.repo.Namespace+"/"+tt.repo.Name, true); decision := r.determineSyncDecision(ctx, tt.repo) assert.Equal(t, tt.expectedType, decision.Type) assert.Equal(t, tt.expectedNeed, decision.SyncNecessary) }) @@ -1608,7 +1608,7 @@ func TestDetermineSyncDecision_PriorityOrder(t *testing.T) { HealthCheckFrequency: 5 * time.Minute, FullSyncFrequency: 1 * time.Hour, } - decision := r.determineSyncDecision(ctx, tt.repo) + r.coldStartRepos.Store(tt.repo.Namespace+"/"+tt.repo.Name, true); decision := r.determineSyncDecision(ctx, tt.repo) assert.Equal(t, tt.expectedType, decision.Type, tt.description) assert.Equal(t, tt.expectedNeed, decision.SyncNecessary, tt.description) }) diff --git a/deployments/porch/5-rbac.yaml b/deployments/porch/5-rbac.yaml index 3d7b33cde..51b2374f3 100644 --- a/deployments/porch/5-rbac.yaml +++ b/deployments/porch/5-rbac.yaml @@ -33,7 +33,7 @@ rules: verbs: ["get", "list", "watch", "create", "update", "patch"] - apiGroups: ["porch.kpt.dev"] resources: ["packagerevisions", "packagerevisions/status", "packagerevisionresources"] - verbs: ["get", "list", "update", "watch"] + verbs: ["get", "list", "update", "watch", "patch"] - apiGroups: ["config.porch.kpt.dev"] resources: ["packagerevs", "packagerevs/status"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] diff --git a/deployments/porch/9-controllers.yaml b/deployments/porch/9-controllers.yaml index bd42b7d5f..738a4b108 100644 --- a/deployments/porch/9-controllers.yaml +++ b/deployments/porch/9-controllers.yaml @@ -45,6 +45,7 @@ spec: imagePullPolicy: IfNotPresent args: - --repositories.cache-type=DB + - --repositories.create-v1alpha2-rpkg=false securityContext: runAsNonRoot: true runAsUser: 10001 @@ -52,8 +53,6 @@ spec: volumeMounts: - name: cache-volume mountPath: /cache - # Note: only the existence of the variable matters for enabling the reconciler - # So, be sure to remove the var not just change the value to false env: - name: ENABLE_PACKAGEVARIANTS value: "true" diff --git a/go.mod b/go.mod index 2174cc52a..d1f4e725d 100644 --- a/go.mod +++ b/go.mod @@ -49,6 +49,7 @@ require ( gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.34.1 + k8s.io/apiextensions-apiserver v0.34.1 k8s.io/apimachinery v0.34.1 k8s.io/apiserver v0.34.1 k8s.io/cli-runtime v0.34.1 @@ -240,7 +241,6 @@ require ( gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gotest.tools/v3 v3.2.0 // indirect - k8s.io/apiextensions-apiserver v0.34.1 // indirect k8s.io/kms v0.34.1 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect diff --git a/internal/cliutils/apiversion.go b/internal/cliutils/apiversion.go new file mode 100644 index 000000000..726d86bb0 --- /dev/null +++ b/internal/cliutils/apiversion.go @@ -0,0 +1,50 @@ +// Copyright 2026 The kpt and Nephio 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 porch + +import ( + "os" + + "github.com/spf13/cobra" +) + +const ( + APIVersionV1Alpha1 = "v1alpha1" + APIVersionV1Alpha2 = "v1alpha2" + EnvAPIVersion = "PORCHCTL_API_VERSION" + FlagAPIVersion = "api-version" +) + +// GetAPIVersion returns the API version from the command's persistent flag +// or the PORCHCTL_API_VERSION environment variable. Defaults to v1alpha1. +func GetAPIVersion(cmd *cobra.Command) string { + // Look up the flag in the full flag set (includes inherited persistent flags). + if f := cmd.Flags().Lookup(FlagAPIVersion); f != nil && f.Changed && f.Value.String() != "" { + return f.Value.String() + } + // Also check parent persistent flags directly (for pre-Execute lookups). + if f := cmd.InheritedFlags().Lookup(FlagAPIVersion); f != nil && f.Changed && f.Value.String() != "" { + return f.Value.String() + } + if v := os.Getenv(EnvAPIVersion); v != "" { + return v + } + return APIVersionV1Alpha1 +} + +// IsV1Alpha2 is a convenience check. +func IsV1Alpha2(cmd *cobra.Command) bool { + return GetAPIVersion(cmd) == APIVersionV1Alpha2 +} diff --git a/internal/cliutils/approval_v1alpha2.go b/internal/cliutils/approval_v1alpha2.go new file mode 100644 index 000000000..848662105 --- /dev/null +++ b/internal/cliutils/approval_v1alpha2.go @@ -0,0 +1,45 @@ +// Copyright 2026 The kpt and Nephio 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 porch + +import ( + "context" + "fmt" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// UpdatePackageRevisionApprovalV1Alpha2 approves or rejects a v1alpha2 PackageRevision. +// CRDs don't support custom subresources, so this uses a regular Update. +func UpdatePackageRevisionApprovalV1Alpha2(ctx context.Context, c client.Client, pr *porchv1alpha2.PackageRevision, new porchv1alpha2.PackageRevisionLifecycle) error { + switch lifecycle := pr.Spec.Lifecycle; lifecycle { + case porchv1alpha2.PackageRevisionLifecycleProposed: + if new != porchv1alpha2.PackageRevisionLifecyclePublished && new != porchv1alpha2.PackageRevisionLifecycleDraft { + return fmt.Errorf(ApproveErrorOut, lifecycle, new) + } + case porchv1alpha2.PackageRevisionLifecycleDeletionProposed: + if new != porchv1alpha2.PackageRevisionLifecyclePublished { + return fmt.Errorf(ApproveErrorOut, lifecycle, new) + } + case new: + return nil + default: + return fmt.Errorf(ApproveErrorOut, lifecycle, new) + } + + pr.Spec.Lifecycle = new + return c.Update(ctx, pr) +} diff --git a/internal/cliutils/client_v1alpha2.go b/internal/cliutils/client_v1alpha2.go new file mode 100644 index 000000000..049227b2c --- /dev/null +++ b/internal/cliutils/client_v1alpha2.go @@ -0,0 +1,101 @@ +// Copyright 2026 The kpt and Nephio 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 porch + +import ( + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + configapi "github.com/nephio-project/porch/api/porchconfig/v1alpha1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + coreapi "k8s.io/api/core/v1" +) + +// CreateV1Alpha2ClientWithFlags creates a controller-runtime client that maps +// PackageRevision to porch.kpt.dev/v1alpha2 (CRD) while keeping +// PackageRevisionResources at porch.kpt.dev/v1alpha1 (APIService). +func CreateV1Alpha2ClientWithFlags(flags *genericclioptions.ConfigFlags) (client.Client, error) { + config, err := flags.ToRESTConfig() + if err != nil { + return nil, err + } + return CreateV1Alpha2Client(config) +} + +// CreateV1Alpha2Client creates a controller-runtime client for v1alpha2. +func CreateV1Alpha2Client(config *rest.Config) (client.Client, error) { + scheme, err := createV1Alpha2Scheme() + if err != nil { + return nil, err + } + return client.New(config, client.Options{ + Scheme: scheme, + Mapper: createV1Alpha2RESTMapper(), + }) +} + +func createV1Alpha2Scheme() (*runtime.Scheme, error) { + scheme := runtime.NewScheme() + for _, api := range (runtime.SchemeBuilder{ + porchv1alpha2.AddToScheme, + porchapi.AddToScheme, // needed for PackageRevisionResources (stays at v1alpha1) + configapi.AddToScheme, + coreapi.AddToScheme, + metav1.AddMetaToScheme, + }) { + if err := api(scheme); err != nil { + return nil, err + } + } + return scheme, nil +} + +func createV1Alpha2RESTMapper() meta.RESTMapper { + rm := meta.NewDefaultRESTMapper([]schema.GroupVersion{ + porchv1alpha2.SchemeGroupVersion, + porchapi.SchemeGroupVersion, + configapi.GroupVersion, + coreapi.SchemeGroupVersion, + metav1.SchemeGroupVersion, + }) + + for _, r := range []struct { + kind schema.GroupVersionKind + plural, singular string + }{ + // v1alpha2 PackageRevision (CRD) + {kind: porchv1alpha2.SchemeGroupVersion.WithKind("PackageRevision"), plural: "packagerevisions", singular: "packagerevision"}, + // v1alpha1 PackageRevisionResources (APIService — stays at v1alpha1) + {kind: porchapi.SchemeGroupVersion.WithKind("PackageRevisionResources"), plural: "packagerevisionresources", singular: "packagerevisionresources"}, + // Other resources + {kind: configapi.GroupVersion.WithKind("Repository"), plural: "repositories", singular: "repository"}, + {kind: coreapi.SchemeGroupVersion.WithKind("Secret"), plural: "secrets", singular: "secret"}, + {kind: metav1.SchemeGroupVersion.WithKind("Table"), plural: "tables", singular: "table"}, + } { + rm.AddSpecific( + r.kind, + r.kind.GroupVersion().WithResource(r.plural), + r.kind.GroupVersion().WithResource(r.singular), + meta.RESTScopeNamespace, + ) + } + return rm +} diff --git a/internal/cliutils/dispatch.go b/internal/cliutils/dispatch.go new file mode 100644 index 000000000..b7c3b63fe --- /dev/null +++ b/internal/cliutils/dispatch.go @@ -0,0 +1,48 @@ +// Copyright 2026 The kpt and Nephio 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 porch + +import "github.com/spf13/cobra" + +// RunFunc is the signature for cobra PreRunE/RunE. +type RunFunc func(cmd *cobra.Command, args []string) error + +// WrapVersionDispatch wraps a cobra.Command so that PreRunE and RunE +// dispatch to v1alpha2 implementations when --api-version=v1alpha2. +// The original (v1alpha1) PreRunE/RunE are preserved as the default path. +func WrapVersionDispatch(cmd *cobra.Command, v2PreRunE, v2RunE RunFunc) { + origPreRunE := cmd.PreRunE + origRunE := cmd.RunE + + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + if IsV1Alpha2(cmd) { + if v2PreRunE != nil { + return v2PreRunE(cmd, args) + } + return nil + } + if origPreRunE != nil { + return origPreRunE(cmd, args) + } + return nil + } + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if IsV1Alpha2(cmd) { + return v2RunE(cmd, args) + } + return origRunE(cmd, args) + } +} diff --git a/internal/cliutils/dispatch_test.go b/internal/cliutils/dispatch_test.go new file mode 100644 index 000000000..aa938a4d4 --- /dev/null +++ b/internal/cliutils/dispatch_test.go @@ -0,0 +1,217 @@ +// Copyright 2026 The kpt and Nephio 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 porch + +import ( + "fmt" + "os" + "testing" + + "github.com/spf13/cobra" +) + +// makeTree creates a parent command with --api-version persistent flag +// and a child command, mimicking the real rpkg command tree. +func makeTree(childRunE func(*cobra.Command, []string) error) (*cobra.Command, *cobra.Command) { + parent := &cobra.Command{Use: "root"} + parent.PersistentFlags().String(FlagAPIVersion, "", "") + child := &cobra.Command{ + Use: "test", + PreRunE: func(_ *cobra.Command, _ []string) error { return nil }, + RunE: childRunE, + } + parent.AddCommand(child) + return parent, child +} + +func TestGetAPIVersion_Default(t *testing.T) { + os.Unsetenv(EnvAPIVersion) + parent, child := makeTree(func(cmd *cobra.Command, _ []string) error { + if got := GetAPIVersion(cmd); got != APIVersionV1Alpha1 { + return fmt.Errorf("expected %s, got %s", APIVersionV1Alpha1, got) + } + return nil + }) + parent.SetArgs([]string{"test"}) + if err := parent.Execute(); err != nil { + t.Fatal(err) + } + // Also test directly on child (pre-Execute) + if got := GetAPIVersion(child); got != APIVersionV1Alpha1 { + t.Errorf("expected %s, got %s", APIVersionV1Alpha1, got) + } +} + +func TestGetAPIVersion_Flag(t *testing.T) { + os.Unsetenv(EnvAPIVersion) + parent, _ := makeTree(func(cmd *cobra.Command, _ []string) error { + if got := GetAPIVersion(cmd); got != APIVersionV1Alpha2 { + return fmt.Errorf("expected %s, got %s", APIVersionV1Alpha2, got) + } + return nil + }) + parent.SetArgs([]string{"test", "--api-version=v1alpha2"}) + if err := parent.Execute(); err != nil { + t.Fatal(err) + } +} + +func TestGetAPIVersion_Env(t *testing.T) { + os.Setenv(EnvAPIVersion, APIVersionV1Alpha2) + defer os.Unsetenv(EnvAPIVersion) + parent, _ := makeTree(func(cmd *cobra.Command, _ []string) error { + if got := GetAPIVersion(cmd); got != APIVersionV1Alpha2 { + return fmt.Errorf("expected %s, got %s", APIVersionV1Alpha2, got) + } + return nil + }) + parent.SetArgs([]string{"test"}) + if err := parent.Execute(); err != nil { + t.Fatal(err) + } +} + +func TestGetAPIVersion_FlagOverridesEnv(t *testing.T) { + os.Setenv(EnvAPIVersion, APIVersionV1Alpha2) + defer os.Unsetenv(EnvAPIVersion) + parent, _ := makeTree(func(cmd *cobra.Command, _ []string) error { + if got := GetAPIVersion(cmd); got != APIVersionV1Alpha1 { + return fmt.Errorf("expected %s, got %s", APIVersionV1Alpha1, got) + } + return nil + }) + parent.SetArgs([]string{"test", "--api-version=v1alpha1"}) + if err := parent.Execute(); err != nil { + t.Fatal(err) + } +} + +func TestIsV1Alpha2(t *testing.T) { + os.Unsetenv(EnvAPIVersion) + var gotDefault, gotV2 bool + parent, _ := makeTree(func(cmd *cobra.Command, _ []string) error { + gotDefault = IsV1Alpha2(cmd) + return nil + }) + parent.SetArgs([]string{"test"}) + if err := parent.Execute(); err != nil { + t.Fatal(err) + } + if gotDefault { + t.Error("expected false for default") + } + + parent2, _ := makeTree(func(cmd *cobra.Command, _ []string) error { + gotV2 = IsV1Alpha2(cmd) + return nil + }) + parent2.SetArgs([]string{"test", "--api-version=v1alpha2"}) + if err := parent2.Execute(); err != nil { + t.Fatal(err) + } + if !gotV2 { + t.Error("expected true for v1alpha2") + } +} + +func TestWrapVersionDispatch(t *testing.T) { + var v1Called, v2Called bool + + parent := &cobra.Command{Use: "root"} + parent.PersistentFlags().String(FlagAPIVersion, "", "") + + child := &cobra.Command{ + Use: "test", + PreRunE: func(_ *cobra.Command, _ []string) error { return nil }, + RunE: func(_ *cobra.Command, _ []string) error { + v1Called = true + return nil + }, + } + parent.AddCommand(child) + + WrapVersionDispatch(child, + func(_ *cobra.Command, _ []string) error { return nil }, + func(_ *cobra.Command, _ []string) error { + v2Called = true + return nil + }, + ) + + // Default: v1alpha1 + v1Called, v2Called = false, false + parent.SetArgs([]string{"test"}) + if err := parent.Execute(); err != nil { + t.Fatal(err) + } + if !v1Called || v2Called { + t.Errorf("expected v1 called, got v1=%v v2=%v", v1Called, v2Called) + } + + // With flag: v1alpha2 + v1Called, v2Called = false, false + parent.SetArgs([]string{"test", "--api-version=v1alpha2"}) + if err := parent.Execute(); err != nil { + t.Fatal(err) + } + if v1Called || !v2Called { + t.Errorf("expected v2 called, got v1=%v v2=%v", v1Called, v2Called) + } +} + +func TestWrapVersionDispatch_V2PreRunEError(t *testing.T) { + parent := &cobra.Command{Use: "root"} + parent.PersistentFlags().String(FlagAPIVersion, "", "") + + child := &cobra.Command{ + Use: "test", + PreRunE: func(_ *cobra.Command, _ []string) error { return nil }, + RunE: func(_ *cobra.Command, _ []string) error { return nil }, + } + parent.AddCommand(child) + + WrapVersionDispatch(child, + func(_ *cobra.Command, _ []string) error { return fmt.Errorf("v2 prerun error") }, + func(_ *cobra.Command, _ []string) error { return nil }, + ) + + parent.SetArgs([]string{"test", "--api-version=v1alpha2"}) + err := parent.Execute() + if err == nil { + t.Fatal("expected error from v2 preRunE") + } +} + +func TestWrapVersionDispatch_NilV1PreRunE(t *testing.T) { + parent := &cobra.Command{Use: "root"} + parent.PersistentFlags().String(FlagAPIVersion, "", "") + + child := &cobra.Command{ + Use: "test", + RunE: func(_ *cobra.Command, _ []string) error { return nil }, + // No PreRunE set + } + parent.AddCommand(child) + + WrapVersionDispatch(child, nil, + func(_ *cobra.Command, _ []string) error { return nil }, + ) + + // Should not panic when v1 PreRunE is nil + parent.SetArgs([]string{"test"}) + if err := parent.Execute(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/cliutils/v1alpha2_test.go b/internal/cliutils/v1alpha2_test.go new file mode 100644 index 000000000..b23b869f3 --- /dev/null +++ b/internal/cliutils/v1alpha2_test.go @@ -0,0 +1,104 @@ +package porch + +import ( + "context" + "testing" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func v1alpha2Scheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + require.NoError(t, porchv1alpha2.AddToScheme(s)) + return s +} + +func TestUpdatePackageRevisionApprovalV1Alpha2_ProposedToPublished(t *testing.T) { + s := v1alpha2Scheme(t) + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "ns"}, + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecycleProposed}, + } + c := fake.NewClientBuilder().WithScheme(s).WithObjects(pr).Build() + + err := UpdatePackageRevisionApprovalV1Alpha2(context.Background(), c, pr, porchv1alpha2.PackageRevisionLifecyclePublished) + assert.NoError(t, err) + assert.Equal(t, porchv1alpha2.PackageRevisionLifecyclePublished, pr.Spec.Lifecycle) +} + +func TestUpdatePackageRevisionApprovalV1Alpha2_ProposedToDraft(t *testing.T) { + s := v1alpha2Scheme(t) + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "ns"}, + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecycleProposed}, + } + c := fake.NewClientBuilder().WithScheme(s).WithObjects(pr).Build() + + err := UpdatePackageRevisionApprovalV1Alpha2(context.Background(), c, pr, porchv1alpha2.PackageRevisionLifecycleDraft) + assert.NoError(t, err) + assert.Equal(t, porchv1alpha2.PackageRevisionLifecycleDraft, pr.Spec.Lifecycle) +} + +func TestUpdatePackageRevisionApprovalV1Alpha2_ProposedInvalid(t *testing.T) { + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecycleProposed}, + } + err := UpdatePackageRevisionApprovalV1Alpha2(context.Background(), nil, pr, porchv1alpha2.PackageRevisionLifecycleDeletionProposed) + assert.Error(t, err) +} + +func TestUpdatePackageRevisionApprovalV1Alpha2_DeletionProposedToPublished(t *testing.T) { + s := v1alpha2Scheme(t) + pr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "ns"}, + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecycleDeletionProposed}, + } + c := fake.NewClientBuilder().WithScheme(s).WithObjects(pr).Build() + + err := UpdatePackageRevisionApprovalV1Alpha2(context.Background(), c, pr, porchv1alpha2.PackageRevisionLifecyclePublished) + assert.NoError(t, err) +} + +func TestUpdatePackageRevisionApprovalV1Alpha2_DeletionProposedInvalid(t *testing.T) { + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecycleDeletionProposed}, + } + err := UpdatePackageRevisionApprovalV1Alpha2(context.Background(), nil, pr, porchv1alpha2.PackageRevisionLifecycleDraft) + assert.Error(t, err) +} + +func TestUpdatePackageRevisionApprovalV1Alpha2_AlreadyTarget(t *testing.T) { + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished}, + } + err := UpdatePackageRevisionApprovalV1Alpha2(context.Background(), nil, pr, porchv1alpha2.PackageRevisionLifecyclePublished) + assert.NoError(t, err) +} + +func TestUpdatePackageRevisionApprovalV1Alpha2_InvalidCurrent(t *testing.T) { + pr := &porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft}, + } + err := UpdatePackageRevisionApprovalV1Alpha2(context.Background(), nil, pr, porchv1alpha2.PackageRevisionLifecyclePublished) + assert.Error(t, err) +} + +func TestCreateV1Alpha2Scheme(t *testing.T) { + s, err := createV1Alpha2Scheme() + require.NoError(t, err) + assert.True(t, s.IsGroupRegistered(porchv1alpha2.SchemeGroupVersion.Group)) +} + +func TestCreateV1Alpha2RESTMapper(t *testing.T) { + rm := createV1Alpha2RESTMapper() + // Should be able to resolve PackageRevision + mapping, err := rm.RESTMapping(porchv1alpha2.SchemeGroupVersion.WithKind("PackageRevision").GroupKind(), porchv1alpha2.SchemeGroupVersion.Version) + require.NoError(t, err) + assert.Equal(t, "packagerevisions", mapping.Resource.Resource) +} diff --git a/make/deploy.mk b/make/deploy.mk index 708bea492..9f4509086 100644 --- a/make/deploy.mk +++ b/make/deploy.mk @@ -26,6 +26,9 @@ export PORCH_CACHE_TYPE ?= DB # Function runner warm-up pod cache export FN_RUNNER_WARM_UP_POD_CACHE ?= true +# Enable v1alpha2 PackageRevision support (CRD install + controller flag + reconciler) +export CREATE_V1ALPHA2_RPKG ?= false + # Reconciler configuration ALL_RECONCILERS=packagevariants,packagevariantsets,repositories ifndef RECONCILERS @@ -45,6 +48,19 @@ run-in-kind: IMAGE_REPO=porch-kind## Build and deploy porch into a kind cluster run-in-kind: PORCH_CACHE_TYPE=CR run-in-kind: load-images-to-kind deployment-config deploy-current-config +.PHONY: run-in-kind-v1alpha2 +run-in-kind-v1alpha2: IMAGE_REPO=porch-kind## Build and deploy porch into a kind cluster with DB cache and v1alpha2 PackageRevision CRD creation +run-in-kind-v1alpha2: PORCH_CACHE_TYPE=DB +run-in-kind-v1alpha2: CREATE_V1ALPHA2_RPKG=true +run-in-kind-v1alpha2: load-images-to-kind deployment-config deploy-current-config + +.PHONY: run-in-kind-v1alpha2-no-controller +run-in-kind-v1alpha2-no-controller: IMAGE_REPO=porch-kind## Build and deploy porch with DB cache, v1alpha2, fn-runner exposed, no controller (run locally) +run-in-kind-v1alpha2-no-controller: SKIP_CONTROLLER_BUILD=true +run-in-kind-v1alpha2-no-controller: PORCH_CACHE_TYPE=DB +run-in-kind-v1alpha2-no-controller: CREATE_V1ALPHA2_RPKG=true +run-in-kind-v1alpha2-no-controller: load-images-to-kind deployment-config-no-controller deploy-current-config + .PHONY: run-in-kind-db-cache run-in-kind-db-cache: IMAGE_REPO=porch-kind## Build and deploy porch into a kind cluster with postgres backend run-in-kind-db-cache: PORCH_CACHE_TYPE=DB diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go index 5e2049e75..76e93afd9 100644 --- a/pkg/apiserver/apiserver.go +++ b/pkg/apiserver/apiserver.go @@ -25,6 +25,7 @@ import ( "github.com/kptdev/kpt/pkg/lib/runneroptions" "github.com/nephio-project/porch/api/porch/install" porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" configapi "github.com/nephio-project/porch/api/porchconfig/v1alpha1" "github.com/nephio-project/porch/controllers/functionconfigs/reconciler" internalapi "github.com/nephio-project/porch/internal/api/porchinternal/v1alpha1" @@ -166,6 +167,12 @@ func buildCompleteScheme() (*runtime.Scheme, error) { } return nil }, + func(s *runtime.Scheme) error { + if e := porchv1alpha2.AddToScheme(s); e != nil { + return fmt.Errorf("error adding porchv1alpha2 to scheme: %w", e) + } + return nil + }, func(s *runtime.Scheme) error { if e := corev1.AddToScheme(s); e != nil { return fmt.Errorf("error adding corev1 to scheme: %w", e) @@ -258,6 +265,10 @@ func (c completedConfig) buildClient(ctx context.Context) (client.WithWatch, err // informer cache, a subsequent Get can miss the just-created object. // This is not ideal, but crcache doesn't support a level of resources where caching makes a difference &internalapi.PackageRev{}, + // v1alpha2 PackageRevision is a CRD patched by patchRenderRequestAnnotation + // right after a write; bypass the cache to avoid stale reads and the + // cluster-scope watch that the informer would require. + &porchv1alpha2.PackageRevision{}, }, }}) } diff --git a/pkg/cache/contentcache/contentcache.go b/pkg/cache/contentcache/contentcache.go new file mode 100644 index 000000000..6f5bb7e8b --- /dev/null +++ b/pkg/cache/contentcache/contentcache.go @@ -0,0 +1,227 @@ +// Copyright 2026 The kpt and Nephio 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 contentcache + +import ( + "context" + "fmt" + + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + cachetypes "github.com/nephio-project/porch/pkg/cache/types" + "github.com/nephio-project/porch/pkg/externalrepo" + "github.com/nephio-project/porch/pkg/repository" +) + +var _ repository.ContentCache = &contentCache{} + +type contentCache struct { + cache cachetypes.Cache +} + +// NewContentCache creates a ContentCache backed by the given Cache. +func NewContentCache(cache cachetypes.Cache) repository.ContentCache { + return &contentCache{cache: cache} +} + +func (c *contentCache) GetPackageContent(ctx context.Context, repoKey repository.RepositoryKey, pkg, workspace string) (repository.PackageContent, error) { + pkgRev, err := c.findPackageRevision(ctx, repoKey, pkg, workspace) + if err != nil { + return nil, err + } + return &packageContentWrapper{inner: pkgRev}, nil +} + +func (c *contentCache) CreateNewDraft(ctx context.Context, repoKey repository.RepositoryKey, pkgName, workspace, lifecycle string) (repository.PackageRevisionDraftSlim, error) { + repo, err := c.getRepository(repoKey) + if err != nil { + return nil, err + } + + obj := &porchapi.PackageRevision{ + Spec: porchapi.PackageRevisionSpec{ + PackageName: pkgName, + WorkspaceName: workspace, + RepositoryName: repoKey.Name, + Lifecycle: porchapi.PackageRevisionLifecycle(lifecycle), + }, + } + + draft, err := repo.CreatePackageRevisionDraft(ctx, obj) + if err != nil { + return nil, err + } + return &draftSlimWrapper{inner: draft}, nil +} + +func (c *contentCache) CreateDraftFromExisting(ctx context.Context, repoKey repository.RepositoryKey, pkgName, workspace string) (repository.PackageRevisionDraftSlim, error) { + repo, err := c.getRepository(repoKey) + if err != nil { + return nil, err + } + + pkgRev, err := c.findPackageRevision(ctx, repoKey, pkgName, workspace) + if err != nil { + return nil, err + } + + draft, err := repo.UpdatePackageRevision(ctx, pkgRev) + if err != nil { + return nil, err + } + return &draftSlimWrapper{inner: draft}, nil +} + +func (c *contentCache) CloseDraft(ctx context.Context, repoKey repository.RepositoryKey, draft repository.PackageRevisionDraftSlim, version int) error { + repo, err := c.getRepository(repoKey) + if err != nil { + return err + } + + wrapper, ok := draft.(*draftSlimWrapper) + if !ok { + return fmt.Errorf("draft is not a contentCache draft") + } + + _, err = repo.ClosePackageRevisionDraft(ctx, wrapper.inner, version) + return err +} + +func (c *contentCache) UpdateLifecycle(ctx context.Context, repoKey repository.RepositoryKey, pkg, workspace, desired string) (repository.PackageContent, error) { + pkgRev, err := c.findPackageRevision(ctx, repoKey, pkg, workspace) + if err != nil { + return nil, err + } + + desiredLC := porchv1alpha2.PackageRevisionLifecycle(desired) + currentLC := porchv1alpha2.PackageRevisionLifecycle(pkgRev.Lifecycle(ctx)) + + if !isKnownLifecycle(currentLC) { + return nil, fmt.Errorf("invalid current lifecycle value: %q", currentLC) + } + if !isKnownLifecycle(desiredLC) { + return nil, fmt.Errorf("invalid desired lifecycle value: %q", desiredLC) + } + + // Published ↔ DeletionProposed: direct update, no draft needed. + if porchv1alpha2.LifecycleIsPublished(currentLC) { + if err := pkgRev.UpdateLifecycle(ctx, porchapi.PackageRevisionLifecycle(desiredLC)); err != nil { + return nil, err + } + return &packageContentWrapper{inner: pkgRev}, nil + } + + // Proposed → Published: use UpdateLifecycle which handles revision increment and git push. + if currentLC == porchv1alpha2.PackageRevisionLifecycleProposed && desiredLC == porchv1alpha2.PackageRevisionLifecyclePublished { + if err := pkgRev.UpdateLifecycle(ctx, porchapi.PackageRevisionLifecycle(desiredLC)); err != nil { + return nil, err + } + return &packageContentWrapper{inner: pkgRev}, nil + } + + // Draft ↔ Proposed: draft/close cycle. + repo, err := c.getRepository(repoKey) + if err != nil { + return nil, err + } + + draft, err := repo.UpdatePackageRevision(ctx, pkgRev) + if err != nil { + return nil, err + } + + if err := draft.UpdateLifecycle(ctx, porchapi.PackageRevisionLifecycle(desiredLC)); err != nil { + return nil, err + } + + // version 0 for non-publish; cache calculates the real version on publish. + closed, err := repo.ClosePackageRevisionDraft(ctx, draft, 0) + if err != nil { + return nil, err + } + return &packageContentWrapper{inner: closed}, nil +} + +func (c *contentCache) DeletePackage(ctx context.Context, repoKey repository.RepositoryKey, pkg, workspace string) error { + repo, err := c.getRepository(repoKey) + if err != nil { + return err + } + + pkgRev, err := c.findPackageRevision(ctx, repoKey, pkg, workspace) + if err != nil { + return err + } + + return repo.DeletePackageRevision(ctx, pkgRev) +} + +func isKnownLifecycle(lc porchv1alpha2.PackageRevisionLifecycle) bool { + switch lc { + case porchv1alpha2.PackageRevisionLifecycleDraft, + porchv1alpha2.PackageRevisionLifecycleProposed, + porchv1alpha2.PackageRevisionLifecyclePublished, + porchv1alpha2.PackageRevisionLifecycleDeletionProposed: + return true + default: + return false + } +} + +// getRepository resolves a partial RepositoryKey (namespace+name only) to the +// full cache key by scanning GetRepositories(), then looks up the repo. +func (c *contentCache) getRepository(repoKey repository.RepositoryKey) (repository.Repository, error) { + for _, spec := range c.cache.GetRepositories() { + if spec.Namespace == repoKey.Namespace && spec.Name == repoKey.Name { + fullKey, err := externalrepo.RepositoryKey(spec) + if err != nil { + return nil, fmt.Errorf("repository %s: %w", repoKey, err) + } + if repo := c.cache.GetRepository(fullKey); repo != nil { + return repo, nil + } + } + } + return nil, fmt.Errorf("repository %s not found", repoKey) +} + +func (c *contentCache) findPackageRevision(ctx context.Context, repoKey repository.RepositoryKey, pkg, workspace string) (repository.PackageRevision, error) { + repo, err := c.getRepository(repoKey) + if err != nil { + return nil, err + } + + // Split the package name into path and leaf name for matching. + // e.g. "sub/folder/pkg" -> path="sub/folder", leafName="pkg" + pkgPath, leafName := repository.SplitPackagePathName(pkg) + + revisions, err := repo.ListPackageRevisions(ctx, repository.ListPackageRevisionFilter{ + Key: repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{Path: pkgPath, Package: leafName}, + WorkspaceName: workspace, + }, + }) + if err != nil { + return nil, err + } + + for _, rev := range revisions { + key := rev.Key() + if key.WorkspaceName == workspace && key.PkgKey.Package == leafName && key.PkgKey.Path == pkgPath { + return rev, nil + } + } + return nil, fmt.Errorf("package revision %s/%s not found in repository %s", pkg, workspace, repoKey) +} diff --git a/pkg/cache/contentcache/contentcache_test.go b/pkg/cache/contentcache/contentcache_test.go new file mode 100644 index 000000000..bc98ca4b6 --- /dev/null +++ b/pkg/cache/contentcache/contentcache_test.go @@ -0,0 +1,419 @@ +// Copyright 2026 The kpt and Nephio 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 contentcache + +import ( + "context" + "fmt" + "testing" + + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + configapi "github.com/nephio-project/porch/api/porchconfig/v1alpha1" + "github.com/nephio-project/porch/pkg/repository" + mockcachetypes "github.com/nephio-project/porch/test/mockery/mocks/porch/pkg/cache/types" + mockrepository "github.com/nephio-project/porch/test/mockery/mocks/porch/pkg/repository" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// --- NewContentCache --- + +func TestNewContentCache(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + cc := NewContentCache(mockCache) + assert.NotNil(t, cc) +} + +// --- GetPackageContent --- + +func TestGetPackageContent(t *testing.T) { + cc, _, _, _ := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecycleDraft) + + content, err := cc.GetPackageContent(context.Background(), testRepoKey, testPkg, testWS) + require.NoError(t, err) + assert.Equal(t, "Draft", content.Lifecycle(context.Background())) + assert.Equal(t, testPkg, content.Key().PkgKey.Package) +} + +func TestGetPackageContent_RepoNotFound(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + mockGetRepoNotFound(mockCache) + cc := &contentCache{cache: mockCache} + + _, err := cc.GetPackageContent(context.Background(), testRepoKey, testPkg, testWS) + assert.ErrorContains(t, err, "not found") +} + +func TestGetPackageContent_PkgNotFound(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + mockRepo := mockrepository.NewMockRepository(t) + mockGetRepo(mockCache, mockRepo) + mockRepo.EXPECT().ListPackageRevisions(mock.Anything, mock.Anything). + Return([]repository.PackageRevision{}, nil) + + cc := &contentCache{cache: mockCache} + _, err := cc.GetPackageContent(context.Background(), testRepoKey, testPkg, testWS) + assert.ErrorContains(t, err, "not found") +} + +func TestGetPackageContent_ListError(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + mockRepo := mockrepository.NewMockRepository(t) + mockGetRepo(mockCache, mockRepo) + mockRepo.EXPECT().ListPackageRevisions(mock.Anything, mock.Anything). + Return(nil, fmt.Errorf("list failed")) + + cc := &contentCache{cache: mockCache} + _, err := cc.GetPackageContent(context.Background(), testRepoKey, testPkg, testWS) + assert.ErrorContains(t, err, "list failed") +} + +// --- UpdateLifecycle --- + +func TestUpdateLifecycle_DirectUpdate_PublishedToDeletionProposed(t *testing.T) { + cc, _, _, mockPkgRev := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecyclePublished) + mockPkgRev.EXPECT().UpdateLifecycle(mock.Anything, porchapi.PackageRevisionLifecycleDeletionProposed).Return(nil) + + content, err := cc.UpdateLifecycle(context.Background(), testRepoKey, testPkg, testWS, "DeletionProposed") + assert.NoError(t, err) + assert.NotNil(t, content) +} + +func TestUpdateLifecycle_DirectUpdate_DeletionProposedToPublished(t *testing.T) { + cc, _, _, mockPkgRev := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecycleDeletionProposed) + mockPkgRev.EXPECT().UpdateLifecycle(mock.Anything, porchapi.PackageRevisionLifecyclePublished).Return(nil) + + content, err := cc.UpdateLifecycle(context.Background(), testRepoKey, testPkg, testWS, "Published") + assert.NoError(t, err) + assert.NotNil(t, content) +} + +func TestUpdateLifecycle_DraftCycle_DraftToProposed(t *testing.T) { + cc, _, mockRepo, mockPkgRev := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecycleDraft) + + mockDraft := mockrepository.NewMockPackageRevisionDraft(t) + mockRepo.EXPECT().UpdatePackageRevision(mock.Anything, mockPkgRev).Return(mockDraft, nil) + mockDraft.EXPECT().UpdateLifecycle(mock.Anything, porchapi.PackageRevisionLifecycleProposed).Return(nil) + mockRepo.EXPECT().ClosePackageRevisionDraft(mock.Anything, mockDraft, 0).Return(mockPkgRev, nil) + + content, err := cc.UpdateLifecycle(context.Background(), testRepoKey, testPkg, testWS, "Proposed") + assert.NoError(t, err) + assert.NotNil(t, content) +} + +func TestUpdateLifecycle_DraftCycle_ProposedToDraft(t *testing.T) { + cc, _, mockRepo, mockPkgRev := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecycleProposed) + + mockDraft := mockrepository.NewMockPackageRevisionDraft(t) + mockRepo.EXPECT().UpdatePackageRevision(mock.Anything, mockPkgRev).Return(mockDraft, nil) + mockDraft.EXPECT().UpdateLifecycle(mock.Anything, porchapi.PackageRevisionLifecycleDraft).Return(nil) + mockRepo.EXPECT().ClosePackageRevisionDraft(mock.Anything, mockDraft, 0).Return(mockPkgRev, nil) + + content, err := cc.UpdateLifecycle(context.Background(), testRepoKey, testPkg, testWS, "Draft") + assert.NoError(t, err) + assert.NotNil(t, content) +} + +func TestUpdateLifecycle_DraftCycle_ProposedToPublished(t *testing.T) { + cc, _, _, mockPkgRev := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecycleProposed) + + // Proposed → Published uses direct UpdateLifecycle (no draft cycle). + mockPkgRev.EXPECT().UpdateLifecycle(mock.Anything, porchapi.PackageRevisionLifecyclePublished).Return(nil) + + content, err := cc.UpdateLifecycle(context.Background(), testRepoKey, testPkg, testWS, "Published") + assert.NoError(t, err) + assert.NotNil(t, content) +} + +func TestUpdateLifecycle_InvalidCurrentLifecycle(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + mockRepo := mockrepository.NewMockRepository(t) + mockPkgRev := mockrepository.NewMockPackageRevision(t) + + mockGetRepo(mockCache, mockRepo) + mockRepo.EXPECT().ListPackageRevisions(mock.Anything, mock.Anything). + Return([]repository.PackageRevision{mockPkgRev}, nil) + mockPkgRev.EXPECT().Key().Return(repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{Package: testPkg}, + WorkspaceName: testWS, + }) + mockPkgRev.EXPECT().Lifecycle(mock.Anything).Return(porchapi.PackageRevisionLifecycle("Bogus")) + + cc := &contentCache{cache: mockCache} + _, err := cc.UpdateLifecycle(context.Background(), testRepoKey, testPkg, testWS, "Draft") + assert.ErrorContains(t, err, "invalid current lifecycle") +} + +func TestUpdateLifecycle_InvalidDesiredLifecycle(t *testing.T) { + cc, _, _, _ := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecycleDraft) + + _, err := cc.UpdateLifecycle(context.Background(), testRepoKey, testPkg, testWS, "Bogus") + assert.ErrorContains(t, err, "invalid desired lifecycle") +} + +func TestUpdateLifecycle_RepoNotFound(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + mockGetRepoNotFound(mockCache) + cc := &contentCache{cache: mockCache} + + _, err := cc.UpdateLifecycle(context.Background(), testRepoKey, testPkg, testWS, "Proposed") + assert.ErrorContains(t, err, "not found") +} + +func TestUpdateLifecycle_DraftCycle_RepoNotFoundForDraft(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + mockRepo := mockrepository.NewMockRepository(t) + mockPkgRev := mockrepository.NewMockPackageRevision(t) + + mockCache.EXPECT().GetRepositories().Return([]*configapi.Repository{testRepoSpec}).Maybe() + mockCache.EXPECT().GetRepository(testFullRepoKey).Return(mockRepo).Once() + mockCache.EXPECT().GetRepository(testFullRepoKey).Return(nil).Once() + mockRepo.EXPECT().ListPackageRevisions(mock.Anything, mock.Anything). + Return([]repository.PackageRevision{mockPkgRev}, nil) + mockPkgRev.EXPECT().Key().Return(repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{Package: testPkg}, + WorkspaceName: testWS, + }) + mockPkgRev.EXPECT().Lifecycle(mock.Anything).Return(porchapi.PackageRevisionLifecycleDraft) + + cc := &contentCache{cache: mockCache} + _, err := cc.UpdateLifecycle(context.Background(), testRepoKey, testPkg, testWS, "Proposed") + assert.ErrorContains(t, err, "not found") +} + +func TestUpdateLifecycle_DraftCycle_UpdatePackageRevisionError(t *testing.T) { + cc, _, mockRepo, mockPkgRev := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecycleDraft) + mockRepo.EXPECT().UpdatePackageRevision(mock.Anything, mockPkgRev).Return(nil, fmt.Errorf("lock conflict")) + + _, err := cc.UpdateLifecycle(context.Background(), testRepoKey, testPkg, testWS, "Proposed") + assert.ErrorContains(t, err, "lock conflict") +} + +func TestUpdateLifecycle_DraftCycle_UpdateLifecycleOnDraftError(t *testing.T) { + cc, _, mockRepo, mockPkgRev := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecycleDraft) + + mockDraft := mockrepository.NewMockPackageRevisionDraft(t) + mockRepo.EXPECT().UpdatePackageRevision(mock.Anything, mockPkgRev).Return(mockDraft, nil) + mockDraft.EXPECT().UpdateLifecycle(mock.Anything, porchapi.PackageRevisionLifecycleProposed).Return(fmt.Errorf("invalid transition")) + + _, err := cc.UpdateLifecycle(context.Background(), testRepoKey, testPkg, testWS, "Proposed") + assert.ErrorContains(t, err, "invalid transition") +} + +func TestUpdateLifecycle_DraftCycle_CloseError(t *testing.T) { + cc, _, mockRepo, mockPkgRev := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecycleDraft) + + mockDraft := mockrepository.NewMockPackageRevisionDraft(t) + mockRepo.EXPECT().UpdatePackageRevision(mock.Anything, mockPkgRev).Return(mockDraft, nil) + mockDraft.EXPECT().UpdateLifecycle(mock.Anything, porchapi.PackageRevisionLifecycleProposed).Return(nil) + mockRepo.EXPECT().ClosePackageRevisionDraft(mock.Anything, mockDraft, 0).Return(nil, fmt.Errorf("git push failed")) + + _, err := cc.UpdateLifecycle(context.Background(), testRepoKey, testPkg, testWS, "Proposed") + assert.ErrorContains(t, err, "git push failed") +} + +func TestUpdateLifecycle_DirectUpdate_Error(t *testing.T) { + cc, _, _, mockPkgRev := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecyclePublished) + mockPkgRev.EXPECT().UpdateLifecycle(mock.Anything, porchapi.PackageRevisionLifecycleDeletionProposed).Return(fmt.Errorf("branch protected")) + + _, err := cc.UpdateLifecycle(context.Background(), testRepoKey, testPkg, testWS, "DeletionProposed") + assert.ErrorContains(t, err, "branch protected") +} + +// --- CreateDraftFromExisting --- + +func TestCreateDraftFromExisting(t *testing.T) { + cc, _, mockRepo, mockPkgRev := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecycleDraft) + + mockDraft := mockrepository.NewMockPackageRevisionDraft(t) + mockRepo.EXPECT().UpdatePackageRevision(mock.Anything, mockPkgRev).Return(mockDraft, nil) + mockDraft.EXPECT().Key().Return(repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{Package: testPkg}, + WorkspaceName: testWS, + }) + + draft, err := cc.CreateDraftFromExisting(context.Background(), testRepoKey, testPkg, testWS) + require.NoError(t, err) + assert.Equal(t, testPkg, draft.Key().PkgKey.Package) +} + +func TestCreateDraftFromExisting_RepoNotFound(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + mockGetRepoNotFound(mockCache) + cc := &contentCache{cache: mockCache} + + _, err := cc.CreateDraftFromExisting(context.Background(), testRepoKey, testPkg, testWS) + assert.ErrorContains(t, err, "not found") +} + +func TestCreateDraftFromExisting_UpdatePackageRevisionError(t *testing.T) { + cc, _, mockRepo, mockPkgRev := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecycleDraft) + mockRepo.EXPECT().UpdatePackageRevision(mock.Anything, mockPkgRev).Return(nil, fmt.Errorf("conflict")) + + _, err := cc.CreateDraftFromExisting(context.Background(), testRepoKey, testPkg, testWS) + assert.ErrorContains(t, err, "conflict") +} + +func TestCreateDraftFromExisting_FindPkgRevError(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + mockRepo := mockrepository.NewMockRepository(t) + mockGetRepo(mockCache, mockRepo) + mockRepo.EXPECT().ListPackageRevisions(mock.Anything, mock.Anything). + Return(nil, fmt.Errorf("list failed")) + + cc := &contentCache{cache: mockCache} + _, err := cc.CreateDraftFromExisting(context.Background(), testRepoKey, testPkg, testWS) + assert.ErrorContains(t, err, "list failed") +} + +// --- CreateNewDraft --- + +func TestCreateNewDraft(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + mockRepo := mockrepository.NewMockRepository(t) + mockGetRepo(mockCache, mockRepo) + cc := &contentCache{cache: mockCache} + + mockDraft := mockrepository.NewMockPackageRevisionDraft(t) + mockRepo.EXPECT().CreatePackageRevisionDraft(mock.Anything, mock.MatchedBy(func(obj *porchapi.PackageRevision) bool { + return obj.Spec.PackageName == testPkg && + obj.Spec.WorkspaceName == testWS && + obj.Spec.RepositoryName == testRepoKey.Name && + obj.Spec.Lifecycle == porchapi.PackageRevisionLifecycleDraft + })).Return(mockDraft, nil) + mockDraft.EXPECT().Key().Return(repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{Package: testPkg}, + WorkspaceName: testWS, + }) + + draft, err := cc.CreateNewDraft(context.Background(), testRepoKey, testPkg, testWS, "Draft") + require.NoError(t, err) + assert.Equal(t, testPkg, draft.Key().PkgKey.Package) + assert.Equal(t, testWS, draft.Key().WorkspaceName) +} + +func TestCreateNewDraft_RepoNotFound(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + mockGetRepoNotFound(mockCache) + cc := &contentCache{cache: mockCache} + + _, err := cc.CreateNewDraft(context.Background(), testRepoKey, testPkg, testWS, "Draft") + assert.ErrorContains(t, err, "not found") +} + +func TestCreateNewDraft_CreateError(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + mockRepo := mockrepository.NewMockRepository(t) + mockGetRepo(mockCache, mockRepo) + cc := &contentCache{cache: mockCache} + + mockRepo.EXPECT().CreatePackageRevisionDraft(mock.Anything, mock.Anything).Return(nil, fmt.Errorf("already exists")) + + _, err := cc.CreateNewDraft(context.Background(), testRepoKey, testPkg, testWS, "Draft") + assert.ErrorContains(t, err, "already exists") +} + +// --- CloseDraft --- + +func TestCloseDraft(t *testing.T) { + cc, _, mockRepo, mockPkgRev := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecycleDraft) + + innerDraft := mockrepository.NewMockPackageRevisionDraft(t) + draft := &draftSlimWrapper{inner: innerDraft} + mockRepo.EXPECT().ClosePackageRevisionDraft(mock.Anything, innerDraft, 0).Return(mockPkgRev, nil) + + err := cc.CloseDraft(context.Background(), testRepoKey, draft, 0) + assert.NoError(t, err) +} + +func TestCloseDraft_RepoNotFound(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + mockGetRepoNotFound(mockCache) + cc := &contentCache{cache: mockCache} + + draft := &draftSlimWrapper{inner: mockrepository.NewMockPackageRevisionDraft(t)} + err := cc.CloseDraft(context.Background(), testRepoKey, draft, 0) + assert.ErrorContains(t, err, "not found") +} + +func TestCloseDraft_WrongDraftType(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + mockRepo := mockrepository.NewMockRepository(t) + mockGetRepo(mockCache, mockRepo) + cc := &contentCache{cache: mockCache} + + fakeDraft := &fakeDraftSlim{} + err := cc.CloseDraft(context.Background(), testRepoKey, fakeDraft, 0) + assert.ErrorContains(t, err, "not a contentCache draft") +} + +type fakeDraftSlim struct{} + +func (f *fakeDraftSlim) Key() repository.PackageRevisionKey { return repository.PackageRevisionKey{} } +func (f *fakeDraftSlim) UpdateResources(_ context.Context, _ map[string]string, _ string) error { + return nil +} +func (f *fakeDraftSlim) UpdateLifecycle(_ context.Context, _ string) error { return nil } + +// --- DeletePackage --- + +func TestDeletePackage(t *testing.T) { + cc, _, mockRepo, mockPkgRev := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecycleDraft) + mockRepo.EXPECT().DeletePackageRevision(mock.Anything, mockPkgRev).Return(nil) + + err := cc.DeletePackage(context.Background(), testRepoKey, testPkg, testWS) + assert.NoError(t, err) +} + +func TestDeletePackage_RepoNotFound(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + mockGetRepoNotFound(mockCache) + cc := &contentCache{cache: mockCache} + + err := cc.DeletePackage(context.Background(), testRepoKey, testPkg, testWS) + assert.ErrorContains(t, err, "not found") +} + +func TestDeletePackage_DeleteError(t *testing.T) { + cc, _, mockRepo, mockPkgRev := setupCacheWithPkgRev(t, porchapi.PackageRevisionLifecycleDraft) + mockRepo.EXPECT().DeletePackageRevision(mock.Anything, mockPkgRev).Return(fmt.Errorf("permission denied")) + + err := cc.DeletePackage(context.Background(), testRepoKey, testPkg, testWS) + assert.ErrorContains(t, err, "permission denied") +} + +func TestDeletePackage_FindPkgRevError(t *testing.T) { + mockCache := mockcachetypes.NewMockCache(t) + mockRepo := mockrepository.NewMockRepository(t) + mockGetRepo(mockCache, mockRepo) + mockRepo.EXPECT().ListPackageRevisions(mock.Anything, mock.Anything). + Return(nil, fmt.Errorf("list failed")) + + cc := &contentCache{cache: mockCache} + err := cc.DeletePackage(context.Background(), testRepoKey, testPkg, testWS) + assert.ErrorContains(t, err, "list failed") +} + +// --- isKnownLifecycle --- + +func TestIsKnownLifecycle(t *testing.T) { + assert.True(t, isKnownLifecycle("Draft")) + assert.True(t, isKnownLifecycle("Proposed")) + assert.True(t, isKnownLifecycle("Published")) + assert.True(t, isKnownLifecycle("DeletionProposed")) + assert.False(t, isKnownLifecycle("")) + assert.False(t, isKnownLifecycle("Bogus")) +} diff --git a/pkg/cache/contentcache/externalfetcher.go b/pkg/cache/contentcache/externalfetcher.go new file mode 100644 index 000000000..eae4e1eb4 --- /dev/null +++ b/pkg/cache/contentcache/externalfetcher.go @@ -0,0 +1,85 @@ +// Copyright 2026 The kpt and Nephio 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 contentcache + +import ( + "context" + "fmt" + "os" + + kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + configapi "github.com/nephio-project/porch/api/porchconfig/v1alpha1" + "github.com/nephio-project/porch/pkg/externalrepo/git" + externalrepotypes "github.com/nephio-project/porch/pkg/externalrepo/types" + "github.com/nephio-project/porch/pkg/repository" +) + +var _ repository.ExternalPackageFetcher = &externalPackageFetcher{} + +// externalPackageFetcher implements ExternalPackageFetcher using git clone + credential resolution. +type externalPackageFetcher struct { + credentialResolver repository.CredentialResolver + caBundleResolver repository.CredentialResolver + repoOperationRetryAttempts int +} + +// NewExternalPackageFetcher creates an ExternalPackageFetcher with the given credential and CA bundle resolvers. +func NewExternalPackageFetcher(credentialResolver, caBundleResolver repository.CredentialResolver, repoOperationRetryAttempts int) repository.ExternalPackageFetcher { + return &externalPackageFetcher{ + credentialResolver: credentialResolver, + caBundleResolver: caBundleResolver, + repoOperationRetryAttempts: repoOperationRetryAttempts, + } +} + +func (f *externalPackageFetcher) FetchExternalGitPackage(ctx context.Context, gitSpec *porchv1alpha2.GitPackage, namespace string) (map[string]string, kptfilev1.GitLock, error) { + dir, err := os.MkdirTemp("", "clone-git-package-*") + if err != nil { + return nil, kptfilev1.GitLock{}, fmt.Errorf("cannot create temp directory: %w", err) + } + defer os.RemoveAll(dir) + + spec := configapi.GitRepository{ + Repo: gitSpec.Repo, + Directory: gitSpec.Directory, + SecretRef: configapi.SecretRef{Name: gitSpec.SecretRef.Name}, + } + + repo, err := git.OpenRepository(ctx, gitSpec.Repo, namespace, &spec, false, dir, git.GitRepositoryOptions{ + ExternalRepoOptions: externalrepotypes.ExternalRepoOptions{ + CredentialResolver: f.credentialResolver, + CaBundleResolver: f.caBundleResolver, + UseUserDefinedCaBundle: f.caBundleResolver != nil, + RepoOperationRetryAttempts: f.repoOperationRetryAttempts, + }, + MainBranchStrategy: git.SkipVerification, + }) + if err != nil { + return nil, kptfilev1.GitLock{}, fmt.Errorf("cannot clone git repository %s: %w", gitSpec.Repo, err) + } + + revision, lock, err := repo.GetPackageRevision(ctx, gitSpec.Ref, gitSpec.Directory) + if err != nil { + return nil, kptfilev1.GitLock{}, fmt.Errorf("cannot find package %s@%s: %w", gitSpec.Directory, gitSpec.Ref, err) + } + + resources, err := revision.GetResources(ctx) + if err != nil { + return nil, kptfilev1.GitLock{}, fmt.Errorf("cannot read package resources: %w", err) + } + + return resources.Spec.Resources, lock, nil +} diff --git a/pkg/cache/contentcache/externalfetcher_test.go b/pkg/cache/contentcache/externalfetcher_test.go new file mode 100644 index 000000000..d87d08e12 --- /dev/null +++ b/pkg/cache/contentcache/externalfetcher_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 The kpt and Nephio 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 contentcache + +import ( + "context" + "testing" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewExternalPackageFetcher(t *testing.T) { + f := NewExternalPackageFetcher(nil, nil, 3) + require.NotNil(t, f) +} + +func TestNewExternalPackageFetcherWithResolvers(t *testing.T) { + f := NewExternalPackageFetcher(nil, nil, 5) + impl := f.(*externalPackageFetcher) + assert.Equal(t, 5, impl.repoOperationRetryAttempts) + assert.Nil(t, impl.credentialResolver) + assert.Nil(t, impl.caBundleResolver) +} + +func TestFetchExternalGitPackage_InvalidRepo(t *testing.T) { + f := NewExternalPackageFetcher(nil, nil, 1) + + gitSpec := &porchv1alpha2.GitPackage{ + Repo: "not-a-valid-url", + Ref: "main", + Directory: "pkg", + } + + _, _, err := f.FetchExternalGitPackage(context.Background(), gitSpec, "default") + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot clone git repository") +} diff --git a/pkg/cache/contentcache/helpers_test.go b/pkg/cache/contentcache/helpers_test.go new file mode 100644 index 000000000..2f64685ee --- /dev/null +++ b/pkg/cache/contentcache/helpers_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 The kpt and Nephio 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 contentcache + +import ( + "testing" + + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + configapi "github.com/nephio-project/porch/api/porchconfig/v1alpha1" + "github.com/nephio-project/porch/pkg/repository" + mockcachetypes "github.com/nephio-project/porch/test/mockery/mocks/porch/pkg/cache/types" + mockrepository "github.com/nephio-project/porch/test/mockery/mocks/porch/pkg/repository" + "github.com/stretchr/testify/mock" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + testRepoKey = repository.RepositoryKey{Namespace: "ns", Name: "repo"} + testPkg = "my-pkg" + testWS = "ws-1" + + // Full key as produced by externalrepo.RepositoryKey for testRepoSpec. + testFullRepoKey = repository.RepositoryKey{Namespace: "ns", Name: "repo", Path: "", PlaceholderWSname: "main"} + + testRepoSpec = &configapi.Repository{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "repo"}, + Spec: configapi.RepositorySpec{ + Type: configapi.RepositoryTypeGit, + Git: &configapi.GitRepository{Repo: "https://example.com/repo.git", Branch: "main"}, + }, + } +) + +// mockGetRepo sets up the GetRepositories → GetRepository chain on mockCache. +func mockGetRepo(mockCache *mockcachetypes.MockCache, mockRepo *mockrepository.MockRepository) { + mockCache.EXPECT().GetRepositories().Return([]*configapi.Repository{testRepoSpec}).Maybe() + mockCache.EXPECT().GetRepository(testFullRepoKey).Return(mockRepo).Maybe() +} + +// mockGetRepoNotFound sets up GetRepositories returning empty (repo not in cache). +func mockGetRepoNotFound(mockCache *mockcachetypes.MockCache) { + mockCache.EXPECT().GetRepositories().Return([]*configapi.Repository{}).Maybe() +} + +func setupCacheWithPkgRev(t *testing.T, lifecycle porchapi.PackageRevisionLifecycle) (*contentCache, *mockcachetypes.MockCache, *mockrepository.MockRepository, *mockrepository.MockPackageRevision) { + t.Helper() + mockCache := mockcachetypes.NewMockCache(t) + mockRepo := mockrepository.NewMockRepository(t) + mockPkgRev := mockrepository.NewMockPackageRevision(t) + + mockGetRepo(mockCache, mockRepo) + mockRepo.EXPECT().ListPackageRevisions(mock.Anything, mock.Anything). + Return([]repository.PackageRevision{mockPkgRev}, nil).Maybe() + mockPkgRev.EXPECT().Key().Return(repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{Package: testPkg}, + WorkspaceName: testWS, + }).Maybe() + mockPkgRev.EXPECT().Lifecycle(mock.Anything).Return(lifecycle).Maybe() + + cc := &contentCache{cache: mockCache} + return cc, mockCache, mockRepo, mockPkgRev +} diff --git a/pkg/cache/contentcache/wrappers.go b/pkg/cache/contentcache/wrappers.go new file mode 100644 index 000000000..7d5be3267 --- /dev/null +++ b/pkg/cache/contentcache/wrappers.go @@ -0,0 +1,91 @@ +// Copyright 2026 The kpt and Nephio 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 contentcache + +import ( + "context" + "time" + + kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + "github.com/nephio-project/porch/pkg/repository" +) + +// draftSlimWrapper adapts PackageRevisionDraft to PackageRevisionDraftSlim, +// converting string lifecycle to porchapi.PackageRevisionLifecycle. +type draftSlimWrapper struct { + inner repository.PackageRevisionDraft +} + +var _ repository.PackageRevisionDraftSlim = &draftSlimWrapper{} + +func (d *draftSlimWrapper) Key() repository.PackageRevisionKey { + return d.inner.Key() +} + +func (d *draftSlimWrapper) UpdateResources(ctx context.Context, resources map[string]string, commitMsg string) error { + prr := &porchapi.PackageRevisionResources{ + Spec: porchapi.PackageRevisionResourcesSpec{ + Resources: resources, + }, + } + task := &porchapi.Task{ + Type: porchapi.TaskTypeEdit, + } + return d.inner.UpdateResources(ctx, prr, task) +} + +func (d *draftSlimWrapper) UpdateLifecycle(ctx context.Context, lifecycle string) error { + return d.inner.UpdateLifecycle(ctx, porchapi.PackageRevisionLifecycle(lifecycle)) +} + +// packageContentWrapper adapts PackageRevision to PackageContent. +type packageContentWrapper struct { + inner repository.PackageRevision +} + +var _ repository.PackageContent = &packageContentWrapper{} + +func (p *packageContentWrapper) Key() repository.PackageRevisionKey { + return p.inner.Key() +} + +func (p *packageContentWrapper) Lifecycle(ctx context.Context) string { + return string(p.inner.Lifecycle(ctx)) +} + +func (p *packageContentWrapper) GetResourceContents(ctx context.Context) (map[string]string, error) { + res, err := p.inner.GetResources(ctx) + if err != nil { + return nil, err + } + return res.Spec.Resources, nil +} + +func (p *packageContentWrapper) GetKptfile(ctx context.Context) (kptfilev1.KptFile, error) { + return p.inner.GetKptfile(ctx) +} + +func (p *packageContentWrapper) GetUpstreamLock(ctx context.Context) (kptfilev1.Upstream, kptfilev1.Locator, error) { + return p.inner.GetUpstreamLock(ctx) +} + +func (p *packageContentWrapper) GetLock(ctx context.Context) (kptfilev1.Upstream, kptfilev1.Locator, error) { + return p.inner.GetLock(ctx) +} + +func (p *packageContentWrapper) GetCommitInfo() (time.Time, string) { + return p.inner.GetCommitInfo() +} diff --git a/pkg/cache/contentcache/wrappers_test.go b/pkg/cache/contentcache/wrappers_test.go new file mode 100644 index 000000000..729985710 --- /dev/null +++ b/pkg/cache/contentcache/wrappers_test.go @@ -0,0 +1,140 @@ +// Copyright 2026 The kpt and Nephio 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 contentcache + +import ( + "context" + "fmt" + "testing" + "time" + + kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + "github.com/nephio-project/porch/pkg/repository" + mockrepository "github.com/nephio-project/porch/test/mockery/mocks/porch/pkg/repository" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// --- packageContentWrapper --- + +func TestPackageContentWrapper_GetCommitInfo(t *testing.T) { + mockPkgRev := mockrepository.NewMockPackageRevision(t) + now := time.Now() + mockPkgRev.EXPECT().GetCommitInfo().Return(now, "user@example.com") + + w := &packageContentWrapper{inner: mockPkgRev} + ts, author := w.GetCommitInfo() + assert.Equal(t, now, ts) + assert.Equal(t, "user@example.com", author) +} + +func TestPackageContentWrapper_GetResourceContents(t *testing.T) { + mockPkgRev := mockrepository.NewMockPackageRevision(t) + mockPkgRev.EXPECT().GetResources(mock.Anything).Return(&porchapi.PackageRevisionResources{ + Spec: porchapi.PackageRevisionResourcesSpec{ + Resources: map[string]string{"Kptfile": "data"}, + }, + }, nil) + + w := &packageContentWrapper{inner: mockPkgRev} + res, err := w.GetResourceContents(context.Background()) + require.NoError(t, err) + assert.Equal(t, "data", res["Kptfile"]) +} + +func TestPackageContentWrapper_GetResourceContents_Error(t *testing.T) { + mockPkgRev := mockrepository.NewMockPackageRevision(t) + mockPkgRev.EXPECT().GetResources(mock.Anything).Return(nil, fmt.Errorf("io error")) + + w := &packageContentWrapper{inner: mockPkgRev} + _, err := w.GetResourceContents(context.Background()) + assert.ErrorContains(t, err, "io error") +} + +func TestPackageContentWrapper_GetKptfile(t *testing.T) { + mockPkgRev := mockrepository.NewMockPackageRevision(t) + kf := kptfilev1.KptFile{} + kf.Name = "test" + mockPkgRev.EXPECT().GetKptfile(mock.Anything).Return(kf, nil) + + w := &packageContentWrapper{inner: mockPkgRev} + result, err := w.GetKptfile(context.Background()) + require.NoError(t, err) + assert.Equal(t, "test", result.Name) +} + +func TestPackageContentWrapper_GetUpstreamLock(t *testing.T) { + mockPkgRev := mockrepository.NewMockPackageRevision(t) + mockPkgRev.EXPECT().GetUpstreamLock(mock.Anything).Return( + kptfilev1.Upstream{Type: kptfilev1.GitOrigin}, + kptfilev1.Locator{Type: kptfilev1.GitOrigin}, + nil, + ) + + w := &packageContentWrapper{inner: mockPkgRev} + u, ul, err := w.GetUpstreamLock(context.Background()) + require.NoError(t, err) + assert.Equal(t, kptfilev1.GitOrigin, u.Type) + assert.Equal(t, kptfilev1.GitOrigin, ul.Type) +} + +func TestPackageContentWrapper_GetLock(t *testing.T) { + mockPkgRev := mockrepository.NewMockPackageRevision(t) + mockPkgRev.EXPECT().GetLock(mock.Anything).Return( + kptfilev1.Upstream{Type: kptfilev1.GitOrigin}, + kptfilev1.Locator{Type: kptfilev1.GitOrigin}, + nil, + ) + + w := &packageContentWrapper{inner: mockPkgRev} + u, ul, err := w.GetLock(context.Background()) + require.NoError(t, err) + assert.Equal(t, kptfilev1.GitOrigin, u.Type) + assert.Equal(t, kptfilev1.GitOrigin, ul.Type) +} + +// --- draftSlimWrapper --- + +func TestDraftSlimWrapper_Key(t *testing.T) { + mockDraft := mockrepository.NewMockPackageRevisionDraft(t) + key := repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{Package: testPkg}, + WorkspaceName: testWS, + } + mockDraft.EXPECT().Key().Return(key) + + w := &draftSlimWrapper{inner: mockDraft} + assert.Equal(t, key, w.Key()) +} + +func TestDraftSlimWrapper_UpdateLifecycle(t *testing.T) { + mockDraft := mockrepository.NewMockPackageRevisionDraft(t) + mockDraft.EXPECT().UpdateLifecycle(mock.Anything, porchapi.PackageRevisionLifecycleProposed).Return(nil) + + w := &draftSlimWrapper{inner: mockDraft} + err := w.UpdateLifecycle(context.Background(), "Proposed") + assert.NoError(t, err) +} + +func TestDraftSlimWrapper_UpdateResources(t *testing.T) { + mockDraft := mockrepository.NewMockPackageRevisionDraft(t) + mockDraft.EXPECT().UpdateResources(mock.Anything, mock.Anything, mock.Anything).Return(nil) + + w := &draftSlimWrapper{inner: mockDraft} + err := w.UpdateResources(context.Background(), map[string]string{"f.yaml": "data"}, "commit msg") + assert.NoError(t, err) +} diff --git a/pkg/cache/crcache/packagerevision.go b/pkg/cache/crcache/packagerevision.go index 616cc5005..219ed0a79 100644 --- a/pkg/cache/crcache/packagerevision.go +++ b/pkg/cache/crcache/packagerevision.go @@ -17,6 +17,7 @@ package crcache import ( "context" "sync" + "time" porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" "github.com/nephio-project/porch/pkg/cache/crcache/meta" @@ -105,3 +106,7 @@ func (c *cachedPackageRevision) SetMeta(ctx context.Context, pkgRevMeta metav1.O func (c *cachedPackageRevision) IsLatestRevision() bool { return c.isLatestRevision } + +func (c *cachedPackageRevision) GetCommitInfo() (time.Time, string) { + return c.PackageRevision.GetCommitInfo() +} diff --git a/pkg/cache/crcache/repository_test.go b/pkg/cache/crcache/repository_test.go index 8dc2fbc10..6b78af501 100644 --- a/pkg/cache/crcache/repository_test.go +++ b/pkg/cache/crcache/repository_test.go @@ -100,6 +100,10 @@ func TestCachedRepoRefresh(t *testing.T) { } assert.False(t, cr.cachedPackageRevisions[prKey].IsLatestRevision()) + ts, author := cr.cachedPackageRevisions[prKey].GetCommitInfo() + assert.True(t, ts.IsZero()) + assert.Empty(t, author) + err := cr.Refresh(context.TODO()) assert.True(t, err == nil) diff --git a/pkg/cache/dbcache/dbpackagerevision.go b/pkg/cache/dbcache/dbpackagerevision.go index 9e00d1909..fe51a7a02 100644 --- a/pkg/cache/dbcache/dbpackagerevision.go +++ b/pkg/cache/dbcache/dbpackagerevision.go @@ -386,6 +386,10 @@ func (pr *dbPackageRevision) IsLatestRevision() bool { return pr.latest } +func (pr *dbPackageRevision) GetCommitInfo() (time.Time, string) { + return pr.updated, pr.updatedBy +} + func (pr *dbPackageRevision) GetKptfile(ctx context.Context) (kptfile.KptFile, error) { _, span := tracer.Start(ctx, "dbPackageRevision::GetKptfile", trace.WithAttributes()) defer span.End() @@ -453,6 +457,10 @@ func (pr *dbPackageRevision) UpdateResources(ctx context.Context, new *porchapi. _, span := tracer.Start(ctx, "dbPackageRevision::UpdateResources", trace.WithAttributes()) defer span.End() + if pr.repo == nil { + return fmt.Errorf("cannot update resources for package revision %s: no associated repository", pr.KubeObjectName()) + } + if pr.repo.pushDraftsToGit && pr.gitPRDraft != nil { klog.InfoS("[DB Cache] Updating resources in memory and in Git draft for PackageRevision", context1.LogMetadataFrom(ctx)...) defer func() { diff --git a/pkg/cache/dbcache/dbrepository.go b/pkg/cache/dbcache/dbrepository.go index f3bdf5fa1..b6f7ff8dc 100644 --- a/pkg/cache/dbcache/dbrepository.go +++ b/pkg/cache/dbcache/dbrepository.go @@ -380,6 +380,10 @@ func (r *dbRepository) UpdatePackageRevision(ctx context.Context, updatePR repos return nil, fmt.Errorf("cannot update DB package revision %T", updatePR) } + if updatePkgRev.repo == nil { + updatePkgRev.repo = r + } + if err := updatePkgRev.UpdatePackageRevision(ctx); err != nil { return nil, err } diff --git a/pkg/cli/commands/repo/reg/command.go b/pkg/cli/commands/repo/reg/command.go index d2a7c573f..8978a9be3 100644 --- a/pkg/cli/commands/repo/reg/command.go +++ b/pkg/cli/commands/repo/reg/command.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The kpt and Nephio Authors +// Copyright 2022-2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -62,6 +62,7 @@ func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner c.Flags().StringVar(&r.password, "repo-basic-password", "", "Password for repository authentication using basic auth.") c.Flags().BoolVar(&r.workloadIdentity, "repo-workload-identity", false, "Use workload identity for authentication with the repo") c.Flags().StringVar(&r.syncSchedule, "sync-schedule", "", "Cron schedule for reconciling packages in the repository.") + c.Flags().BoolVar(&r.v1alpha2, "v1alpha2", false, "Enable v1alpha2 PackageRevision management for this repository.") return r } @@ -87,6 +88,7 @@ type runner struct { password string workloadIdentity bool syncSchedule string + v1alpha2 bool } func (r *runner) preRunE(_ *cobra.Command, _ []string) error { @@ -178,14 +180,20 @@ func (r *runner) runE(_ *cobra.Command, args []string) error { } } + annotations := map[string]string{} + if r.v1alpha2 { + annotations[configapi.AnnotationKeyV1Alpha2Migration] = configapi.AnnotationValueMigrationEnabled + } + if err := r.client.Create(r.ctx, &configapi.Repository{ TypeMeta: metav1.TypeMeta{ Kind: "Repository", APIVersion: configapi.GroupVersion.Identifier(), }, ObjectMeta: metav1.ObjectMeta{ - Name: r.name, - Namespace: *r.cfg.Namespace, + Name: r.name, + Namespace: *r.cfg.Namespace, + Annotations: annotations, }, Spec: configapi.RepositorySpec{ Description: r.description, diff --git a/pkg/cli/commands/repo/reg/command_test.go b/pkg/cli/commands/repo/reg/command_test.go index 5a8c82b2b..277173bc2 100644 --- a/pkg/cli/commands/repo/reg/command_test.go +++ b/pkg/cli/commands/repo/reg/command_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 The kpt and Nephio Authors +// Copyright 2022, 2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -113,6 +113,28 @@ func TestRepoReg(t *testing.T) { }, }, }, + { + name: "FullRegisterv1Alpha2", + args: []string{ + "https://github.com/platkrm/test-blueprints.git", + "--name=repository-resource-name", + "--description=\"Test Repository Description\"", + "--deployment", + "--directory=/catalog", + "--branch=main-branch", + "--create-branch", + "--namespace=repository-namespace", + "--v1alpha2=true", + }, + actions: []httpAction{ + { + method: http.MethodPost, + path: "/apis/config.porch.kpt.dev/v1alpha1/namespaces/repository-namespace/repositories", + wantRequest: "full-repository-v1alpha2.yaml", + sendResponse: "full-repository-v1alpha2.yaml", + }, + }, + }, { name: "FullRegisterWithSyncSchedule", args: []string{ diff --git a/pkg/cli/commands/repo/reg/testdata/full-repository-v1alpha2.yaml b/pkg/cli/commands/repo/reg/testdata/full-repository-v1alpha2.yaml new file mode 100644 index 000000000..b596003ed --- /dev/null +++ b/pkg/cli/commands/repo/reg/testdata/full-repository-v1alpha2.yaml @@ -0,0 +1,19 @@ +apiVersion: config.porch.kpt.dev/v1alpha1 +kind: Repository +metadata: + annotations: + porch.kpt.dev/v1alpha2-migration: "true" + name: repository-resource-name + namespace: repository-namespace +spec: + deployment: true + description: '"Test Repository Description"' + git: + branch: main-branch + createBranch: true + directory: /catalog + repo: https://github.com/platkrm/test-blueprints.git + secretRef: + name: "" + type: git +status: {} diff --git a/pkg/cli/commands/rpkg/approve/command.go b/pkg/cli/commands/rpkg/approve/command.go index ae86e577b..afaff46fe 100644 --- a/pkg/cli/commands/rpkg/approve/command.go +++ b/pkg/cli/commands/rpkg/approve/command.go @@ -1,4 +1,4 @@ -// Copyright 2022 The kpt and Nephio Authors +// Copyright 2022,2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -34,7 +34,10 @@ const ( ) func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { - return newRunner(ctx, rcg).Command + v1 := newRunner(ctx, rcg) + v2 := newV1Alpha2Runner(ctx, rcg) + cliutils.WrapVersionDispatch(v1.Command, v2.preRunE, v2.runE) + return v1.Command } func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { diff --git a/pkg/cli/commands/rpkg/approve/command_test.go b/pkg/cli/commands/rpkg/approve/command_test.go index db03b8d9c..f38b0c3b1 100644 --- a/pkg/cli/commands/rpkg/approve/command_test.go +++ b/pkg/cli/commands/rpkg/approve/command_test.go @@ -247,3 +247,13 @@ func TestLastErrWorkaround(t *testing.T) { t.Fatal("expected error but got nil") } } + +func TestNewCommand(t *testing.T) { + ns := "default" + flags := genericclioptions.NewConfigFlags(true) + flags.Namespace = &ns + cmd := NewCommand(context.Background(), flags) + if cmd == nil { + t.Fatal("NewCommand returned nil") + } +} diff --git a/pkg/cli/commands/rpkg/approve/v1alpha2.go b/pkg/cli/commands/rpkg/approve/v1alpha2.go new file mode 100644 index 000000000..1fe58bc97 --- /dev/null +++ b/pkg/cli/commands/rpkg/approve/v1alpha2.go @@ -0,0 +1,93 @@ +// Copyright 2026 The kpt and Nephio 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 approve + +import ( + "context" + "fmt" + "strings" + + "github.com/kptdev/kpt/pkg/lib/errors" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + cliutils "github.com/nephio-project/porch/internal/cliutils" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type v1alpha2Runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client +} + +func newV1Alpha2Runner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *v1alpha2Runner { + return &v1alpha2Runner{ctx: ctx, cfg: rcg} +} + +func (r *v1alpha2Runner) preRunE(_ *cobra.Command, _ []string) error { + const op errors.Op = command + ".preRunE" + if r.client == nil { + c, err := cliutils.CreateV1Alpha2ClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = c + } + return nil +} + +func (r *v1alpha2Runner) runE(cmd *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + var messages []string + namespace := *r.cfg.Namespace + + for _, name := range args { + key := client.ObjectKey{Namespace: namespace, Name: name} + var lastErr error + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + var pr porchv1alpha2.PackageRevision + if err := r.client.Get(r.ctx, key, &pr); err != nil { + lastErr = err + return err + } + if !porchv1alpha2.PackageRevisionIsReady(pr.Spec.ReadinessGates, pr.Status.PackageConditions) { + lastErr = fmt.Errorf("readiness conditions not met") + return lastErr + } + err := cliutils.UpdatePackageRevisionApprovalV1Alpha2(r.ctx, r.client, &pr, porchv1alpha2.PackageRevisionLifecyclePublished) + if err != nil { + lastErr = err + } else { + lastErr = nil + } + return err + }) + if err == nil && lastErr != nil { + err = lastErr + } + if err != nil { + messages = append(messages, err.Error()) + fmt.Fprintf(cmd.ErrOrStderr(), "%s failed (%s)\n", name, err) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "%s approved\n", name) + } + } + if len(messages) > 0 { + return errors.E(op, fmt.Errorf("errors:\n %s", strings.Join(messages, "\n "))) + } + return nil +} diff --git a/pkg/cli/commands/rpkg/approve/v1alpha2_test.go b/pkg/cli/commands/rpkg/approve/v1alpha2_test.go new file mode 100644 index 000000000..e34cd4654 --- /dev/null +++ b/pkg/cli/commands/rpkg/approve/v1alpha2_test.go @@ -0,0 +1,266 @@ +// Copyright 2026 The kpt and Nephio 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 approve + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "testing" + + "github.com/google/go-cmp/cmp" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/util" + "github.com/spf13/cobra" + "github.com/stretchr/testify/mock" + mockclient "github.com/nephio-project/porch/test/mockery/mocks/external/sigs.k8s.io/controller-runtime/pkg/client" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestV1Alpha2PreRunE(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + cfg := &genericclioptions.ConfigFlags{Namespace: &ns} + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: cfg, + } + + cmd := &cobra.Command{} + err := r.preRunE(cmd, []string{}) + + // preRunE should succeed (it just creates a client) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if r.client == nil { + t.Error("expected client to be set") + } +} + +func TestV1Alpha2PreRunEClientError(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + // Use invalid kubeconfig to trigger client creation error + cfg := &genericclioptions.ConfigFlags{ + Namespace: &ns, + KubeConfig: func() *string { s := "/nonexistent/kubeconfig"; return &s }(), + } + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: cfg, + } + + cmd := &cobra.Command{} + err := r.preRunE(cmd, []string{}) + + // preRunE should fail with client creation error + if err == nil { + t.Error("expected error for invalid kubeconfig") + } +} + +func TestV1Alpha2Cmd(t *testing.T) { + pkgRevName := "test-fjdos9u2nfe2f32" + scheme := util.V1Alpha2Scheme(t) + testCases := map[string]struct { + lc porchv1alpha2.PackageRevisionLifecycle + output string + wantErr bool + ns string + }{ + "Package not found in ns": { + output: pkgRevName + " failed (packagerevisions.porch.kpt.dev \"" + pkgRevName + "\" not found)\n", + ns: "doesnotexist", + wantErr: true, + }, + "Approve proposed package": { + output: pkgRevName + " approved\n", + lc: porchv1alpha2.PackageRevisionLifecycleProposed, + ns: "ns", + }, + "Cannot approve draft package": { + output: pkgRevName + " failed (cannot change approval from Draft to Published)\n", + lc: porchv1alpha2.PackageRevisionLifecycleDraft, + ns: "ns", + wantErr: true, + }, + } + + for tn := range testCases { + tc := testCases[tn] + t.Run(tn, func(t *testing.T) { + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(func() *porchv1alpha2.PackageRevision { + pr := util.NewV1Alpha2PackageRevision("ns", pkgRevName) + pr.Spec.Lifecycle = tc.lc + return pr + }()).Build() + + cmd := &cobra.Command{} + o := os.Stdout + e := os.Stderr + read, write, _ := os.Pipe() + os.Stdout = write + os.Stderr = write + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{ + Namespace: &tc.ns, + }, + client: c, + } + go func() { + defer write.Close() + err := r.runE(cmd, []string{pkgRevName}) + if err != nil && !tc.wantErr { + t.Errorf("unexpected error: %v", err) + } + }() + out, _ := io.ReadAll(read) + os.Stdout = o + os.Stderr = e + + if diff := cmp.Diff(tc.output, string(out)); diff != "" { + t.Errorf("Unexpected result (-want, +got): %s", diff) + } + }) + } +} + +func TestV1Alpha2NewRunner(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + cfg := &genericclioptions.ConfigFlags{Namespace: &ns} + + r := newV1Alpha2Runner(ctx, cfg) + + if r == nil { + t.Fatal("expected non-nil runner") + } + if r.ctx != ctx { + t.Errorf("expected context to be set") + } + if r.cfg != cfg { + t.Errorf("expected config to be set") + } +} + +func TestV1Alpha2RunEGetError(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Get(ctx, mock.AnythingOfType("types.NamespacedName"), mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("package not found")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + cmd.SetErr(output) + + err := r.runE(cmd, []string{"test-pkg"}) + if err == nil { + t.Error("expected error for package not found") + } +} + +func TestV1Alpha2ReadinessGates(t *testing.T) { + pkgRevName := "test-pkg" + ns := "ns" + scheme := util.V1Alpha2Scheme(t) + + testCases := map[string]struct { + gates []porchv1alpha2.ReadinessGate + conditions []porchv1alpha2.PackageCondition + wantErr bool + wantLC porchv1alpha2.PackageRevisionLifecycle + }{ + "no gates - approve succeeds": { + wantLC: porchv1alpha2.PackageRevisionLifecyclePublished, + }, + "gate met - approve succeeds": { + gates: []porchv1alpha2.ReadinessGate{{ConditionType: "foo.bar/Ready"}}, + conditions: []porchv1alpha2.PackageCondition{ + {Type: "foo.bar/Ready", Status: porchv1alpha2.PackageConditionTrue}, + }, + wantLC: porchv1alpha2.PackageRevisionLifecyclePublished, + }, + "gate not met - approve fails": { + gates: []porchv1alpha2.ReadinessGate{{ConditionType: "foo.bar/Ready"}}, + conditions: []porchv1alpha2.PackageCondition{ + {Type: "foo.bar/Ready", Status: porchv1alpha2.PackageConditionFalse}, + }, + wantErr: true, + wantLC: porchv1alpha2.PackageRevisionLifecycleProposed, // unchanged + }, + "gate missing condition - approve fails": { + gates: []porchv1alpha2.ReadinessGate{{ConditionType: "foo.bar/Ready"}}, + wantErr: true, + wantLC: porchv1alpha2.PackageRevisionLifecycleProposed, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(func() *porchv1alpha2.PackageRevision { + pr := util.NewV1Alpha2PackageRevision(ns, pkgRevName) + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecycleProposed + pr.Spec.ReadinessGates = tc.gates + pr.Status.PackageConditions = tc.conditions + return pr + }()).Build() + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + + err := r.runE(&cobra.Command{}, []string{pkgRevName}) + if tc.wantErr && err == nil { + t.Fatal("expected error but got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var pr porchv1alpha2.PackageRevision + if err := c.Get(context.Background(), client.ObjectKey{Namespace: ns, Name: pkgRevName}, &pr); err != nil { + t.Fatalf("failed to get PR: %v", err) + } + if pr.Spec.Lifecycle != tc.wantLC { + t.Errorf("expected lifecycle %s, got %s", tc.wantLC, pr.Spec.Lifecycle) + } + }) + } +} diff --git a/pkg/cli/commands/rpkg/clone/command.go b/pkg/cli/commands/rpkg/clone/command.go index 57b84ae43..d754b9b54 100644 --- a/pkg/cli/commands/rpkg/clone/command.go +++ b/pkg/cli/commands/rpkg/clone/command.go @@ -1,4 +1,4 @@ -// Copyright 2022 The kpt and Nephio Authors +// Copyright 2022,2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -35,7 +35,10 @@ const ( ) func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { - return newRunner(ctx, rcg).Command + v1 := newRunner(ctx, rcg) + v2 := newV1Alpha2Runner(ctx, rcg) + cliutils.WrapVersionDispatch(v1.Command, v2.preRunE, v2.runE) + return v1.Command } func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { diff --git a/pkg/cli/commands/rpkg/clone/v1alpha2.go b/pkg/cli/commands/rpkg/clone/v1alpha2.go new file mode 100644 index 000000000..2b2197af5 --- /dev/null +++ b/pkg/cli/commands/rpkg/clone/v1alpha2.go @@ -0,0 +1,167 @@ +// Copyright 2026 The kpt and Nephio 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 clone + +import ( + "context" + "fmt" + "strings" + + "github.com/kptdev/kpt/pkg/lib/errors" + "github.com/kptdev/kpt/pkg/lib/util/parse" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + cliutils "github.com/nephio-project/porch/internal/cliutils" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/util" + pkgutil "github.com/nephio-project/porch/pkg/util" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type v1alpha2Runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + + upstream porchv1alpha2.UpstreamPackage + repository string + workspace string + target string +} + +func newV1Alpha2Runner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *v1alpha2Runner { + return &v1alpha2Runner{ctx: ctx, cfg: rcg} +} + +func (r *v1alpha2Runner) preRunE(cmd *cobra.Command, args []string) error { + const op errors.Op = command + ".preRunE" + if r.client == nil { + c, err := cliutils.CreateV1Alpha2ClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = c + } + + if len(args) < 2 { + return errors.E(op, fmt.Errorf("SOURCE_PACKAGE and NAME are required positional arguments; %d provided", len(args))) + } + + r.repository, _ = cmd.Flags().GetString("repository") + r.workspace, _ = cmd.Flags().GetString("workspace") + directory, _ := cmd.Flags().GetString("directory") + ref, _ := cmd.Flags().GetString("ref") + secretRef, _ := cmd.Flags().GetString("secret-ref") + + if r.repository == "" { + return errors.E(op, fmt.Errorf("--repository is required to specify downstream repository")) + } + if r.workspace == "" { + return errors.E(op, fmt.Errorf("--workspace is required to specify downstream workspace name")) + } + + source := args[0] + r.target = args[1] + + pkgExists, err := util.PackageAlreadyExistsV1Alpha2(r.ctx, r.client, r.repository, r.target, *r.cfg.Namespace) + if err != nil { + return err + } + if pkgExists { + return fmt.Errorf("`clone` cannot create a new revision for package %q that already exists in repo %q; make subsequent revisions using `copy`", + r.target, r.repository) + } + + switch { + case strings.HasPrefix(source, "oci://"): + return errors.E(op, fmt.Errorf("OCI upstream is not supported in v1alpha2")) + + case strings.Contains(source, "/"): + if parse.HasGitSuffix(source) { + repo, dir, parsedRef, err := parse.URL(source) + if err != nil { + return err + } + if directory != "" && dir != "" && directory != dir { + return errors.E(op, fmt.Errorf("directory %s specified by --directory contradicts directory %s specified by SOURCE_PACKAGE", + directory, dir)) + } + if ref != "" && parsedRef != "" && ref != parsedRef { + return errors.E(op, fmt.Errorf("ref %s specified by --ref contradicts ref %s specified by SOURCE_PACKAGE", + ref, parsedRef)) + } + if directory == "" { + directory = dir + } + if ref == "" { + ref = parsedRef + } + source = repo + ".git" + } + if ref == "" { + ref = "main" + } + if directory == "" { + directory = "/" + } + r.upstream = porchv1alpha2.UpstreamPackage{ + Type: porchv1alpha2.RepositoryTypeGit, + Git: &porchv1alpha2.GitPackage{ + Repo: source, + Ref: ref, + Directory: directory, + SecretRef: porchv1alpha2.SecretRef{Name: secretRef}, + }, + } + + default: + r.upstream = porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: source}, + } + } + + return nil +} + +func (r *v1alpha2Runner) runE(cmd *cobra.Command, _ []string) error { + const op errors.Op = command + ".runE" + + pr := &porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: *r.cfg.Namespace, + Name: pkgutil.ComposePkgRevObjName(r.repository, "", r.target, r.workspace), + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: r.target, + WorkspaceName: r.workspace, + RepositoryName: r.repository, + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &r.upstream, + }, + }, + } + if err := r.client.Create(r.ctx, pr); err != nil { + return errors.E(op, err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "%s created\n", pr.Name) + return nil +} diff --git a/pkg/cli/commands/rpkg/clone/v1alpha2_test.go b/pkg/cli/commands/rpkg/clone/v1alpha2_test.go new file mode 100644 index 000000000..570e73f7d --- /dev/null +++ b/pkg/cli/commands/rpkg/clone/v1alpha2_test.go @@ -0,0 +1,636 @@ +// Copyright 2026 The kpt and Nephio 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 clone + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/util" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + mockclient "github.com/nephio-project/porch/test/mockery/mocks/external/sigs.k8s.io/controller-runtime/pkg/client" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func createV1Alpha2Scheme() (*runtime.Scheme, error) { + scheme := runtime.NewScheme() + for _, api := range (runtime.SchemeBuilder{ + porchv1alpha2.AddToScheme, + }) { + if err := api(scheme); err != nil { + return nil, err + } + } + return scheme, nil +} + +func TestV1Alpha2CloneFromUpstreamRef(t *testing.T) { + ns := "ns" + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + repository: "target-repo", + workspace: "v1", + target: "my-pkg", + upstream: porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "source-pkg-rev"}, + }, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err = r.runE(cmd, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if diff := cmp.Diff("target-repo.my-pkg.v1 created\n", output.String()); diff != "" { + t.Errorf("Unexpected output (-want, +got): %s", diff) + } +} + +func TestV1Alpha2CloneFromGit(t *testing.T) { + ns := "ns" + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + repository: "target-repo", + workspace: "v1", + target: "my-pkg", + upstream: porchv1alpha2.UpstreamPackage{ + Type: porchv1alpha2.RepositoryTypeGit, + Git: &porchv1alpha2.GitPackage{ + Repo: "https://github.com/example/repo.git", + Ref: "main", + Directory: "/", + }, + }, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err = r.runE(cmd, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if diff := cmp.Diff("target-repo.my-pkg.v1 created\n", output.String()); diff != "" { + t.Errorf("Unexpected output (-want, +got): %s", diff) + } +} + +func TestV1Alpha2PreRunEValidation(t *testing.T) { + ns := "ns" + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + } + + // Verify runner fields are empty before setup + if r.repository != "" || r.workspace != "" || r.target != "" { + t.Fatal("expected empty runner fields before preRunE") + } +} + +func TestV1Alpha2PreRunEClientError(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + // Use invalid kubeconfig to trigger client creation error + cfg := &genericclioptions.ConfigFlags{ + Namespace: &ns, + KubeConfig: func() *string { s := "/nonexistent/kubeconfig"; return &s }(), + } + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: cfg, + } + + cmd := &cobra.Command{} + cmd.Flags().String("repository", "repo", "") + cmd.Flags().String("workspace", "v1", "") + cmd.Flags().String("directory", "", "") + cmd.Flags().String("ref", "", "") + cmd.Flags().String("secret-ref", "", "") + + err := r.preRunE(cmd, []string{"source-pkg", "target-pkg"}) + + // preRunE should fail with client creation error + if err == nil { + t.Error("expected error for invalid kubeconfig") + } +} + +func TestV1Alpha2ClonePackageAlreadyExists(t *testing.T) { + ns := "ns" + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(&porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: "repo.existing-pkg.v1", + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + RepositoryName: "repo", + PackageName: "existing-pkg", + }, + }).Build() + + // Manually set the client (preRunE would normally create it from kubeconfig) + // and call the validation logic that preRunE performs after client creation. + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + + // Simulate the validation that preRunE does after creating the client + pkgExists, err := util.PackageAlreadyExistsV1Alpha2(r.ctx, r.client, "repo", "existing-pkg", ns) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !pkgExists { + t.Fatal("expected package to already exist") + } +} + + +// Mockery-based tests for error paths +func TestV1Alpha2CloneCreateError(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("create failed")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + repository: "target-repo", + workspace: "v1", + target: "my-pkg", + upstream: porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "source-pkg-rev"}, + }, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "create failed") +} + +func TestV1Alpha2CloneCreateErrorFromGit(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("failed to create package revision")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + repository: "target-repo", + workspace: "v1", + target: "my-pkg", + upstream: porchv1alpha2.UpstreamPackage{ + Type: porchv1alpha2.RepositoryTypeGit, + Git: &porchv1alpha2.GitPackage{ + Repo: "https://github.com/example/repo.git", + Ref: "main", + Directory: "/", + }, + }, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create package revision") +} + +func TestV1Alpha2CloneCreateErrorAlreadyExists(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("already exists")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + repository: "target-repo", + workspace: "v1", + target: "my-pkg", + upstream: porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "source-pkg-rev"}, + }, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") +} + +func TestV1Alpha2CloneCreateErrorPermissionDenied(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("permission denied")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + repository: "target-repo", + workspace: "v1", + target: "my-pkg", + upstream: porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "source-pkg-rev"}, + }, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") +} + +func TestV1Alpha2CloneCreateErrorInvalidRequest(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("invalid package revision spec")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + repository: "target-repo", + workspace: "v1", + target: "my-pkg", + upstream: porchv1alpha2.UpstreamPackage{ + Type: porchv1alpha2.RepositoryTypeGit, + Git: &porchv1alpha2.GitPackage{ + Repo: "https://github.com/example/repo.git", + Ref: "main", + Directory: "/", + }, + }, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid package revision spec") +} + +func TestV1Alpha2CloneCreateErrorNetworkFailure(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("connection refused")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + repository: "target-repo", + workspace: "v1", + target: "my-pkg", + upstream: porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "source-pkg-rev"}, + }, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "connection refused") +} + +func TestV1Alpha2CloneCreateErrorTimeout(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("context deadline exceeded")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + repository: "target-repo", + workspace: "v1", + target: "my-pkg", + upstream: porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "source-pkg-rev"}, + }, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "context deadline exceeded") +} + +func TestV1Alpha2CloneCreateErrorInternalServerError(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("internal server error")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + repository: "target-repo", + workspace: "v1", + target: "my-pkg", + upstream: porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: "source-pkg-rev"}, + }, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "internal server error") +} + +// PreRunE validation tests +func TestV1Alpha2ClonePreRunEMissingArgs(t *testing.T) { + ns := "ns" + ctx := context.Background() + + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + + cmd := &cobra.Command{} + cmd.Flags().String("repository", "repo", "") + cmd.Flags().String("workspace", "v1", "") + cmd.Flags().String("directory", "", "") + cmd.Flags().String("ref", "", "") + cmd.Flags().String("secret-ref", "", "") + + err = r.preRunE(cmd, []string{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "SOURCE_PACKAGE and NAME are required") +} + +func TestV1Alpha2ClonePreRunEMissingRepository(t *testing.T) { + ns := "ns" + ctx := context.Background() + + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + + cmd := &cobra.Command{} + cmd.Flags().String("repository", "", "") + cmd.Flags().String("workspace", "v1", "") + cmd.Flags().String("directory", "", "") + cmd.Flags().String("ref", "", "") + cmd.Flags().String("secret-ref", "", "") + + err = r.preRunE(cmd, []string{"source-pkg", "target-pkg"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "--repository is required") +} + +func TestV1Alpha2ClonePreRunEMissingWorkspace(t *testing.T) { + ns := "ns" + ctx := context.Background() + + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + + cmd := &cobra.Command{} + cmd.Flags().String("repository", "repo", "") + cmd.Flags().String("workspace", "", "") + cmd.Flags().String("directory", "", "") + cmd.Flags().String("ref", "", "") + cmd.Flags().String("secret-ref", "", "") + + err = r.preRunE(cmd, []string{"source-pkg", "target-pkg"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "--workspace is required") +} + +func TestV1Alpha2ClonePreRunEOCINotSupported(t *testing.T) { + ns := "ns" + ctx := context.Background() + + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + + cmd := &cobra.Command{} + cmd.Flags().String("repository", "repo", "") + cmd.Flags().String("workspace", "v1", "") + cmd.Flags().String("directory", "", "") + cmd.Flags().String("ref", "", "") + cmd.Flags().String("secret-ref", "", "") + + err = r.preRunE(cmd, []string{"oci://example.com/pkg", "target-pkg"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "OCI upstream is not supported") +} + +func TestV1Alpha2ClonePreRunEGitURL(t *testing.T) { + ns := "ns" + ctx := context.Background() + + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + + cmd := &cobra.Command{} + cmd.Flags().String("repository", "repo", "") + cmd.Flags().String("workspace", "v1", "") + cmd.Flags().String("directory", "", "") + cmd.Flags().String("ref", "", "") + cmd.Flags().String("secret-ref", "", "") + + err = r.preRunE(cmd, []string{"https://github.com/example/repo.git/path/to/pkg@main", "target-pkg"}) + assert.NoError(t, err) + assert.Equal(t, porchv1alpha2.RepositoryTypeGit, r.upstream.Type) + assert.NotNil(t, r.upstream.Git) + assert.Equal(t, "main", r.upstream.Git.Ref) +} + +func TestV1Alpha2ClonePreRunEUpstreamRef(t *testing.T) { + ns := "ns" + ctx := context.Background() + + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + + cmd := &cobra.Command{} + cmd.Flags().String("repository", "repo", "") + cmd.Flags().String("workspace", "v1", "") + cmd.Flags().String("directory", "", "") + cmd.Flags().String("ref", "", "") + cmd.Flags().String("secret-ref", "", "") + + err = r.preRunE(cmd, []string{"upstream-pkg-rev", "target-pkg"}) + assert.NoError(t, err) + assert.NotNil(t, r.upstream.UpstreamRef) + assert.Equal(t, "upstream-pkg-rev", r.upstream.UpstreamRef.Name) +} diff --git a/pkg/cli/commands/rpkg/copy/command.go b/pkg/cli/commands/rpkg/copy/command.go index 0ecbe1598..18293cc3d 100644 --- a/pkg/cli/commands/rpkg/copy/command.go +++ b/pkg/cli/commands/rpkg/copy/command.go @@ -1,4 +1,4 @@ -// Copyright 2022 The kpt and Nephio Authors +// Copyright 2022,2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -34,7 +34,10 @@ const ( ) func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { - return newRunner(ctx, rcg).Command + v1 := newRunner(ctx, rcg) + v2 := newV1Alpha2Runner(ctx, rcg) + cliutils.WrapVersionDispatch(v1.Command, v2.preRunE, v2.runE) + return v1.Command } func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { diff --git a/pkg/cli/commands/rpkg/copy/command_test.go b/pkg/cli/commands/rpkg/copy/command_test.go index ce2161281..c77e692fb 100644 --- a/pkg/cli/commands/rpkg/copy/command_test.go +++ b/pkg/cli/commands/rpkg/copy/command_test.go @@ -161,3 +161,13 @@ func TestCmd(t *testing.T) { }) } } + +func TestNewCommand(t *testing.T) { + ns := "default" + flags := genericclioptions.NewConfigFlags(true) + flags.Namespace = &ns + cmd := NewCommand(context.Background(), flags) + if cmd == nil { + t.Fatal("NewCommand returned nil") + } +} diff --git a/pkg/cli/commands/rpkg/copy/v1alpha2.go b/pkg/cli/commands/rpkg/copy/v1alpha2.go new file mode 100644 index 000000000..09219b3d0 --- /dev/null +++ b/pkg/cli/commands/rpkg/copy/v1alpha2.go @@ -0,0 +1,107 @@ +// Copyright 2026 The kpt and Nephio 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 copy + +import ( + "context" + "fmt" + + "github.com/kptdev/kpt/pkg/lib/errors" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + cliutils "github.com/nephio-project/porch/internal/cliutils" + pkgutil "github.com/nephio-project/porch/pkg/util" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type v1alpha2Runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + + sourceName string +} + +func newV1Alpha2Runner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *v1alpha2Runner { + return &v1alpha2Runner{ctx: ctx, cfg: rcg} +} + +func (r *v1alpha2Runner) preRunE(cmd *cobra.Command, args []string) error { + const op errors.Op = command + ".preRunE" + if r.client == nil { + c, err := cliutils.CreateV1Alpha2ClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = c + } + + if len(args) < 1 { + return errors.E(op, fmt.Errorf("SOURCE_PACKAGE is a required positional argument")) + } + if len(args) > 1 { + return errors.E(op, fmt.Errorf("too many arguments; SOURCE_PACKAGE is the only accepted positional arguments")) + } + + r.sourceName = args[0] + return nil +} + +func (r *v1alpha2Runner) runE(cmd *cobra.Command, _ []string) error { + const op errors.Op = command + ".runE" + + workspace, _ := cmd.Flags().GetString("workspace") + if workspace == "" { + return errors.E(op, fmt.Errorf("--workspace is required to specify workspace name")) + } + + // Look up the source package to get its repo and package name + var source porchv1alpha2.PackageRevision + if err := r.client.Get(r.ctx, types.NamespacedName{ + Name: r.sourceName, + Namespace: *r.cfg.Namespace, + }, &source); err != nil { + return errors.E(op, err) + } + + pr := &porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: *r.cfg.Namespace, + Name: pkgutil.ComposePkgRevObjName(source.Spec.RepositoryName, "", source.Spec.PackageName, workspace), + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: source.Spec.PackageName, + WorkspaceName: workspace, + RepositoryName: source.Spec.RepositoryName, + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + Source: &porchv1alpha2.PackageSource{ + CopyFrom: &porchv1alpha2.PackageRevisionRef{Name: r.sourceName}, + }, + }, + } + if err := r.client.Create(r.ctx, pr); err != nil { + return errors.E(op, err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "%s created\n", pr.Name) + return nil +} diff --git a/pkg/cli/commands/rpkg/copy/v1alpha2_test.go b/pkg/cli/commands/rpkg/copy/v1alpha2_test.go new file mode 100644 index 000000000..e821108c2 --- /dev/null +++ b/pkg/cli/commands/rpkg/copy/v1alpha2_test.go @@ -0,0 +1,269 @@ +// Copyright 2026 The kpt and Nephio 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 copy + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/spf13/cobra" + "github.com/stretchr/testify/mock" + mockclient "github.com/nephio-project/porch/test/mockery/mocks/external/sigs.k8s.io/controller-runtime/pkg/client" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func createV1Alpha2Scheme() (*runtime.Scheme, error) { + scheme := runtime.NewScheme() + for _, api := range (runtime.SchemeBuilder{ + porchv1alpha2.AddToScheme, + }) { + if err := api(scheme); err != nil { + return nil, err + } + } + return scheme, nil +} + +func TestV1Alpha2NewRunner(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + cfg := &genericclioptions.ConfigFlags{Namespace: &ns} + + r := newV1Alpha2Runner(ctx, cfg) + + if r == nil { + t.Fatal("expected non-nil runner") + } + if r.ctx != ctx { + t.Errorf("expected context to be set") + } + if r.cfg != cfg { + t.Errorf("expected config to be set") + } +} + +func TestV1Alpha2PreRunE(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + cfg := &genericclioptions.ConfigFlags{Namespace: &ns} + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: cfg, + } + + cmd := &cobra.Command{} + err := r.preRunE(cmd, []string{"source-pkg"}) + + // preRunE should succeed (it just creates a client) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if r.client == nil { + t.Error("expected client to be set") + } + if r.sourceName != "source-pkg" { + t.Errorf("expected sourceName to be set to 'source-pkg', got %q", r.sourceName) + } +} + +func TestV1Alpha2PreRunEMissingArgs(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + cfg := &genericclioptions.ConfigFlags{Namespace: &ns} + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: cfg, + } + + cmd := &cobra.Command{} + err := r.preRunE(cmd, []string{}) + + // preRunE should fail with missing args + if err == nil { + t.Error("expected error for missing SOURCE_PACKAGE argument") + } +} + +func TestV1Alpha2CopyCmd(t *testing.T) { + ns := "ns" + sourceName := "repo--my-pkg-v1" + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(&porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: sourceName, + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + RepositoryName: "repo", + PackageName: "my-pkg", + WorkspaceName: "v1", + Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished, + }, + }).Build() + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + sourceName: sourceName, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.Flags().String("workspace", "v2", "") + cmd.SetOut(output) + + err = r.runE(cmd, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if diff := cmp.Diff("repo.my-pkg.v2 created\n", output.String()); diff != "" { + t.Errorf("Unexpected output (-want, +got): %s", diff) + } +} + +func TestV1Alpha2CopyMissingWorkspace(t *testing.T) { + ns := "ns" + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + sourceName: "some-source", + } + + cmd := &cobra.Command{} + cmd.Flags().String("workspace", "", "") + + err = r.runE(cmd, nil) + if err == nil { + t.Fatal("expected error for missing --workspace") + } +} + +func TestV1Alpha2CopySourceNotFound(t *testing.T) { + ns := "ns" + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + sourceName: "nonexistent-source", + } + + cmd := &cobra.Command{} + cmd.Flags().String("workspace", "v2", "") + + err = r.runE(cmd, nil) + if err == nil { + t.Fatal("expected error for nonexistent source") + } +} + +func TestV1Alpha2RunEGetError(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Get(ctx, mock.AnythingOfType("types.NamespacedName"), mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("package not found")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + cmd.SetErr(output) + cmd.Flags().String("workspace", "v2", "") + + err := r.runE(cmd, nil) + if err == nil { + t.Error("expected error for package not found") + } +} + +func TestV1Alpha2PreRunEValidation(t *testing.T) { + ns := "ns" + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + } + + // Verify runner fields are empty before setup + if r.sourceName != "" { + t.Fatal("expected empty sourceName before preRunE") + } +} + +func TestV1Alpha2PreRunEClientError(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + // Use invalid kubeconfig to trigger client creation error + cfg := &genericclioptions.ConfigFlags{ + Namespace: &ns, + KubeConfig: func() *string { s := "/nonexistent/kubeconfig"; return &s }(), + } + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: cfg, + } + + cmd := &cobra.Command{} + err := r.preRunE(cmd, []string{"source-pkg"}) + + // preRunE should fail with client creation error + if err == nil { + t.Error("expected error for invalid kubeconfig") + } +} diff --git a/pkg/cli/commands/rpkg/del/command.go b/pkg/cli/commands/rpkg/del/command.go index 7dd292a11..5dc1b8c3a 100644 --- a/pkg/cli/commands/rpkg/del/command.go +++ b/pkg/cli/commands/rpkg/del/command.go @@ -1,4 +1,4 @@ -// Copyright 2022 The kpt and Nephio Authors +// Copyright 2022,2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -58,7 +58,10 @@ func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner } func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { - return newRunner(ctx, rcg).Command + v1 := newRunner(ctx, rcg) + v2 := newV1Alpha2Runner(ctx, rcg) + cliutils.WrapVersionDispatch(v1.Command, v2.preRunE, v2.runE) + return v1.Command } type runner struct { diff --git a/pkg/cli/commands/rpkg/del/command_test.go b/pkg/cli/commands/rpkg/del/command_test.go index 63998f919..34fa6f7d8 100644 --- a/pkg/cli/commands/rpkg/del/command_test.go +++ b/pkg/cli/commands/rpkg/del/command_test.go @@ -111,3 +111,13 @@ func TestCmd(t *testing.T) { }) } } + +func TestNewCommand(t *testing.T) { + ns := "default" + flags := genericclioptions.NewConfigFlags(true) + flags.Namespace = &ns + cmd := NewCommand(context.Background(), flags) + if cmd == nil { + t.Fatal("NewCommand returned nil") + } +} diff --git a/pkg/cli/commands/rpkg/del/v1alpha2.go b/pkg/cli/commands/rpkg/del/v1alpha2.go new file mode 100644 index 000000000..f2964f913 --- /dev/null +++ b/pkg/cli/commands/rpkg/del/v1alpha2.go @@ -0,0 +1,79 @@ +// Copyright 2026 The kpt and Nephio 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 del + +import ( + "context" + "fmt" + "strings" + + "github.com/kptdev/kpt/pkg/lib/errors" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + cliutils "github.com/nephio-project/porch/internal/cliutils" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type v1alpha2Runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client +} + +func newV1Alpha2Runner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *v1alpha2Runner { + return &v1alpha2Runner{ctx: ctx, cfg: rcg} +} + +func (r *v1alpha2Runner) preRunE(_ *cobra.Command, _ []string) error { + const op errors.Op = command + ".preRunE" + if r.client == nil { + c, err := cliutils.CreateV1Alpha2ClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = c + } + return nil +} + +func (r *v1alpha2Runner) runE(cmd *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + var messages []string + + for _, pkg := range args { + pr := &porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: *r.cfg.Namespace, + Name: pkg, + }, + } + if err := r.client.Delete(r.ctx, pr); err != nil { + messages = append(messages, err.Error()) + fmt.Fprintf(cmd.ErrOrStderr(), "%s failed (%s)\n", pkg, err) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "%s deleted\n", pkg) + } + } + if len(messages) > 0 { + return errors.E(op, fmt.Errorf("errors:\n %s", strings.Join(messages, "\n "))) + } + return nil +} diff --git a/pkg/cli/commands/rpkg/del/v1alpha2_test.go b/pkg/cli/commands/rpkg/del/v1alpha2_test.go new file mode 100644 index 000000000..9451f5362 --- /dev/null +++ b/pkg/cli/commands/rpkg/del/v1alpha2_test.go @@ -0,0 +1,181 @@ +// Copyright 2026 The kpt and Nephio 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 del + +import ( + "context" + "io" + "os" + "testing" + + "github.com/google/go-cmp/cmp" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/util" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestV1Alpha2NewRunner(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + cfg := &genericclioptions.ConfigFlags{Namespace: &ns} + + r := newV1Alpha2Runner(ctx, cfg) + + if r == nil { + t.Fatal("expected non-nil runner") + } + if r.ctx != ctx { + t.Errorf("expected context to be set") + } + if r.cfg != cfg { + t.Errorf("expected config to be set") + } +} + +func TestV1Alpha2PreRunE(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + cfg := &genericclioptions.ConfigFlags{Namespace: &ns} + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: cfg, + } + + cmd := &cobra.Command{} + err := r.preRunE(cmd, []string{}) + + // preRunE should succeed (it just creates a client) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if r.client == nil { + t.Error("expected client to be set") + } +} + +func TestV1Alpha2PreRunEClientError(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + // Use invalid kubeconfig to trigger client creation error + cfg := &genericclioptions.ConfigFlags{ + Namespace: &ns, + KubeConfig: func() *string { s := "/nonexistent/kubeconfig"; return &s }(), + } + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: cfg, + } + + cmd := &cobra.Command{} + err := r.preRunE(cmd, []string{}) + + // preRunE should fail with client creation error + if err == nil { + t.Error("expected error for invalid kubeconfig") + } +} + +func TestV1Alpha2Cmd(t *testing.T) { + pkgRevName := "test-fjdos9u2nfe2f32" + scheme := util.V1Alpha2Scheme(t) + testCases := map[string]struct { + output string + wantErr bool + ns string + }{ + "Package not found in ns": { + output: pkgRevName + " failed (packagerevisions.porch.kpt.dev \"" + pkgRevName + "\" not found)\n", + ns: "doesnotexist", + wantErr: true, + }, + "delete package": { + output: pkgRevName + " deleted\n", + ns: "ns", + }, + } + + for tn := range testCases { + tc := testCases[tn] + t.Run(tn, func(t *testing.T) { + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(util.NewV1Alpha2PackageRevision("ns", pkgRevName)). + Build() + + cmd := &cobra.Command{} + o := os.Stdout + e := os.Stderr + read, write, _ := os.Pipe() + os.Stdout = write + os.Stderr = write + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{ + Namespace: &tc.ns, + }, + client: c, + } + go func() { + defer write.Close() + err := r.runE(cmd, []string{pkgRevName}) + if err != nil && !tc.wantErr { + t.Errorf("unexpected error: %v", err) + } + }() + out, _ := io.ReadAll(read) + os.Stdout = o + os.Stderr = e + + if diff := cmp.Diff(tc.output, string(out)); diff != "" { + t.Errorf("Unexpected result (-want, +got): %s", diff) + } + }) + } +} + +func TestV1Alpha2DeleteActuallyRemoves(t *testing.T) { + pkgRevName := "test-pkg" + ns := "ns" + scheme := util.V1Alpha2Scheme(t) + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(util.NewV1Alpha2PackageRevision(ns, pkgRevName)). + Build() + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + + err := r.runE(&cobra.Command{}, []string{pkgRevName}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify the object is gone + var pr porchv1alpha2.PackageRevision + err = c.Get(context.Background(), client.ObjectKey{Namespace: ns, Name: pkgRevName}, &pr) + if err == nil { + t.Fatal("expected not-found error, got nil") + } +} diff --git a/pkg/cli/commands/rpkg/get/command.go b/pkg/cli/commands/rpkg/get/command.go index c50173e84..e8a18f438 100644 --- a/pkg/cli/commands/rpkg/get/command.go +++ b/pkg/cli/commands/rpkg/get/command.go @@ -42,7 +42,7 @@ import ( const ( command = "cmdrpkgget" - resourceName = "packagerevisions.porch.kpt.dev" + resourceName = "packagerevisions.v1alpha1.porch.kpt.dev" ) func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { @@ -77,7 +77,9 @@ func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner } func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { - return newRunner(ctx, rcg).Command + r := newRunner(ctx, rcg) + cliutils.WrapVersionDispatch(r.Command, r.preRunE, r.runV1Alpha2) + return r.Command } type runner struct { diff --git a/pkg/cli/commands/rpkg/get/v1alpha2.go b/pkg/cli/commands/rpkg/get/v1alpha2.go new file mode 100644 index 000000000..4e739ce81 --- /dev/null +++ b/pkg/cli/commands/rpkg/get/v1alpha2.go @@ -0,0 +1,201 @@ +// Copyright 2026 The kpt and Nephio 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 get + +import ( + "fmt" + "strconv" + "strings" + + "github.com/kptdev/kpt/pkg/lib/errors" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/client-go/rest" + "k8s.io/klog/v2" +) + +const ( + // v1alpha2 CRD resource — same plural name, different API version. + // The resource builder resolves the version via discovery. + resourceNameV1Alpha2 = "packagerevisions.v1alpha2.porch.kpt.dev" +) + +// runV1Alpha2 is the v1alpha2 variant of the get command's runE. +// The only differences from v1alpha1: +// - resource name targets v1alpha2 +// - field selector uses status.revision instead of spec.revision +func (r *runner) runV1Alpha2(cmd *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + + if r.showKptfile { + if len(args) != 1 { + return errors.E(op, fmt.Errorf("--show-kptfile requires exactly one package revision name as an argument")) + } + return r.showKptfileContent(cmd, args[0]) + } + + var objs []runtime.Object + b, err := r.getFlags.ResourceBuilder() + if err != nil { + return err + } + + if r.requestTable { + scheme := runtime.NewScheme() + if err := metav1.AddMetaToScheme(scheme); err != nil { + return fmt.Errorf("error building runtime.Scheme: %w", err) + } + b = b.WithScheme(scheme, schema.GroupVersion{Version: "v1"}) + } else { + b = b.Unstructured() + } + + useSelectors := true + if len(args) > 0 { + b = b.ResourceNames(resourceNameV1Alpha2, args...) + useSelectors = false + } else { + b = b.ResourceTypes(resourceNameV1Alpha2) + } + + if useSelectors { + fieldSelector := fields.Everything() + if r.revision != -2 { + // v1alpha2: revision is in status, not spec + fieldSelector = fields.OneTermEqualSelector("status.revision", strconv.FormatInt(r.revision, 10)) + } + if r.workspace != "" { + fieldSelector = fields.OneTermEqualSelector("spec.workspaceName", r.workspace) + } + if r.packageName != "" { + fieldSelector = fields.OneTermEqualSelector("spec.packageName", r.packageName) + } + if s := fieldSelector.String(); s != "" { + b = b.FieldSelectorParam(s) + } else { + b = b.SelectAllParam(true) + } + } + + b = b.ContinueOnError(). + Latest(). + Flatten() + + if r.requestTable { + b = b.TransformRequests(func(req *rest.Request) { + req.SetHeader("Accept", strings.Join([]string{ + "application/json;as=Table;g=meta.k8s.io;v=v1", + "application/json", + }, ",")) + }) + } + + res := b.Do() + if err := res.Err(); err != nil { + return errors.E(op, err) + } + + infos, err := res.Infos() + if err != nil { + return errors.E(op, err) + } + + for _, i := range infos { + if table, ok := i.Object.(*metav1.Table); ok { + for i := range table.Rows { + row := &table.Rows[i] + if row.Object.Object == nil && row.Object.Raw != nil { + u := &unstructured.Unstructured{} + if err := u.UnmarshalJSON(row.Object.Raw); err != nil { + klog.Warningf("error parsing raw object: %v", err) + } + row.Object.Object = u + } + } + } + } + + for _, i := range infos { + switch obj := i.Object.(type) { + case *unstructured.Unstructured: + match, err := r.packageRevisionMatchesV1Alpha2(obj) + if err != nil { + return errors.E(op, err) + } + if match { + objs = append(objs, obj) + } + case *metav1.Table: + if err := r.filterTableRowsV1Alpha2(obj); err != nil { + return err + } + objs = append(objs, obj) + default: + return errors.E(op, fmt.Sprintf("Unrecognized response %T", obj)) + } + } + + printer, err := r.printFlags.ToPrinter() + if err != nil { + return errors.E(op, err) + } + + w := printers.GetNewTabWriter(cmd.OutOrStdout()) + for _, obj := range objs { + if err := printer.PrintObj(obj, w); err != nil { + return errors.E(op, err) + } + } + return w.Flush() +} + +// packageRevisionMatchesV1Alpha2 filters unstructured objects using v1alpha2 field paths. +// In v1alpha2, revision is in status.revision instead of spec.revision. +func (r *runner) packageRevisionMatchesV1Alpha2(o *unstructured.Unstructured) (bool, error) { + packageName, _, err := unstructured.NestedString(o.Object, "spec", "packageName") + if err != nil { + return false, err + } + // v1alpha2: revision is in status + revision, _, err := unstructured.NestedInt64(o.Object, "status", "revision") + if err != nil { + return false, err + } + workspace, _, err := unstructured.NestedString(o.Object, "spec", "workspaceName") + if err != nil { + return false, err + } + if r.packageName != "" && r.packageName != packageName { + return false, nil + } + if r.revision != -2 && r.revision != revision { + return false, nil + } + if r.workspace != "" && r.workspace != workspace { + return false, nil + } + return true, nil +} + +// filterTableRowsV1Alpha2 filters table rows for v1alpha2. +// Column names come from kubebuilder printcolumn markers — same names as v1alpha1. +func (r *runner) filterTableRowsV1Alpha2(table *metav1.Table) error { + return r.filterTableRows(table) +} diff --git a/pkg/cli/commands/rpkg/get/v1alpha2_test.go b/pkg/cli/commands/rpkg/get/v1alpha2_test.go new file mode 100644 index 000000000..f4b216a9f --- /dev/null +++ b/pkg/cli/commands/rpkg/get/v1alpha2_test.go @@ -0,0 +1,442 @@ +// Copyright 2026 The kpt and Nephio 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 get + +import ( + "bytes" + "context" + "testing" + + "github.com/kptdev/kpt/pkg/lib/util/cmdutil" + porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestPackageRevisionMatchesV1Alpha2(t *testing.T) { + tests := []struct { + name string + flags runner + object *unstructured.Unstructured + expected bool + }{ + { + name: "match all filters - revision in status", + flags: runner{ + packageName: "pkg1", + revision: 1, + workspace: "ws1", + }, + object: &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "packageName": "pkg1", + "workspaceName": "ws1", + }, + "status": map[string]any{ + "revision": int64(1), + }, + }, + }, + expected: true, + }, + { + name: "revision mismatch in status", + flags: runner{ + packageName: "pkg1", + revision: 2, + workspace: "ws1", + }, + object: &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "packageName": "pkg1", + "workspaceName": "ws1", + }, + "status": map[string]any{ + "revision": int64(1), + }, + }, + }, + expected: false, + }, + { + name: "no filters set - matches everything", + flags: runner{ + revision: -2, // default "unset" value + }, + object: &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "packageName": "anything", + "workspaceName": "ws", + }, + "status": map[string]any{ + "revision": int64(5), + }, + }, + }, + expected: true, + }, + { + name: "package name mismatch", + flags: runner{ + packageName: "pkg1", + revision: -2, + }, + object: &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "packageName": "pkg2", + }, + "status": map[string]any{}, + }, + }, + expected: false, + }, + { + name: "workspace mismatch", + flags: runner{ + workspace: "ws1", + revision: -2, + }, + object: &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "workspaceName": "ws2", + }, + "status": map[string]any{}, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match, err := tt.flags.packageRevisionMatchesV1Alpha2(tt.object) + assert.NoError(t, err) + assert.Equal(t, tt.expected, match) + }) + } +} + +func TestFilterTableRowsV1Alpha2(t *testing.T) { + r := &runner{ + packageName: "pkg1", + revision: -2, + } + + table := &metav1.Table{ + ColumnDefinitions: []metav1.TableColumnDefinition{ + {Name: "Name"}, + {Name: "Package"}, + {Name: "WorkspaceName"}, + {Name: "Revision"}, + }, + Rows: []metav1.TableRow{ + {Cells: []any{"pr1", "pkg1", "ws1", int64(1)}}, + {Cells: []any{"pr2", "pkg2", "ws2", int64(2)}}, + {Cells: []any{"pr3", "pkg1", "ws3", int64(3)}}, + }, + } + + err := r.filterTableRowsV1Alpha2(table) + assert.NoError(t, err) + assert.Len(t, table.Rows, 2) +} + +func TestV1Alpha2RunV1Alpha2(t *testing.T) { + ctx := context.Background() + ns := "test-ns" + gcf := genericclioptions.ConfigFlags{Namespace: &ns} + r := newRunner(ctx, &gcf) + + cmd := &cobra.Command{} + + // runV1Alpha2 requires a ResourceBuilder which needs a real config + // For now, just verify it doesn't panic with empty args + err := r.runV1Alpha2(cmd, []string{}) + // We expect an error since we don't have a real k8s config + // but the function should be callable + _ = err +} + +func TestV1Alpha2ShowKptfile(t *testing.T) { + ctx := context.Background() + ns := "ns" + + scheme := runtime.NewScheme() + if err := porchapi.AddToScheme(scheme); err != nil { + t.Fatalf("Failed to add scheme: %v", err) + } + + kptfileContent := `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: test-pkg +` + + t.Run("happy path", func(t *testing.T) { + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&porchapi.PackageRevisionResources{ + ObjectMeta: metav1.ObjectMeta{Name: "test-repo.test-pkg.v1", Namespace: ns}, + Spec: porchapi.PackageRevisionResourcesSpec{Resources: map[string]string{"Kptfile": kptfileContent}}, + }).Build() + r := &runner{ + ctx: ctx, + showKptfile: true, + getFlags: cmdutil.Options{ConfigFlags: &genericclioptions.ConfigFlags{Namespace: &ns}}, + client: c, + } + out := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(out) + err := r.runV1Alpha2(cmd, []string{"test-repo.test-pkg.v1"}) + assert.NoError(t, err) + assert.Equal(t, kptfileContent, out.String()) + }) + + t.Run("no args", func(t *testing.T) { + r := &runner{ + ctx: ctx, + showKptfile: true, + getFlags: cmdutil.Options{ConfigFlags: &genericclioptions.ConfigFlags{Namespace: &ns}}, + } + cmd := &cobra.Command{} + err := r.runV1Alpha2(cmd, []string{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "--show-kptfile requires exactly one package revision name") + }) +} + +func TestV1Alpha2RunV1Alpha2WithFilters(t *testing.T) { + tests := []struct { + name string + packageName string + revision int64 + workspace string + }{ + { + name: "filter by package name", + packageName: "test-pkg", + revision: -2, + workspace: "", + }, + { + name: "filter by revision", + packageName: "", + revision: 1, + workspace: "", + }, + { + name: "filter by workspace", + packageName: "", + revision: -2, + workspace: "v1", + }, + { + name: "filter by all", + packageName: "test-pkg", + revision: 1, + workspace: "v1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + ns := "test-ns" + gcf := genericclioptions.ConfigFlags{Namespace: &ns} + r := newRunner(ctx, &gcf) + r.packageName = tt.packageName + r.revision = tt.revision + r.workspace = tt.workspace + + cmd := &cobra.Command{} + + // Just verify the function handles different filter combinations + err := r.runV1Alpha2(cmd, []string{}) + _ = err + }) + } +} + +func TestV1Alpha2PackageRevisionMatchesV1Alpha2WithErrors(t *testing.T) { + tests := []struct { + name string + object *unstructured.Unstructured + wantError bool + }{ + { + name: "invalid package name type", + object: &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "packageName": 123, // wrong type + }, + "status": map[string]any{ + "revision": int64(1), + }, + }, + }, + wantError: true, + }, + { + name: "invalid revision type", + object: &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "packageName": "pkg", + }, + "status": map[string]any{ + "revision": "not-a-number", // wrong type + }, + }, + }, + wantError: true, + }, + { + name: "invalid workspace type", + object: &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "packageName": "pkg", + "workspaceName": 456, // wrong type + }, + "status": map[string]any{ + "revision": int64(1), + }, + }, + }, + wantError: true, + }, + { + name: "missing spec", + object: &unstructured.Unstructured{ + Object: map[string]any{ + "status": map[string]any{ + "revision": int64(1), + }, + }, + }, + wantError: false, // missing fields are OK, just return false + }, + { + name: "missing status", + object: &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "packageName": "pkg", + }, + }, + }, + wantError: false, // missing fields are OK + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &runner{ + packageName: "pkg", + revision: 1, + workspace: "ws", + } + + _, err := r.packageRevisionMatchesV1Alpha2(tt.object) + if tt.wantError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestV1Alpha2FilterTableRowsV1Alpha2WithFilters(t *testing.T) { + tests := []struct { + name string + packageName string + revision int64 + workspace string + inputRows int + expectedRows int + }{ + { + name: "filter by package name", + packageName: "pkg1", + revision: -2, + workspace: "", + inputRows: 3, + expectedRows: 2, + }, + { + name: "filter by revision", + packageName: "", + revision: 1, + workspace: "", + inputRows: 3, + expectedRows: 1, + }, + { + name: "filter by workspace", + packageName: "", + revision: -2, + workspace: "ws1", + inputRows: 3, + expectedRows: 1, + }, + { + name: "no filters - all rows", + packageName: "", + revision: -2, + workspace: "", + inputRows: 3, + expectedRows: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &runner{ + packageName: tt.packageName, + revision: tt.revision, + workspace: tt.workspace, + } + + table := &metav1.Table{ + ColumnDefinitions: []metav1.TableColumnDefinition{ + {Name: "Name"}, + {Name: "Package"}, + {Name: "WorkspaceName"}, + {Name: "Revision"}, + }, + Rows: []metav1.TableRow{ + {Cells: []any{"pr1", "pkg1", "ws1", int64(1)}}, + {Cells: []any{"pr2", "pkg2", "ws2", int64(2)}}, + {Cells: []any{"pr3", "pkg1", "ws3", int64(3)}}, + }, + } + + err := r.filterTableRowsV1Alpha2(table) + assert.NoError(t, err) + assert.Len(t, table.Rows, tt.expectedRows) + }) + } +} diff --git a/pkg/cli/commands/rpkg/init/command.go b/pkg/cli/commands/rpkg/init/command.go index 37ac8b43a..3830a427f 100644 --- a/pkg/cli/commands/rpkg/init/command.go +++ b/pkg/cli/commands/rpkg/init/command.go @@ -1,4 +1,4 @@ -// Copyright 2022 The kpt and Nephio Authors +// Copyright 2022,2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -33,7 +33,10 @@ const ( ) func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { - return newRunner(ctx, rcg).Command + v1 := newRunner(ctx, rcg) + v2 := newV1Alpha2Runner(ctx, rcg) + cliutils.WrapVersionDispatch(v1.Command, v2.preRunE, v2.runE) + return v1.Command } func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { diff --git a/pkg/cli/commands/rpkg/init/command_test.go b/pkg/cli/commands/rpkg/init/command_test.go index e9ef5c82c..27c3919a8 100644 --- a/pkg/cli/commands/rpkg/init/command_test.go +++ b/pkg/cli/commands/rpkg/init/command_test.go @@ -109,3 +109,13 @@ func TestCmd(t *testing.T) { }) } } + +func TestNewCommand(t *testing.T) { + ns := "default" + flags := genericclioptions.NewConfigFlags(true) + flags.Namespace = &ns + cmd := NewCommand(context.Background(), flags) + if cmd == nil { + t.Fatal("NewCommand returned nil") + } +} diff --git a/pkg/cli/commands/rpkg/init/v1alpha2.go b/pkg/cli/commands/rpkg/init/v1alpha2.go new file mode 100644 index 000000000..e63317a48 --- /dev/null +++ b/pkg/cli/commands/rpkg/init/v1alpha2.go @@ -0,0 +1,123 @@ +// Copyright 2026 The kpt and Nephio 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 init + +import ( + "context" + "fmt" + + "github.com/kptdev/kpt/pkg/lib/errors" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + cliutils "github.com/nephio-project/porch/internal/cliutils" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/util" + pkgutil "github.com/nephio-project/porch/pkg/util" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type v1alpha2Runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + + // Flags — shared with v1alpha1 runner via the same cobra.Command + Keywords []string + Description string + Site string + name string + repository string + workspace string +} + +func newV1Alpha2Runner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *v1alpha2Runner { + return &v1alpha2Runner{ctx: ctx, cfg: rcg} +} + +func (r *v1alpha2Runner) preRunE(cmd *cobra.Command, args []string) error { + const op errors.Op = command + ".preRunE" + + if r.client == nil { + c, err := cliutils.CreateV1Alpha2ClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = c + } + + if len(args) < 1 { + return errors.E(op, "PACKAGE_NAME is a required positional argument") + } + + // Read shared flags from the command + r.repository, _ = cmd.Flags().GetString("repository") + r.workspace, _ = cmd.Flags().GetString("workspace") + r.Description, _ = cmd.Flags().GetString("description") + r.Site, _ = cmd.Flags().GetString("site") + r.Keywords, _ = cmd.Flags().GetStringSlice("keywords") + + if r.repository == "" { + return errors.E(op, fmt.Errorf("--repository is required to specify target repository")) + } + if r.workspace == "" { + return errors.E(op, fmt.Errorf("--workspace is required to specify workspace name")) + } + + r.name = args[0] + pkgExists, err := util.PackageAlreadyExistsV1Alpha2(r.ctx, r.client, r.repository, r.name, *r.cfg.Namespace) + if err != nil { + return err + } + if pkgExists { + return fmt.Errorf("`init` cannot create a new revision for package %q that already exists in repo %q; make subsequent revisions using `copy`", + r.name, r.repository) + } + return nil +} + +func (r *v1alpha2Runner) runE(cmd *cobra.Command, _ []string) error { + const op errors.Op = command + ".runE" + + pr := &porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: *r.cfg.Namespace, + Name: pkgutil.ComposePkgRevObjName(r.repository, "", r.name, r.workspace), + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: r.name, + WorkspaceName: r.workspace, + RepositoryName: r.repository, + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + Source: &porchv1alpha2.PackageSource{ + Init: &porchv1alpha2.PackageInitSpec{ + Description: r.Description, + Keywords: r.Keywords, + Site: r.Site, + }, + }, + }, + } + if err := r.client.Create(r.ctx, pr); err != nil { + return errors.E(op, err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "%s created\n", pr.Name) + return nil +} diff --git a/pkg/cli/commands/rpkg/init/v1alpha2_test.go b/pkg/cli/commands/rpkg/init/v1alpha2_test.go new file mode 100644 index 000000000..79e42cb88 --- /dev/null +++ b/pkg/cli/commands/rpkg/init/v1alpha2_test.go @@ -0,0 +1,502 @@ +// Copyright 2026 The kpt and Nephio 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 init + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/util" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + mockclient "github.com/nephio-project/porch/test/mockery/mocks/external/sigs.k8s.io/controller-runtime/pkg/client" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func createV1Alpha2Scheme() (*runtime.Scheme, error) { + scheme := runtime.NewScheme() + for _, api := range (runtime.SchemeBuilder{ + porchv1alpha2.AddToScheme, + }) { + if err := api(scheme); err != nil { + return nil, err + } + } + return scheme, nil +} + +func TestV1Alpha2NewRunner(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + cfg := &genericclioptions.ConfigFlags{Namespace: &ns} + + r := newV1Alpha2Runner(ctx, cfg) + + if r == nil { + t.Fatal("expected non-nil runner") + } + if r.ctx != ctx { + t.Errorf("expected context to be set") + } + if r.cfg != cfg { + t.Errorf("expected config to be set") + } +} + +func TestV1Alpha2InitCmd(t *testing.T) { + ns := "ns" + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + name: "new-pkg", + repository: "repo", + workspace: "v1", + Description: "test package", + Keywords: []string{"test"}, + Site: "https://example.com", + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err = r.runE(cmd, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if diff := cmp.Diff("repo.new-pkg.v1 created\n", output.String()); diff != "" { + t.Errorf("Unexpected output (-want, +got): %s", diff) + } + + // Verify the object was created with correct fields + var pr porchv1alpha2.PackageRevision + if err := c.Get(context.Background(), client.ObjectKey{Namespace: ns, Name: "repo.new-pkg.v1"}, &pr); err != nil { + t.Fatalf("failed to get created PR: %v", err) + } + if pr.Spec.PackageName != "new-pkg" { + t.Errorf("expected packageName 'new-pkg', got %q", pr.Spec.PackageName) + } + if pr.Spec.Lifecycle != porchv1alpha2.PackageRevisionLifecycleDraft { + t.Errorf("expected lifecycle Draft, got %s", pr.Spec.Lifecycle) + } + if pr.Spec.Source == nil || pr.Spec.Source.Init == nil { + t.Fatal("expected source.init to be set") + } + if pr.Spec.Source.Init.Description != "test package" { + t.Errorf("expected description 'test package', got %q", pr.Spec.Source.Init.Description) + } +} + +func TestV1Alpha2InitPackageAlreadyExists(t *testing.T) { + ns := "ns" + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(&porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: "repo.existing-pkg.v1", + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + RepositoryName: "repo", + PackageName: "existing-pkg", + }, + }).Build() + + // Directly test the validation logic (preRunE tries to create a real k8s client) + pkgExists, err := util.PackageAlreadyExistsV1Alpha2(context.Background(), c, "repo", "existing-pkg", ns) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !pkgExists { + t.Fatal("expected package to already exist") + } +} + +func TestV1Alpha2InitCreateError(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("create failed")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + name: "new-pkg", + repository: "repo", + workspace: "v1", + Description: "test package", + Keywords: []string{"test"}, + Site: "https://example.com", + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "create failed") +} + +func TestV1Alpha2InitCreateErrorAlreadyExists(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("already exists")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + name: "existing-pkg", + repository: "repo", + workspace: "v1", + Description: "test package", + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") +} + +func TestV1Alpha2InitCreateErrorPermissionDenied(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("permission denied")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + name: "new-pkg", + repository: "repo", + workspace: "v1", + Description: "test package", + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") +} + +func TestV1Alpha2InitCreateErrorInvalidRequest(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("invalid package revision spec")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + name: "new-pkg", + repository: "repo", + workspace: "v1", + Description: "test package", + Keywords: []string{"test", "init"}, + Site: "https://example.com", + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid package revision spec") +} + +func TestV1Alpha2InitCreateErrorNetworkFailure(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("connection refused")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + name: "new-pkg", + repository: "repo", + workspace: "v1", + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "connection refused") +} + +func TestV1Alpha2InitCreateErrorTimeout(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("context deadline exceeded")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + name: "new-pkg", + repository: "repo", + workspace: "v1", + Description: "test package", + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "context deadline exceeded") +} + +func TestV1Alpha2InitCreateErrorInternalServerError(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("internal server error")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + name: "new-pkg", + repository: "repo", + workspace: "v1", + Description: "test package", + Keywords: []string{"test"}, + Site: "https://example.com", + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "internal server error") +} + +func TestV1Alpha2InitCreateErrorQuotaExceeded(t *testing.T) { + ns := "ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(ctx, mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("quota exceeded")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + name: "new-pkg", + repository: "repo", + workspace: "v1", + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "quota exceeded") +} + +// PreRunE validation tests +func TestV1Alpha2InitPreRunEMissingArgs(t *testing.T) { + ns := "ns" + ctx := context.Background() + + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + + cmd := &cobra.Command{} + cmd.Flags().String("repository", "repo", "") + cmd.Flags().String("workspace", "v1", "") + cmd.Flags().String("description", "", "") + cmd.Flags().String("site", "", "") + cmd.Flags().StringSlice("keywords", nil, "") + + err = r.preRunE(cmd, []string{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "PACKAGE_NAME is a required positional argument") +} + +func TestV1Alpha2InitPreRunEMissingRepository(t *testing.T) { + ns := "ns" + ctx := context.Background() + + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + + cmd := &cobra.Command{} + cmd.Flags().String("repository", "", "") + cmd.Flags().String("workspace", "v1", "") + cmd.Flags().String("description", "", "") + cmd.Flags().String("site", "", "") + cmd.Flags().StringSlice("keywords", nil, "") + + err = r.preRunE(cmd, []string{"my-pkg"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "--repository is required") +} + +func TestV1Alpha2InitPreRunEMissingWorkspace(t *testing.T) { + ns := "ns" + ctx := context.Background() + + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + + cmd := &cobra.Command{} + cmd.Flags().String("repository", "repo", "") + cmd.Flags().String("workspace", "", "") + cmd.Flags().String("description", "", "") + cmd.Flags().String("site", "", "") + cmd.Flags().StringSlice("keywords", nil, "") + + err = r.preRunE(cmd, []string{"my-pkg"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "--workspace is required") +} + +func TestV1Alpha2InitPreRunEValidFlags(t *testing.T) { + ns := "ns" + ctx := context.Background() + + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + + cmd := &cobra.Command{} + cmd.Flags().String("repository", "repo", "") + cmd.Flags().String("workspace", "v1", "") + cmd.Flags().String("description", "test package", "") + cmd.Flags().String("site", "https://example.com", "") + cmd.Flags().StringSlice("keywords", []string{"test", "pkg"}, "") + + err = r.preRunE(cmd, []string{"my-pkg"}) + assert.NoError(t, err) + assert.Equal(t, "my-pkg", r.name) + assert.Equal(t, "repo", r.repository) + assert.Equal(t, "v1", r.workspace) + assert.Equal(t, "test package", r.Description) + assert.Equal(t, "https://example.com", r.Site) + assert.Equal(t, []string{"test", "pkg"}, r.Keywords) +} diff --git a/pkg/cli/commands/rpkg/propose/command.go b/pkg/cli/commands/rpkg/propose/command.go index f274a6683..8e7c2ffe9 100644 --- a/pkg/cli/commands/rpkg/propose/command.go +++ b/pkg/cli/commands/rpkg/propose/command.go @@ -1,4 +1,4 @@ -// Copyright 2022 The kpt and Nephio Authors +// Copyright 2022,2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -34,7 +34,10 @@ const ( ) func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { - return newRunner(ctx, rcg).Command + v1 := newRunner(ctx, rcg) + v2 := newV1Alpha2Runner(ctx, rcg) + cliutils.WrapVersionDispatch(v1.Command, v2.preRunE, v2.runE) + return v1.Command } func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { diff --git a/pkg/cli/commands/rpkg/propose/command_test.go b/pkg/cli/commands/rpkg/propose/command_test.go index 026e228f4..095f950f4 100644 --- a/pkg/cli/commands/rpkg/propose/command_test.go +++ b/pkg/cli/commands/rpkg/propose/command_test.go @@ -149,3 +149,13 @@ func TestLastErrWorkaround(t *testing.T) { t.Fatal("expected error but got nil") } } + +func TestNewCommand(t *testing.T) { + ns := "default" + flags := genericclioptions.NewConfigFlags(true) + flags.Namespace = &ns + cmd := NewCommand(context.Background(), flags) + if cmd == nil { + t.Fatal("NewCommand returned nil") + } +} diff --git a/pkg/cli/commands/rpkg/propose/v1alpha2.go b/pkg/cli/commands/rpkg/propose/v1alpha2.go new file mode 100644 index 000000000..1841ad880 --- /dev/null +++ b/pkg/cli/commands/rpkg/propose/v1alpha2.go @@ -0,0 +1,105 @@ +// Copyright 2026 The kpt and Nephio 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 propose + +import ( + "context" + "fmt" + "strings" + + "github.com/kptdev/kpt/pkg/lib/errors" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + cliutils "github.com/nephio-project/porch/internal/cliutils" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type v1alpha2Runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + cmd *cobra.Command +} + +func newV1Alpha2Runner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *v1alpha2Runner { + return &v1alpha2Runner{ctx: ctx, cfg: rcg} +} + +func (r *v1alpha2Runner) preRunE(_ *cobra.Command, _ []string) error { + const op errors.Op = command + ".preRunE" + if r.client == nil { + c, err := cliutils.CreateV1Alpha2ClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = c + } + return nil +} + +func (r *v1alpha2Runner) runE(cmd *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + r.cmd = cmd + var messages []string + namespace := *r.cfg.Namespace + + for _, name := range args { + key := client.ObjectKey{Namespace: namespace, Name: name} + var lastErr error + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + var pr porchv1alpha2.PackageRevision + if err := r.client.Get(r.ctx, key, &pr); err != nil { + lastErr = err + return err + } + if !porchv1alpha2.PackageRevisionIsReady(pr.Spec.ReadinessGates, pr.Status.PackageConditions) { + lastErr = fmt.Errorf("readiness conditions not met") + return lastErr + } + switch pr.Spec.Lifecycle { + case porchv1alpha2.PackageRevisionLifecycleDraft: + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecycleProposed + err := r.client.Update(r.ctx, &pr) + if err == nil { + lastErr = nil + fmt.Fprintf(cmd.OutOrStdout(), "%s proposed\n", name) + } else { + lastErr = err + } + return err + case porchv1alpha2.PackageRevisionLifecycleProposed: + lastErr = nil + fmt.Fprintf(cmd.OutOrStderr(), "%s is already proposed\n", name) + return nil + default: + lastErr = fmt.Errorf("cannot propose %s package", pr.Spec.Lifecycle) + return lastErr + } + }) + if err == nil && lastErr != nil { + err = lastErr + } + if err != nil { + messages = append(messages, err.Error()) + fmt.Fprintf(cmd.ErrOrStderr(), "%s failed (%s)\n", name, err) + } + } + if len(messages) > 0 { + return errors.E(op, fmt.Errorf("errors:\n %s", strings.Join(messages, "\n "))) + } + return nil +} diff --git a/pkg/cli/commands/rpkg/propose/v1alpha2_test.go b/pkg/cli/commands/rpkg/propose/v1alpha2_test.go new file mode 100644 index 000000000..0dba7dbdb --- /dev/null +++ b/pkg/cli/commands/rpkg/propose/v1alpha2_test.go @@ -0,0 +1,261 @@ +// Copyright 2026 The kpt and Nephio 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 propose + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "testing" + + "github.com/google/go-cmp/cmp" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/util" + "github.com/spf13/cobra" + "github.com/stretchr/testify/mock" + mockclient "github.com/nephio-project/porch/test/mockery/mocks/external/sigs.k8s.io/controller-runtime/pkg/client" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestV1Alpha2NewRunner(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + cfg := &genericclioptions.ConfigFlags{Namespace: &ns} + + r := newV1Alpha2Runner(ctx, cfg) + + if r == nil { + t.Fatal("expected non-nil runner") + } + if r.ctx != ctx { + t.Errorf("expected context to be set") + } + if r.cfg != cfg { + t.Errorf("expected config to be set") + } +} + +func TestV1Alpha2PreRunE(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + cfg := &genericclioptions.ConfigFlags{Namespace: &ns} + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: cfg, + } + + cmd := &cobra.Command{} + err := r.preRunE(cmd, []string{}) + + // preRunE should succeed (it just creates a client) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if r.client == nil { + t.Error("expected client to be set") + } +} + +func TestV1Alpha2PreRunEClientError(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + // Use invalid kubeconfig to trigger client creation error + cfg := &genericclioptions.ConfigFlags{ + Namespace: &ns, + KubeConfig: func() *string { s := "/nonexistent/kubeconfig"; return &s }(), + } + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: cfg, + } + + cmd := &cobra.Command{} + err := r.preRunE(cmd, []string{}) + + // preRunE should fail with client creation error + if err == nil { + t.Error("expected error for invalid kubeconfig") + } +} + +func TestV1Alpha2Cmd(t *testing.T) { + pkgRevName := "test-fjdos9u2nfe2f32" + ns := "ns" + scheme := util.V1Alpha2Scheme(t) + testCases := map[string]struct { + lc porchv1alpha2.PackageRevisionLifecycle + output string + wantErr bool + }{ + "Package already proposed": { + output: pkgRevName + " is already proposed\n", + lc: porchv1alpha2.PackageRevisionLifecycleProposed, + }, + "Propose package": { + output: pkgRevName + " proposed\n", + lc: porchv1alpha2.PackageRevisionLifecycleDraft, + }, + "Cannot propose package": { + output: pkgRevName + " failed (cannot propose Published package)\n", + lc: porchv1alpha2.PackageRevisionLifecyclePublished, + wantErr: true, + }, + } + + for tn := range testCases { + tc := testCases[tn] + t.Run(tn, func(t *testing.T) { + pr := util.NewV1Alpha2PackageRevision(ns, pkgRevName) + pr.Spec.Lifecycle = tc.lc + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pr). + Build() + + cmd := &cobra.Command{} + o := os.Stdout + e := os.Stderr + read, write, _ := os.Pipe() + os.Stdout = write + os.Stderr = write + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + go func() { + defer write.Close() + err := r.runE(cmd, []string{pkgRevName}) + if err != nil && !tc.wantErr { + t.Errorf("unexpected error: %v", err) + } + }() + out, _ := io.ReadAll(read) + os.Stdout = o + os.Stderr = e + + if diff := cmp.Diff(tc.output, string(out)); diff != "" { + t.Errorf("Unexpected result (-want, +got): %s", diff) + } + }) + } +} + +func TestV1Alpha2RunEGetError(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Get(ctx, mock.AnythingOfType("types.NamespacedName"), mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("package not found")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + cmd.SetErr(output) + + err := r.runE(cmd, []string{"test-pkg"}) + if err == nil { + t.Error("expected error for package not found") + } +} + +func TestV1Alpha2ReadinessGates(t *testing.T) { + pkgRevName := "test-pkg" + ns := "ns" + scheme := util.V1Alpha2Scheme(t) + + testCases := map[string]struct { + gates []porchv1alpha2.ReadinessGate + conditions []porchv1alpha2.PackageCondition + wantErr bool + wantLC porchv1alpha2.PackageRevisionLifecycle + }{ + "no gates - propose succeeds": { + wantLC: porchv1alpha2.PackageRevisionLifecycleProposed, + }, + "gate met - propose succeeds": { + gates: []porchv1alpha2.ReadinessGate{{ConditionType: "foo.bar/Ready"}}, + conditions: []porchv1alpha2.PackageCondition{ + {Type: "foo.bar/Ready", Status: porchv1alpha2.PackageConditionTrue}, + }, + wantLC: porchv1alpha2.PackageRevisionLifecycleProposed, + }, + "gate not met - propose fails": { + gates: []porchv1alpha2.ReadinessGate{{ConditionType: "foo.bar/Ready"}}, + conditions: []porchv1alpha2.PackageCondition{ + {Type: "foo.bar/Ready", Status: porchv1alpha2.PackageConditionFalse}, + }, + wantErr: true, + wantLC: porchv1alpha2.PackageRevisionLifecycleDraft, + }, + "gate missing condition - propose fails": { + gates: []porchv1alpha2.ReadinessGate{{ConditionType: "foo.bar/Ready"}}, + wantErr: true, + wantLC: porchv1alpha2.PackageRevisionLifecycleDraft, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + pr := util.NewV1Alpha2PackageRevision(ns, pkgRevName) + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecycleDraft + pr.Spec.ReadinessGates = tc.gates + pr.Status.PackageConditions = tc.conditions + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pr). + Build() + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + + err := r.runE(&cobra.Command{}, []string{pkgRevName}) + if tc.wantErr && err == nil { + t.Fatal("expected error but got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var updatedPr porchv1alpha2.PackageRevision + if err := c.Get(context.Background(), client.ObjectKey{Namespace: ns, Name: pkgRevName}, &updatedPr); err != nil { + t.Fatalf("failed to get PR: %v", err) + } + if updatedPr.Spec.Lifecycle != tc.wantLC { + t.Errorf("expected lifecycle %s, got %s", tc.wantLC, updatedPr.Spec.Lifecycle) + } + }) + } +} diff --git a/pkg/cli/commands/rpkg/proposedelete/command.go b/pkg/cli/commands/rpkg/proposedelete/command.go index be6ab8c42..35ce409c3 100644 --- a/pkg/cli/commands/rpkg/proposedelete/command.go +++ b/pkg/cli/commands/rpkg/proposedelete/command.go @@ -1,4 +1,4 @@ -// Copyright 2022 The kpt and Nephio Authors +// Copyright 2022,2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -57,7 +57,10 @@ func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner } func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { - return newRunner(ctx, rcg).Command + v1 := newRunner(ctx, rcg) + v2 := newV1Alpha2Runner(ctx, rcg) + cliutils.WrapVersionDispatch(v1.Command, v2.preRunE, v2.runE) + return v1.Command } type runner struct { diff --git a/pkg/cli/commands/rpkg/proposedelete/command_test.go b/pkg/cli/commands/rpkg/proposedelete/command_test.go index 3c019706d..a2efadcb9 100644 --- a/pkg/cli/commands/rpkg/proposedelete/command_test.go +++ b/pkg/cli/commands/rpkg/proposedelete/command_test.go @@ -155,3 +155,13 @@ func TestLastErrWorkaround(t *testing.T) { t.Fatal("expected error but got nil") } } + +func TestNewCommand(t *testing.T) { + ns := "default" + flags := genericclioptions.NewConfigFlags(true) + flags.Namespace = &ns + cmd := NewCommand(context.Background(), flags) + if cmd == nil { + t.Fatal("NewCommand returned nil") + } +} diff --git a/pkg/cli/commands/rpkg/proposedelete/v1alpha2.go b/pkg/cli/commands/rpkg/proposedelete/v1alpha2.go new file mode 100644 index 000000000..a6e406812 --- /dev/null +++ b/pkg/cli/commands/rpkg/proposedelete/v1alpha2.go @@ -0,0 +1,113 @@ +// Copyright 2026 The kpt and Nephio 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 proposedelete + +import ( + "context" + "fmt" + "strings" + + "github.com/kptdev/kpt/pkg/lib/errors" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + cliutils "github.com/nephio-project/porch/internal/cliutils" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/docs" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func newV1Alpha2Runner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *v1alpha2Runner { + r := &v1alpha2Runner{ctx: ctx, cfg: rcg} + r.Command = &cobra.Command{ + Use: "propose-delete PACKAGE", + Aliases: []string{"propose-del"}, + Short: docs.ProposeDeleteShort, + Long: docs.ProposeDeleteShort + "\n" + docs.ProposeDeleteLong, + Example: docs.ProposeDeleteExamples, + SuggestFor: []string{}, + PreRunE: r.preRunE, + RunE: r.runE, + Hidden: cliutils.HidePorchCommands, + } + return r +} + +type v1alpha2Runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + Command *cobra.Command +} + +func (r *v1alpha2Runner) preRunE(_ *cobra.Command, _ []string) error { + const op errors.Op = command + ".preRunE" + if r.client == nil { + c, err := cliutils.CreateV1Alpha2ClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = c + } + return nil +} + +func (r *v1alpha2Runner) runE(_ *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + var messages []string + namespace := *r.cfg.Namespace + + for _, name := range args { + key := client.ObjectKey{Namespace: namespace, Name: name} + var lastErr error + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + var pr porchv1alpha2.PackageRevision + if err := r.client.Get(r.ctx, key, &pr); err != nil { + lastErr = err + return err + } + switch pr.Spec.Lifecycle { + case porchv1alpha2.PackageRevisionLifecyclePublished: + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecycleDeletionProposed + err := r.client.Update(r.ctx, &pr) + if err == nil { + lastErr = nil + fmt.Fprintf(r.Command.OutOrStdout(), "%s proposed for deletion\n", name) + } else { + lastErr = err + } + return err + case porchv1alpha2.PackageRevisionLifecycleDeletionProposed: + lastErr = nil + fmt.Fprintf(r.Command.OutOrStderr(), "%s is already proposed for deletion\n", name) + return nil + default: + lastErr = fmt.Errorf("can only propose published packages for deletion; package %s is not published", name) + return lastErr + } + }) + if err == nil && lastErr != nil { + err = lastErr + } + if err != nil { + messages = append(messages, err.Error()) + fmt.Fprintf(r.Command.ErrOrStderr(), "%s failed (%s)\n", name, err) + } + } + if len(messages) > 0 { + return errors.E(op, fmt.Errorf("errors:\n %s", strings.Join(messages, "\n "))) + } + return nil +} diff --git a/pkg/cli/commands/rpkg/proposedelete/v1alpha2_test.go b/pkg/cli/commands/rpkg/proposedelete/v1alpha2_test.go new file mode 100644 index 000000000..38b7b0607 --- /dev/null +++ b/pkg/cli/commands/rpkg/proposedelete/v1alpha2_test.go @@ -0,0 +1,225 @@ +// Copyright 2026 The kpt and Nephio 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 proposedelete + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/util" + "github.com/spf13/cobra" + "github.com/stretchr/testify/mock" + mockclient "github.com/nephio-project/porch/test/mockery/mocks/external/sigs.k8s.io/controller-runtime/pkg/client" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestV1Alpha2NewRunner(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + cfg := &genericclioptions.ConfigFlags{Namespace: &ns} + + r := newV1Alpha2Runner(ctx, cfg) + + if r == nil { + t.Fatal("expected non-nil runner") + } + if r.ctx != ctx { + t.Errorf("expected context to be set") + } + if r.cfg != cfg { + t.Errorf("expected config to be set") + } +} + +func TestV1Alpha2PreRunE(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + cfg := &genericclioptions.ConfigFlags{Namespace: &ns} + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: cfg, + } + + cmd := &cobra.Command{} + err := r.preRunE(cmd, []string{}) + + // preRunE should succeed (it just creates a client) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if r.client == nil { + t.Error("expected client to be set") + } +} + +func TestV1Alpha2PreRunEClientError(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + // Use invalid kubeconfig to trigger client creation error + cfg := &genericclioptions.ConfigFlags{ + Namespace: &ns, + KubeConfig: func() *string { s := "/nonexistent/kubeconfig"; return &s }(), + } + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: cfg, + } + + cmd := &cobra.Command{} + err := r.preRunE(cmd, []string{}) + + // preRunE should fail with client creation error + if err == nil { + t.Error("expected error for invalid kubeconfig") + } +} + +func TestV1Alpha2Cmd(t *testing.T) { + pkgRevName := "test-fjdos9u2nfe2f32" + scheme := util.V1Alpha2Scheme(t) + testCases := map[string]struct { + lc porchv1alpha2.PackageRevisionLifecycle + output string + wantErr bool + ns string + }{ + "Package not found in ns": { + output: pkgRevName + " failed (packagerevisions.porch.kpt.dev \"" + pkgRevName + "\" not found)\n", + ns: "doesnotexist", + wantErr: true, + }, + "Package not published": { + output: pkgRevName + " failed (can only propose published packages for deletion; package " + pkgRevName + " is not published)\n", + ns: "ns", + wantErr: true, + }, + "Already proposed for deletion": { + lc: porchv1alpha2.PackageRevisionLifecycleDeletionProposed, + output: pkgRevName + " is already proposed for deletion\n", + ns: "ns", + }, + "Propose delete package": { + lc: porchv1alpha2.PackageRevisionLifecyclePublished, + output: pkgRevName + " proposed for deletion\n", + ns: "ns", + }, + } + + for tn := range testCases { + tc := testCases[tn] + t.Run(tn, func(t *testing.T) { + pr := util.NewV1Alpha2PackageRevision("ns", pkgRevName) + pr.Spec.Lifecycle = tc.lc + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pr). + Build() + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + cmd.SetErr(output) + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{ + Namespace: &tc.ns, + }, + client: c, + Command: cmd, + } + err := r.runE(cmd, []string{pkgRevName}) + if err != nil && !tc.wantErr { + t.Errorf("unexpected error: %v", err) + } + out := output.Bytes() + + if diff := cmp.Diff(tc.output, string(out)); diff != "" { + t.Errorf("Unexpected result (-want, +got): %s", diff) + } + }) + } +} + +func TestV1Alpha2RunEGetError(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Get(ctx, mock.AnythingOfType("types.NamespacedName"), mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("package not found")) + + cmd := &cobra.Command{} + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + Command: cmd, + } + + output := &bytes.Buffer{} + cmd.SetOut(output) + cmd.SetErr(output) + + err := r.runE(cmd, []string{"test-pkg"}) + if err == nil { + t.Error("expected error for package not found") + } +} + +func TestV1Alpha2LifecycleTransition(t *testing.T) { + pkgRevName := "test-pkg" + ns := "ns" + scheme := util.V1Alpha2Scheme(t) + + pr := util.NewV1Alpha2PackageRevision(ns, pkgRevName) + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pr). + Build() + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + Command: &cobra.Command{}, + } + + err := r.runE(r.Command, []string{pkgRevName}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify the lifecycle was actually changed + var updatedPr porchv1alpha2.PackageRevision + if err := c.Get(context.Background(), client.ObjectKey{Namespace: ns, Name: pkgRevName}, &updatedPr); err != nil { + t.Fatalf("failed to get PR: %v", err) + } + if updatedPr.Spec.Lifecycle != porchv1alpha2.PackageRevisionLifecycleDeletionProposed { + t.Errorf("expected lifecycle DeletionProposed, got %s", updatedPr.Spec.Lifecycle) + } +} diff --git a/pkg/cli/commands/rpkg/reject/command.go b/pkg/cli/commands/rpkg/reject/command.go index 4d750dfb8..2f4c4f5cb 100644 --- a/pkg/cli/commands/rpkg/reject/command.go +++ b/pkg/cli/commands/rpkg/reject/command.go @@ -1,4 +1,4 @@ -// Copyright 2022 The kpt and Nephio Authors +// Copyright 2022,2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -34,7 +34,10 @@ const ( ) func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { - return newRunner(ctx, rcg).Command + v1 := newRunner(ctx, rcg) + v2 := newV1Alpha2Runner(ctx, rcg) + cliutils.WrapVersionDispatch(v1.Command, v2.preRunE, v2.runE) + return v1.Command } func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { diff --git a/pkg/cli/commands/rpkg/reject/command_test.go b/pkg/cli/commands/rpkg/reject/command_test.go index 6070ed758..69b08d32b 100644 --- a/pkg/cli/commands/rpkg/reject/command_test.go +++ b/pkg/cli/commands/rpkg/reject/command_test.go @@ -216,3 +216,13 @@ func TestLastErrWorkaround(t *testing.T) { t.Fatal("expected error but got nil") } } + +func TestNewCommand(t *testing.T) { + ns := "default" + flags := genericclioptions.NewConfigFlags(true) + flags.Namespace = &ns + cmd := NewCommand(context.Background(), flags) + if cmd == nil { + t.Fatal("NewCommand returned nil") + } +} diff --git a/pkg/cli/commands/rpkg/reject/v1alpha2.go b/pkg/cli/commands/rpkg/reject/v1alpha2.go new file mode 100644 index 000000000..2a82bbf7d --- /dev/null +++ b/pkg/cli/commands/rpkg/reject/v1alpha2.go @@ -0,0 +1,107 @@ +// Copyright 2026 The kpt and Nephio 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 reject + +import ( + "context" + "fmt" + "strings" + + "github.com/kptdev/kpt/pkg/lib/errors" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + cliutils "github.com/nephio-project/porch/internal/cliutils" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type v1alpha2Runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client +} + +func newV1Alpha2Runner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *v1alpha2Runner { + return &v1alpha2Runner{ctx: ctx, cfg: rcg} +} + +func (r *v1alpha2Runner) preRunE(_ *cobra.Command, _ []string) error { + const op errors.Op = command + ".preRunE" + if r.client == nil { + c, err := cliutils.CreateV1Alpha2ClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = c + } + return nil +} + +func (r *v1alpha2Runner) runE(cmd *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + var messages []string + namespace := *r.cfg.Namespace + + for _, name := range args { + key := client.ObjectKey{Namespace: namespace, Name: name} + var proposedFor string + var lastErr error + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + var pr porchv1alpha2.PackageRevision + if err := r.client.Get(r.ctx, key, &pr); err != nil { + lastErr = err + return err + } + switch pr.Spec.Lifecycle { + case porchv1alpha2.PackageRevisionLifecycleProposed: + proposedFor = "approval" + err := cliutils.UpdatePackageRevisionApprovalV1Alpha2(r.ctx, r.client, &pr, porchv1alpha2.PackageRevisionLifecycleDraft) + if err == nil { + lastErr = nil + } else { + lastErr = err + } + return err + case porchv1alpha2.PackageRevisionLifecycleDeletionProposed: + proposedFor = "deletion" + pr.Spec.Lifecycle = porchv1alpha2.PackageRevisionLifecyclePublished + err := r.client.Update(r.ctx, &pr) + if err == nil { + lastErr = nil + } else { + lastErr = err + } + return err + default: + lastErr = fmt.Errorf("cannot reject %s with lifecycle '%s'", name, pr.Spec.Lifecycle) + return lastErr + } + }) + if err == nil && lastErr != nil { + err = lastErr + } + if err != nil { + messages = append(messages, err.Error()) + fmt.Fprintf(cmd.ErrOrStderr(), "%s failed (%s)\n", name, err) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "%s no longer proposed for %s\n", name, proposedFor) + } + } + if len(messages) > 0 { + return errors.E(op, fmt.Errorf("errors:\n %s", strings.Join(messages, "\n "))) + } + return nil +} diff --git a/pkg/cli/commands/rpkg/reject/v1alpha2_test.go b/pkg/cli/commands/rpkg/reject/v1alpha2_test.go new file mode 100644 index 000000000..64db2f88c --- /dev/null +++ b/pkg/cli/commands/rpkg/reject/v1alpha2_test.go @@ -0,0 +1,253 @@ +// Copyright 2026 The kpt and Nephio 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 reject + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "testing" + + "github.com/google/go-cmp/cmp" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/pkg/cli/commands/rpkg/util" + "github.com/spf13/cobra" + "github.com/stretchr/testify/mock" + mockclient "github.com/nephio-project/porch/test/mockery/mocks/external/sigs.k8s.io/controller-runtime/pkg/client" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestV1Alpha2Cmd(t *testing.T) { + pkgRevName := "test-fjdos9u2nfe2f32" + scheme := util.V1Alpha2Scheme(t) + testCases := map[string]struct { + lc porchv1alpha2.PackageRevisionLifecycle + output string + wantErr bool + ns string + }{ + "Package not found in ns": { + output: pkgRevName + " failed (packagerevisions.porch.kpt.dev \"" + pkgRevName + "\" not found)\n", + ns: "doesnotexist", + wantErr: true, + }, + "Reject proposed package": { + output: pkgRevName + " no longer proposed for approval\n", + lc: porchv1alpha2.PackageRevisionLifecycleProposed, + ns: "ns", + }, + "Reject deletion proposed package": { + output: pkgRevName + " no longer proposed for deletion\n", + lc: porchv1alpha2.PackageRevisionLifecycleDeletionProposed, + ns: "ns", + }, + "Cannot reject draft package": { + output: pkgRevName + " failed (cannot reject " + pkgRevName + " with lifecycle 'Draft')\n", + lc: porchv1alpha2.PackageRevisionLifecycleDraft, + ns: "ns", + wantErr: true, + }, + } + + for tn := range testCases { + tc := testCases[tn] + t.Run(tn, func(t *testing.T) { + pr := util.NewV1Alpha2PackageRevision("ns", pkgRevName) + pr.Spec.Lifecycle = tc.lc + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pr). + Build() + + cmd := &cobra.Command{} + o := os.Stdout + e := os.Stderr + read, write, _ := os.Pipe() + os.Stdout = write + os.Stderr = write + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{ + Namespace: &tc.ns, + }, + client: c, + } + go func() { + defer write.Close() + err := r.runE(cmd, []string{pkgRevName}) + if err != nil && !tc.wantErr { + t.Errorf("unexpected error: %v", err) + } + }() + out, _ := io.ReadAll(read) + os.Stdout = o + os.Stderr = e + + if diff := cmp.Diff(tc.output, string(out)); diff != "" { + t.Errorf("Unexpected result (-want, +got): %s", diff) + } + }) + } +} + +func TestV1Alpha2NewRunner(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + cfg := &genericclioptions.ConfigFlags{Namespace: &ns} + + r := newV1Alpha2Runner(ctx, cfg) + + if r == nil { + t.Fatal("expected non-nil runner") + } + if r.ctx != ctx { + t.Errorf("expected context to be set") + } + if r.cfg != cfg { + t.Errorf("expected config to be set") + } +} + +func TestV1Alpha2PreRunE(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + cfg := &genericclioptions.ConfigFlags{Namespace: &ns} + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: cfg, + } + + cmd := &cobra.Command{} + err := r.preRunE(cmd, []string{}) + + // preRunE should succeed (it just creates a client) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if r.client == nil { + t.Error("expected client to be set") + } +} + +func TestV1Alpha2PreRunEClientError(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + // Use invalid kubeconfig to trigger client creation error + cfg := &genericclioptions.ConfigFlags{ + Namespace: &ns, + KubeConfig: func() *string { s := "/nonexistent/kubeconfig"; return &s }(), + } + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: cfg, + } + + cmd := &cobra.Command{} + err := r.preRunE(cmd, []string{}) + + // preRunE should fail with client creation error + if err == nil { + t.Error("expected error for invalid kubeconfig") + } +} + +func TestV1Alpha2RunEGetError(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Get(ctx, mock.AnythingOfType("types.NamespacedName"), mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("package not found")) + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + cmd.SetErr(output) + + err := r.runE(cmd, []string{"test-pkg"}) + if err == nil { + t.Error("expected error for package not found") + } +} + +func TestV1Alpha2RejectLifecycleTransitions(t *testing.T) { + ns := "ns" + scheme := util.V1Alpha2Scheme(t) + + testCases := map[string]struct { + initialLC porchv1alpha2.PackageRevisionLifecycle + wantLC porchv1alpha2.PackageRevisionLifecycle + wantErr bool + }{ + "Proposed -> Draft": { + initialLC: porchv1alpha2.PackageRevisionLifecycleProposed, + wantLC: porchv1alpha2.PackageRevisionLifecycleDraft, + }, + "DeletionProposed -> Published": { + initialLC: porchv1alpha2.PackageRevisionLifecycleDeletionProposed, + wantLC: porchv1alpha2.PackageRevisionLifecyclePublished, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + pkgRevName := "test-pkg" + pr := util.NewV1Alpha2PackageRevision(ns, pkgRevName) + pr.Spec.Lifecycle = tc.initialLC + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pr). + Build() + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + } + + err := r.runE(&cobra.Command{}, []string{pkgRevName}) + if tc.wantErr && err == nil { + t.Fatal("expected error but got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var updatedPr porchv1alpha2.PackageRevision + if err := c.Get(context.Background(), client.ObjectKey{Namespace: ns, Name: pkgRevName}, &updatedPr); err != nil { + t.Fatalf("failed to get PR: %v", err) + } + if updatedPr.Spec.Lifecycle != tc.wantLC { + t.Errorf("expected lifecycle %s, got %s", tc.wantLC, updatedPr.Spec.Lifecycle) + } + }) + } +} diff --git a/pkg/cli/commands/rpkg/rpkgcmd.go b/pkg/cli/commands/rpkg/rpkgcmd.go index 034888998..345bc9cb8 100644 --- a/pkg/cli/commands/rpkg/rpkgcmd.go +++ b/pkg/cli/commands/rpkg/rpkgcmd.go @@ -1,4 +1,4 @@ -// Copyright 2022, 2025 The kpt and Nephio Authors +// Copyright 2022, 2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -67,6 +67,9 @@ func NewCommand(ctx context.Context, version string) *cobra.Command { return rc } + pf.String(cliutils.FlagAPIVersion, "", + "API version for PackageRevision (v1alpha1 or v1alpha2). Default: v1alpha1. Env: PORCHCTL_API_VERSION") + pf.AddGoFlagSet(flag.CommandLine) rpkg.AddCommand( diff --git a/pkg/cli/commands/rpkg/rpkgcmd_test.go b/pkg/cli/commands/rpkg/rpkgcmd_test.go new file mode 100644 index 000000000..1a77d17a0 --- /dev/null +++ b/pkg/cli/commands/rpkg/rpkgcmd_test.go @@ -0,0 +1,16 @@ +package rpkg + +import ( + "context" + "testing" +) + +func TestNewCommand(t *testing.T) { + cmd := NewCommand(context.Background(), "test") + if cmd == nil { + t.Fatal("NewCommand returned nil") + } + if cmd.Use != "rpkg" { + t.Errorf("expected Use=rpkg, got %s", cmd.Use) + } +} diff --git a/pkg/cli/commands/rpkg/upgrade/command.go b/pkg/cli/commands/rpkg/upgrade/command.go index 02fe9b7e1..521853472 100644 --- a/pkg/cli/commands/rpkg/upgrade/command.go +++ b/pkg/cli/commands/rpkg/upgrade/command.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Nephio Authors +// Copyright 2025,2026 The Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -40,7 +40,10 @@ const ( ) func NewCommand(ctx context.Context, rcg *genericclioptions.ConfigFlags) *cobra.Command { - return newRunner(ctx, rcg).Command + v1 := newRunner(ctx, rcg) + v2 := newV1Alpha2Runner(ctx, rcg) + cliutils.WrapVersionDispatch(v1.Command, v2.preRunE, v2.runE) + return v1.Command } func newRunner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *runner { diff --git a/pkg/cli/commands/rpkg/upgrade/discover_v1alpha2.go b/pkg/cli/commands/rpkg/upgrade/discover_v1alpha2.go new file mode 100644 index 000000000..93d198c34 --- /dev/null +++ b/pkg/cli/commands/rpkg/upgrade/discover_v1alpha2.go @@ -0,0 +1,176 @@ +// Copyright 2026 The kpt and Nephio 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 upgrade + +import ( + "fmt" + "io" + "strings" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/nephio-project/porch/pkg/repository" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/printers" +) + +func (r *v1alpha2Runner) discoverUpdates(cmd *cobra.Command, args []string) error { + var prs []porchv1alpha2.PackageRevision + var errs []string + if len(args) == 0 || r.discover == downstream { + prs = r.prs + } else { + for _, arg := range args { + pr := r.findPackageRevision(arg) + if pr == nil { + errs = append(errs, fmt.Sprintf("could not find package revision %s", arg)) + continue + } + prs = append(prs, *pr) + } + } + if len(errs) > 0 { + return fmt.Errorf("errors:\n %s", strings.Join(errs, "\n ")) + } + + switch r.discover { + case upstream: + return r.findUpstreamUpdates(prs, cmd.OutOrStdout()) + case downstream: + return r.findDownstreamUpdates(prs, args, cmd.OutOrStdout()) + default: + return fmt.Errorf("invalid argument %q for --discover", r.discover) + } +} + +// availableUpdatesV2 finds published package revisions in the same repo/package +// that have a higher revision than the current upstream. +func (r *v1alpha2Runner) availableUpdatesV2(pr porchv1alpha2.PackageRevision) ([]porchv1alpha2.PackageRevision, string) { + upstreamName := r.findUpstreamName(&pr) + if upstreamName == "" { + return nil, "" + } + + upstreamPr := r.findPackageRevision(upstreamName) + if upstreamPr == nil { + return nil, "" + } + + var updates []porchv1alpha2.PackageRevision + for i := range r.prs { + candidate := &r.prs[i] + if !candidate.IsPublished() { + continue + } + if candidate.Spec.RepositoryName != upstreamPr.Spec.RepositoryName { + continue + } + if candidate.Spec.PackageName != upstreamPr.Spec.PackageName { + continue + } + if candidate.Status.Revision > upstreamPr.Status.Revision { + updates = append(updates, *candidate) + } + } + return updates, upstreamPr.Spec.RepositoryName +} + +func (r *v1alpha2Runner) findUpstreamUpdates(prs []porchv1alpha2.PackageRevision, w io.Writer) error { + var rows [][]string + for _, pr := range prs { + updates, repoName := r.availableUpdatesV2(pr) + if len(updates) == 0 { + rows = append(rows, []string{pr.Name, repoName, "No update available"}) + } else { + var revisions []string + for _, u := range updates { + revisions = append(revisions, "v"+repository.Revision2Str(u.Status.Revision)) + } + rows = append(rows, []string{pr.Name, repoName, strings.Join(revisions, ", ")}) + } + } + return printUpstreamUpdates(rows, w) +} + +func (r *v1alpha2Runner) findDownstreamUpdates(prs []porchv1alpha2.PackageRevision, args []string, w io.Writer) error { + rows := r.buildDownstreamRows(prs) + filtered := filterRowsByArgs(rows, args) + return printDownstreamRowsV2(filtered, w) +} + +func (r *v1alpha2Runner) buildDownstreamRows(prs []porchv1alpha2.PackageRevision) [][]string { + // map key: "upstreamName:newRevision" + downstreamMap := make(map[string][]porchv1alpha2.PackageRevision) + for _, pr := range prs { + updates, _ := r.availableUpdatesV2(pr) + for _, u := range updates { + key := fmt.Sprintf("%s:%d", u.Name, u.Status.Revision) + downstreamMap[key] = append(downstreamMap[key], pr) + } + } + + var rows [][]string + for upstreamKey, downstreamPrs := range downstreamMap { + parts := strings.SplitN(upstreamKey, ":", 2) + upstreamName := parts[0] + newRev := parts[1] + for _, dsPr := range downstreamPrs { + oldRev := r.oldUpstreamRevStr(&dsPr) + rows = append(rows, []string{upstreamName, dsPr.Name, fmt.Sprintf("%s->v%s", oldRev, newRev)}) + } + } + return rows +} + +func (r *v1alpha2Runner) oldUpstreamRevStr(pr *porchv1alpha2.PackageRevision) string { + oldUpstreamName := r.findUpstreamName(pr) + if up := r.findPackageRevision(oldUpstreamName); up != nil { + return "v" + repository.Revision2Str(up.Status.Revision) + } + return "v0" +} + +func filterRowsByArgs(rows [][]string, args []string) [][]string { + if len(args) == 0 { + return rows + } + var filtered [][]string + for _, arg := range args { + for _, row := range rows { + if arg == row[0] { + filtered = append(filtered, row) + } + } + } + return filtered +} + +func printDownstreamRowsV2(rows [][]string, w io.Writer) error { + printer := printers.GetNewTabWriter(w) + if len(rows) == 0 { + if _, err := fmt.Fprintln(printer, "All downstream packages are up to date."); err != nil { + return err + } + } else { + if _, err := fmt.Fprintln(printer, "PACKAGE REVISION\tDOWNSTREAM PACKAGE\tDOWNSTREAM UPDATE"); err != nil { + return err + } + for _, row := range rows { + if _, err := fmt.Fprintln(printer, strings.Join(row, "\t")); err != nil { + return err + } + } + } + return printer.Flush() +} diff --git a/pkg/cli/commands/rpkg/upgrade/v1alpha2.go b/pkg/cli/commands/rpkg/upgrade/v1alpha2.go new file mode 100644 index 000000000..200a4dc9a --- /dev/null +++ b/pkg/cli/commands/rpkg/upgrade/v1alpha2.go @@ -0,0 +1,325 @@ +// Copyright 2026 The kpt and Nephio 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 upgrade + +import ( + "context" + "fmt" + "slices" + + "github.com/kptdev/kpt/pkg/lib/errors" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + cliutils "github.com/nephio-project/porch/internal/cliutils" + pkgutil "github.com/nephio-project/porch/pkg/util" + pkgerrors "github.com/pkg/errors" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type v1alpha2Runner struct { + ctx context.Context + cfg *genericclioptions.ConfigFlags + client client.Client + + revision int + workspace string + strategy string + discover string + + prs []porchv1alpha2.PackageRevision +} + +func newV1Alpha2Runner(ctx context.Context, rcg *genericclioptions.ConfigFlags) *v1alpha2Runner { + return &v1alpha2Runner{ctx: ctx, cfg: rcg} +} + +func (r *v1alpha2Runner) preRunE(cmd *cobra.Command, args []string) error { + const op errors.Op = command + ".preRunE" + if r.client == nil { + c, err := cliutils.CreateV1Alpha2ClientWithFlags(r.cfg) + if err != nil { + return errors.E(op, err) + } + r.client = c + } + + // Read shared flags from the command (flags are bound to the v1alpha1 runner) + r.revision, _ = cmd.Flags().GetInt("revision") + r.workspace, _ = cmd.Flags().GetString("workspace") + r.strategy, _ = cmd.Flags().GetString("strategy") + r.discover, _ = cmd.Flags().GetString("discover") + + switch r.discover { + case "": + if err := r.validateUpgradeArgs(args); err != nil { + return errors.E(op, err) + } + case upstream, downstream: + // do nothing + default: + return errors.E(op, fmt.Errorf("argument for 'discover' must be one of 'upstream' or 'downstream'")) + } + + var list porchv1alpha2.PackageRevisionList + listOpts := []client.ListOption{} + if r.cfg.Namespace != nil && *r.cfg.Namespace != "" { + listOpts = append(listOpts, client.InNamespace(*r.cfg.Namespace)) + } + if err := r.client.List(r.ctx, &list, listOpts...); err != nil { + return errors.E(op, err) + } + r.prs = list.Items + + return nil +} + +func (r *v1alpha2Runner) validateUpgradeArgs(args []string) error { + if len(args) < 1 { + return fmt.Errorf("SOURCE_PACKAGE_REVISION is a required positional argument") + } + if len(args) > 1 { + return fmt.Errorf("too many arguments; SOURCE_PACKAGE_REVISION is the only accepted positional arguments") + } + if r.revision < 0 { + return fmt.Errorf("revision must be positive (and not main)") + } + if r.workspace == "" { + return fmt.Errorf("workspace is required") + } + if r.strategy != "" { + validStrategies := []string{ + string(porchv1alpha2.ResourceMerge), + string(porchv1alpha2.FastForward), + string(porchv1alpha2.ForceDeleteReplace), + string(porchv1alpha2.CopyMerge), + } + if !slices.Contains(validStrategies, r.strategy) { + return fmt.Errorf("invalid strategy %q; must be one of: %v", r.strategy, validStrategies) + } + } + return nil +} + +func (r *v1alpha2Runner) runE(cmd *cobra.Command, args []string) error { + const op errors.Op = command + ".runE" + + if r.discover != "" { + if err := r.discoverUpdates(cmd, args); err != nil { + return errors.E(op, err) + } + return nil + } + + pr := r.findPackageRevision(args[0]) + if pr == nil { + return errors.E(op, pkgerrors.Errorf("could not find package revision %s", args[0])) + } + + key := client.ObjectKeyFromObject(pr) + var newPr *porchv1alpha2.PackageRevision + var lastErr error + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + if err := r.client.Get(r.ctx, key, pr); err != nil { + lastErr = err + return err + } + var err error + newPr, err = r.doUpgrade(pr) + if err == nil { + lastErr = nil + } else { + lastErr = err + } + return err + }) + if err == nil && lastErr != nil { + err = lastErr + } + if err != nil { + return errors.E(op, err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "%s upgraded to %s\n", pr.Name, newPr.Name) + return nil +} + +func (r *v1alpha2Runner) doUpgrade(pr *porchv1alpha2.PackageRevision) (*porchv1alpha2.PackageRevision, error) { + if !pr.IsPublished() { + return nil, pkgerrors.Errorf("to upgrade a package, it must be in a published state, not %q", pr.Spec.Lifecycle) + } + + oldUpstreamPr, err := r.resolveOldUpstream(pr) + if err != nil { + return nil, err + } + + newUpstreamPr, err := r.resolveNewUpstream(oldUpstreamPr.Spec.PackageName, oldUpstreamPr.Spec.RepositoryName) + if err != nil { + return nil, err + } + + if !newUpstreamPr.IsPublished() { + return nil, pkgerrors.Errorf("new upstream package revision %s is not published", newUpstreamPr.Name) + } + + newPr := buildUpgradePackageRevision(pr, oldUpstreamPr, newUpstreamPr, r.workspace, r.strategy) + if err := r.client.Create(r.ctx, newPr); err != nil { + return nil, pkgerrors.Wrapf(err, "failed to create package revision %q", newPr.Name) + } + return newPr, nil +} + +func (r *v1alpha2Runner) resolveOldUpstream(pr *porchv1alpha2.PackageRevision) (*porchv1alpha2.PackageRevision, error) { + oldUpstreamName := r.findUpstreamName(pr) + if oldUpstreamName == "" { + return nil, pkgerrors.Errorf("upstream source not found for package revision %q:"+ + " no clone or upgrade source was found in the source spec of the package", pr.Spec.PackageName) + } + + oldUpstreamPr := r.findPackageRevision(oldUpstreamName) + if oldUpstreamPr == nil { + return nil, pkgerrors.Errorf("upstream package revision %s no longer exists", oldUpstreamName) + } + if !oldUpstreamPr.IsPublished() { + return nil, pkgerrors.Errorf("old upstream package revision %s is not published", oldUpstreamPr.Name) + } + return oldUpstreamPr, nil +} + +func (r *v1alpha2Runner) resolveNewUpstream(pkgName, repoName string) (*porchv1alpha2.PackageRevision, error) { + if r.revision == 0 { + pr := r.findLatestPackageRevisionForRef(pkgName, repoName) + if pr == nil { + return nil, pkgerrors.Errorf("failed to find latest published revision for package %s in repo %s", pkgName, repoName) + } + return pr, nil + } + pr := r.findPackageRevisionForRef(pkgName, repoName, r.revision) + if pr == nil { + return nil, pkgerrors.Errorf("revision %d does not exist for package %s in repo %s", r.revision, pkgName, repoName) + } + return pr, nil +} + +func buildUpgradePackageRevision(pr, oldUpstream, newUpstream *porchv1alpha2.PackageRevision, workspace, strategy string) *porchv1alpha2.PackageRevision { + return &porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: pr.Namespace, + Name: pkgutil.ComposePkgRevObjName(pr.Spec.RepositoryName, "", pr.Spec.PackageName, workspace), + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + PackageName: pr.Spec.PackageName, + RepositoryName: pr.Spec.RepositoryName, + WorkspaceName: workspace, + Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft, + Source: &porchv1alpha2.PackageSource{ + Upgrade: &porchv1alpha2.PackageUpgradeSpec{ + OldUpstream: porchv1alpha2.PackageRevisionRef{Name: oldUpstream.Name}, + NewUpstream: porchv1alpha2.PackageRevisionRef{Name: newUpstream.Name}, + CurrentPackage: porchv1alpha2.PackageRevisionRef{Name: pr.Name}, + Strategy: porchv1alpha2.PackageMergeStrategy(strategy), + }, + }, + }, + } +} + +func (r *v1alpha2Runner) findPackageRevision(prName string) *porchv1alpha2.PackageRevision { + for i := range r.prs { + if r.prs[i].Name == prName { + return &r.prs[i] + } + } + return nil +} + +func (r *v1alpha2Runner) findPackageRevisionForRef(name, repo string, revision int) *porchv1alpha2.PackageRevision { + for i := range r.prs { + pr := &r.prs[i] + if pr.Spec.PackageName == name && pr.Spec.RepositoryName == repo && pr.IsPublished() && pr.Status.Revision == revision { + return pr + } + } + return nil +} + +func (r *v1alpha2Runner) findLatestPackageRevisionForRef(name, repo string) *porchv1alpha2.PackageRevision { + latest := 0 + var output *porchv1alpha2.PackageRevision + for i := range r.prs { + pr := &r.prs[i] + if pr.Spec.PackageName == name && pr.Spec.RepositoryName == repo && pr.IsPublished() && pr.Status.Revision > latest { + latest = pr.Status.Revision + output = pr + } + } + return output +} + +// findUpstreamName walks spec.source to find the upstream PR name. +func (r *v1alpha2Runner) findUpstreamName(pr *porchv1alpha2.PackageRevision) string { + if pr.Spec.Source == nil { + return "" + } + switch { + case pr.Spec.Source.CloneFrom != nil: + if pr.Spec.Source.CloneFrom.UpstreamRef != nil { + return pr.Spec.Source.CloneFrom.UpstreamRef.Name + } + // Git URL clone — no upstream PR name in spec. Match via selfLock. + if up := r.findUpstreamBySelfLock(pr.Status.UpstreamLock); up != nil { + return up.Name + } + return "" + case pr.Spec.Source.CopyFrom != nil: + if source := r.findPackageRevision(pr.Spec.Source.CopyFrom.Name); source != nil { + return r.findUpstreamName(source) + } + return "" + case pr.Spec.Source.Upgrade != nil: + return pr.Spec.Source.Upgrade.NewUpstream.Name + default: + return "" + } +} + +// findUpstreamBySelfLock finds a published PR whose selfLock matches the given lock. +// Used when the downstream was cloned via git URL (no upstreamRef name in spec). +func (r *v1alpha2Runner) findUpstreamBySelfLock(lock *porchv1alpha2.Locator) *porchv1alpha2.PackageRevision { + if lock == nil || lock.Git == nil { + return nil + } + for i := range r.prs { + pr := &r.prs[i] + if !pr.IsPublished() { + continue + } + if pr.Status.SelfLock == nil || pr.Status.SelfLock.Git == nil { + continue + } + git := pr.Status.SelfLock.Git + if git.Repo == lock.Git.Repo && git.Directory == lock.Git.Directory && git.Ref == lock.Git.Ref { + return pr + } + } + return nil +} diff --git a/pkg/cli/commands/rpkg/upgrade/v1alpha2_test.go b/pkg/cli/commands/rpkg/upgrade/v1alpha2_test.go new file mode 100644 index 000000000..8a2304614 --- /dev/null +++ b/pkg/cli/commands/rpkg/upgrade/v1alpha2_test.go @@ -0,0 +1,784 @@ +// Copyright 2026 The kpt and Nephio 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 upgrade + +import ( + "bytes" + "context" + "fmt" + "regexp" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + mockclient "github.com/nephio-project/porch/test/mockery/mocks/external/sigs.k8s.io/controller-runtime/pkg/client" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func createV1Alpha2Scheme() (*runtime.Scheme, error) { + scheme := runtime.NewScheme() + for _, api := range (runtime.SchemeBuilder{ + porchv1alpha2.AddToScheme, + }) { + if err := api(scheme); err != nil { + return nil, err + } + } + return scheme, nil +} + +func TestV1Alpha2PreRunE(t *testing.T) { + ns := "test-ns" + ctx := context.Background() + cfg := &genericclioptions.ConfigFlags{Namespace: &ns} + + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + r := &v1alpha2Runner{ + ctx: ctx, + cfg: cfg, + client: fake.NewClientBuilder().WithScheme(scheme).Build(), + } + + cmd := &cobra.Command{} + cmd.Flags().Int("revision", 1, "") + cmd.Flags().String("workspace", "v1", "") + cmd.Flags().String("strategy", "resource-merge", "") + cmd.Flags().String("discover", "", "") + err = r.preRunE(cmd, []string{"test-pkg"}) + + // preRunE should succeed (it just creates a client) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if r.client == nil { + t.Error("expected client to be set") + } +} + +func makeV2Pr(ns, repo, pkg string, revision int, lc porchv1alpha2.PackageRevisionLifecycle, source *porchv1alpha2.PackageSource) *porchv1alpha2.PackageRevision { + return &porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: fmt.Sprintf("%s.%s.v%d", repo, pkg, revision), + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + RepositoryName: repo, + PackageName: pkg, + WorkspaceName: fmt.Sprintf("v%d", revision), + Lifecycle: lc, + Source: source, + }, + Status: porchv1alpha2.PackageRevisionStatus{ + Revision: revision, + }, + } +} + +func cloneSource(upstreamRefName string) *porchv1alpha2.PackageSource { + return &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{ + UpstreamRef: &porchv1alpha2.PackageRevisionRef{Name: upstreamRefName}, + }, + } +} + +func createV2Runner(ctx context.Context, c client.Client, prs []porchv1alpha2.PackageRevision, ns string, revision int, workspace string) *v1alpha2Runner { + return &v1alpha2Runner{ + ctx: ctx, + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: c, + revision: revision, + workspace: workspace, + prs: prs, + } +} + +func TestV1Alpha2UpgradeCommand(t *testing.T) { + ctx := context.Background() + ns := "ns" + + origV1 := makeV2Pr(ns, "upstream-repo", "orig", 1, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + origV2 := makeV2Pr(ns, "upstream-repo", "orig", 2, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + + localPr := makeV2Pr(ns, "local-repo", "clone", 1, porchv1alpha2.PackageRevisionLifecyclePublished, cloneSource(origV1.Name)) + localDraft := makeV2Pr(ns, "local-repo", "clone-draft", 0, porchv1alpha2.PackageRevisionLifecycleDraft, cloneSource(origV1.Name)) + localDraft.Name = "local-repo--clone-draft-v0" + + prs := []porchv1alpha2.PackageRevision{*origV1, *origV2, *localPr, *localDraft} + + scheme, err := createV1Alpha2Scheme() + if err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + interceptorFuncs := interceptor.Funcs{ + Create: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.CreateOption) error { + if obj.GetObjectKind().GroupVersionKind().Kind == "PackageRevision" { + obj.SetName("upgraded-pr") + } + return nil + }, + } + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(origV1, origV2, localPr, localDraft). + WithInterceptorFuncs(interceptorFuncs). + Build() + + testCases := []struct { + name string + args []string + revision int + expectedOutput string + expectedError string + }{ + { + name: "Successful upgrade to specific revision", + args: []string{localPr.Name}, + revision: 2, + expectedOutput: fmt.Sprintf("%s upgraded to upgraded-pr\n", localPr.Name), + }, + { + name: "Successful upgrade to latest", + args: []string{localPr.Name}, + revision: 0, + expectedOutput: fmt.Sprintf("%s upgraded to upgraded-pr\n", localPr.Name), + }, + { + name: "Draft package revision", + args: []string{localDraft.Name}, + revision: 2, + expectedError: "must be in a published state", + }, + { + name: "Non-existent package revision", + args: []string{"non-existent"}, + revision: 2, + expectedError: "could not find package revision non-existent", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + output := &bytes.Buffer{} + r := createV2Runner(ctx, fakeClient, prs, ns, tc.revision, "upgrade-ws") + + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, tc.args) + + if tc.expectedError != "" { + if err == nil || !strings.Contains(err.Error(), tc.expectedError) { + t.Fatalf("Expected error containing %q, got %v", tc.expectedError, err) + } + } else if err != nil { + t.Fatalf("Unexpected error: %+v", err) + } + + if tc.expectedOutput != "" { + if diff := cmp.Diff(strings.TrimSpace(tc.expectedOutput), strings.TrimSpace(output.String())); diff != "" { + t.Errorf("Unexpected output (-want, +got): %s", diff) + } + } + }) + } +} + +func TestV1Alpha2FindPackageRevision(t *testing.T) { + prs := []porchv1alpha2.PackageRevision{ + {ObjectMeta: metav1.ObjectMeta{Name: "pr1"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "pr2"}}, + } + r := &v1alpha2Runner{prs: prs} + + assert.NotNil(t, r.findPackageRevision("pr1")) + assert.NotNil(t, r.findPackageRevision("pr2")) + assert.Nil(t, r.findPackageRevision("pr3")) +} + +func TestV1Alpha2FindLatestPackageRevisionForRef(t *testing.T) { + ns := "ns" + prs := []porchv1alpha2.PackageRevision{ + *makeV2Pr(ns, "repo", "pkg", 1, porchv1alpha2.PackageRevisionLifecyclePublished, nil), + *makeV2Pr(ns, "repo", "pkg", 3, porchv1alpha2.PackageRevisionLifecyclePublished, nil), + *makeV2Pr(ns, "repo", "pkg", 2, porchv1alpha2.PackageRevisionLifecyclePublished, nil), + } + r := &v1alpha2Runner{prs: prs} + + found := r.findLatestPackageRevisionForRef("pkg", "repo") + assert.NotNil(t, found) + assert.Equal(t, 3, found.Status.Revision) + + assert.Nil(t, r.findLatestPackageRevisionForRef("nonexistent", "repo")) +} + +func TestV1Alpha2FindUpstreamName(t *testing.T) { + clonePr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "local.clone.v1"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished, + Source: cloneSource("upstream.orig.v1"), + }, + } + copyPr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "local.clone.v2"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished, + Source: &porchv1alpha2.PackageSource{ + CopyFrom: &porchv1alpha2.PackageRevisionRef{Name: "local.clone.v1"}, + }, + }, + } + noSourcePr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "local.nosource.v1"}, + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished}, + } + + r := &v1alpha2Runner{prs: []porchv1alpha2.PackageRevision{*clonePr, *copyPr, *noSourcePr}} + + assert.Equal(t, "upstream.orig.v1", r.findUpstreamName(clonePr)) + assert.Equal(t, "upstream.orig.v1", r.findUpstreamName(copyPr)) + assert.Equal(t, "", r.findUpstreamName(noSourcePr)) +} + +func TestV1Alpha2FindUpstreamNameGitURLClone(t *testing.T) { + selfLock := &porchv1alpha2.Locator{ + Git: &porchv1alpha2.GitLock{ + Repo: "https://github.com/user/repo", + Directory: "packages/orig", + Ref: "orig/v1", + }, + } + + // Upstream PR with selfLock + upstreamPr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "upstream-repo.orig.v1"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished, + RepositoryName: "upstream-repo", + PackageName: "orig", + }, + Status: porchv1alpha2.PackageRevisionStatus{ + Revision: 1, + SelfLock: selfLock, + }, + } + + // Downstream cloned via git URL — upstreamLock matches upstream's selfLock + gitClonePr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "local.clone.v1"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished, + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{ + Type: porchv1alpha2.RepositoryTypeGit, + Git: &porchv1alpha2.GitPackage{ + Repo: "https://github.com/user/repo", + Ref: "orig/v1", + }, + }, + }, + }, + Status: porchv1alpha2.PackageRevisionStatus{ + UpstreamLock: selfLock, + }, + } + + // Git clone with no matching upstream + noMatchPr := &porchv1alpha2.PackageRevision{ + ObjectMeta: metav1.ObjectMeta{Name: "local.nomatch.v1"}, + Spec: porchv1alpha2.PackageRevisionSpec{ + Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished, + Source: &porchv1alpha2.PackageSource{ + CloneFrom: &porchv1alpha2.UpstreamPackage{ + Type: porchv1alpha2.RepositoryTypeGit, + Git: &porchv1alpha2.GitPackage{Repo: "https://other.com/repo"}, + }, + }, + }, + Status: porchv1alpha2.PackageRevisionStatus{ + UpstreamLock: &porchv1alpha2.Locator{ + Git: &porchv1alpha2.GitLock{Repo: "https://other.com/repo", Ref: "v1"}, + }, + }, + } + + r := &v1alpha2Runner{prs: []porchv1alpha2.PackageRevision{*upstreamPr, *gitClonePr, *noMatchPr}} + + assert.Equal(t, "upstream-repo.orig.v1", r.findUpstreamName(gitClonePr)) + assert.Equal(t, "", r.findUpstreamName(noMatchPr)) +} + +func TestV1Alpha2FindUpstreamBySelfLock(t *testing.T) { + lock := &porchv1alpha2.Locator{ + Git: &porchv1alpha2.GitLock{ + Repo: "https://github.com/user/repo", + Directory: "packages/foo", + Ref: "refs/tags/v1", + }, + } + + prs := []porchv1alpha2.PackageRevision{ + { + ObjectMeta: metav1.ObjectMeta{Name: "match"}, + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished}, + Status: porchv1alpha2.PackageRevisionStatus{SelfLock: lock}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "draft"}, + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecycleDraft}, + Status: porchv1alpha2.PackageRevisionStatus{SelfLock: lock}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "no-lock"}, + Spec: porchv1alpha2.PackageRevisionSpec{Lifecycle: porchv1alpha2.PackageRevisionLifecyclePublished}, + }, + } + r := &v1alpha2Runner{prs: prs} + + assert.Equal(t, "match", r.findUpstreamBySelfLock(lock).Name) + assert.Nil(t, r.findUpstreamBySelfLock(nil)) + assert.Nil(t, r.findUpstreamBySelfLock(&porchv1alpha2.Locator{})) + assert.Nil(t, r.findUpstreamBySelfLock(&porchv1alpha2.Locator{Git: &porchv1alpha2.GitLock{Repo: "other"}})) +} + +func TestV1Alpha2DiscoverUpstreamUpdates(t *testing.T) { + ns := "ns" + + origV1 := makeV2Pr(ns, "upstream-repo", "orig", 1, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + origV2 := makeV2Pr(ns, "upstream-repo", "orig", 2, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + localPr := makeV2Pr(ns, "local-repo", "clone", 1, porchv1alpha2.PackageRevisionLifecyclePublished, cloneSource(origV1.Name)) + + prs := []porchv1alpha2.PackageRevision{*origV1, *origV2, *localPr} + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + r := &v1alpha2Runner{ + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + prs: prs, + discover: upstream, + } + + err := r.discoverUpdates(cmd, []string{localPr.Name}) + assert.NoError(t, err) + assert.Regexp(t, regexp.MustCompile(`local-repo\.clone\.v1\s+upstream-repo\s+v2`), output.String()) +} + +func TestV1Alpha2DiscoverDownstreamUpdates(t *testing.T) { + ns := "ns" + + origV1 := makeV2Pr(ns, "upstream-repo", "orig", 1, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + origV2 := makeV2Pr(ns, "upstream-repo", "orig", 2, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + localPr := makeV2Pr(ns, "local-repo", "clone", 1, porchv1alpha2.PackageRevisionLifecyclePublished, cloneSource(origV1.Name)) + + prs := []porchv1alpha2.PackageRevision{*origV1, *origV2, *localPr} + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + r := &v1alpha2Runner{ + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + prs: prs, + discover: downstream, + } + + err := r.discoverUpdates(cmd, []string{}) + assert.NoError(t, err) + assert.Contains(t, output.String(), "DOWNSTREAM PACKAGE") +} + +func TestV1Alpha2DiscoverNoUpdates(t *testing.T) { + ns := "ns" + + origV1 := makeV2Pr(ns, "upstream-repo", "orig", 1, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + r := &v1alpha2Runner{ + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + prs: []porchv1alpha2.PackageRevision{*origV1}, + discover: upstream, + } + + err := r.discoverUpdates(cmd, []string{origV1.Name}) + assert.NoError(t, err) + assert.Contains(t, output.String(), "No update available") +} + +func TestV1Alpha2DiscoverInvalidParam(t *testing.T) { + ns := "ns" + cmd := &cobra.Command{} + + r := &v1alpha2Runner{ + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + prs: []porchv1alpha2.PackageRevision{}, + discover: "invalid", + } + + err := r.discoverUpdates(cmd, []string{}) + assert.ErrorContains(t, err, "invalid argument \"invalid\" for --discover") +} + +func TestV1Alpha2DiscoverPrNotFound(t *testing.T) { + ns := "ns" + cmd := &cobra.Command{} + + r := &v1alpha2Runner{ + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + prs: []porchv1alpha2.PackageRevision{}, + discover: upstream, + } + + err := r.discoverUpdates(cmd, []string{"nonexistent"}) + assert.ErrorContains(t, err, "could not find") +} + +func TestV1Alpha2DoUpgradeErrors(t *testing.T) { + ns := "ns" + origV1 := makeV2Pr(ns, "upstream-repo", "orig", 1, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + + testCases := []struct { + name string + pr *porchv1alpha2.PackageRevision + prs []porchv1alpha2.PackageRevision + revision int + errMsg string + }{ + { + name: "no source", + pr: makeV2Pr(ns, "repo", "pkg", 1, porchv1alpha2.PackageRevisionLifecyclePublished, nil), + errMsg: "upstream source not found", + }, + { + name: "init source (no upstream)", + pr: makeV2Pr(ns, "repo", "pkg", 1, porchv1alpha2.PackageRevisionLifecyclePublished, &porchv1alpha2.PackageSource{Init: &porchv1alpha2.PackageInitSpec{}}), + errMsg: "upstream source not found", + }, + { + name: "upstream PR no longer exists", + pr: makeV2Pr(ns, "repo", "pkg", 1, porchv1alpha2.PackageRevisionLifecyclePublished, cloneSource("does-not-exist")), + errMsg: "no longer exists", + }, + { + name: "specific revision not found", + pr: makeV2Pr(ns, "local", "clone", 1, porchv1alpha2.PackageRevisionLifecyclePublished, cloneSource(origV1.Name)), + prs: []porchv1alpha2.PackageRevision{*origV1}, + revision: 99, + errMsg: "revision 99 does not exist", + }, + { + name: "specific revision not published", + pr: makeV2Pr(ns, "local", "clone", 1, porchv1alpha2.PackageRevisionLifecyclePublished, cloneSource(origV1.Name)), + prs: []porchv1alpha2.PackageRevision{ + *origV1, + *makeV2Pr(ns, "upstream-repo", "orig", 2, porchv1alpha2.PackageRevisionLifecycleDraft, nil), + }, + revision: 2, + errMsg: "revision 2 does not exist", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + prs := tc.prs + if prs == nil { + prs = []porchv1alpha2.PackageRevision{} + } + r := &v1alpha2Runner{ + prs: prs, + revision: tc.revision, + } + _, err := r.doUpgrade(tc.pr) + assert.ErrorContains(t, err, tc.errMsg) + }) + } +} + +func TestV1Alpha2AvailableUpdatesV2NoSource(t *testing.T) { + r := &v1alpha2Runner{} + + // nil source + pr := porchv1alpha2.PackageRevision{} + updates, repo := r.availableUpdatesV2(pr) + assert.Nil(t, updates) + assert.Empty(t, repo) + + // init source (no upstream) + pr.Spec.Source = &porchv1alpha2.PackageSource{Init: &porchv1alpha2.PackageInitSpec{}} + updates, repo = r.availableUpdatesV2(pr) + assert.Nil(t, updates) + assert.Empty(t, repo) +} + +func TestV1Alpha2AvailableUpdatesV2NoUpstreamMatch(t *testing.T) { + r := &v1alpha2Runner{ + prs: []porchv1alpha2.PackageRevision{}, + } + pr := porchv1alpha2.PackageRevision{ + Spec: porchv1alpha2.PackageRevisionSpec{ + Source: cloneSource("does-not-exist"), + }, + } + updates, repo := r.availableUpdatesV2(pr) + assert.Nil(t, updates) + assert.Empty(t, repo) +} + +func TestV1Alpha2FindPackageRevisionForRef(t *testing.T) { + ns := "ns" + prs := []porchv1alpha2.PackageRevision{ + *makeV2Pr(ns, "repo", "pkg", 1, porchv1alpha2.PackageRevisionLifecyclePublished, nil), + *makeV2Pr(ns, "repo", "pkg", 2, porchv1alpha2.PackageRevisionLifecycleDraft, nil), + } + r := &v1alpha2Runner{prs: prs} + + assert.NotNil(t, r.findPackageRevisionForRef("pkg", "repo", 1)) + // draft should not match + assert.Nil(t, r.findPackageRevisionForRef("pkg", "repo", 2)) + // wrong revision + assert.Nil(t, r.findPackageRevisionForRef("pkg", "repo", 99)) +} + +func TestV1Alpha2DownstreamUpdatesWithArgFilter(t *testing.T) { + ns := "ns" + + origV1 := makeV2Pr(ns, "upstream-repo", "orig", 1, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + origV2 := makeV2Pr(ns, "upstream-repo", "orig", 2, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + localPr := makeV2Pr(ns, "local-repo", "clone", 1, porchv1alpha2.PackageRevisionLifecyclePublished, cloneSource(origV1.Name)) + + prs := []porchv1alpha2.PackageRevision{*origV1, *origV2, *localPr} + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + r := &v1alpha2Runner{ + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + prs: prs, + discover: downstream, + } + + // Filter with a specific arg that matches the upstream name + err := r.discoverUpdates(cmd, []string{origV2.Name}) + assert.NoError(t, err) + assert.Contains(t, output.String(), "DOWNSTREAM") +} + +func TestV1Alpha2RunEDiscoverError(t *testing.T) { + ns := "ns" + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + prs: []porchv1alpha2.PackageRevision{}, + discover: upstream, + } + + cmd := &cobra.Command{} + err := r.runE(cmd, []string{"nonexistent"}) + assert.Error(t, err) +} + +func TestV1Alpha2DoUpgradeCreateError(t *testing.T) { + ns := "ns" + origV1 := makeV2Pr(ns, "upstream-repo", "orig", 1, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + origV2 := makeV2Pr(ns, "upstream-repo", "orig", 2, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + localPr := makeV2Pr(ns, "local-repo", "clone", 1, porchv1alpha2.PackageRevisionLifecyclePublished, cloneSource(origV1.Name)) + + prs := []porchv1alpha2.PackageRevision{*origV1, *origV2, *localPr} + + // Mock client that fails on Create + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Create(context.Background(), mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("create failed")) + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + revision: 2, + workspace: "upgrade-ws", + prs: prs, + } + + _, err := r.doUpgrade(localPr) + assert.ErrorContains(t, err, "create failed") +} + +func TestV1Alpha2RunECreateError(t *testing.T) { + ns := "ns" + origV1 := makeV2Pr(ns, "upstream-repo", "orig", 1, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + origV2 := makeV2Pr(ns, "upstream-repo", "orig", 2, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + localPr := makeV2Pr(ns, "local-repo", "clone", 1, porchv1alpha2.PackageRevisionLifecyclePublished, cloneSource(origV1.Name)) + + prs := []porchv1alpha2.PackageRevision{*origV1, *origV2, *localPr} + + // Mock client that fails on Create + mockC := mockclient.NewMockClient(t) + mockC.EXPECT(). + Get(context.Background(), mock.AnythingOfType("types.NamespacedName"), mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(nil). + Run(func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) { + *obj.(*porchv1alpha2.PackageRevision) = *localPr + }) + mockC.EXPECT(). + Create(context.Background(), mock.AnythingOfType("*v1alpha2.PackageRevision")). + Return(fmt.Errorf("create failed")) + + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + client: mockC, + revision: 2, + workspace: "upgrade-ws", + prs: prs, + } + + output := &bytes.Buffer{} + cmd := &cobra.Command{} + cmd.SetOut(output) + + err := r.runE(cmd, []string{localPr.Name}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "create failed") +} + + +func TestV1Alpha2ValidateUpgradeArgs(t *testing.T) { + testCases := []struct { + name string + args []string + revision int + workspace string + strategy string + errMsg string + }{ + { + name: "no args", + args: []string{}, + errMsg: "SOURCE_PACKAGE_REVISION is a required positional argument", + }, + { + name: "too many args", + args: []string{"a", "b"}, + errMsg: "too many arguments", + }, + { + name: "negative revision", + args: []string{"pkg"}, + revision: -1, + errMsg: "revision must be positive", + }, + { + name: "empty workspace", + args: []string{"pkg"}, + revision: 1, + errMsg: "workspace is required", + }, + { + name: "invalid strategy", + args: []string{"pkg"}, + revision: 1, + workspace: "ws", + strategy: "bogus", + errMsg: "invalid strategy", + }, + { + name: "valid", + args: []string{"pkg"}, + revision: 1, + workspace: "ws", + strategy: "resource-merge", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := &v1alpha2Runner{ + revision: tc.revision, + workspace: tc.workspace, + strategy: tc.strategy, + } + err := r.validateUpgradeArgs(tc.args) + if tc.errMsg != "" { + assert.ErrorContains(t, err, tc.errMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestV1Alpha2OldUpstreamRevStr(t *testing.T) { + ns := "ns" + origV1 := makeV2Pr(ns, "upstream-repo", "orig", 1, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + localPr := makeV2Pr(ns, "local-repo", "clone", 1, porchv1alpha2.PackageRevisionLifecyclePublished, cloneSource(origV1.Name)) + + t.Run("upstream found", func(t *testing.T) { + r := &v1alpha2Runner{prs: []porchv1alpha2.PackageRevision{*origV1, *localPr}} + assert.Equal(t, "v1", r.oldUpstreamRevStr(localPr)) + }) + + t.Run("upstream not found", func(t *testing.T) { + r := &v1alpha2Runner{prs: []porchv1alpha2.PackageRevision{*localPr}} + assert.Equal(t, "v0", r.oldUpstreamRevStr(localPr)) + }) + + t.Run("no source", func(t *testing.T) { + noSrc := makeV2Pr(ns, "repo", "pkg", 1, porchv1alpha2.PackageRevisionLifecyclePublished, nil) + r := &v1alpha2Runner{prs: []porchv1alpha2.PackageRevision{*noSrc}} + assert.Equal(t, "v0", r.oldUpstreamRevStr(noSrc)) + }) +} + +func TestV1Alpha2PreRunEInvalidDiscover(t *testing.T) { + ns := "test-ns" + r := &v1alpha2Runner{ + ctx: context.Background(), + cfg: &genericclioptions.ConfigFlags{Namespace: &ns}, + } + + cmd := &cobra.Command{} + cmd.Flags().Int("revision", 0, "") + cmd.Flags().String("workspace", "", "") + cmd.Flags().String("strategy", "", "") + cmd.Flags().String("discover", "invalid", "") + + err := r.preRunE(cmd, []string{}) + assert.ErrorContains(t, err, "argument for 'discover' must be one of") +} diff --git a/pkg/cli/commands/rpkg/util/common_v1alpha2.go b/pkg/cli/commands/rpkg/util/common_v1alpha2.go new file mode 100644 index 000000000..4c18874ea --- /dev/null +++ b/pkg/cli/commands/rpkg/util/common_v1alpha2.go @@ -0,0 +1,35 @@ +// Copyright 2026 The kpt and Nephio 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 util + +import ( + "context" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func PackageAlreadyExistsV1Alpha2(ctx context.Context, c client.Client, repository, packageName, namespace string) (bool, error) { + var list porchv1alpha2.PackageRevisionList + if err := c.List(ctx, &list, &client.ListOptions{Namespace: namespace}); err != nil { + return false, err + } + for _, pr := range list.Items { + if pr.Spec.RepositoryName == repository && pr.Spec.PackageName == packageName { + return true, nil + } + } + return false, nil +} diff --git a/pkg/cli/commands/rpkg/util/common_v1alpha2_test.go b/pkg/cli/commands/rpkg/util/common_v1alpha2_test.go new file mode 100644 index 000000000..5e9ad3d15 --- /dev/null +++ b/pkg/cli/commands/rpkg/util/common_v1alpha2_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 The kpt and Nephio 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 util + +import ( + "context" + "testing" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestPackageAlreadyExistsV1Alpha2(t *testing.T) { + scheme := runtime.NewScheme() + if err := porchv1alpha2.AddToScheme(scheme); err != nil { + t.Fatalf("error creating scheme: %v", err) + } + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(&porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "repo.my-pkg.v1", + }, + Spec: porchv1alpha2.PackageRevisionSpec{ + RepositoryName: "repo", + PackageName: "my-pkg", + }, + }).Build() + + exists, err := PackageAlreadyExistsV1Alpha2(context.Background(), c, "repo", "my-pkg", "ns") + assert.NoError(t, err) + assert.True(t, exists) + + exists, err = PackageAlreadyExistsV1Alpha2(context.Background(), c, "repo", "other-pkg", "ns") + assert.NoError(t, err) + assert.False(t, exists) + + exists, err = PackageAlreadyExistsV1Alpha2(context.Background(), c, "other-repo", "my-pkg", "ns") + assert.NoError(t, err) + assert.False(t, exists) + + exists, err = PackageAlreadyExistsV1Alpha2(context.Background(), c, "repo", "my-pkg", "other-ns") + assert.NoError(t, err) + assert.False(t, exists) +} diff --git a/pkg/cli/commands/rpkg/util/testhelpers_v1alpha2.go b/pkg/cli/commands/rpkg/util/testhelpers_v1alpha2.go new file mode 100644 index 000000000..2267c2f7d --- /dev/null +++ b/pkg/cli/commands/rpkg/util/testhelpers_v1alpha2.go @@ -0,0 +1,50 @@ +// Copyright 2026 The kpt and Nephio 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 util + +import ( + "testing" + + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// V1Alpha2Scheme returns a runtime.Scheme with the v1alpha2 API registered. +// It calls t.Fatal on error. +func V1Alpha2Scheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := porchv1alpha2.AddToScheme(scheme); err != nil { + t.Fatalf("error creating v1alpha2 scheme: %v", err) + } + return scheme +} + +// NewV1Alpha2PackageRevision builds a v1alpha2 PackageRevision with the +// TypeMeta pre-filled. Callers set Spec/Status/ObjectMeta fields on the +// returned object as needed. +func NewV1Alpha2PackageRevision(ns, name string) *porchv1alpha2.PackageRevision { + return &porchv1alpha2.PackageRevision{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageRevision", + APIVersion: porchv1alpha2.SchemeGroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: name, + }, + } +} diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index d52f830c9..da9b88907 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -47,6 +47,7 @@ type CaDEngine interface { ObjectCache() WatcherManager UpdatePackageResources(ctx context.Context, repositoryObj *configapi.Repository, oldPackage repository.PackageRevision, old, new *porchapi.PackageRevisionResources) (repository.PackageRevision, *porchapi.RenderStatus, error) + UpdatePackageResourcesWithoutRender(ctx context.Context, repositoryObj *configapi.Repository, oldPackage repository.PackageRevision, old, new *porchapi.PackageRevisionResources) (repository.PackageRevision, error) ListPackageRevisions(ctx context.Context, filter repository.ListPackageRevisionFilter) ([]repository.PackageRevision, error) CreatePackageRevision(ctx context.Context, repositoryObj *configapi.Repository, obj *porchapi.PackageRevision, parent repository.PackageRevision) (repository.PackageRevision, error) @@ -527,6 +528,49 @@ func (cad *cadEngine) FindAllUpstreamReferencesInRepositories(ctx context.Contex return cad.cache.FindAllUpstreamReferencesInRepositories(ctx, namespace, prName) } +// UpdatePackageResourcesWithoutRender writes new resources without rendering. +// Used by the PRR handler for v1alpha2 repos where the PR controller renders async. +func (cad *cadEngine) UpdatePackageResourcesWithoutRender(ctx context.Context, repositoryObj *configapi.Repository, pr2Update repository.PackageRevision, oldRes, newRes *porchapi.PackageRevisionResources) (repository.PackageRevision, error) { + ctx, span := tracer.Start(ctx, "cadEngine::UpdatePackageResourcesWithoutRender", trace.WithAttributes()) + defer span.End() + + klog.InfoS("[CaD Engine] Writing resources without render for v1alpha2", context1.LogMetadataFrom(ctx)...) + + newRV := newRes.GetResourceVersion() + if len(newRV) == 0 { + return nil, fmt.Errorf("resourceVersion must be specified for an update") + } + if newRV != oldRes.GetResourceVersion() { + return nil, apierrors.NewConflict(porchapi.Resource("packagerevisionresources"), oldRes.GetName(), errors.New(OptimisticLockErrorMsg)) + } + + switch lifecycle := pr2Update.Lifecycle(ctx); lifecycle { + case porchapi.PackageRevisionLifecycleDraft: + default: + return nil, fmt.Errorf("cannot update a package revision with lifecycle value %q; package must be Draft", lifecycle) + } + + repo, err := cad.cache.OpenRepository(ctx, repositoryObj) + if err != nil { + return nil, err + } + draft, err := repo.UpdatePackageRevision(ctx, pr2Update) + if err != nil { + return nil, err + } + + prr := &porchapi.PackageRevisionResources{ + Spec: porchapi.PackageRevisionResourcesSpec{ + Resources: newRes.Spec.Resources, + }, + } + if err := draft.UpdateResources(ctx, prr, &porchapi.Task{Type: porchapi.TaskTypeRender}); err != nil { + return nil, err + } + + return repo.ClosePackageRevisionDraft(ctx, draft, 0) +} + // handleMutationError decides whether to bail out or allow push-on-render-failure. // Returns a non-nil error to signal the caller should return immediately. // Returns a nil error to signal the caller should proceed to close the draft. diff --git a/pkg/engine/engine_test.go b/pkg/engine/engine_test.go index 44a7c99cd..444bbc0d0 100644 --- a/pkg/engine/engine_test.go +++ b/pkg/engine/engine_test.go @@ -891,3 +891,122 @@ func TestUpdatePackageResourcesRenderFailure(t *testing.T) { }) } } + +func TestUpdatePackageResourcesWithoutRender(t *testing.T) { + tests := []struct { + name string + lifecycle porchapi.PackageRevisionLifecycle + oldRV string + newRV string + closeErr error + expectError bool + errorContains string + }{ + { + name: "success - draft lifecycle", + lifecycle: porchapi.PackageRevisionLifecycleDraft, + oldRV: "1", + newRV: "1", + }, + { + name: "failure - published lifecycle rejected", + lifecycle: porchapi.PackageRevisionLifecyclePublished, + oldRV: "1", + newRV: "1", + expectError: true, + errorContains: "cannot update a package revision with lifecycle value", + }, + { + name: "failure - empty resource version", + lifecycle: porchapi.PackageRevisionLifecycleDraft, + oldRV: "1", + newRV: "", + expectError: true, + errorContains: "resourceVersion must be specified", + }, + { + name: "failure - resource version conflict", + lifecycle: porchapi.PackageRevisionLifecycleDraft, + oldRV: "1", + newRV: "2", + expectError: true, + errorContains: OptimisticLockErrorMsg, + }, + { + name: "failure - close draft error", + lifecycle: porchapi.PackageRevisionLifecycleDraft, + oldRV: "1", + newRV: "1", + closeErr: fmt.Errorf("git push failed"), + expectError: true, + errorContains: "git push failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRepo := &mockrepo.MockRepository{} + mockCache := &mockCache{} + mockPkgRev := &mockrepo.MockPackageRevision{} + mockDraft := &mockrepo.MockPackageRevisionDraft{} + + repositoryObj := &configapi.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + } + + oldRes := &porchapi.PackageRevisionResources{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pkg", + ResourceVersion: tt.oldRV, + }, + } + newRes := &porchapi.PackageRevisionResources{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pkg", + ResourceVersion: tt.newRV, + }, + Spec: porchapi.PackageRevisionResourcesSpec{ + Resources: map[string]string{"Kptfile": "test"}, + }, + } + + mockPkgRev.On("Lifecycle", mock.Anything).Return(tt.lifecycle).Maybe() + mockPkgRev.On("Key").Return(repository.PackageRevisionKey{}).Maybe() + + // Only expect repo open + draft flow when we pass validation + needsDraft := tt.newRV != "" && tt.oldRV == tt.newRV && tt.lifecycle == porchapi.PackageRevisionLifecycleDraft + if needsDraft { + mockCache.On("OpenRepository", mock.Anything, repositoryObj).Return(mockRepo, nil) + mockRepo.On("UpdatePackageRevision", mock.Anything, mockPkgRev).Return(mockDraft, nil) + mockDraft.On("UpdateResources", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + closeRet := mockrepo.MockPackageRevision{} + if tt.closeErr != nil { + mockRepo.On("ClosePackageRevisionDraft", mock.Anything, mockDraft, 0).Return(nil, tt.closeErr) + } else { + mockRepo.On("ClosePackageRevisionDraft", mock.Anything, mockDraft, 0).Return(&closeRet, nil) + } + } + + engine := &cadEngine{cache: mockCache} + + result, err := engine.UpdatePackageResourcesWithoutRender(context.Background(), repositoryObj, mockPkgRev, oldRes, newRes) + + if tt.expectError { + assert.Error(t, err) + assert.ErrorContains(t, err, tt.errorContains) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + } + + mockRepo.AssertExpectations(t) + mockCache.AssertExpectations(t) + mockPkgRev.AssertExpectations(t) + }) + } +} diff --git a/pkg/engine/grpcruntime.go b/pkg/engine/grpcruntime.go index 4784b9ae9..0a8d8cc4e 100644 --- a/pkg/engine/grpcruntime.go +++ b/pkg/engine/grpcruntime.go @@ -22,6 +22,7 @@ import ( kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" "github.com/kptdev/kpt/pkg/fn" "github.com/kptdev/kpt/pkg/lib/kptops" + "github.com/nephio-project/porch/controllers/functionconfigs/reconciler" "github.com/nephio-project/porch/func/evaluator" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" "google.golang.org/grpc" @@ -113,3 +114,23 @@ func (gr *grpcRunner) Run(r io.Reader, w io.Writer) error { } return nil } + +// NewMultiFunctionRuntime creates a FunctionRuntime that tries builtin functions +// first, then falls back to the gRPC fn-runner. +func NewMultiFunctionRuntime(grpcAddress string, maxGrpcMessageSize int, functionConfigStore *reconciler.FunctionConfigStore) (fn.FunctionRuntime, error) { + builtin := newBuiltinRuntime(functionConfigStore) + + if grpcAddress == "" { + return builtin, nil + } + + grpc, err := newGRPCFunctionRuntime(GRPCRuntimeOptions{ + FunctionRunnerAddress: grpcAddress, + MaxGrpcMessageSize: maxGrpcMessageSize, + }) + if err != nil { + return nil, err + } + + return fn.NewMultiRuntime([]fn.FunctionRuntime{builtin, grpc}), nil +} diff --git a/pkg/engine/grpcruntime_test.go b/pkg/engine/grpcruntime_test.go index c7264d636..4cc80b5cd 100644 --- a/pkg/engine/grpcruntime_test.go +++ b/pkg/engine/grpcruntime_test.go @@ -1,4 +1,4 @@ -// Copyright 2022, 2025 The kpt and Nephio Authors +// Copyright 2022, 2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ import ( "testing" v1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" + "github.com/nephio-project/porch/controllers/functionconfigs/reconciler" "github.com/nephio-project/porch/func/evaluator" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -304,3 +305,38 @@ type errorWriter struct{} func (e *errorWriter) Write(p []byte) (n int, err error) { return 0, errors.New("write error") } + +func TestNewMultiFunctionRuntime_BuiltinOnly(t *testing.T) { + store := newTestFunctionConfigStore() + runtime, err := NewMultiFunctionRuntime("", 1024, store) + require.NoError(t, err) + require.NotNil(t, runtime) + + // Should be a builtinRuntime (no gRPC fallback) + _, ok := runtime.(*builtinRuntime) + assert.True(t, ok, "expected builtinRuntime when no gRPC address is provided") +} + +func TestNewMultiFunctionRuntime_WithGRPC(t *testing.T) { + addr, stop := startMockServer(t) + defer stop() + + store := newTestFunctionConfigStore() + runtime, err := NewMultiFunctionRuntime(addr, 1024, store) + require.NoError(t, err) + require.NotNil(t, runtime) + + // Should be a MultiRuntime wrapping builtin + gRPC + _, ok := runtime.(*builtinRuntime) + assert.False(t, ok, "expected MultiRuntime when gRPC address is provided") +} + +func TestNewMultiFunctionRuntime_NilStorePanics(t *testing.T) { + assert.Panics(t, func() { + _, _ = NewMultiFunctionRuntime("", 1024, nil) + }) +} + +func newTestFunctionConfigStore() *reconciler.FunctionConfigStore { + return reconciler.NewFunctionConfigStore("", "") +} diff --git a/pkg/engine/options.go b/pkg/engine/options.go index e4e01af13..8c8b79e52 100644 --- a/pkg/engine/options.go +++ b/pkg/engine/options.go @@ -1,4 +1,4 @@ -// Copyright 2022, 2024-2026 The kpt and Nephio Authors +// Copyright 2022, 2024-2025 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/externalrepo/fake/packagerevision.go b/pkg/externalrepo/fake/packagerevision.go index 2118f9824..747d636ec 100644 --- a/pkg/externalrepo/fake/packagerevision.go +++ b/pkg/externalrepo/fake/packagerevision.go @@ -16,6 +16,7 @@ package fake import ( "context" + "time" kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" @@ -125,6 +126,10 @@ func (fpr *FakePackageRevision) IsLatestRevision() bool { return true } +func (fpr *FakePackageRevision) GetCommitInfo() (time.Time, string) { + return time.Time{}, "" +} + func (fpr *FakePackageRevision) SetMeta(context.Context, metav1.ObjectMeta) error { fpr.Ops = append(fpr.Ops, "SetMeta") return fpr.Err diff --git a/pkg/externalrepo/fake/packagerevision_test.go b/pkg/externalrepo/fake/packagerevision_test.go index bfe259c2e..cd0499e75 100644 --- a/pkg/externalrepo/fake/packagerevision_test.go +++ b/pkg/externalrepo/fake/packagerevision_test.go @@ -51,4 +51,8 @@ func TestPackageRevisionGetters(t *testing.T) { assert.True(t, fakePr.SetMeta(context.TODO(), meta) == nil) assert.True(t, fakePr.IsLatestRevision()) + + ts, author := fakePr.GetCommitInfo() + assert.True(t, ts.IsZero()) + assert.Empty(t, author) } diff --git a/pkg/externalrepo/git/git.go b/pkg/externalrepo/git/git.go index ea6f0e941..f8963ad30 100644 --- a/pkg/externalrepo/git/git.go +++ b/pkg/externalrepo/git/git.go @@ -1933,14 +1933,27 @@ func (r *gitRepository) ClosePackageRevisionDraft(ctx context.Context, prd repos commitHash = newRef.Hash() } + // Set publish metadata on the actual Proposed → Published transition. + var updatedTime time.Time + var updatedBy string + if d.lifecycle == porchapi.PackageRevisionLifecyclePublished { + updatedTime = time.Now() + if r.userInfoProvider != nil { + if userInfo := r.userInfoProvider.GetUserInfo(ctx); userInfo != nil { + updatedBy = userInfo.Email + } + } + } + return &gitPackageRevision{ - prKey: d.prKey, - repo: d.repo, - updated: d.updated, - ref: newRef, - tree: d.tree, - commit: commitHash, - tasks: d.tasks, + prKey: d.prKey, + repo: d.repo, + updated: updatedTime, + updatedBy: updatedBy, + ref: newRef, + tree: d.tree, + commit: commitHash, + tasks: d.tasks, }, nil } diff --git a/pkg/externalrepo/git/package.go b/pkg/externalrepo/git/package.go index 9a897744b..5c186aae6 100644 --- a/pkg/externalrepo/git/package.go +++ b/pkg/externalrepo/git/package.go @@ -252,6 +252,14 @@ func (p *gitPackageRevision) GetLock(ctx context.Context) (kptfilev1.Upstream, k }, nil } +func (p *gitPackageRevision) GetCommitInfo() (time.Time, string) { + return p.updated, p.updatedBy +} + +func (p *gitPackageRevision) IsLatestRevision() bool { + return false +} + func (p *gitPackageRevision) Lifecycle(ctx context.Context) porchapi.PackageRevisionLifecycle { return p.repo.GetLifecycle(ctx, p) } diff --git a/pkg/externalrepo/git/package_test.go b/pkg/externalrepo/git/package_test.go index be76f332e..cf6bb381d 100644 --- a/pkg/externalrepo/git/package_test.go +++ b/pkg/externalrepo/git/package_test.go @@ -18,6 +18,7 @@ import ( "context" "path/filepath" "testing" + "time" "github.com/go-git/go-git/v5/plumbing" "github.com/google/go-cmp/cmp" @@ -145,4 +146,21 @@ func TestPackageGetters(t *testing.T) { assert.Equal(t, "my-repo.my-package.my-workspace", gitPr.KubeObjectName()) assert.Equal(t, "my-namespace", gitPr.KubeObjectNamespace()) assert.Equal(t, types.UID("7007e8aa-0928-50f9-b980-92a44942f055"), gitPr.UID()) + assert.False(t, gitPr.IsLatestRevision()) + + ts, author := gitPr.GetCommitInfo() + assert.True(t, ts.IsZero()) + assert.Empty(t, author) +} + +func TestPackageGetters_WithCommitInfo(t *testing.T) { + now := time.Now() + gitPr := gitPackageRevision{ + updated: now, + updatedBy: "user@example.com", + } + + ts, author := gitPr.GetCommitInfo() + assert.Equal(t, now, ts) + assert.Equal(t, "user@example.com", author) } diff --git a/pkg/externalrepo/oci/oci.go b/pkg/externalrepo/oci/oci.go index ff4d84b1d..14d960f8f 100644 --- a/pkg/externalrepo/oci/oci.go +++ b/pkg/externalrepo/oci/oci.go @@ -452,3 +452,11 @@ func (p *ociPackageRevision) SetMeta(_ context.Context, metadata metav1.ObjectMe p.metadata = metadata return nil } + +func (p *ociPackageRevision) GetCommitInfo() (time.Time, string) { + return time.Time{}, "" +} + +func (p *ociPackageRevision) IsLatestRevision() bool { + return false +} diff --git a/pkg/externalrepo/oci/oci_test.go b/pkg/externalrepo/oci/oci_test.go index 8929a4345..1e4bfe6ed 100644 --- a/pkg/externalrepo/oci/oci_test.go +++ b/pkg/externalrepo/oci/oci_test.go @@ -82,4 +82,9 @@ func TestPackageGettersAndSetters(t *testing.T) { assert.Equal(t, "oci-repo-name", fakePr.parent.key.Name) assert.Panics(t, func() { fakePr.ToMainPackageRevision(context.TODO()) }, "The code did not panic") + + assert.False(t, fakePr.IsLatestRevision()) + ts, author := fakePr.GetCommitInfo() + assert.True(t, ts.IsZero()) + assert.Empty(t, author) } diff --git a/pkg/registry/porch/packagecommon.go b/pkg/registry/porch/packagecommon.go index defc490e2..1fef9e705 100644 --- a/pkg/registry/porch/packagecommon.go +++ b/pkg/registry/porch/packagecommon.go @@ -1,4 +1,4 @@ -// Copyright 2022, 2024-2025 The kpt and Nephio Authors +// Copyright 2022, 2024-2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -37,6 +37,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) +// isV1Alpha2Repo returns true if the repository is annotated for v1alpha2 CRD management. +func isV1Alpha2Repo(repo *configapi.Repository) bool { + return repo.Annotations[configapi.AnnotationKeyV1Alpha2Migration] == configapi.AnnotationValueMigrationEnabled +} + const ConflictErrorMsgBase = "another request is already in progress %s" var GenericConflictErrorMsg = fmt.Sprintf(ConflictErrorMsgBase, "on %s \"%s\"") @@ -64,7 +69,13 @@ func (r *packageCommon) listPackageRevisions(ctx context.Context, filter reposit if err != nil { return err } + + v1alpha2Repos := r.getV1Alpha2RepoSet(ctx, revisions) + for _, rev := range revisions { + if v1alpha2Repos[rev.Key().RKey().Name] { + continue + } if err := callback(ctx, rev); err != nil { klog.Warningf("callback error for revision from repository: %+v", err) continue @@ -73,6 +84,25 @@ func (r *packageCommon) listPackageRevisions(ctx context.Context, filter reposit return nil } +// getV1Alpha2RepoSet returns a set of repo names that are v1alpha2-managed, +// deduplicating lookups by repo name. Uses the informer-cached coreClient. +func (r *packageCommon) getV1Alpha2RepoSet(ctx context.Context, revisions []repository.PackageRevision) map[string]bool { + v1alpha2Repos := map[string]bool{} + for _, rev := range revisions { + repoName := rev.Key().RKey().Name + if _, checked := v1alpha2Repos[repoName]; checked { + continue + } + var repo configapi.Repository + if err := r.coreClient.Get(ctx, types.NamespacedName{Name: repoName, Namespace: rev.KubeObjectNamespace()}, &repo); err != nil { + v1alpha2Repos[repoName] = false // fail open + continue + } + v1alpha2Repos[repoName] = isV1Alpha2Repo(&repo) + } + return v1alpha2Repos +} + func (r *packageCommon) listPackages(ctx context.Context, filter repository.ListPackageFilter, callback func(p repository.Package) error) error { var opts []client.ListOption if ns := filter.Key.RepoKey.Namespace; ns != "" { @@ -121,20 +151,35 @@ func (n *namespaceFilteringWatcher) OnPackageRevisionChange(eventType watch.Even return n.delegate.OnPackageRevisionChange(eventType, obj) } -func (r *packageCommon) watchPackages(ctx context.Context, filter repository.ListPackageRevisionFilter, callback engine.ObjectWatcher) error { - ns, namespaced := genericapirequest.NamespaceFrom(ctx) - wrappedCallback := callback - if namespaced && ns != "" { - wrappedCallback = &namespaceFilteringWatcher{ - ns: ns, - delegate: callback, - } +// v1alpha2FilteringWatcher filters out watch events for v1alpha2-managed repositories. +type v1alpha2FilteringWatcher struct { + coreClient client.Client + delegate engine.ObjectWatcher +} + +func (v *v1alpha2FilteringWatcher) OnPackageRevisionChange(eventType watch.EventType, obj repository.PackageRevision) bool { + repoName := obj.Key().RKey().Name + ns := obj.KubeObjectNamespace() + var repo configapi.Repository + if err := v.coreClient.Get(context.Background(), types.NamespacedName{Name: repoName, Namespace: ns}, &repo); err != nil { + // If we can't look up the repo, let the event through (fail open) + return v.delegate.OnPackageRevisionChange(eventType, obj) } - if err := r.cad.ObjectCache().WatchPackageRevisions(ctx, filter, wrappedCallback); err != nil { - return err + if isV1Alpha2Repo(&repo) { + return true // skip, but keep watching } + return v.delegate.OnPackageRevisionChange(eventType, obj) +} - return nil +func (r *packageCommon) watchPackages(ctx context.Context, filter repository.ListPackageRevisionFilter, callback engine.ObjectWatcher) error { + var watcher engine.ObjectWatcher = callback + + if ns, namespaced := genericapirequest.NamespaceFrom(ctx); namespaced && ns != "" { + watcher = &namespaceFilteringWatcher{ns: ns, delegate: watcher} + } + watcher = &v1alpha2FilteringWatcher{coreClient: r.coreClient, delegate: watcher} + + return r.cad.ObjectCache().WatchPackageRevisions(ctx, filter, watcher) } func (r *packageCommon) getRepositoryObj(ctx context.Context, repositoryID types.NamespacedName) (*configapi.Repository, error) { @@ -162,6 +207,11 @@ func (r *packageCommon) getRepoPkgRev(ctx context.Context, name string) (reposit return nil, err } + repositoryObj, err := r.getRepositoryObj(ctx, types.NamespacedName{Name: prKey.RKey().Name, Namespace: prKey.RKey().Namespace}) + if err == nil && isV1Alpha2Repo(repositoryObj) { + return nil, apierrors.NewNotFound(r.gr, name) + } + revisions, err := r.cad.ListPackageRevisions(ctx, repository.ListPackageRevisionFilter{Key: prKey}) if err != nil { return nil, err @@ -316,6 +366,10 @@ func (r *packageCommon) updatePackageRevision(ctx context.Context, name string, return nil, false, apierrors.NewInternalError(fmt.Errorf("error getting repository %v: %w", repositoryID, err)) } + if isV1Alpha2Repo(&repositoryObj) { + return nil, false, apierrors.NewGone(fmt.Sprintf("repository %q is managed by v1alpha2; use the v1alpha2 API", repositoryID.Name)) + } + var parentPackage repository.PackageRevision if newApiPkgRev.Spec.Parent != nil && newApiPkgRev.Spec.Parent.Name != "" { p, err := r.getRepoPkgRev(ctx, newApiPkgRev.Spec.Parent.Name) diff --git a/pkg/registry/porch/packagecommon_test.go b/pkg/registry/porch/packagecommon_test.go index 7f6f7a266..e9e1d553e 100644 --- a/pkg/registry/porch/packagecommon_test.go +++ b/pkg/registry/porch/packagecommon_test.go @@ -153,10 +153,17 @@ func (f *fakeWatcherManager) WatchPackageRevisions(ctx context.Context, filter r return nil } +func newMockCoreClientForWatcher() *mockclient.MockClient { + c := &mockclient.MockClient{} + c.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything). + Return(nil).Maybe() + return c +} + func TestWatchPackages_CallsCallback(t *testing.T) { mockCad := &mockcad.MockCaDEngine{} mockCad.On("ObjectCache").Return(&fakeWatcherManager{}) - pc := &packageCommon{cad: mockCad} + pc := &packageCommon{cad: mockCad, coreClient: newMockCoreClientForWatcher()} called := false callback := &testWatcher{onChange: func(eventType watch.EventType, obj repository.PackageRevision) bool { @@ -185,7 +192,7 @@ func TestWatchPackages_CallsCallback(t *testing.T) { func TestWatchPackages_NoNamespace(t *testing.T) { mockCad := &mockcad.MockCaDEngine{} mockCad.On("ObjectCache").Return(&fakeWatcherManager{}) - pc := &packageCommon{cad: mockCad} + pc := &packageCommon{cad: mockCad, coreClient: newMockCoreClientForWatcher()} called := false callback := &testWatcher{onChange: func(eventType watch.EventType, obj repository.PackageRevision) bool { @@ -211,7 +218,7 @@ func (e *errorWatcherManager) WatchPackageRevisions(ctx context.Context, filter func TestWatchPackages_ErrorPath(t *testing.T) { mockCad := &mockcad.MockCaDEngine{} mockCad.On("ObjectCache").Return(&errorWatcherManager{}) - pc := &packageCommon{cad: mockCad} + pc := &packageCommon{cad: mockCad, coreClient: newMockCoreClientForWatcher()} callback := &testWatcher{onChange: func(eventType watch.EventType, obj repository.PackageRevision) bool { return false @@ -228,7 +235,7 @@ func TestWatchPackages_ErrorPath(t *testing.T) { func TestWatchPackages_WithNamespaceFilteringWatcher(t *testing.T) { mockCad := &mockcad.MockCaDEngine{} mockCad.On("ObjectCache").Return(&fakeWatcherManager{}) - pc := &packageCommon{cad: mockCad} + pc := &packageCommon{cad: mockCad, coreClient: newMockCoreClientForWatcher()} called := false callback := &testWatcher{onChange: func(eventType watch.EventType, obj repository.PackageRevision) bool { @@ -262,6 +269,14 @@ func TestListPackageRevisions(t *testing.T) { setupMocks: func(c *mockclient.MockClient, pkgRev *mockrepo.MockPackageRevision, cad *mockcad.MockCaDEngine) { cad.On("ListPackageRevisions", mock.Anything, mock.Anything). Return([]repository.PackageRevision{pkgRev}, nil) + pkgRev.On("Key").Return(repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{ + RepoKey: repository.RepositoryKey{Name: "repo", Namespace: "ns"}, + }, + }) + pkgRev.On("KubeObjectNamespace").Return("ns") + c.On("Get", mock.Anything, types.NamespacedName{Name: "repo", Namespace: "ns"}, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything). + Return(nil) }, filter: repository.ListPackageRevisionFilter{}, expectedError: nil, @@ -273,6 +288,14 @@ func TestListPackageRevisions(t *testing.T) { setupMocks: func(c *mockclient.MockClient, pkgRev *mockrepo.MockPackageRevision, cad *mockcad.MockCaDEngine) { cad.On("ListPackageRevisions", mock.Anything, mock.Anything). Return([]repository.PackageRevision{pkgRev}, nil) + pkgRev.On("Key").Return(repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{ + RepoKey: repository.RepositoryKey{Name: "repo", Namespace: "test-namespace"}, + }, + }) + pkgRev.On("KubeObjectNamespace").Return("test-namespace") + c.On("Get", mock.Anything, types.NamespacedName{Name: "repo", Namespace: "test-namespace"}, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything). + Return(nil) }, filter: repository.ListPackageRevisionFilter{ Key: repository.PackageRevisionKey{ @@ -370,6 +393,9 @@ func TestGetRepoPkgRev(t *testing.T) { }, } + c.On("Get", mock.Anything, types.NamespacedName{Name: "repo", Namespace: "test-ns"}, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything). + Return(nil) + prKey, _ := repository.PkgRevK8sName2Key("test-ns", "repo.pkg.wsn") cad.On("ListPackageRevisions", mock.Anything, repository.ListPackageRevisionFilter{Key: prKey}). @@ -414,6 +440,8 @@ func TestGetRepoPkgRev(t *testing.T) { pkgRevName: "repo.pkg.wsn", ctx: genericapirequest.WithNamespace(context.Background(), "test-ns"), setupMocks: func(c *mockclient.MockClient, cad *mockcad.MockCaDEngine) { + c.On("Get", mock.Anything, types.NamespacedName{Name: "repo", Namespace: "test-ns"}, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything). + Return(nil) prKey, _ := repository.PkgRevK8sName2Key("test-ns", "repo.pkg.wsn") cad.On("ListPackageRevisions", mock.Anything, repository.ListPackageRevisionFilter{Key: prKey}). @@ -427,6 +455,8 @@ func TestGetRepoPkgRev(t *testing.T) { pkgRevName: "repo.pkg.wsn", ctx: genericapirequest.WithNamespace(context.Background(), "test-ns"), setupMocks: func(c *mockclient.MockClient, cad *mockcad.MockCaDEngine) { + c.On("Get", mock.Anything, types.NamespacedName{Name: "repo", Namespace: "test-ns"}, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything). + Return(nil) prKey, _ := repository.PkgRevK8sName2Key("test-ns", "repo.pkg.wsn") cad.On("ListPackageRevisions", mock.Anything, repository.ListPackageRevisionFilter{Key: prKey}). @@ -441,6 +471,8 @@ func TestGetRepoPkgRev(t *testing.T) { pkgRevName: "repo.pkg.wsn", ctx: genericapirequest.WithNamespace(context.Background(), "test-ns"), setupMocks: func(c *mockclient.MockClient, cad *mockcad.MockCaDEngine) { + c.On("Get", mock.Anything, types.NamespacedName{Name: "repo", Namespace: "test-ns"}, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything). + Return(nil) prKey, _ := repository.PkgRevK8sName2Key("test-ns", "repo.pkg.wsn") cad.On("ListPackageRevisions", mock.Anything, repository.ListPackageRevisionFilter{Key: prKey}). @@ -723,7 +755,7 @@ func TestUpdatePackageRevision(t *testing.T) { } c.On("Get", mock.Anything, types.NamespacedName{Name: "repo", Namespace: "test-ns"}, mock.Anything). - Return(nil).Once() + Return(nil) prKey, _ := repository.PkgRevK8sName2Key("test-ns", "repo.pkg.wsn") cad.On("ListPackageRevisions", mock.Anything, @@ -754,6 +786,8 @@ func TestUpdatePackageRevision(t *testing.T) { name: "Package not found - no forceAllowCreate", pkgRevName: "repo.pkg.wsn", setupMocks: func(c *mockclient.MockClient, cad *mockcad.MockCaDEngine, pkgRev *mockrepo.MockPackageRevision) { + c.On("Get", mock.Anything, types.NamespacedName{Name: "repo", Namespace: "test-ns"}, mock.Anything). + Return(nil) prKey, _ := repository.PkgRevK8sName2Key("test-ns", "repo.pkg.wsn") cad.On("ListPackageRevisions", mock.Anything, repository.ListPackageRevisionFilter{Key: prKey}). @@ -773,7 +807,7 @@ func TestUpdatePackageRevision(t *testing.T) { } c.On("Get", mock.Anything, types.NamespacedName{Name: "repo", Namespace: "test-ns"}, mock.Anything). - Return(nil).Once() + Return(nil) prKey, _ := repository.PkgRevK8sName2Key("test-ns", "repo.pkg.wsn") cad.On("ListPackageRevisions", mock.Anything, @@ -852,3 +886,183 @@ func (f *fakeUpdatedObjectInfo) UpdatedObject(ctx context.Context, oldObj runtim func (f *fakeUpdatedObjectInfo) Preconditions() *metav1.Preconditions { return nil } + +func TestIsV1Alpha2Repo(t *testing.T) { + tests := []struct { + name string + repo *configapi.Repository + expected bool + }{ + {"nil annotations", &configapi.Repository{}, false}, + {"empty annotations", &configapi.Repository{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}}, false}, + {"wrong value", &configapi.Repository{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{configapi.AnnotationKeyV1Alpha2Migration: "false"}}}, false}, + {"enabled", &configapi.Repository{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{configapi.AnnotationKeyV1Alpha2Migration: configapi.AnnotationValueMigrationEnabled}}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, isV1Alpha2Repo(tt.repo)) + }) + } +} + +func TestV1Alpha2FilteringWatcher(t *testing.T) { + fakePR := &fakeextrepo.FakePackageRevision{ + PrKey: repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{ + RepoKey: repository.RepositoryKey{Name: "my-repo", Namespace: "ns"}, + }, + }, + } + + t.Run("skips v1alpha2 repo", func(t *testing.T) { + mockClient := &mockclient.MockClient{} + mockClient.On("Get", mock.Anything, types.NamespacedName{Name: "my-repo", Namespace: "ns"}, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything). + Run(func(args mock.Arguments) { + repo := args.Get(2).(*configapi.Repository) + repo.Annotations = map[string]string{configapi.AnnotationKeyV1Alpha2Migration: configapi.AnnotationValueMigrationEnabled} + }).Return(nil) + + called := false + w := &v1alpha2FilteringWatcher{ + coreClient: mockClient, + delegate: &testWatcher{onChange: func(_ watch.EventType, _ repository.PackageRevision) bool { called = true; return true }}, + } + result := w.OnPackageRevisionChange(watch.Added, fakePR) + assert.True(t, result) + assert.False(t, called) + }) + + t.Run("passes through non-v1alpha2 repo", func(t *testing.T) { + mockClient := &mockclient.MockClient{} + mockClient.On("Get", mock.Anything, types.NamespacedName{Name: "my-repo", Namespace: "ns"}, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything). + Return(nil) + + called := false + w := &v1alpha2FilteringWatcher{ + coreClient: mockClient, + delegate: &testWatcher{onChange: func(_ watch.EventType, _ repository.PackageRevision) bool { called = true; return true }}, + } + result := w.OnPackageRevisionChange(watch.Added, fakePR) + assert.True(t, result) + assert.True(t, called) + }) + + t.Run("passes through on lookup error (fail open)", func(t *testing.T) { + mockClient := &mockclient.MockClient{} + mockClient.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(fmt.Errorf("api error")) + + called := false + w := &v1alpha2FilteringWatcher{ + coreClient: mockClient, + delegate: &testWatcher{onChange: func(_ watch.EventType, _ repository.PackageRevision) bool { called = true; return true }}, + } + result := w.OnPackageRevisionChange(watch.Added, fakePR) + assert.True(t, result) + assert.True(t, called) + }) +} + +func TestListPackageRevisions_SkipsV1Alpha2Repos(t *testing.T) { + mockCoreClient := &mockclient.MockClient{} + mockCaD := &mockcad.MockCaDEngine{} + + pc := &packageCommon{ + cad: mockCaD, + coreClient: mockCoreClient, + } + + v1PkgRev := &fakeextrepo.FakePackageRevision{ + PrKey: repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{ + RepoKey: repository.RepositoryKey{Name: "v1-repo", Namespace: "ns"}, + }, + }, + } + v2PkgRev := &fakeextrepo.FakePackageRevision{ + PrKey: repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{ + RepoKey: repository.RepositoryKey{Name: "v2-repo", Namespace: "ns"}, + }, + }, + } + + mockCaD.On("ListPackageRevisions", mock.Anything, mock.Anything). + Return([]repository.PackageRevision{v1PkgRev, v2PkgRev}, nil) + + mockCoreClient.On("Get", mock.Anything, types.NamespacedName{Name: "v1-repo", Namespace: "ns"}, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything). + Return(nil) + mockCoreClient.On("Get", mock.Anything, types.NamespacedName{Name: "v2-repo", Namespace: "ns"}, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything). + Run(func(args mock.Arguments) { + repo := args.Get(2).(*configapi.Repository) + repo.Annotations = map[string]string{configapi.AnnotationKeyV1Alpha2Migration: configapi.AnnotationValueMigrationEnabled} + }).Return(nil) + + callCount := 0 + err := pc.listPackageRevisions(context.Background(), repository.ListPackageRevisionFilter{}, func(_ context.Context, _ repository.PackageRevision) error { + callCount++ + return nil + }) + + assert.NoError(t, err) + assert.Equal(t, 1, callCount, "should only list from v1 repo") +} + +func TestListPackageRevisions_AllV1Alpha2Repos(t *testing.T) { + mockCoreClient := &mockclient.MockClient{} + mockCaD := &mockcad.MockCaDEngine{} + + pc := &packageCommon{ + cad: mockCaD, + coreClient: mockCoreClient, + } + + v2PkgRev := &fakeextrepo.FakePackageRevision{ + PrKey: repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{ + RepoKey: repository.RepositoryKey{Name: "v2-repo", Namespace: "ns"}, + }, + }, + } + + mockCaD.On("ListPackageRevisions", mock.Anything, mock.Anything). + Return([]repository.PackageRevision{v2PkgRev}, nil) + + mockCoreClient.On("Get", mock.Anything, types.NamespacedName{Name: "v2-repo", Namespace: "ns"}, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything). + Run(func(args mock.Arguments) { + repo := args.Get(2).(*configapi.Repository) + repo.Annotations = map[string]string{configapi.AnnotationKeyV1Alpha2Migration: configapi.AnnotationValueMigrationEnabled} + }).Return(nil) + + callCount := 0 + err := pc.listPackageRevisions(context.Background(), repository.ListPackageRevisionFilter{}, func(_ context.Context, _ repository.PackageRevision) error { + callCount++ + return nil + }) + + assert.NoError(t, err) + assert.Equal(t, 0, callCount) +} + +func TestGetRepoPkgRev_V1Alpha2RepoReturnsNotFound(t *testing.T) { + mockCoreClient := &mockclient.MockClient{} + mockCaDEngine := &mockcad.MockCaDEngine{} + + pc := &packageCommon{ + coreClient: mockCoreClient, + cad: mockCaDEngine, + gr: porchapi.Resource("packagerevisions"), + } + + mockCoreClient.On("Get", mock.Anything, types.NamespacedName{Name: "repo", Namespace: "test-ns"}, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything). + Run(func(args mock.Arguments) { + repo := args.Get(2).(*configapi.Repository) + repo.Annotations = map[string]string{configapi.AnnotationKeyV1Alpha2Migration: configapi.AnnotationValueMigrationEnabled} + }).Return(nil) + + ctx := genericapirequest.WithNamespace(context.Background(), "test-ns") + _, err := pc.getRepoPkgRev(ctx, "repo.pkg.wsn") + + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err)) +} diff --git a/pkg/registry/porch/packagerevision.go b/pkg/registry/porch/packagerevision.go index d16114d10..943d3f27b 100644 --- a/pkg/registry/porch/packagerevision.go +++ b/pkg/registry/porch/packagerevision.go @@ -1,4 +1,4 @@ -// Copyright 2022, 2024 The kpt and Nephio Authors +// Copyright 2022, 2024-2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -179,6 +179,10 @@ func (r *packageRevisions) Create(ctx context.Context, runtimeObject runtime.Obj return nil, err } + if isV1Alpha2Repo(repositoryObj) { + return nil, apierrors.NewGone(fmt.Sprintf("repository %q is managed by v1alpha2; use the v1alpha2 API", repositoryName)) + } + fieldErrors := r.createStrategy.Validate(ctx, runtimeObject) if len(fieldErrors) > 0 { return nil, apierrors.NewInvalid(porchapi.SchemeGroupVersion.WithKind("PackageRevision").GroupKind(), newApiPkgRev.Name, fieldErrors) diff --git a/pkg/registry/porch/packagerevision_approval_test.go b/pkg/registry/porch/packagerevision_approval_test.go index dc174cd1e..a8897224a 100644 --- a/pkg/registry/porch/packagerevision_approval_test.go +++ b/pkg/registry/porch/packagerevision_approval_test.go @@ -130,7 +130,7 @@ func TestApprovalUpdate(t *testing.T) { mockEngine.On("ListPackageRevisions", mock.Anything, mock.Anything).Return([]repository.PackageRevision{ proposedPackageRevision, }, nil).Once() - mockClient.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + mockClient.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything).Return(nil).Maybe() mockEngine.On("UpdatePackageRevision", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(proposedPackageRevision, nil).Once() objInfo := &mockApprovalUpdatedObjectInfo{ @@ -156,7 +156,6 @@ func TestApprovalUpdate(t *testing.T) { mockEngine.On("ListPackageRevisions", mock.Anything, mock.Anything).Return([]repository.PackageRevision{ proposedPackageRevision, }, nil).Once() - mockClient.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() mockEngine.On("UpdatePackageRevision", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("approval update failed")).Once() result, created, err = approval.Update(ctx, pkgRevName, objInfo, nil, nil, false, &metav1.UpdateOptions{}) diff --git a/pkg/registry/porch/packagerevision_test.go b/pkg/registry/porch/packagerevision_test.go index 0b8768c1a..f5ea7b294 100644 --- a/pkg/registry/porch/packagerevision_test.go +++ b/pkg/registry/porch/packagerevision_test.go @@ -130,8 +130,20 @@ func setup(t *testing.T) (mockClient *mockclient.MockClient, mockEngine *mockeng return } +// filterCalls removes expected calls for a given method name, used to reset mock expectations. +func filterCalls(calls []*mock.Call, method string) []*mock.Call { + var filtered []*mock.Call + for _, c := range calls { + if c.Method != method { + filtered = append(filtered, c) + } + } + return filtered +} + func TestList(t *testing.T) { - _, mockEngine := setup(t) + mockClient, mockEngine := setup(t) + mockClient.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything).Return(nil).Maybe() mockEngine.On("ListPackageRevisions", mock.Anything, mock.Anything).Return([]repository.PackageRevision{ packageRevision, }, nil).Once() @@ -154,6 +166,10 @@ func TestList(t *testing.T) { mockEngine.On("ListPackageRevisions", mock.Anything, mock.Anything).Return([]repository.PackageRevision{ mockPkgRev, }, nil) + mockPkgRev.On("Key").Return(repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{RepoKey: repository.RepositoryKey{Name: "repo"}}, + }).Maybe() + mockPkgRev.On("KubeObjectNamespace").Return("").Maybe() mockPkgRev.On("KubeObjectName").Return("test-package").Maybe() mockPkgRev.On("GetPackageRevision", mock.Anything).Return(nil, errors.New("error getting API package revision")).Once() result, err = packagerevisions.List(context.TODO(), &internalversion.ListOptions{}) @@ -164,7 +180,8 @@ func TestList(t *testing.T) { } func TestGet(t *testing.T) { - _, mockEngine := setup(t) + mockClient, mockEngine := setup(t) + mockClient.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything).Return(nil).Maybe() pkgRevName := "repo.1234567890.ws" // Success case @@ -205,6 +222,7 @@ func TestGet(t *testing.T) { func TestCreate(t *testing.T) { mockClient, mockEngine := setup(t) + mockClient.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything).Return(nil).Maybe() ctx := request.WithNamespace(context.TODO(), "someDummyNamespace") // Success case - Init task @@ -219,7 +237,6 @@ func TestCreate(t *testing.T) { }, } - mockClient.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() mockEngine.On("CreatePackageRevision", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(packageRevision, nil).Once() result, err := packagerevisions.Create(ctx, newPkgRev, nil, &metav1.CreateOptions{}) @@ -261,13 +278,29 @@ func TestCreate(t *testing.T) { //========================================================================================= // Error from CreatePackageRevision - mockClient.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() mockEngine.On("CreatePackageRevision", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("creation failed")).Once() result, err = packagerevisions.Create(ctx, newPkgRev, nil, &metav1.CreateOptions{}) assert.Error(t, err) assert.Nil(t, result) assert.True(t, apierrors.IsInternalError(err)) + + //========================================================================================= + + // v1alpha2 repo returns Forbidden + // Reset Get mock to return v1alpha2 annotation + mockClient.ExpectedCalls = filterCalls(mockClient.ExpectedCalls, "Get") + mockClient.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything). + Run(func(args mock.Arguments) { + repo := args.Get(2).(*configapi.Repository) + repo.Annotations = map[string]string{configapi.AnnotationKeyV1Alpha2Migration: configapi.AnnotationValueMigrationEnabled} + }).Return(nil).Maybe() + + result, err = packagerevisions.Create(ctx, newPkgRev, nil, &metav1.CreateOptions{}) + assert.Error(t, err) + assert.Nil(t, result) + assert.True(t, apierrors.IsGone(err)) + assert.ErrorContains(t, err, "managed by v1alpha2") } func TestDelete(t *testing.T) { @@ -317,8 +350,8 @@ info: }, } - // Need 1 Get call for validateDelete->getRepositoryObj - mockClient.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + // Need Get calls for getRepoPkgRev->getRepositoryObj and validateDelete->getRepositoryObj + mockClient.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything).Return(nil).Maybe() mockEngine.On("ListPackageRevisions", mock.Anything, mock.Anything).Return([]repository.PackageRevision{ deletionProposedPkgRev, }, nil).Once() @@ -389,7 +422,6 @@ info: }, } - mockClient.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() mockEngine.On("ListPackageRevisions", mock.Anything, mock.Anything).Return([]repository.PackageRevision{ draftPkgRev, }, nil).Once() @@ -425,7 +457,6 @@ info: //========================================================================================= // Error from DeletePackageRevision - mockClient.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() mockEngine.On("ListPackageRevisions", mock.Anything, mock.Anything).Return([]repository.PackageRevision{ deletionProposedPkgRev, }, nil).Once() @@ -809,6 +840,7 @@ func TestCheckIfUpstreamIsReferenced(t *testing.T) { func TestUpdate(t *testing.T) { mockClient, mockEngine := setup(t) + mockClient.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything).Return(nil).Maybe() ctx := request.WithNamespace(context.TODO(), "someDummyNamespace") pkgRevName := "repo.1234567890.ws" @@ -840,7 +872,6 @@ func TestUpdate(t *testing.T) { mockEngine.On("ListPackageRevisions", mock.Anything, mock.Anything).Return([]repository.PackageRevision{ draftPackageRevision, }, nil).Once() - mockClient.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() mockEngine.On("UpdatePackageRevision", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(draftPackageRevision, nil).Once() objInfo := &mockUpdatedObjectInfo{ @@ -866,7 +897,6 @@ func TestUpdate(t *testing.T) { mockEngine.On("ListPackageRevisions", mock.Anything, mock.Anything).Return([]repository.PackageRevision{ draftPackageRevision, }, nil).Once() - mockClient.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() mockEngine.On("UpdatePackageRevision", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("update failed")).Once() result, created, err = packagerevisions.Update(ctx, pkgRevName, objInfo, nil, nil, false, &metav1.UpdateOptions{}) @@ -891,7 +921,6 @@ func TestUpdate(t *testing.T) { mockEngine.On("ListPackageRevisions", mock.Anything, mock.Anything).Return([]repository.PackageRevision{ draftPackageRevision, }, nil).Once() - mockClient.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() mockEngine.On("UpdatePackageRevision", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, apierrors.NewConflict(porchapi.Resource("packagerevisions"), pkgRevName, fmt.Errorf("the object has been modified; please apply your changes to the latest version and try again"))).Once() result, created, err = packagerevisions.Update(ctx, pkgRevName, objInfo, nil, nil, false, &metav1.UpdateOptions{}) diff --git a/pkg/registry/porch/packagerevisionresources.go b/pkg/registry/porch/packagerevisionresources.go index 2113dbafb..ff9b8d0eb 100644 --- a/pkg/registry/porch/packagerevisionresources.go +++ b/pkg/registry/porch/packagerevisionresources.go @@ -1,4 +1,4 @@ -// Copyright 2022, 2024-2025 The kpt and Nephio Authors +// Copyright 2022, 2024-2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ import ( "fmt" porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" "github.com/nephio-project/porch/api/porchconfig/v1alpha1" "github.com/nephio-project/porch/pkg/repository" context1 "github.com/nephio-project/porch/pkg/util/context" @@ -31,6 +32,7 @@ import ( genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" ) type packageRevisionResources struct { @@ -113,7 +115,7 @@ func (r *packageRevisionResources) Get(ctx context.Context, name string, _ *meta klog.V(3).InfoS("Get PackageRevisionResources started", context1.LogMetadataFrom(ctx)...) - pkg, err := r.getRepoPkgRev(ctx, name) + pkg, err := r.getRepoPkgRevForResources(ctx, name) if err != nil { return nil, err } @@ -155,7 +157,7 @@ func (r *packageRevisionResources) Update(ctx context.Context, name string, objI } defer pkgMutex.Unlock() - oldRepoPkgRev, err := r.getRepoPkgRev(ctx, name) + oldRepoPkgRev, err := r.getRepoPkgRevForResources(ctx, name) if err != nil { return nil, false, err } @@ -199,9 +201,21 @@ func (r *packageRevisionResources) Update(ctx context.Context, name string, objI return nil, false, apierrors.NewInternalError(fmt.Errorf("error getting repository %v: %w", repositoryID, err)) } - rev, renderStatus, err := r.cad.UpdatePackageResources(ctx, &repositoryObj, oldRepoPkgRev, oldApiPkgRevResources, newObj) - if err != nil { - return nil, false, apierrors.NewInternalError(err) + var rev repository.PackageRevision + var renderStatus *porchapi.RenderStatus + + if isV1Alpha2Repo(&repositoryObj) { + // v1alpha2: write resources without render. PR controller renders async. + rev, err = r.cad.UpdatePackageResourcesWithoutRender(ctx, &repositoryObj, oldRepoPkgRev, oldApiPkgRevResources, newObj) + if err != nil { + return nil, false, apierrors.NewInternalError(err) + } + r.patchRenderRequestAnnotation(ctx, namespace, name, rev.ResourceVersion()) + } else { + rev, renderStatus, err = r.cad.UpdatePackageResources(ctx, &repositoryObj, oldRepoPkgRev, oldApiPkgRevResources, newObj) + if err != nil { + return nil, false, apierrors.NewInternalError(err) + } } created, err := rev.GetResources(ctx) @@ -216,3 +230,64 @@ func (r *packageRevisionResources) Update(ctx context.Context, name string, objI return created, false, nil } + +// patchRenderRequestAnnotation patches the render-request annotation on the +// v1alpha2 PackageRevision CRD to trigger async rendering. +func (r *packageRevisionResources) patchRenderRequestAnnotation(ctx context.Context, namespace, name, resourceVersion string) { + pr := &porchv1alpha2.PackageRevision{} + key := client.ObjectKey{Namespace: namespace, Name: name} + if err := r.coreClient.Get(ctx, key, pr); err != nil { + klog.Warningf("failed to get v1alpha2 PR %s/%s for render-request patch: %v", namespace, name, err) + return + } + + patch := client.MergeFrom(pr.DeepCopy()) + if pr.Annotations == nil { + pr.Annotations = map[string]string{} + } + pr.Annotations[porchv1alpha2.AnnotationRenderRequest] = resourceVersion + if err := r.coreClient.Patch(ctx, pr, patch); err != nil { + klog.Warningf("failed to patch render-request annotation on %s/%s: %v", namespace, name, err) + } +} + +// getRepoPkgRevForResources looks up a package revision in the cache, including v1alpha2 repos. +// TODO: Replace r.cad.ListPackageRevisions with direct cache access when engine is removed +func (r *packageRevisionResources) getRepoPkgRevForResources(ctx context.Context, name string) (repository.PackageRevision, error) { + ctx, span := tracer.Start(ctx, "packageRevisionResources::getRepoPkgRevForResources", trace.WithAttributes()) + defer span.End() + + namespace, namespaced := genericapirequest.NamespaceFrom(ctx) + if !namespaced { + return nil, fmt.Errorf("namespace must be specified") + } + + prKey, err := repository.PkgRevK8sName2Key(namespace, name) + if err != nil { + return nil, err + } + + repositoryObj, err := r.getRepositoryObj(ctx, types.NamespacedName{ + Name: prKey.RKey().Name, + Namespace: prKey.RKey().Namespace, + }) + if err != nil { + return nil, err + } + + if repositoryObj.DeletionTimestamp != nil { + return nil, apierrors.NewNotFound(r.gr, name) + } + + revisions, err := r.cad.ListPackageRevisions(ctx, repository.ListPackageRevisionFilter{Key: prKey}) + if err != nil { + return nil, err + } + for _, rev := range revisions { + if rev.KubeObjectName() == name { + return rev, nil + } + } + + return nil, apierrors.NewNotFound(r.gr, name) +} diff --git a/pkg/registry/porch/packagerevisionresources_test.go b/pkg/registry/porch/packagerevisionresources_test.go index 7c1839636..441b3415b 100644 --- a/pkg/registry/porch/packagerevisionresources_test.go +++ b/pkg/registry/porch/packagerevisionresources_test.go @@ -63,7 +63,8 @@ func setupResourcesTest(t *testing.T) (mockClient *mockclient.MockClient, mockEn } func TestListResources(t *testing.T) { - _, mockEngine := setupResourcesTest(t) + mockClient, mockEngine := setupResourcesTest(t) + mockClient.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything).Return(nil).Maybe() mockEngine.On("ListPackageRevisions", mock.Anything, mock.Anything).Return([]repository.PackageRevision{ packageRevision, }, nil).Once() @@ -86,6 +87,10 @@ func TestListResources(t *testing.T) { mockEngine.On("ListPackageRevisions", mock.Anything, mock.Anything).Return([]repository.PackageRevision{ mockPkgRev, }, nil) + mockPkgRev.On("Key").Return(repository.PackageRevisionKey{ + PkgKey: repository.PackageKey{RepoKey: repository.RepositoryKey{Name: "repo"}}, + }).Maybe() + mockPkgRev.On("KubeObjectNamespace").Return("").Maybe() mockPkgRev.On("GetResources", mock.Anything).Return(nil, errors.New("error getting API package revision")).Once() result, err = packagerevisionresources.List(context.TODO(), &internalversion.ListOptions{}) assert.NoError(t, err) @@ -95,7 +100,8 @@ func TestListResources(t *testing.T) { } func TestGetResources(t *testing.T) { - _, mockEngine := setupResourcesTest(t) + mockClient, mockEngine := setupResourcesTest(t) + mockClient.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.Repository"), mock.Anything).Return(nil).Maybe() pkgRevName := "repo.1234567890.ws" // Success case diff --git a/pkg/repository/content.go b/pkg/repository/content.go new file mode 100644 index 000000000..98a4d1f39 --- /dev/null +++ b/pkg/repository/content.go @@ -0,0 +1,61 @@ +// Copyright 2026 The kpt and Nephio 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 repository + +import ( + "context" + "time" + + kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" + porchv1alpha2 "github.com/nephio-project/porch/api/porch/v1alpha2" +) + +// PackageContent provides version-neutral read access to package revision +// state and content. +type PackageContent interface { + Key() PackageRevisionKey + Lifecycle(ctx context.Context) string + GetResourceContents(ctx context.Context) (map[string]string, error) + GetKptfile(ctx context.Context) (kptfilev1.KptFile, error) + GetUpstreamLock(ctx context.Context) (kptfilev1.Upstream, kptfilev1.Locator, error) + GetLock(ctx context.Context) (kptfilev1.Upstream, kptfilev1.Locator, error) + GetCommitInfo() (commitTime time.Time, commitAuthor string) +} + +// PackageRevisionDraftSlim is a version-neutral draft interface using plain +// strings instead of v1alpha1 types. +type PackageRevisionDraftSlim interface { + Key() PackageRevisionKey + UpdateResources(ctx context.Context, resources map[string]string, commitMsg string) error + UpdateLifecycle(ctx context.Context, lifecycle string) error +} + +// ContentCache provides the PR controller with version-neutral access to the +// shared cache. It encapsulates git-internal state lookup so the controller +// only passes plain strings. +type ContentCache interface { + GetPackageContent(ctx context.Context, repoKey RepositoryKey, pkg, workspace string) (PackageContent, error) + UpdateLifecycle(ctx context.Context, repoKey RepositoryKey, pkg, workspace, desired string) (PackageContent, error) + CreateNewDraft(ctx context.Context, repoKey RepositoryKey, pkgName, workspace, lifecycle string) (PackageRevisionDraftSlim, error) + CreateDraftFromExisting(ctx context.Context, repoKey RepositoryKey, pkgName, workspace string) (PackageRevisionDraftSlim, error) + CloseDraft(ctx context.Context, repoKey RepositoryKey, draft PackageRevisionDraftSlim, version int) error + DeletePackage(ctx context.Context, repoKey RepositoryKey, pkg, workspace string) error +} + +// ExternalPackageFetcher fetches package content from sources outside the registered repo cache. +// Used by clone-from-git and potentially future external sources (e.g. DB, OCI). +type ExternalPackageFetcher interface { + FetchExternalGitPackage(ctx context.Context, gitSpec *porchv1alpha2.GitPackage, namespace string) (map[string]string, kptfilev1.GitLock, error) +} diff --git a/pkg/repository/repository.go b/pkg/repository/repository.go index ff2f3a8b1..bfa3e57bc 100644 --- a/pkg/repository/repository.go +++ b/pkg/repository/repository.go @@ -1,4 +1,4 @@ -// Copyright 2022, 2024-2025 The kpt and Nephio Authors +// Copyright 2022, 2024-2026 The kpt and Nephio Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import ( "path/filepath" "slices" "strings" + "time" "github.com/go-git/go-git/v5/plumbing/transport" kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" @@ -296,9 +297,12 @@ type PackageRevision interface { // Set the Kubernetes metadata for the package revision SetMeta(ctx context.Context, meta metav1.ObjectMeta) error -} -type hasLatestRevisionInfo interface { + // GetCommitInfo returns the commit time and author for the package revision. + // Returns zero values for backends that don't track commits (e.g. OCI). + GetCommitInfo() (time.Time, string) + + // IsLatestRevision returns true if this is the latest published revision of its package. IsLatestRevision() bool } @@ -404,12 +408,7 @@ func getPkgRevLabels(p PackageRevision) labels.Set { } return labels }() - isLatest := func() bool { - if cachedPr, ok := p.(hasLatestRevisionInfo); ok { - return cachedPr.IsLatestRevision() - } - return false - }() + isLatest := p.IsLatestRevision() if isLatest { labelSet[porchapi.LatestPackageRevisionKey] = porchapi.LatestPackageRevisionValue } diff --git a/pkg/repository/repository_test.go b/pkg/repository/repository_test.go index 25cee84bf..8b7af7e81 100644 --- a/pkg/repository/repository_test.go +++ b/pkg/repository/repository_test.go @@ -19,6 +19,7 @@ import ( "errors" "strings" "testing" + "time" kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" porchapi "github.com/nephio-project/porch/api/porch/v1alpha1" @@ -514,6 +515,10 @@ func (f *fakePackageRevision) IsLatestRevision() bool { return f.isLatest } +func (f *fakePackageRevision) GetCommitInfo() (time.Time, string) { + return time.Time{}, "" +} + type fakePackage struct { namespace string latestRevision int diff --git a/scripts/common.sh b/scripts/common.sh index 1d5b89434..df0a23f43 100644 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -21,4 +21,5 @@ ENABLED_RECONCILERS=${ENABLED_RECONCILERS:-"packagevariants,packagevariantsets,r PORCH_CACHE_TYPE=${PORCH_CACHE_TYPE:-CR} FN_RUNNER_WARM_UP_POD_CACHE=${FN_RUNNER_WARM_UP_POD_CACHE:-true} DB_PUSH_DRAFTS_TO_GIT=${DB_PUSH_DRAFTS_TO_GIT:-false} +CREATE_V1ALPHA2_RPKG=${CREATE_V1ALPHA2_RPKG:-false} DEPLOYPORCHCONFIGDIR=${DEPLOYPORCHCONFIGDIR:-${PORCHDIR}/.build/deploy} diff --git a/scripts/create-deployment-blueprint.sh b/scripts/create-deployment-blueprint.sh index 1c4ed8961..61fdff1d1 100755 --- a/scripts/create-deployment-blueprint.sh +++ b/scripts/create-deployment-blueprint.sh @@ -40,6 +40,7 @@ Supported Flags: --porch-cache-type TYPE ... porch cache type (CR or DB) --db-push-drafts-to-git BOOL ... enable db-push-drafts-to-git flag for porch-server --dockerhub-mirror REGISTRY ... alternate registry to pull additional images from (postgres) + --create-v1alpha2-rpkg BOOL ... enable v1alpha2 PackageRevision CRD creation by repo controller EOF exit 1 } @@ -56,6 +57,7 @@ DOCKERHUB_MIRROR="" FN_RUNNER_WARM_UP_POD_CACHE="true" PORCH_CACHE_TYPE="DB" DB_PUSH_DRAFTS_TO_GIT="false" +CREATE_V1ALPHA2_RPKG="false" while [[ $# -gt 0 ]]; do key="${1}" @@ -104,6 +106,10 @@ while [[ $# -gt 0 ]]; do DOCKERHUB_MIRROR="${2}" shift 2 ;; + --create-v1alpha2-rpkg) + CREATE_V1ALPHA2_RPKG="${2}" + shift 2 + ;; *) error "Invalid argument: ${key}" ;; @@ -215,6 +221,43 @@ function enable_db_push_drafts_to_git() { -- by-value="--db-push-drafts-to-git=false" put-value="--db-push-drafts-to-git=true" } +function enable_v1alpha2_packagerevisions() { + echo "Enabling v1alpha2 PackageRevision CRD creation and PR controller" + + # Install the v1alpha2 PackageRevision CRD + cp "${PORCH_DIR}/api/porch/v1alpha2/porch.kpt.dev_packagerevisions.yaml" \ + "${DESTINATION}/0-v1alpha2-packagerevisions.yaml" + + # Flip the controller flag + kpt fn eval ${DESTINATION} \ + --image ${SEARCH_REPLACE_IMG} \ + --match-kind Deployment \ + --match-name porch-controllers \ + --match-namespace porch-system \ + -- by-value="--repositories.create-v1alpha2-rpkg=false" put-value="--repositories.create-v1alpha2-rpkg=true" + + # Enable the PR controller that reconciles v1alpha2 CRDs + ENABLED_RECONCILERS="${ENABLED_RECONCILERS},packagerevisions" + + # Wire the fn-runner address so the PR controller can render packages + kpt fn eval ${DESTINATION} \ + --image ${STARLARK_IMG} \ + --match-kind Deployment \ + --match-name porch-controllers \ + --match-namespace porch-system \ + -- 'source= +for resource in ctx.resource_list["items"]: + for container in resource["spec"]["template"]["spec"]["containers"]: + if container["name"] == "porch-controllers": + if container["env"] == None: + container["env"] = [] + container["env"].append({ + "name": "FUNCTION_RUNNER_ADDRESS", + "value": "function-runner.porch-system.svc.cluster.local:9445" + }) +' +} + function configure_porch_cache() { echo "Configuring Porch: cache=${PORCH_CACHE_TYPE}" @@ -349,6 +392,10 @@ function main() { enable_db_push_drafts_to_git fi + if [[ "${CREATE_V1ALPHA2_RPKG}" == "true" ]]; then + enable_v1alpha2_packagerevisions + fi + customize_controller_reconcilers customize_image \ diff --git a/scripts/create-deployment-config.sh b/scripts/create-deployment-config.sh index 8814ff681..bec22e789 100755 --- a/scripts/create-deployment-config.sh +++ b/scripts/create-deployment-config.sh @@ -35,6 +35,7 @@ mkdir -p "${DEPLOYPORCHCONFIGDIR}" --fn-runner-warm-up-pod-cache "${FN_RUNNER_WARM_UP_POD_CACHE}" \ --porch-cache-type "${PORCH_CACHE_TYPE}" \ --db-push-drafts-to-git "${DB_PUSH_DRAFTS_TO_GIT}" \ + --create-v1alpha2-rpkg "${CREATE_V1ALPHA2_RPKG}" \ $(if [ -n "${PORCH_GHCR_PREFIX_URL}" ]; then echo "--ghcr-image-prefix \"${PORCH_GHCR_PREFIX_URL}\""; fi) \ $(if [ -n "${DOCKERHUB_MIRROR}" ]; then echo "--dockerhub-mirror ${DOCKERHUB_MIRROR}"; fi) diff --git a/scripts/remove-controller-from-deployment-config.sh b/scripts/remove-controller-from-deployment-config.sh index 5d75096c8..be48da46f 100755 --- a/scripts/remove-controller-from-deployment-config.sh +++ b/scripts/remove-controller-from-deployment-config.sh @@ -18,12 +18,25 @@ set -e # Exit on error set -u # Must predefine variables set -o pipefail # Check errors in piped commands -function_runner_ip="${1:-172.18.255.201}" +function_runner_ip="${1:-172.18.255.202}" self_dir="$(dirname "$(readlink -f "$0")")" deployment_config_dir="${DEPLOYPORCHCONFIGDIR:-$(readlink -f "${self_dir}/../.build/deploy")}" cd "${deployment_config_dir}" +# expose function-runner to local processes +kpt fn eval \ + --image ghcr.io/kptdev/krm-functions-catalog/starlark:v0.5.0 \ + --match-kind Service \ + --match-name function-runner \ + --match-namespace porch-system \ + -- "ip=${function_runner_ip}" 'source= +ip = ctx.resource_list["functionConfig"]["data"]["ip"] +for resource in ctx.resource_list["items"]: + resource["metadata"].setdefault("annotations", {})["metallb.universe.tf/loadBalancerIPs"] = ip + resource["spec"]["type"] = "LoadBalancer" + resource["spec"]["ports"][0]["nodePort"] = 30001' + # remove porch-controllers Deployment from package kpt fn eval \ --image ghcr.io/kptdev/krm-functions-catalog/starlark:v0.5.0 \ diff --git a/test/e2e/cli/testdata/rpkg-push-on-render-failure/config.yaml b/test/e2e/cli/testdata/rpkg-push-on-render-failure/config.yaml index d9f615dc0..7115f2d8f 100644 --- a/test/e2e/cli/testdata/rpkg-push-on-render-failure/config.yaml +++ b/test/e2e/cli/testdata/rpkg-push-on-render-failure/config.yaml @@ -25,7 +25,7 @@ commands: - args: - kubectl - annotate - - packagerevision + - packagerevisions.v1alpha1.porch.kpt.dev - git.test-push-true.v1 - --namespace=rpkg-push-on-render-failure - porch.kpt.dev/push-on-render-failure=true @@ -82,7 +82,7 @@ commands: - args: - kubectl - annotate - - packagerevision + - packagerevisions.v1alpha1.porch.kpt.dev - git.test-push-false.v1 - --namespace=rpkg-push-on-render-failure - porch.kpt.dev/push-on-render-failure=false diff --git a/test/e2e/cli/testdata/rpkg-upgrade/config.yaml b/test/e2e/cli/testdata/rpkg-upgrade/config.yaml index 1438121a6..ac85f04ad 100644 --- a/test/e2e/cli/testdata/rpkg-upgrade/config.yaml +++ b/test/e2e/cli/testdata/rpkg-upgrade/config.yaml @@ -234,7 +234,7 @@ commands: - args: - kubectl - get - - packagerevision + - packagerevisions.v1alpha1.porch.kpt.dev - downstream1.upgrade-orig.v2 - --namespace=rpkg-upgrade - -o=jsonpath={.spec.tasks[0].upgrade.newUpstreamRef.name} @@ -299,7 +299,7 @@ commands: - args: - kubectl - get - - packagerevision + - packagerevisions.v1alpha1.porch.kpt.dev - downstream2.upgrade-orig.v2 - --namespace=rpkg-upgrade - -o=jsonpath={.spec.tasks[0].upgrade.newUpstreamRef.name} diff --git a/test/mockery/mocks/porch/pkg/engine/mock_CaDEngine.go b/test/mockery/mocks/porch/pkg/engine/mock_CaDEngine.go index f83b8312a..ec6ea62d0 100644 --- a/test/mockery/mocks/porch/pkg/engine/mock_CaDEngine.go +++ b/test/mockery/mocks/porch/pkg/engine/mock_CaDEngine.go @@ -538,6 +538,92 @@ func (_c *MockCaDEngine_UpdatePackageResources_Call) RunAndReturn(run func(ctx c return _c } +// UpdatePackageResourcesWithoutRender provides a mock function for the type MockCaDEngine +func (_mock *MockCaDEngine) UpdatePackageResourcesWithoutRender(ctx context.Context, repositoryObj *v1alpha1.Repository, oldPackage repository.PackageRevision, old *v1alpha10.PackageRevisionResources, new *v1alpha10.PackageRevisionResources) (repository.PackageRevision, error) { + ret := _mock.Called(ctx, repositoryObj, oldPackage, old, new) + + if len(ret) == 0 { + panic("no return value specified for UpdatePackageResourcesWithoutRender") + } + + var r0 repository.PackageRevision + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *v1alpha1.Repository, repository.PackageRevision, *v1alpha10.PackageRevisionResources, *v1alpha10.PackageRevisionResources) (repository.PackageRevision, error)); ok { + return returnFunc(ctx, repositoryObj, oldPackage, old, new) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *v1alpha1.Repository, repository.PackageRevision, *v1alpha10.PackageRevisionResources, *v1alpha10.PackageRevisionResources) repository.PackageRevision); ok { + r0 = returnFunc(ctx, repositoryObj, oldPackage, old, new) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(repository.PackageRevision) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *v1alpha1.Repository, repository.PackageRevision, *v1alpha10.PackageRevisionResources, *v1alpha10.PackageRevisionResources) error); ok { + r1 = returnFunc(ctx, repositoryObj, oldPackage, old, new) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockCaDEngine_UpdatePackageResourcesWithoutRender_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdatePackageResourcesWithoutRender' +type MockCaDEngine_UpdatePackageResourcesWithoutRender_Call struct { + *mock.Call +} + +// UpdatePackageResourcesWithoutRender is a helper method to define mock.On call +// - ctx context.Context +// - repositoryObj *v1alpha1.Repository +// - oldPackage repository.PackageRevision +// - old *v1alpha10.PackageRevisionResources +// - new *v1alpha10.PackageRevisionResources +func (_e *MockCaDEngine_Expecter) UpdatePackageResourcesWithoutRender(ctx interface{}, repositoryObj interface{}, oldPackage interface{}, old interface{}, new interface{}) *MockCaDEngine_UpdatePackageResourcesWithoutRender_Call { + return &MockCaDEngine_UpdatePackageResourcesWithoutRender_Call{Call: _e.mock.On("UpdatePackageResourcesWithoutRender", ctx, repositoryObj, oldPackage, old, new)} +} + +func (_c *MockCaDEngine_UpdatePackageResourcesWithoutRender_Call) Run(run func(ctx context.Context, repositoryObj *v1alpha1.Repository, oldPackage repository.PackageRevision, old *v1alpha10.PackageRevisionResources, new *v1alpha10.PackageRevisionResources)) *MockCaDEngine_UpdatePackageResourcesWithoutRender_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *v1alpha1.Repository + if args[1] != nil { + arg1 = args[1].(*v1alpha1.Repository) + } + var arg2 repository.PackageRevision + if args[2] != nil { + arg2 = args[2].(repository.PackageRevision) + } + var arg3 *v1alpha10.PackageRevisionResources + if args[3] != nil { + arg3 = args[3].(*v1alpha10.PackageRevisionResources) + } + var arg4 *v1alpha10.PackageRevisionResources + if args[4] != nil { + arg4 = args[4].(*v1alpha10.PackageRevisionResources) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *MockCaDEngine_UpdatePackageResourcesWithoutRender_Call) Return(packageRevision repository.PackageRevision, err error) *MockCaDEngine_UpdatePackageResourcesWithoutRender_Call { + _c.Call.Return(packageRevision, err) + return _c +} + +func (_c *MockCaDEngine_UpdatePackageResourcesWithoutRender_Call) RunAndReturn(run func(ctx context.Context, repositoryObj *v1alpha1.Repository, oldPackage repository.PackageRevision, old *v1alpha10.PackageRevisionResources, new *v1alpha10.PackageRevisionResources) (repository.PackageRevision, error)) *MockCaDEngine_UpdatePackageResourcesWithoutRender_Call { + _c.Call.Return(run) + return _c +} + // UpdatePackageRevision provides a mock function for the type MockCaDEngine func (_mock *MockCaDEngine) UpdatePackageRevision(ctx context.Context, version int, repositoryObj *v1alpha1.Repository, oldPackage repository.PackageRevision, old *v1alpha10.PackageRevision, new *v1alpha10.PackageRevision, parent repository.PackageRevision) (repository.PackageRevision, error) { ret := _mock.Called(ctx, version, repositoryObj, oldPackage, old, new, parent) diff --git a/test/mockery/mocks/porch/pkg/repository/mock_ContentCache.go b/test/mockery/mocks/porch/pkg/repository/mock_ContentCache.go new file mode 100644 index 000000000..2aeaf54ed --- /dev/null +++ b/test/mockery/mocks/porch/pkg/repository/mock_ContentCache.go @@ -0,0 +1,509 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package repository + +import ( + "context" + + "github.com/nephio-project/porch/pkg/repository" + mock "github.com/stretchr/testify/mock" +) + +// NewMockContentCache creates a new instance of MockContentCache. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockContentCache(t interface { + mock.TestingT + Cleanup(func()) +}) *MockContentCache { + mock := &MockContentCache{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockContentCache is an autogenerated mock type for the ContentCache type +type MockContentCache struct { + mock.Mock +} + +type MockContentCache_Expecter struct { + mock *mock.Mock +} + +func (_m *MockContentCache) EXPECT() *MockContentCache_Expecter { + return &MockContentCache_Expecter{mock: &_m.Mock} +} + +// CloseDraft provides a mock function for the type MockContentCache +func (_mock *MockContentCache) CloseDraft(ctx context.Context, repoKey repository.RepositoryKey, draft repository.PackageRevisionDraftSlim, version int) error { + ret := _mock.Called(ctx, repoKey, draft, version) + + if len(ret) == 0 { + panic("no return value specified for CloseDraft") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, repository.RepositoryKey, repository.PackageRevisionDraftSlim, int) error); ok { + r0 = returnFunc(ctx, repoKey, draft, version) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockContentCache_CloseDraft_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CloseDraft' +type MockContentCache_CloseDraft_Call struct { + *mock.Call +} + +// CloseDraft is a helper method to define mock.On call +// - ctx context.Context +// - repoKey repository.RepositoryKey +// - draft repository.PackageRevisionDraftSlim +// - version int +func (_e *MockContentCache_Expecter) CloseDraft(ctx interface{}, repoKey interface{}, draft interface{}, version interface{}) *MockContentCache_CloseDraft_Call { + return &MockContentCache_CloseDraft_Call{Call: _e.mock.On("CloseDraft", ctx, repoKey, draft, version)} +} + +func (_c *MockContentCache_CloseDraft_Call) Run(run func(ctx context.Context, repoKey repository.RepositoryKey, draft repository.PackageRevisionDraftSlim, version int)) *MockContentCache_CloseDraft_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 repository.RepositoryKey + if args[1] != nil { + arg1 = args[1].(repository.RepositoryKey) + } + var arg2 repository.PackageRevisionDraftSlim + if args[2] != nil { + arg2 = args[2].(repository.PackageRevisionDraftSlim) + } + var arg3 int + if args[3] != nil { + arg3 = args[3].(int) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockContentCache_CloseDraft_Call) Return(err error) *MockContentCache_CloseDraft_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockContentCache_CloseDraft_Call) RunAndReturn(run func(ctx context.Context, repoKey repository.RepositoryKey, draft repository.PackageRevisionDraftSlim, version int) error) *MockContentCache_CloseDraft_Call { + _c.Call.Return(run) + return _c +} + +// CreateDraftFromExisting provides a mock function for the type MockContentCache +func (_mock *MockContentCache) CreateDraftFromExisting(ctx context.Context, repoKey repository.RepositoryKey, pkgName string, workspace string) (repository.PackageRevisionDraftSlim, error) { + ret := _mock.Called(ctx, repoKey, pkgName, workspace) + + if len(ret) == 0 { + panic("no return value specified for CreateDraftFromExisting") + } + + var r0 repository.PackageRevisionDraftSlim + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, repository.RepositoryKey, string, string) (repository.PackageRevisionDraftSlim, error)); ok { + return returnFunc(ctx, repoKey, pkgName, workspace) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, repository.RepositoryKey, string, string) repository.PackageRevisionDraftSlim); ok { + r0 = returnFunc(ctx, repoKey, pkgName, workspace) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(repository.PackageRevisionDraftSlim) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, repository.RepositoryKey, string, string) error); ok { + r1 = returnFunc(ctx, repoKey, pkgName, workspace) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockContentCache_CreateDraftFromExisting_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateDraftFromExisting' +type MockContentCache_CreateDraftFromExisting_Call struct { + *mock.Call +} + +// CreateDraftFromExisting is a helper method to define mock.On call +// - ctx context.Context +// - repoKey repository.RepositoryKey +// - pkgName string +// - workspace string +func (_e *MockContentCache_Expecter) CreateDraftFromExisting(ctx interface{}, repoKey interface{}, pkgName interface{}, workspace interface{}) *MockContentCache_CreateDraftFromExisting_Call { + return &MockContentCache_CreateDraftFromExisting_Call{Call: _e.mock.On("CreateDraftFromExisting", ctx, repoKey, pkgName, workspace)} +} + +func (_c *MockContentCache_CreateDraftFromExisting_Call) Run(run func(ctx context.Context, repoKey repository.RepositoryKey, pkgName string, workspace string)) *MockContentCache_CreateDraftFromExisting_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 repository.RepositoryKey + if args[1] != nil { + arg1 = args[1].(repository.RepositoryKey) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockContentCache_CreateDraftFromExisting_Call) Return(packageRevisionDraftSlim repository.PackageRevisionDraftSlim, err error) *MockContentCache_CreateDraftFromExisting_Call { + _c.Call.Return(packageRevisionDraftSlim, err) + return _c +} + +func (_c *MockContentCache_CreateDraftFromExisting_Call) RunAndReturn(run func(ctx context.Context, repoKey repository.RepositoryKey, pkgName string, workspace string) (repository.PackageRevisionDraftSlim, error)) *MockContentCache_CreateDraftFromExisting_Call { + _c.Call.Return(run) + return _c +} + +// CreateNewDraft provides a mock function for the type MockContentCache +func (_mock *MockContentCache) CreateNewDraft(ctx context.Context, repoKey repository.RepositoryKey, pkgName string, workspace string, lifecycle string) (repository.PackageRevisionDraftSlim, error) { + ret := _mock.Called(ctx, repoKey, pkgName, workspace, lifecycle) + + if len(ret) == 0 { + panic("no return value specified for CreateNewDraft") + } + + var r0 repository.PackageRevisionDraftSlim + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, repository.RepositoryKey, string, string, string) (repository.PackageRevisionDraftSlim, error)); ok { + return returnFunc(ctx, repoKey, pkgName, workspace, lifecycle) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, repository.RepositoryKey, string, string, string) repository.PackageRevisionDraftSlim); ok { + r0 = returnFunc(ctx, repoKey, pkgName, workspace, lifecycle) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(repository.PackageRevisionDraftSlim) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, repository.RepositoryKey, string, string, string) error); ok { + r1 = returnFunc(ctx, repoKey, pkgName, workspace, lifecycle) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockContentCache_CreateNewDraft_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateNewDraft' +type MockContentCache_CreateNewDraft_Call struct { + *mock.Call +} + +// CreateNewDraft is a helper method to define mock.On call +// - ctx context.Context +// - repoKey repository.RepositoryKey +// - pkgName string +// - workspace string +// - lifecycle string +func (_e *MockContentCache_Expecter) CreateNewDraft(ctx interface{}, repoKey interface{}, pkgName interface{}, workspace interface{}, lifecycle interface{}) *MockContentCache_CreateNewDraft_Call { + return &MockContentCache_CreateNewDraft_Call{Call: _e.mock.On("CreateNewDraft", ctx, repoKey, pkgName, workspace, lifecycle)} +} + +func (_c *MockContentCache_CreateNewDraft_Call) Run(run func(ctx context.Context, repoKey repository.RepositoryKey, pkgName string, workspace string, lifecycle string)) *MockContentCache_CreateNewDraft_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 repository.RepositoryKey + if args[1] != nil { + arg1 = args[1].(repository.RepositoryKey) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + var arg4 string + if args[4] != nil { + arg4 = args[4].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *MockContentCache_CreateNewDraft_Call) Return(packageRevisionDraftSlim repository.PackageRevisionDraftSlim, err error) *MockContentCache_CreateNewDraft_Call { + _c.Call.Return(packageRevisionDraftSlim, err) + return _c +} + +func (_c *MockContentCache_CreateNewDraft_Call) RunAndReturn(run func(ctx context.Context, repoKey repository.RepositoryKey, pkgName string, workspace string, lifecycle string) (repository.PackageRevisionDraftSlim, error)) *MockContentCache_CreateNewDraft_Call { + _c.Call.Return(run) + return _c +} + +// DeletePackage provides a mock function for the type MockContentCache +func (_mock *MockContentCache) DeletePackage(ctx context.Context, repoKey repository.RepositoryKey, pkg string, workspace string) error { + ret := _mock.Called(ctx, repoKey, pkg, workspace) + + if len(ret) == 0 { + panic("no return value specified for DeletePackage") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, repository.RepositoryKey, string, string) error); ok { + r0 = returnFunc(ctx, repoKey, pkg, workspace) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockContentCache_DeletePackage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeletePackage' +type MockContentCache_DeletePackage_Call struct { + *mock.Call +} + +// DeletePackage is a helper method to define mock.On call +// - ctx context.Context +// - repoKey repository.RepositoryKey +// - pkg string +// - workspace string +func (_e *MockContentCache_Expecter) DeletePackage(ctx interface{}, repoKey interface{}, pkg interface{}, workspace interface{}) *MockContentCache_DeletePackage_Call { + return &MockContentCache_DeletePackage_Call{Call: _e.mock.On("DeletePackage", ctx, repoKey, pkg, workspace)} +} + +func (_c *MockContentCache_DeletePackage_Call) Run(run func(ctx context.Context, repoKey repository.RepositoryKey, pkg string, workspace string)) *MockContentCache_DeletePackage_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 repository.RepositoryKey + if args[1] != nil { + arg1 = args[1].(repository.RepositoryKey) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockContentCache_DeletePackage_Call) Return(err error) *MockContentCache_DeletePackage_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockContentCache_DeletePackage_Call) RunAndReturn(run func(ctx context.Context, repoKey repository.RepositoryKey, pkg string, workspace string) error) *MockContentCache_DeletePackage_Call { + _c.Call.Return(run) + return _c +} + +// GetPackageContent provides a mock function for the type MockContentCache +func (_mock *MockContentCache) GetPackageContent(ctx context.Context, repoKey repository.RepositoryKey, pkg string, workspace string) (repository.PackageContent, error) { + ret := _mock.Called(ctx, repoKey, pkg, workspace) + + if len(ret) == 0 { + panic("no return value specified for GetPackageContent") + } + + var r0 repository.PackageContent + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, repository.RepositoryKey, string, string) (repository.PackageContent, error)); ok { + return returnFunc(ctx, repoKey, pkg, workspace) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, repository.RepositoryKey, string, string) repository.PackageContent); ok { + r0 = returnFunc(ctx, repoKey, pkg, workspace) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(repository.PackageContent) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, repository.RepositoryKey, string, string) error); ok { + r1 = returnFunc(ctx, repoKey, pkg, workspace) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockContentCache_GetPackageContent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPackageContent' +type MockContentCache_GetPackageContent_Call struct { + *mock.Call +} + +// GetPackageContent is a helper method to define mock.On call +// - ctx context.Context +// - repoKey repository.RepositoryKey +// - pkg string +// - workspace string +func (_e *MockContentCache_Expecter) GetPackageContent(ctx interface{}, repoKey interface{}, pkg interface{}, workspace interface{}) *MockContentCache_GetPackageContent_Call { + return &MockContentCache_GetPackageContent_Call{Call: _e.mock.On("GetPackageContent", ctx, repoKey, pkg, workspace)} +} + +func (_c *MockContentCache_GetPackageContent_Call) Run(run func(ctx context.Context, repoKey repository.RepositoryKey, pkg string, workspace string)) *MockContentCache_GetPackageContent_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 repository.RepositoryKey + if args[1] != nil { + arg1 = args[1].(repository.RepositoryKey) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockContentCache_GetPackageContent_Call) Return(packageContent repository.PackageContent, err error) *MockContentCache_GetPackageContent_Call { + _c.Call.Return(packageContent, err) + return _c +} + +func (_c *MockContentCache_GetPackageContent_Call) RunAndReturn(run func(ctx context.Context, repoKey repository.RepositoryKey, pkg string, workspace string) (repository.PackageContent, error)) *MockContentCache_GetPackageContent_Call { + _c.Call.Return(run) + return _c +} + +// UpdateLifecycle provides a mock function for the type MockContentCache +func (_mock *MockContentCache) UpdateLifecycle(ctx context.Context, repoKey repository.RepositoryKey, pkg string, workspace string, desired string) (repository.PackageContent, error) { + ret := _mock.Called(ctx, repoKey, pkg, workspace, desired) + + if len(ret) == 0 { + panic("no return value specified for UpdateLifecycle") + } + + var r0 repository.PackageContent + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, repository.RepositoryKey, string, string, string) (repository.PackageContent, error)); ok { + return returnFunc(ctx, repoKey, pkg, workspace, desired) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, repository.RepositoryKey, string, string, string) repository.PackageContent); ok { + r0 = returnFunc(ctx, repoKey, pkg, workspace, desired) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(repository.PackageContent) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, repository.RepositoryKey, string, string, string) error); ok { + r1 = returnFunc(ctx, repoKey, pkg, workspace, desired) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockContentCache_UpdateLifecycle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateLifecycle' +type MockContentCache_UpdateLifecycle_Call struct { + *mock.Call +} + +// UpdateLifecycle is a helper method to define mock.On call +// - ctx context.Context +// - repoKey repository.RepositoryKey +// - pkg string +// - workspace string +// - desired string +func (_e *MockContentCache_Expecter) UpdateLifecycle(ctx interface{}, repoKey interface{}, pkg interface{}, workspace interface{}, desired interface{}) *MockContentCache_UpdateLifecycle_Call { + return &MockContentCache_UpdateLifecycle_Call{Call: _e.mock.On("UpdateLifecycle", ctx, repoKey, pkg, workspace, desired)} +} + +func (_c *MockContentCache_UpdateLifecycle_Call) Run(run func(ctx context.Context, repoKey repository.RepositoryKey, pkg string, workspace string, desired string)) *MockContentCache_UpdateLifecycle_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 repository.RepositoryKey + if args[1] != nil { + arg1 = args[1].(repository.RepositoryKey) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + var arg4 string + if args[4] != nil { + arg4 = args[4].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *MockContentCache_UpdateLifecycle_Call) Return(packageContent repository.PackageContent, err error) *MockContentCache_UpdateLifecycle_Call { + _c.Call.Return(packageContent, err) + return _c +} + +func (_c *MockContentCache_UpdateLifecycle_Call) RunAndReturn(run func(ctx context.Context, repoKey repository.RepositoryKey, pkg string, workspace string, desired string) (repository.PackageContent, error)) *MockContentCache_UpdateLifecycle_Call { + _c.Call.Return(run) + return _c +} diff --git a/test/mockery/mocks/porch/pkg/repository/mock_ExternalPackageFetcher.go b/test/mockery/mocks/porch/pkg/repository/mock_ExternalPackageFetcher.go new file mode 100644 index 000000000..f15c886fb --- /dev/null +++ b/test/mockery/mocks/porch/pkg/repository/mock_ExternalPackageFetcher.go @@ -0,0 +1,120 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package repository + +import ( + "context" + + "github.com/kptdev/kpt/pkg/api/kptfile/v1" + "github.com/nephio-project/porch/api/porch/v1alpha2" + mock "github.com/stretchr/testify/mock" +) + +// NewMockExternalPackageFetcher creates a new instance of MockExternalPackageFetcher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockExternalPackageFetcher(t interface { + mock.TestingT + Cleanup(func()) +}) *MockExternalPackageFetcher { + mock := &MockExternalPackageFetcher{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockExternalPackageFetcher is an autogenerated mock type for the ExternalPackageFetcher type +type MockExternalPackageFetcher struct { + mock.Mock +} + +type MockExternalPackageFetcher_Expecter struct { + mock *mock.Mock +} + +func (_m *MockExternalPackageFetcher) EXPECT() *MockExternalPackageFetcher_Expecter { + return &MockExternalPackageFetcher_Expecter{mock: &_m.Mock} +} + +// FetchExternalGitPackage provides a mock function for the type MockExternalPackageFetcher +func (_mock *MockExternalPackageFetcher) FetchExternalGitPackage(ctx context.Context, gitSpec *v1alpha2.GitPackage, namespace string) (map[string]string, v1.GitLock, error) { + ret := _mock.Called(ctx, gitSpec, namespace) + + if len(ret) == 0 { + panic("no return value specified for FetchExternalGitPackage") + } + + var r0 map[string]string + var r1 v1.GitLock + var r2 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *v1alpha2.GitPackage, string) (map[string]string, v1.GitLock, error)); ok { + return returnFunc(ctx, gitSpec, namespace) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *v1alpha2.GitPackage, string) map[string]string); ok { + r0 = returnFunc(ctx, gitSpec, namespace) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]string) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *v1alpha2.GitPackage, string) v1.GitLock); ok { + r1 = returnFunc(ctx, gitSpec, namespace) + } else { + r1 = ret.Get(1).(v1.GitLock) + } + if returnFunc, ok := ret.Get(2).(func(context.Context, *v1alpha2.GitPackage, string) error); ok { + r2 = returnFunc(ctx, gitSpec, namespace) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// MockExternalPackageFetcher_FetchExternalGitPackage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FetchExternalGitPackage' +type MockExternalPackageFetcher_FetchExternalGitPackage_Call struct { + *mock.Call +} + +// FetchExternalGitPackage is a helper method to define mock.On call +// - ctx context.Context +// - gitSpec *v1alpha2.GitPackage +// - namespace string +func (_e *MockExternalPackageFetcher_Expecter) FetchExternalGitPackage(ctx interface{}, gitSpec interface{}, namespace interface{}) *MockExternalPackageFetcher_FetchExternalGitPackage_Call { + return &MockExternalPackageFetcher_FetchExternalGitPackage_Call{Call: _e.mock.On("FetchExternalGitPackage", ctx, gitSpec, namespace)} +} + +func (_c *MockExternalPackageFetcher_FetchExternalGitPackage_Call) Run(run func(ctx context.Context, gitSpec *v1alpha2.GitPackage, namespace string)) *MockExternalPackageFetcher_FetchExternalGitPackage_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *v1alpha2.GitPackage + if args[1] != nil { + arg1 = args[1].(*v1alpha2.GitPackage) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockExternalPackageFetcher_FetchExternalGitPackage_Call) Return(stringToString map[string]string, gitLock v1.GitLock, err error) *MockExternalPackageFetcher_FetchExternalGitPackage_Call { + _c.Call.Return(stringToString, gitLock, err) + return _c +} + +func (_c *MockExternalPackageFetcher_FetchExternalGitPackage_Call) RunAndReturn(run func(ctx context.Context, gitSpec *v1alpha2.GitPackage, namespace string) (map[string]string, v1.GitLock, error)) *MockExternalPackageFetcher_FetchExternalGitPackage_Call { + _c.Call.Return(run) + return _c +} diff --git a/test/mockery/mocks/porch/pkg/repository/mock_PackageContent.go b/test/mockery/mocks/porch/pkg/repository/mock_PackageContent.go new file mode 100644 index 000000000..72767294e --- /dev/null +++ b/test/mockery/mocks/porch/pkg/repository/mock_PackageContent.go @@ -0,0 +1,443 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package repository + +import ( + "context" + "time" + + "github.com/kptdev/kpt/pkg/api/kptfile/v1" + "github.com/nephio-project/porch/pkg/repository" + mock "github.com/stretchr/testify/mock" +) + +// NewMockPackageContent creates a new instance of MockPackageContent. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockPackageContent(t interface { + mock.TestingT + Cleanup(func()) +}) *MockPackageContent { + mock := &MockPackageContent{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockPackageContent is an autogenerated mock type for the PackageContent type +type MockPackageContent struct { + mock.Mock +} + +type MockPackageContent_Expecter struct { + mock *mock.Mock +} + +func (_m *MockPackageContent) EXPECT() *MockPackageContent_Expecter { + return &MockPackageContent_Expecter{mock: &_m.Mock} +} + +// GetCommitInfo provides a mock function for the type MockPackageContent +func (_mock *MockPackageContent) GetCommitInfo() (time.Time, string) { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetCommitInfo") + } + + var r0 time.Time + var r1 string + if returnFunc, ok := ret.Get(0).(func() (time.Time, string)); ok { + return returnFunc() + } + if returnFunc, ok := ret.Get(0).(func() time.Time); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(time.Time) + } + if returnFunc, ok := ret.Get(1).(func() string); ok { + r1 = returnFunc() + } else { + r1 = ret.Get(1).(string) + } + return r0, r1 +} + +// MockPackageContent_GetCommitInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCommitInfo' +type MockPackageContent_GetCommitInfo_Call struct { + *mock.Call +} + +// GetCommitInfo is a helper method to define mock.On call +func (_e *MockPackageContent_Expecter) GetCommitInfo() *MockPackageContent_GetCommitInfo_Call { + return &MockPackageContent_GetCommitInfo_Call{Call: _e.mock.On("GetCommitInfo")} +} + +func (_c *MockPackageContent_GetCommitInfo_Call) Run(run func()) *MockPackageContent_GetCommitInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockPackageContent_GetCommitInfo_Call) Return(commitTime time.Time, commitAuthor string) *MockPackageContent_GetCommitInfo_Call { + _c.Call.Return(commitTime, commitAuthor) + return _c +} + +func (_c *MockPackageContent_GetCommitInfo_Call) RunAndReturn(run func() (time.Time, string)) *MockPackageContent_GetCommitInfo_Call { + _c.Call.Return(run) + return _c +} + +// GetKptfile provides a mock function for the type MockPackageContent +func (_mock *MockPackageContent) GetKptfile(ctx context.Context) (v1.KptFile, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetKptfile") + } + + var r0 v1.KptFile + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) (v1.KptFile, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) v1.KptFile); ok { + r0 = returnFunc(ctx) + } else { + r0 = ret.Get(0).(v1.KptFile) + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockPackageContent_GetKptfile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetKptfile' +type MockPackageContent_GetKptfile_Call struct { + *mock.Call +} + +// GetKptfile is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockPackageContent_Expecter) GetKptfile(ctx interface{}) *MockPackageContent_GetKptfile_Call { + return &MockPackageContent_GetKptfile_Call{Call: _e.mock.On("GetKptfile", ctx)} +} + +func (_c *MockPackageContent_GetKptfile_Call) Run(run func(ctx context.Context)) *MockPackageContent_GetKptfile_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockPackageContent_GetKptfile_Call) Return(kptFile v1.KptFile, err error) *MockPackageContent_GetKptfile_Call { + _c.Call.Return(kptFile, err) + return _c +} + +func (_c *MockPackageContent_GetKptfile_Call) RunAndReturn(run func(ctx context.Context) (v1.KptFile, error)) *MockPackageContent_GetKptfile_Call { + _c.Call.Return(run) + return _c +} + +// GetLock provides a mock function for the type MockPackageContent +func (_mock *MockPackageContent) GetLock(ctx context.Context) (v1.Upstream, v1.Locator, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetLock") + } + + var r0 v1.Upstream + var r1 v1.Locator + var r2 error + if returnFunc, ok := ret.Get(0).(func(context.Context) (v1.Upstream, v1.Locator, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) v1.Upstream); ok { + r0 = returnFunc(ctx) + } else { + r0 = ret.Get(0).(v1.Upstream) + } + if returnFunc, ok := ret.Get(1).(func(context.Context) v1.Locator); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Get(1).(v1.Locator) + } + if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok { + r2 = returnFunc(ctx) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// MockPackageContent_GetLock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLock' +type MockPackageContent_GetLock_Call struct { + *mock.Call +} + +// GetLock is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockPackageContent_Expecter) GetLock(ctx interface{}) *MockPackageContent_GetLock_Call { + return &MockPackageContent_GetLock_Call{Call: _e.mock.On("GetLock", ctx)} +} + +func (_c *MockPackageContent_GetLock_Call) Run(run func(ctx context.Context)) *MockPackageContent_GetLock_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockPackageContent_GetLock_Call) Return(upstream v1.Upstream, locator v1.Locator, err error) *MockPackageContent_GetLock_Call { + _c.Call.Return(upstream, locator, err) + return _c +} + +func (_c *MockPackageContent_GetLock_Call) RunAndReturn(run func(ctx context.Context) (v1.Upstream, v1.Locator, error)) *MockPackageContent_GetLock_Call { + _c.Call.Return(run) + return _c +} + +// GetResourceContents provides a mock function for the type MockPackageContent +func (_mock *MockPackageContent) GetResourceContents(ctx context.Context) (map[string]string, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetResourceContents") + } + + var r0 map[string]string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) (map[string]string, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) map[string]string); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]string) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockPackageContent_GetResourceContents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetResourceContents' +type MockPackageContent_GetResourceContents_Call struct { + *mock.Call +} + +// GetResourceContents is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockPackageContent_Expecter) GetResourceContents(ctx interface{}) *MockPackageContent_GetResourceContents_Call { + return &MockPackageContent_GetResourceContents_Call{Call: _e.mock.On("GetResourceContents", ctx)} +} + +func (_c *MockPackageContent_GetResourceContents_Call) Run(run func(ctx context.Context)) *MockPackageContent_GetResourceContents_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockPackageContent_GetResourceContents_Call) Return(stringToString map[string]string, err error) *MockPackageContent_GetResourceContents_Call { + _c.Call.Return(stringToString, err) + return _c +} + +func (_c *MockPackageContent_GetResourceContents_Call) RunAndReturn(run func(ctx context.Context) (map[string]string, error)) *MockPackageContent_GetResourceContents_Call { + _c.Call.Return(run) + return _c +} + +// GetUpstreamLock provides a mock function for the type MockPackageContent +func (_mock *MockPackageContent) GetUpstreamLock(ctx context.Context) (v1.Upstream, v1.Locator, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetUpstreamLock") + } + + var r0 v1.Upstream + var r1 v1.Locator + var r2 error + if returnFunc, ok := ret.Get(0).(func(context.Context) (v1.Upstream, v1.Locator, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) v1.Upstream); ok { + r0 = returnFunc(ctx) + } else { + r0 = ret.Get(0).(v1.Upstream) + } + if returnFunc, ok := ret.Get(1).(func(context.Context) v1.Locator); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Get(1).(v1.Locator) + } + if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok { + r2 = returnFunc(ctx) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// MockPackageContent_GetUpstreamLock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUpstreamLock' +type MockPackageContent_GetUpstreamLock_Call struct { + *mock.Call +} + +// GetUpstreamLock is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockPackageContent_Expecter) GetUpstreamLock(ctx interface{}) *MockPackageContent_GetUpstreamLock_Call { + return &MockPackageContent_GetUpstreamLock_Call{Call: _e.mock.On("GetUpstreamLock", ctx)} +} + +func (_c *MockPackageContent_GetUpstreamLock_Call) Run(run func(ctx context.Context)) *MockPackageContent_GetUpstreamLock_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockPackageContent_GetUpstreamLock_Call) Return(upstream v1.Upstream, locator v1.Locator, err error) *MockPackageContent_GetUpstreamLock_Call { + _c.Call.Return(upstream, locator, err) + return _c +} + +func (_c *MockPackageContent_GetUpstreamLock_Call) RunAndReturn(run func(ctx context.Context) (v1.Upstream, v1.Locator, error)) *MockPackageContent_GetUpstreamLock_Call { + _c.Call.Return(run) + return _c +} + +// Key provides a mock function for the type MockPackageContent +func (_mock *MockPackageContent) Key() repository.PackageRevisionKey { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Key") + } + + var r0 repository.PackageRevisionKey + if returnFunc, ok := ret.Get(0).(func() repository.PackageRevisionKey); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(repository.PackageRevisionKey) + } + return r0 +} + +// MockPackageContent_Key_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Key' +type MockPackageContent_Key_Call struct { + *mock.Call +} + +// Key is a helper method to define mock.On call +func (_e *MockPackageContent_Expecter) Key() *MockPackageContent_Key_Call { + return &MockPackageContent_Key_Call{Call: _e.mock.On("Key")} +} + +func (_c *MockPackageContent_Key_Call) Run(run func()) *MockPackageContent_Key_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockPackageContent_Key_Call) Return(packageRevisionKey repository.PackageRevisionKey) *MockPackageContent_Key_Call { + _c.Call.Return(packageRevisionKey) + return _c +} + +func (_c *MockPackageContent_Key_Call) RunAndReturn(run func() repository.PackageRevisionKey) *MockPackageContent_Key_Call { + _c.Call.Return(run) + return _c +} + +// Lifecycle provides a mock function for the type MockPackageContent +func (_mock *MockPackageContent) Lifecycle(ctx context.Context) string { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Lifecycle") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok { + r0 = returnFunc(ctx) + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockPackageContent_Lifecycle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Lifecycle' +type MockPackageContent_Lifecycle_Call struct { + *mock.Call +} + +// Lifecycle is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockPackageContent_Expecter) Lifecycle(ctx interface{}) *MockPackageContent_Lifecycle_Call { + return &MockPackageContent_Lifecycle_Call{Call: _e.mock.On("Lifecycle", ctx)} +} + +func (_c *MockPackageContent_Lifecycle_Call) Run(run func(ctx context.Context)) *MockPackageContent_Lifecycle_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockPackageContent_Lifecycle_Call) Return(s string) *MockPackageContent_Lifecycle_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockPackageContent_Lifecycle_Call) RunAndReturn(run func(ctx context.Context) string) *MockPackageContent_Lifecycle_Call { + _c.Call.Return(run) + return _c +} diff --git a/test/mockery/mocks/porch/pkg/repository/mock_PackageRevision.go b/test/mockery/mocks/porch/pkg/repository/mock_PackageRevision.go index 2930fba11..a8a676ae9 100644 --- a/test/mockery/mocks/porch/pkg/repository/mock_PackageRevision.go +++ b/test/mockery/mocks/porch/pkg/repository/mock_PackageRevision.go @@ -6,6 +6,7 @@ package repository import ( "context" + "time" "github.com/kptdev/kpt/pkg/api/kptfile/v1" "github.com/nephio-project/porch/api/porch/v1alpha1" @@ -42,6 +43,59 @@ func (_m *MockPackageRevision) EXPECT() *MockPackageRevision_Expecter { return &MockPackageRevision_Expecter{mock: &_m.Mock} } +// GetCommitInfo provides a mock function for the type MockPackageRevision +func (_mock *MockPackageRevision) GetCommitInfo() (time.Time, string) { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetCommitInfo") + } + + var r0 time.Time + var r1 string + if returnFunc, ok := ret.Get(0).(func() (time.Time, string)); ok { + return returnFunc() + } + if returnFunc, ok := ret.Get(0).(func() time.Time); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(time.Time) + } + if returnFunc, ok := ret.Get(1).(func() string); ok { + r1 = returnFunc() + } else { + r1 = ret.Get(1).(string) + } + return r0, r1 +} + +// MockPackageRevision_GetCommitInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCommitInfo' +type MockPackageRevision_GetCommitInfo_Call struct { + *mock.Call +} + +// GetCommitInfo is a helper method to define mock.On call +func (_e *MockPackageRevision_Expecter) GetCommitInfo() *MockPackageRevision_GetCommitInfo_Call { + return &MockPackageRevision_GetCommitInfo_Call{Call: _e.mock.On("GetCommitInfo")} +} + +func (_c *MockPackageRevision_GetCommitInfo_Call) Run(run func()) *MockPackageRevision_GetCommitInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockPackageRevision_GetCommitInfo_Call) Return(time1 time.Time, s string) *MockPackageRevision_GetCommitInfo_Call { + _c.Call.Return(time1, s) + return _c +} + +func (_c *MockPackageRevision_GetCommitInfo_Call) RunAndReturn(run func() (time.Time, string)) *MockPackageRevision_GetCommitInfo_Call { + _c.Call.Return(run) + return _c +} + // GetKptfile provides a mock function for the type MockPackageRevision func (_mock *MockPackageRevision) GetKptfile(ctx context.Context) (v1.KptFile, error) { ret := _mock.Called(ctx) @@ -402,6 +456,50 @@ func (_c *MockPackageRevision_GetUpstreamLock_Call) RunAndReturn(run func(ctx co return _c } +// IsLatestRevision provides a mock function for the type MockPackageRevision +func (_mock *MockPackageRevision) IsLatestRevision() bool { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for IsLatestRevision") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func() bool); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// MockPackageRevision_IsLatestRevision_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsLatestRevision' +type MockPackageRevision_IsLatestRevision_Call struct { + *mock.Call +} + +// IsLatestRevision is a helper method to define mock.On call +func (_e *MockPackageRevision_Expecter) IsLatestRevision() *MockPackageRevision_IsLatestRevision_Call { + return &MockPackageRevision_IsLatestRevision_Call{Call: _e.mock.On("IsLatestRevision")} +} + +func (_c *MockPackageRevision_IsLatestRevision_Call) Run(run func()) *MockPackageRevision_IsLatestRevision_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockPackageRevision_IsLatestRevision_Call) Return(b bool) *MockPackageRevision_IsLatestRevision_Call { + _c.Call.Return(b) + return _c +} + +func (_c *MockPackageRevision_IsLatestRevision_Call) RunAndReturn(run func() bool) *MockPackageRevision_IsLatestRevision_Call { + _c.Call.Return(run) + return _c +} + // Key provides a mock function for the type MockPackageRevision func (_mock *MockPackageRevision) Key() repository.PackageRevisionKey { ret := _mock.Called()