From cf4db385934a065a4bdc5e4bac239add39700cc2 Mon Sep 17 00:00:00 2001 From: Ryan Cole Date: Thu, 7 May 2026 13:12:17 -0400 Subject: [PATCH] feat(stoneintg-1350): add support for nested componentGroups Allows users to add a ComponentGroup to another ComponentGroup's spec.Components field. The resulting data structure is a DAG in which all leaf nodes are Components and all non-leaves are ComponentGroups. When a Component is built, its ComponentGroups will be gathered and snapshots will be created for them. The Snapshot will contain the GCL and updated images for that ComponentGroup and all ComponentGroups nested within it. After that snapshot is created the snapshot controller will create snapshots for all ComponentGroups that contain the first ComponentGroup. This allows Konflux to support complex use-cases for ComponentGroups in which users have multi-tiered applications with subgroups that must be tested independently Signed-off-by: Ryan Cole --- api/v1beta2/componentgroup_types.go | 18 +- api/v1beta2/componentgroup_types_test.go | 10 - api/v1beta2/zz_generated.deepcopy.go | 5 - .../appstudio.redhat.com_componentgroups.yaml | 22 +- .../appstudio_v1beta2_componentgroup.yaml | 7 +- gitops/snapshot.go | 33 ++ go.mod | 2 +- go.sum | 4 +- .../buildpipeline/buildpipeline_adapter.go | 2 +- .../buildpipeline_adapter_test.go | 34 +- .../controller/snapshot/snapshot_adapter.go | 87 +++- .../snapshot/snapshot_adapter_test.go | 180 +++++++ .../snapshot/snapshot_controller.go | 2 + .../webhook/v1beta2/componentgroup_webhook.go | 35 ++ loader/loader.go | 72 ++- loader/loader_mock.go | 25 + loader/loader_mock_test.go | 46 ++ loader/loader_test.go | 43 +- snapshot/components.go | 74 +++ snapshot/components_test.go | 188 +++++++ snapshot/create.go | 227 ++++++--- snapshot/create_test.go | 469 ++++++++++++++++-- snapshot/gcl.go | 16 +- snapshot/utils.go | 11 +- .../api/v1alpha1/component_types.go | 28 +- .../api/v1alpha1/snapshot_types.go | 19 + .../api/v1alpha1/zz_generated.deepcopy.go | 68 ++- vendor/modules.txt | 2 +- 28 files changed, 1552 insertions(+), 177 deletions(-) create mode 100644 snapshot/components.go create mode 100644 snapshot/components_test.go diff --git a/api/v1beta2/componentgroup_types.go b/api/v1beta2/componentgroup_types.go index ed2819aab5..217302873b 100644 --- a/api/v1beta2/componentgroup_types.go +++ b/api/v1beta2/componentgroup_types.go @@ -41,11 +41,6 @@ type ComponentGroupSpec struct { // +required Components []ComponentReference `json:"components"` - // Dependents is a list of ComponentGroup names that are dependent on this ComponentGroup. - // When a snapshot is created for this ComponentGroup, snapshots will also be created for all dependents. - // +optional - Dependents []string `json:"dependents,omitempty"` - // TestGraph describes the desired order in which tests associated with the ComponentGroup should be executed. // If not specified, all tests will run in parallel. // The map key is the test scenario name, and the value is a list of parent test scenarios it depends on. @@ -58,17 +53,24 @@ type ComponentGroupSpec struct { SnapshotCreator *SnapshotCreatorSpec `json:"snapshotCreator,omitempty"` } -// ComponentReference references a Component and its specific branch/version +// ComponentReference references a Component or ComponentGroup and its specific branch/version type ComponentReference struct { - // Name is the name of the Component + // Name is the name of the Component or ComponentGroup // +kubebuilder:validation:Pattern=^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ // +required Name string `json:"name"` + // Kind is the type of resource being referenced + // Must be 'component' or 'componentGroup', case-insensitive + // +kubebuilder:default:="component" + // +kubebuilder:validation:Pattern=`(?i)^(component|componentGroup)$` + Kind string `json:"kind,omitempty"` + // ComponentVersion references the ComponentVersion for this Component. + // Can only be set if 'Kind' is set to 'Component' or empty // The ComponentVersion CRD will be implemented by the build team as part of STONEBLD-3604. // For now, this contains the branch name and GCL (Global Candidate List) information. - // +required + // +optional ComponentVersion ComponentVersionReference `json:"componentVersion"` } diff --git a/api/v1beta2/componentgroup_types_test.go b/api/v1beta2/componentgroup_types_test.go index 92215fd651..b9c8b92373 100644 --- a/api/v1beta2/componentgroup_types_test.go +++ b/api/v1beta2/componentgroup_types_test.go @@ -52,7 +52,6 @@ func TestComponentGroupSpec(t *testing.T) { }, }, }, - Dependents: []string{"child-cg-1", "child-cg-2"}, TestGraph: map[string][]TestGraphNode{ "verify": { {Name: "clamav-scan"}, @@ -116,10 +115,6 @@ func TestComponentGroupSpec(t *testing.T) { t.Errorf("Expected second component branch 'components/second-component', got '%s'", cg.Spec.Components[1].ComponentVersion.Name) } - if len(cg.Spec.Dependents) != 2 { - t.Errorf("Expected 2 dependents, got %d", len(cg.Spec.Dependents)) - } - if len(cg.Spec.TestGraph) != 2 { t.Errorf("Expected 2 test graph entries, got %d", len(cg.Spec.TestGraph)) } @@ -192,9 +187,6 @@ func TestComponentGroupMinimalSpec(t *testing.T) { if cg.Spec.Components[0].ComponentVersion.Revision != "" { t.Errorf("Expected empty Revision, got '%s'", cg.Spec.Components[0].ComponentVersion.Revision) } - if len(cg.Spec.Dependents) != 0 { - t.Errorf("Expected 0 dependents, got %d", len(cg.Spec.Dependents)) - } if cg.Spec.TestGraph != nil { t.Error("Expected nil TestGraph") } @@ -257,7 +249,6 @@ func TestComponentGroupDeepCopy(t *testing.T) { }, }, }, - Dependents: []string{"dependent-1"}, }, } @@ -360,7 +351,6 @@ func TestComponentGroupSpecDeepCopy(t *testing.T) { Components: []ComponentReference{ {Name: "comp-1", ComponentVersion: ComponentVersionReference{Name: "main"}}, }, - Dependents: []string{"dep-1"}, TestGraph: map[string][]TestGraphNode{ "test-1": {{Name: "node-1", FailFast: true}}, }, diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index 28c081ec04..4f46eae65c 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -92,11 +92,6 @@ func (in *ComponentGroupSpec) DeepCopyInto(out *ComponentGroupSpec) { *out = make([]ComponentReference, len(*in)) copy(*out, *in) } - if in.Dependents != nil { - in, out := &in.Dependents, &out.Dependents - *out = make([]string, len(*in)) - copy(*out, *in) - } if in.TestGraph != nil { in, out := &in.TestGraph, &out.TestGraph *out = make(map[string][]TestGraphNode, len(*in)) diff --git a/config/crd/bases/appstudio.redhat.com_componentgroups.yaml b/config/crd/bases/appstudio.redhat.com_componentgroups.yaml index 20965a56e5..a060ccfea8 100644 --- a/config/crd/bases/appstudio.redhat.com_componentgroups.yaml +++ b/config/crd/bases/appstudio.redhat.com_componentgroups.yaml @@ -54,12 +54,13 @@ spec: Components is a list of Components (name and branch) that belong to the ComponentGroup. This is the source of truth for logical groupings of versioned Components. items: - description: ComponentReference references a Component and its specific - branch/version + description: ComponentReference references a Component or ComponentGroup + and its specific branch/version properties: componentVersion: description: |- ComponentVersion references the ComponentVersion for this Component. + Can only be set if 'Kind' is set to 'Component' or empty The ComponentVersion CRD will be implemented by the build team as part of STONEBLD-3604. For now, this contains the branch name and GCL (Global Candidate List) information. properties: @@ -82,22 +83,21 @@ spec: required: - name type: object + kind: + default: component + description: |- + Kind is the type of resource being referenced + Must be 'component' or 'componentGroup', case-insensitive + pattern: (?i)^(component|componentGroup)$ + type: string name: - description: Name is the name of the Component + description: Name is the name of the Component or ComponentGroup pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string required: - - componentVersion - name type: object type: array - dependents: - description: |- - Dependents is a list of ComponentGroup names that are dependent on this ComponentGroup. - When a snapshot is created for this ComponentGroup, snapshots will also be created for all dependents. - items: - type: string - type: array snapshotCreator: description: |- SnapshotCreator is an optional field that allows custom logic for Snapshot creation. diff --git a/config/samples/appstudio_v1beta2_componentgroup.yaml b/config/samples/appstudio_v1beta2_componentgroup.yaml index 64aa3446b9..9e57a73c21 100644 --- a/config/samples/appstudio_v1beta2_componentgroup.yaml +++ b/config/samples/appstudio_v1beta2_componentgroup.yaml @@ -24,6 +24,7 @@ spec: componentVersion: name: "main" - name: second-component + kind: component componentBranch: name: "v1" revision: "v1branch" @@ -31,10 +32,8 @@ spec: - name: python-component componentBranch: name: "3.12.4" - dependents: - # when a snapshot for this ComponentGroup is created, - # the integration service will also create one for child-cg - - child-cg + - name: child-cg + kind: componentGroup testGraph: # in this graph clamav-scan, dast-tests, and deployment would run first # e2e-test would start when clamav-scan finishes diff --git a/gitops/snapshot.go b/gitops/snapshot.go index 67a800a529..099a18b255 100644 --- a/gitops/snapshot.go +++ b/gitops/snapshot.go @@ -251,6 +251,10 @@ const ( // SnapshotAutoReleasedCondition is the condition for marking if Snapshot was auto-released released with AppStudio. SnapshotAutoReleasedCondition = "AutoReleased" + // ParentSnapshotsCreatedCondition is the condition marking whether snapshots for all parent ComponentGroups for the + // ComponentGroup that the snapshot belongs to have been created + ParentSnapshotsCreatedCondition = "ParentSnapshotsCreated" + // SnapshotAddedToGlobalCandidateListCondition is the condition for marking if Snapshot's component was added to // the global candidate list. SnapshotAddedToGlobalCandidateListCondition = "AddedToGlobalCandidateList" @@ -328,6 +332,10 @@ const ( GitCommentPolicyAnnotation = "test.appstudio.openshift.io/comment_strategy" // GitCommentPolicyAllDisabled is the value to disable all test comments for the component got pac repository GitCommentPolicyAllDisabled = "disable_all" + + // ChildSnapshotAnnotation is used in nested snapshots. Denotes the name of the child snapshot whose creation triggered the creation of the snapshot + // on which the annotation is set + ChildSnapshotAnnotation = TestLabelPrefix + "/child-snapshot" ) var ( @@ -670,6 +678,31 @@ func MarkSnapshotAsAutoReleased(ctx context.Context, adapterClient client.Client return nil } +func ParentSnapshotsCreated(snapshot *applicationapiv1alpha1.Snapshot) bool { + return IsSnapshotStatusConditionSet(snapshot, ParentSnapshotsCreatedCondition, metav1.ConditionTrue, "") +} + +func SetParentSnapshotsCreatedCondition(snapshot *applicationapiv1alpha1.Snapshot, status metav1.ConditionStatus, reason, message string) { + condition := metav1.Condition{ + Type: ParentSnapshotsCreatedCondition, + Status: status, + Reason: reason, + Message: message, + } + meta.SetStatusCondition(&snapshot.Status.Conditions, condition) +} + +func AddParentSnapshotDataToSnapshotStatus(snapshot *applicationapiv1alpha1.Snapshot, created bool, parentCG, parentSnapshot, message string) { + if snapshot.Status.ParentSnapshots == nil { + snapshot.Status.ParentSnapshots = make(map[string]applicationapiv1alpha1.ParentSnapshotData) + } + snapshot.Status.ParentSnapshots[parentCG] = applicationapiv1alpha1.ParentSnapshotData{ + Created: created, + Name: parentSnapshot, + Message: message, + } +} + // IsSnapshotMarkedAsAddedToGlobalCandidateList returns true if snapshot's AddedToGlobalCandidateListAnnotation result is marked as true to global candidate list func IsSnapshotMarkedAsAddedToGlobalCandidateList(snapshot *applicationapiv1alpha1.Snapshot) bool { annotationValue, ok := snapshot.GetAnnotations()[AddedToGlobalCandidateListAnnotation] diff --git a/go.mod b/go.mod index e4607712ab..37d1fe9715 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.0 require ( codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3 v3.0.0 - github.com/konflux-ci/application-api v0.0.0-20260312190025-5154ad273e17 + github.com/konflux-ci/application-api v0.0.0-20260603073049-dd8c9b1a64c2 github.com/konflux-ci/coverport/instrumentation/go v0.0.0-20251127115143-b5207b335f8b github.com/konflux-ci/image-controller v0.0.0-20241128141349-9986c9955e05 github.com/konflux-ci/operator-toolkit v0.0.0-20251118152634-b4f41f073069 diff --git a/go.sum b/go.sum index 2b36c2585f..c78836a004 100644 --- a/go.sum +++ b/go.sum @@ -351,8 +351,8 @@ github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3J github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/konflux-ci/application-api v0.0.0-20260312190025-5154ad273e17 h1:auNv0idITCdlV3fP9i+V6EeT2MbZc/9+05GtKzOk1Ss= -github.com/konflux-ci/application-api v0.0.0-20260312190025-5154ad273e17/go.mod h1:948Z+a1IbfRT0RtoHzWWSN9YEucSbMJTHaMhz7dVICc= +github.com/konflux-ci/application-api v0.0.0-20260603073049-dd8c9b1a64c2 h1:ldBiggDxskW+X1Edqa1REuonbCo+hVmdwA4bC8v674Q= +github.com/konflux-ci/application-api v0.0.0-20260603073049-dd8c9b1a64c2/go.mod h1:948Z+a1IbfRT0RtoHzWWSN9YEucSbMJTHaMhz7dVICc= github.com/konflux-ci/coverport/instrumentation/go v0.0.0-20251127115143-b5207b335f8b h1:NoriO1KRc+7d2/JA07JizqxP0LlA2oJdD5AuQOvEIjE= github.com/konflux-ci/coverport/instrumentation/go v0.0.0-20251127115143-b5207b335f8b/go.mod h1:WVMHU9A2464s/vjH1xOTm4LJDD4xP+VlEiU+KM0gkSU= github.com/konflux-ci/image-controller v0.0.0-20241128141349-9986c9955e05 h1:5Xawkybl99uEiXhkdkxWtHDWitgnf+kAjpNVTanVGRE= diff --git a/internal/controller/buildpipeline/buildpipeline_adapter.go b/internal/controller/buildpipeline/buildpipeline_adapter.go index fd290d6c8e..e8302e7654 100644 --- a/internal/controller/buildpipeline/buildpipeline_adapter.go +++ b/internal/controller/buildpipeline/buildpipeline_adapter.go @@ -219,7 +219,7 @@ func (a *Adapter) EnsureSnapshotExists() (result controller.OperationResult, err } for _, componentGroup := range *a.componentGroups { - expectedSnapshot, err := snapshot.PrepareSnapshotForPipelineRun(a.context, a.client, a.pipelineRun, a.component.Name, &componentGroup) + expectedSnapshot, err := snapshot.PrepareSnapshotForPipelineRun(a.context, a.client, a.pipelineRun, a.component.Name, &componentGroup, a.loader) if err != nil { return a.updatePipelineRunWithCustomizedError(&canRemoveFinalizer, err, a.context, a.pipelineRun, a.client, a.logger) } diff --git a/internal/controller/buildpipeline/buildpipeline_adapter_test.go b/internal/controller/buildpipeline/buildpipeline_adapter_test.go index 96b14dfa5b..a823d1072f 100644 --- a/internal/controller/buildpipeline/buildpipeline_adapter_test.go +++ b/internal/controller/buildpipeline/buildpipeline_adapter_test.go @@ -664,6 +664,10 @@ var _ = Describe("Pipeline Adapter", Ordered, func() { ContextKey: loader.ComponentGroupContextKey, Resource: hasCompGroup, }, + { + ContextKey: loader.NestedComponentGroupsContextKey, + Resource: []v1beta2.ComponentGroup{}, + }, { ContextKey: loader.ComponentContextKey, Resource: hasComp, @@ -804,6 +808,10 @@ var _ = Describe("Pipeline Adapter", Ordered, func() { ContextKey: loader.ComponentGroupContextKey, Resource: hasCompGroup, }, + { + ContextKey: loader.NestedComponentGroupsContextKey, + Resource: []v1beta2.ComponentGroup{}, + }, { ContextKey: loader.ComponentContextKey, Resource: hasComp, @@ -823,7 +831,7 @@ var _ = Describe("Pipeline Adapter", Ordered, func() { }, }, }) - _, err := snapshot.PrepareSnapshotForPipelineRun(adapter.context, adapter.client, adapter.pipelineRun, adapter.component.Name, hasCompGroup) + _, err := snapshot.PrepareSnapshotForPipelineRun(adapter.context, adapter.client, adapter.pipelineRun, adapter.component.Name, hasCompGroup, adapter.loader) Expect(helpers.IsInvalidImageDigestError(err)).To(BeTrue()) Eventually(func() bool { result, err := adapter.EnsureSnapshotExists() @@ -1386,6 +1394,10 @@ var _ = Describe("Pipeline Adapter", Ordered, func() { ContextKey: loader.ComponentGroupContextKey, Resource: hasCompGroup, }, + { + ContextKey: loader.NestedComponentGroupsContextKey, + Resource: []v1beta2.ComponentGroup{}, + }, { ContextKey: loader.ComponentContextKey, Resource: hasComp, @@ -1426,6 +1438,10 @@ var _ = Describe("Pipeline Adapter", Ordered, func() { ContextKey: loader.ComponentGroupContextKey, Resource: hasCompGroup, }, + { + ContextKey: loader.NestedComponentGroupsContextKey, + Resource: []v1beta2.ComponentGroup{}, + }, { ContextKey: loader.ComponentContextKey, Resource: hasComp, @@ -1804,6 +1820,10 @@ var _ = Describe("Pipeline Adapter", Ordered, func() { hasCompGroup.Name: nil, }, }, + { + ContextKey: loader.NestedComponentGroupsContextKey, + Resource: []v1beta2.ComponentGroup{}, + }, }) Eventually(func() bool { @@ -2091,6 +2111,10 @@ var _ = Describe("Pipeline Adapter", Ordered, func() { Resource: nil, Err: notFoundErr, }, + { + ContextKey: loader.NestedComponentGroupsContextKey, + Resource: []v1beta2.ComponentGroup{}, + }, }) Eventually(func() bool { @@ -2126,6 +2150,10 @@ var _ = Describe("Pipeline Adapter", Ordered, func() { Resource: buildPipelineRun, Err: conflictErr, }, + { + ContextKey: loader.NestedComponentGroupsContextKey, + Resource: []v1beta2.ComponentGroup{}, + }, }) Eventually(func() bool { @@ -2167,6 +2195,10 @@ var _ = Describe("Pipeline Adapter", Ordered, func() { ContextKey: loader.AllSnapshotsContextKey, Resource: []applicationapiv1alpha1.Snapshot{*hasSnapshot}, }, + { + ContextKey: loader.NestedComponentGroupsContextKey, + Resource: []v1beta2.ComponentGroup{}, + }, }) Eventually(func() bool { diff --git a/internal/controller/snapshot/snapshot_adapter.go b/internal/controller/snapshot/snapshot_adapter.go index 3f55a3597a..c8853bc449 100644 --- a/internal/controller/snapshot/snapshot_adapter.go +++ b/internal/controller/snapshot/snapshot_adapter.go @@ -47,6 +47,7 @@ import ( "github.com/konflux-ci/operator-toolkit/metadata" releasev1alpha1 "github.com/konflux-ci/release-service/api/v1alpha1" tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -98,6 +99,88 @@ func NewAdapter(context context.Context, snapshot *applicationapiv1alpha1.Snapsh } } +// EnsureParentSnapshotsExist is responsible for creating snapshots for ComponentGroups that contain the ComponentGroup for this snapshot +func (a *Adapter) EnsureParentSnapshotsExist() (controller.OperationResult, error) { + // TODO: remove when we deprecate old application model + if a.componentGroup == nil { // do not run if we're using application model for this snapshot + return controller.ContinueProcessing() + } + if gitops.ParentSnapshotsCreated(a.snapshot) { + return controller.ContinueProcessing() + } + + patch := client.MergeFrom(a.snapshot.DeepCopy()) + defer func() { + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + patchErr := a.client.Status().Patch(a.context, a.snapshot, patch) + if clienterrors.IsConflict(patchErr) { + latestSnapshot := &applicationapiv1alpha1.Snapshot{} + if fetchErr := a.client.Get(a.context, client.ObjectKeyFromObject(a.snapshot), latestSnapshot); fetchErr != nil { + return fetchErr + } + patch = client.MergeFrom(latestSnapshot.DeepCopy()) + // This is the only function in which we should be modifying the ParentSnapshots status fields, so we + // don't need to check if we're overwriting anything from another source + latestSnapshot.Status.ParentSnapshots = a.snapshot.Status.ParentSnapshots + for _, cond := range a.snapshot.Status.Conditions { + if cond.Type == gitops.ParentSnapshotsCreatedCondition { + gitops.SetParentSnapshotsCreatedCondition(latestSnapshot, cond.Status, cond.Reason, cond.Message) + break + } + } + a.snapshot = latestSnapshot + } + return patchErr + }) + if err != nil { + // NOTE: if we return controller.ContinueProcessing() and the patch fails, the parent snapshots may be created twice. + // may be created twice. However, if we return controller.RequeueWithError(err), the parent snapshots will + // be created twice. Therefore, its not worth requeueing if the patch fails. The RetryOnConflict statement + // should handle most race conditions and instability so this failure should be rare + a.logger.Error(err, "warning: could not patch snapshot after creating parent snapshots. As a result, parent snapshots may be created again") + } + }() + + parentCGs, err := a.loader.GetComponentGroupsContainingComponentGroup(a.context, a.client, a.componentGroup) + if err != nil { + return controller.RequeueWithError(err) + } + + // NOTE: group snapshots WILL NOT WORK if components are part of the same componentGroup but one component is nested and one is not + for _, parentComponentGroup := range parentCGs { + if snapshot, ok := a.snapshot.Status.ParentSnapshots[parentComponentGroup.Name]; ok { + if snapshot.Created { + a.logger.Info("Parent snapshot already exists", "parentComponentGroup.Name", parentComponentGroup.Name, "snapshot.Name", snapshot.Name) + continue + } + } + parentSnapshot, err := snapshot.PrepareParentSnapshot(a.context, a.client, a.loader, a.logger, &parentComponentGroup, a.snapshot) + if err != nil { + errMsg := fmt.Sprintf("Could not prepare new snapshot: %s", err.Error()) + gitops.AddParentSnapshotDataToSnapshotStatus(a.snapshot, false, parentComponentGroup.Name, "", errMsg) + return controller.RequeueWithError(err) + } + + err = retry.OnError(retry.DefaultRetry, func(_ error) bool { return true }, func() error { + return snapshot.CreateSnapshotWithCollisionHandling(a.context, a.client, nil, parentSnapshot, parentComponentGroup, a.logger) + }) + if err != nil { + errMsg := fmt.Sprintf("Could not create snapshot in cluster: %s", err.Error()) + gitops.AddParentSnapshotDataToSnapshotStatus(a.snapshot, false, parentComponentGroup.Name, "", errMsg) + return controller.RequeueWithError(err) + } + + gitops.AddParentSnapshotDataToSnapshotStatus(a.snapshot, true, parentComponentGroup.Name, parentSnapshot.Name, "successfully created snapshot") + } + a.logger.Info("Created parent snapshots for snapshot", "snapshot.Name", a.snapshot.Name, "snapshot.Status.ParentSnapshots", a.snapshot.Status.ParentSnapshots) + + // This function sets the condition but does not patch the resource + // It gets updated along side the other status patches in the defer instead + gitops.SetParentSnapshotsCreatedCondition(a.snapshot, metav1.ConditionTrue, "SnapshotCreated", "All parent snapshots have successfully been created") + + return controller.ContinueProcessing() +} + // EnsureRerunPipelineRunsExist is responsible for recreating integration test pipelineruns triggered by users func (a *Adapter) EnsureRerunPipelineRunsExist() (controller.OperationResult, error) { runLabelValue, ok := gitops.GetIntegrationTestRunLabelValue(a.snapshot) @@ -1009,7 +1092,7 @@ func (a *Adapter) prepareGroupSnapshot(prGroup, prGroupHash string) (*applicatio ownerName := a.componentGroup.Name ownerLabel := gitops.ComponentGroupNameLabel namespace := a.componentGroup.Namespace - snapshotComponentsFromGCL, invalidComponents := snapshot.GetSnapshotComponentsFromGCL(a.componentGroup, a.logger.Logger) + snapshotComponentsFromGCL, invalidComponents := snapshot.GetSnapshotComponentsFromGCL(a.componentGroup, a.logger) componentsToCheck, err := a.loader.GetComponentsFromSnapshotForPRGroup(a.context, a.client, namespace, prGroupHash, ownerName, ownerLabel) if err != nil { @@ -1060,7 +1143,7 @@ func (a *Adapter) prepareGroupSnapshot(prGroup, prGroupHash string) (*applicatio a.logger.Info("can't find snapshot with open pull/merge request for component, try to find snapshotComponent from Global Candidate List", "component", groupComponent.Name) // if there is no component snapshot found for open PR/MR, we get snapshotComponent from gcl - snapshotComponent, err := snapshot.FetchSnapshotComponentFromGCL(groupComponent.Name, snapshotComponentsFromGCL, invalidComponents) + snapshotComponent, err := snapshot.FetchSnapshotComponentFromGCL(groupComponent.Name, groupComponent.ComponentVersion.Name, snapshotComponentsFromGCL, invalidComponents) if err != nil { a.logger.Error(err, "component cannot be added to snapshot", "component.Name", groupComponent.Name) continue diff --git a/internal/controller/snapshot/snapshot_adapter_test.go b/internal/controller/snapshot/snapshot_adapter_test.go index 91b03ebb85..11f0a92166 100644 --- a/internal/controller/snapshot/snapshot_adapter_test.go +++ b/internal/controller/snapshot/snapshot_adapter_test.go @@ -4689,4 +4689,184 @@ var _ = Describe("Snapshot Adapter", Ordered, func() { }) }) + When("Adapter is created for EnsureParentSnapshotsExist", func() { + var parentCompGroup *v1beta2.ComponentGroup + + BeforeEach(func() { + parentCompGroup = &v1beta2.ComponentGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "parent-component-group-sample", + Namespace: "default", + }, + Spec: v1beta2.ComponentGroupSpec{ + Components: []v1beta2.ComponentReference{ + { + Name: "component-sample", + ComponentVersion: v1beta2.ComponentVersionReference{ + Name: "v1", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, parentCompGroup)).Should(Succeed()) + parentCompGroup.Status = v1beta2.ComponentGroupStatus{ + GlobalCandidateList: []v1beta2.ComponentState{ + { + Name: "component-sample", + Version: "v1", + URL: SampleRepoLink, + LastPromotedImage: sample_image + "@" + sampleDigest, + }, + }, + } + Expect(k8sClient.Status().Update(ctx, parentCompGroup)).Should(Succeed()) + }) + + AfterEach(func() { + err := k8sClient.Delete(ctx, parentCompGroup) + Expect(err == nil || errors.IsNotFound(err)).To(BeTrue()) + }) + + It("skips when componentGroup is nil (application model)", func() { + adapter = NewAdapterWithApplication(ctx, hasCGSnapshot, hasApp, logger, loader.NewMockLoader(), k8sClient) + result, err := adapter.EnsureParentSnapshotsExist() + Expect(err).ToNot(HaveOccurred()) + Expect(result.CancelRequest).To(BeFalse()) + Expect(result.RequeueRequest).To(BeFalse()) + }) + + It("skips when parent snapshots have already been created", func() { + meta.SetStatusCondition(&hasCGSnapshot.Status.Conditions, metav1.Condition{ + Type: gitops.ParentSnapshotsCreatedCondition, + Status: metav1.ConditionTrue, + Reason: "SnapshotsCreated", + Message: "All parent snapshots have been created", + }) + adapter = NewAdapter(ctx, hasCGSnapshot, hasCompGroup, logger, loader.NewMockLoader(), k8sClient) + result, err := adapter.EnsureParentSnapshotsExist() + Expect(err).ToNot(HaveOccurred()) + Expect(result.CancelRequest).To(BeFalse()) + Expect(result.RequeueRequest).To(BeFalse()) + }) + + It("marks as created and continues when no parent component groups exist", func() { + adapter = NewAdapter(ctx, hasCGSnapshot, hasCompGroup, logger, loader.NewMockLoader(), k8sClient) + adapter.context = toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: loader.ComponentGroupsContextKey, + Resource: []v1beta2.ComponentGroup{}, + }, + }) + + result, err := adapter.EnsureParentSnapshotsExist() + Expect(err).ToNot(HaveOccurred()) + Expect(result.CancelRequest).To(BeFalse()) + Expect(result.RequeueRequest).To(BeFalse()) + + // Use Eventually to handle the deferred status patch racing with the Get. + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: hasCGSnapshot.Name, Namespace: hasCGSnapshot.Namespace, + }, hasCGSnapshot) + return err == nil && gitops.ParentSnapshotsCreated(hasCGSnapshot) + }, time.Second*10).Should(BeTrue()) + }) + + It("creates a parent snapshot for each parent component group and marks as created", func() { + adapter = NewAdapter(ctx, hasCGSnapshot, hasCompGroup, logger, loader.NewMockLoader(), k8sClient) + adapter.context = toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: loader.ComponentGroupsContextKey, + Resource: []v1beta2.ComponentGroup{*parentCompGroup}, + }, + { + ContextKey: loader.NestedComponentGroupsContextKey, + Resource: []v1beta2.ComponentGroup{}, + }, + }) + + result, err := adapter.EnsureParentSnapshotsExist() + Expect(err).ToNot(HaveOccurred()) + Expect(result.CancelRequest).To(BeFalse()) + Expect(result.RequeueRequest).To(BeFalse()) + + // The child snapshot should be marked as having parent snapshots created. + // Use Eventually to handle the deferred status patch racing with the Get. + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: hasCGSnapshot.Name, Namespace: hasCGSnapshot.Namespace, + }, hasCGSnapshot) + if err != nil { + return false + } + return gitops.ParentSnapshotsCreated(hasCGSnapshot) && + hasCGSnapshot.Status.ParentSnapshots != nil && + hasCGSnapshot.Status.ParentSnapshots[parentCompGroup.Name].Created + }, time.Second*10).Should(BeTrue()) + }) + + It("requeuees with error when the loader fails to get parent component groups", func() { + adapter = NewAdapter(ctx, hasCGSnapshot, hasCompGroup, logger, loader.NewMockLoader(), k8sClient) + adapter.context = toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: loader.ComponentGroupsContextKey, + Err: fmt.Errorf("simulated loader error"), + }, + }) + + result, err := adapter.EnsureParentSnapshotsExist() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated loader error")) + Expect(result.RequeueRequest).To(BeTrue()) + Expect(result.CancelRequest).To(BeFalse()) + }) + + It("requeuees with error when PrepareParentSnapshot fails due to all-invalid GCL components", func() { + allInvalidParentCG := &v1beta2.ComponentGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "all-invalid-parent-cg-sample", + Namespace: "default", + }, + Spec: v1beta2.ComponentGroupSpec{ + Components: []v1beta2.ComponentReference{ + { + Name: "component-sample", + ComponentVersion: v1beta2.ComponentVersionReference{Name: "v1"}, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, allInvalidParentCG)).Should(Succeed()) + DeferCleanup(func() { + err := k8sClient.Delete(ctx, allInvalidParentCG) + Expect(err == nil || errors.IsNotFound(err)).To(BeTrue()) + }) + allInvalidParentCG.Status = v1beta2.ComponentGroupStatus{ + GlobalCandidateList: []v1beta2.ComponentState{ + // empty LastPromotedImage → fails ValidateImageDigest → all invalid → MissingValidComponentError + {Name: "component-sample", Version: "v1"}, + }, + } + Expect(k8sClient.Status().Update(ctx, allInvalidParentCG)).Should(Succeed()) + + adapter = NewAdapter(ctx, hasCGSnapshot, hasCompGroup, logger, loader.NewMockLoader(), k8sClient) + adapter.context = toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: loader.ComponentGroupsContextKey, + Resource: []v1beta2.ComponentGroup{*allInvalidParentCG}, + }, + { + ContextKey: loader.NestedComponentGroupsContextKey, + Resource: []v1beta2.ComponentGroup{}, + }, + }) + + result, err := adapter.EnsureParentSnapshotsExist() + Expect(err).To(HaveOccurred()) + Expect(result.RequeueRequest).To(BeTrue()) + Expect(result.CancelRequest).To(BeFalse()) + }) + }) + }) diff --git a/internal/controller/snapshot/snapshot_controller.go b/internal/controller/snapshot/snapshot_controller.go index 744cd32986..10149cce7c 100644 --- a/internal/controller/snapshot/snapshot_controller.go +++ b/internal/controller/snapshot/snapshot_controller.go @@ -183,6 +183,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu adapter.EnsureGlobalCandidateImageUpdated, adapter.EnsureRerunPipelineRunsExist, adapter.EnsureIntegrationPipelineRunsExist, + adapter.EnsureParentSnapshotsExist, }) } @@ -194,6 +195,7 @@ type AdapterInterface interface { EnsureIntegrationPipelineRunsExist() (controller.OperationResult, error) EnsureGlobalCandidateImageUpdated() (controller.OperationResult, error) EnsureOverrideSnapshotValid() (controller.OperationResult, error) + EnsureParentSnapshotsExist() (controller.OperationResult, error) } // SetupController creates a new Integration controller and adds it to the Manager. diff --git a/internal/webhook/v1beta2/componentgroup_webhook.go b/internal/webhook/v1beta2/componentgroup_webhook.go index b8efc15131..e8b573b5c1 100644 --- a/internal/webhook/v1beta2/componentgroup_webhook.go +++ b/internal/webhook/v1beta2/componentgroup_webhook.go @@ -18,6 +18,7 @@ package v1beta2 import ( "reflect" + "strings" "github.com/konflux-ci/integration-service/api/v1beta2" "github.com/konflux-ci/integration-service/pkg/dag" @@ -69,6 +70,12 @@ func (v *ComponentGroupCustomValidator) ValidateCreate(ctx context.Context, obj return nil, fmt.Errorf("error validating test graph: %v", err) } } + + err = validateSpecComponents(componentGroup.Spec.Components, componentGroup.Name) + if err != nil { + return nil, fmt.Errorf("error validating spec.components: %v", err) + } + return nil, nil } @@ -93,9 +100,37 @@ func (v *ComponentGroupCustomValidator) ValidateUpdate(ctx context.Context, oldO } } + if !reflect.DeepEqual(oldComponentGroup.Spec.Components, newComponentGroup.Spec.Components) { + componentgrouplog.Info("validating changes to spec.Components") + err := validateSpecComponents(newComponentGroup.Spec.Components, newComponentGroup.Name) + if err != nil { + return nil, fmt.Errorf("error validating spec.components: %v", err) + } + } + return nil, nil } +func validateSpecComponents(components []v1beta2.ComponentReference, componentGroupName string) error { + for _, component := range components { + if strings.EqualFold(component.Kind, "componentGroup") { + if component.ComponentVersion != (v1beta2.ComponentVersionReference{}) { + return fmt.Errorf("spec.components entries of kind 'componentGroup' cannot have ComponentVersion set") + } + + // Larger cycles are detected at runtime + if component.Name == componentGroupName { + return fmt.Errorf("a componentGroup cannot contain itself") + } + } else { + if component.ComponentVersion == (v1beta2.ComponentVersionReference{}) { + return fmt.Errorf("spec.components entries must contain a ComponentVersion unless they are of kind 'componentGroup'") + } + } + } + return nil +} + // ValidateDelete implements webhook.Validator so a webhook will be registered for the type func (v *ComponentGroupCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { return nil, nil diff --git a/loader/loader.go b/loader/loader.go index 839ff4d726..b728ba81ab 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -20,6 +20,7 @@ package loader import ( "context" "fmt" + "strings" applicationapiv1alpha1 "github.com/konflux-ci/application-api/api/v1alpha1" "github.com/konflux-ci/integration-service/api/v1beta2" @@ -83,6 +84,9 @@ type ObjectLoader interface { GetPRComponentSnapshotsForComponentApplication(ctx context.Context, c client.Client, namespace, applicationName, componentName, prNumber string) (*[]applicationapiv1alpha1.Snapshot, error) GetPRComponentSnapshotsForComponent(ctx context.Context, c client.Client, componentGroupNames []string, namespace, componentName, prNumber string) (*[]applicationapiv1alpha1.Snapshot, error) GetPushComponentSnapshotsForComponent(ctx context.Context, c client.Client, snapshot *applicationapiv1alpha1.Snapshot) (*[]applicationapiv1alpha1.Snapshot, error) + GetComponentGroupsContainingComponentGroup(ctx context.Context, c client.Client, childComponentGroup *v1beta2.ComponentGroup) ([]v1beta2.ComponentGroup, error) + GetAllComponentGroupsInNamespace(ctx context.Context, c client.Client, namespace string) ([]v1beta2.ComponentGroup, error) + GetNestedComponentGroupsForComponentGroup(ctx context.Context, c client.Client, componentGroup *v1beta2.ComponentGroup) ([]v1beta2.ComponentGroup, error) } type loader struct{} @@ -233,15 +237,9 @@ func (l *loader) GetApplicationFromComponent(ctx context.Context, c client.Clien // GetComponentGroupsForComponentVersion loads from the cluster a list of ComponentGroups that use the given ComponentVerison. If // the Component does not belong to any ComponentGroups then an empty list will be returned func (l *loader) GetComponentGroupsForComponentVersion(ctx context.Context, c client.Client, component *applicationapiv1alpha1.Component, version string) (*[]v1beta2.ComponentGroup, error) { - componentGroupList := &v1beta2.ComponentGroupList{} - // Kubernetes FieldSelector cannot filter by "spec.components contains item where name=X and componentBranch.name=Y" // (only top-level or CRD selectableFields are supported, not array containment). List all in namespace and filter in Go. - options := &client.ListOptions{ - Namespace: component.Namespace, - } - - err := c.List(ctx, componentGroupList, options) + componentGroups, err := l.GetAllComponentGroupsInNamespace(ctx, c, component.Namespace) if err != nil { return nil, err } @@ -251,12 +249,15 @@ func (l *loader) GetComponentGroupsForComponentVersion(ctx context.Context, c cl // that contains a list of all components in the ComponentGroup. Then we just have to filter the (smaller) list of // ComponentGroups for matching versions. var result []v1beta2.ComponentGroup - for i := range componentGroupList.Items { - cg := &componentGroupList.Items[i] + for i := range componentGroups { + cg := componentGroups[i] for j := range cg.Spec.Components { ref := &cg.Spec.Components[j] + if strings.EqualFold(ref.Kind, "componentgroup") { + continue + } if ref.Name == component.Name && ref.ComponentVersion.Name == version { - result = append(result, *cg) + result = append(result, cg) break } } @@ -1067,3 +1068,54 @@ func (l *loader) GetPRComponentSnapshotsForComponentApplication(ctx context.Cont } return &snapshots.Items, nil } + +// Gets all of the ComponentGroups that contain the ComponentGroup +func (l *loader) GetComponentGroupsContainingComponentGroup(ctx context.Context, c client.Client, childComponentGroup *v1beta2.ComponentGroup) ([]v1beta2.ComponentGroup, error) { + allComponentGroups, err := l.GetAllComponentGroupsInNamespace(ctx, c, childComponentGroup.Namespace) + if err != nil { + return nil, err + } + + var parentComponentGroups []v1beta2.ComponentGroup + for _, componentGroup := range allComponentGroups { + if componentGroup.Name == childComponentGroup.Name { + continue + } + + for _, component := range componentGroup.Spec.Components { + if strings.EqualFold(component.Kind, "componentgroup") && component.Name == childComponentGroup.Name { + parentComponentGroups = append(parentComponentGroups, componentGroup) + } + } + } + + return parentComponentGroups, nil +} + +func (l *loader) GetAllComponentGroupsInNamespace(ctx context.Context, c client.Client, namespace string) ([]v1beta2.ComponentGroup, error) { + componentGroupList := &v1beta2.ComponentGroupList{} + + // Kubernetes FieldSelector cannot filter by "spec.components contains item where name=X and componentBranch.name=Y" + // (only top-level or CRD selectableFields are supported, not array containment). List all in namespace and filter in Go. + options := &client.ListOptions{ + Namespace: namespace, + } + + err := c.List(ctx, componentGroupList, options) + return componentGroupList.Items, err +} + +func (l *loader) GetNestedComponentGroupsForComponentGroup(ctx context.Context, c client.Client, componentGroup *v1beta2.ComponentGroup) ([]v1beta2.ComponentGroup, error) { + namespace := componentGroup.Namespace + var nestedComponentGroups []v1beta2.ComponentGroup + for _, component := range componentGroup.Spec.Components { + if strings.EqualFold(component.Kind, "componentgroup") { + nestedComponentGroup, err := l.GetComponentGroup(ctx, c, component.Name, namespace) + if err != nil { + return nil, fmt.Errorf("could not get componentGroup %s nested in componentGroup %s: %+v", component.Name, componentGroup.Name, err) + } + nestedComponentGroups = append(nestedComponentGroups, *nestedComponentGroup) + } + } + return nestedComponentGroups, nil +} diff --git a/loader/loader_mock.go b/loader/loader_mock.go index 77aadf45bc..baecd8b102 100644 --- a/loader/loader_mock.go +++ b/loader/loader_mock.go @@ -72,6 +72,7 @@ const ( ResolutionRequestContextKey GetPRComponentSnapshotsForComponentContextKey ComponentGroupsContextKey + NestedComponentGroupsContextKey RequiredIntegrationTestScenariosForSnapshotContextKey GetPushComponentSnapshotsForComponentContextKey ComponentGroupComponentsContextKey @@ -439,3 +440,27 @@ func (l *mockLoader) GetPushComponentSnapshotsForComponent(ctx context.Context, snapshots, err := toolkit.GetMockedResourceAndErrorFromContext(ctx, GetPushComponentSnapshotsForComponentContextKey, []applicationapiv1alpha1.Snapshot{}) return &snapshots, err } + +func (l *mockLoader) GetComponentGroupsContainingComponentGroup(ctx context.Context, c client.Client, childComponentGroup *v1beta2.ComponentGroup) ([]v1beta2.ComponentGroup, error) { + if ctx.Value(ComponentGroupsContextKey) == nil { + return l.loader.GetComponentGroupsContainingComponentGroup(ctx, c, childComponentGroup) + } + componentGroups, err := toolkit.GetMockedResourceAndErrorFromContext(ctx, ComponentGroupsContextKey, []v1beta2.ComponentGroup{}) + return componentGroups, err +} + +func (l *mockLoader) GetAllComponentGroupsInNamespace(ctx context.Context, c client.Client, namespace string) ([]v1beta2.ComponentGroup, error) { + if ctx.Value(ComponentGroupsContextKey) == nil { + return l.loader.GetAllComponentGroupsInNamespace(ctx, c, namespace) + } + componentGroups, err := toolkit.GetMockedResourceAndErrorFromContext(ctx, ComponentGroupsContextKey, []v1beta2.ComponentGroup{}) + return componentGroups, err +} + +func (l *mockLoader) GetNestedComponentGroupsForComponentGroup(ctx context.Context, c client.Client, componentGroup *v1beta2.ComponentGroup) ([]v1beta2.ComponentGroup, error) { + if ctx.Value(NestedComponentGroupsContextKey) == nil { + return l.loader.GetNestedComponentGroupsForComponentGroup(ctx, c, componentGroup) + } + componentGroups, err := toolkit.GetMockedResourceAndErrorFromContext(ctx, NestedComponentGroupsContextKey, []v1beta2.ComponentGroup{}) + return componentGroups, err +} diff --git a/loader/loader_mock_test.go b/loader/loader_mock_test.go index 03480c122d..c7ec2c0502 100644 --- a/loader/loader_mock_test.go +++ b/loader/loader_mock_test.go @@ -507,4 +507,50 @@ var _ = Describe("Release Adapter", Ordered, func() { Expect(err).ToNot(HaveOccurred()) }) }) + + Context("When calling GetAllComponentGroupsInNamespace", func() { + It("returns resource and error from the context", func() { + componentGroups := []v1beta2.ComponentGroup{} + mockContext := toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: ComponentGroupsContextKey, + Resource: componentGroups, + }, + }) + resource, err := loader.GetAllComponentGroupsInNamespace(mockContext, nil, "") + Expect(resource).To(Equal(componentGroups)) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("When calling GetComponentGroupsContainingComponentGroup", func() { + It("returns resource and error from the context", func() { + componentGroups := []v1beta2.ComponentGroup{} + mockContext := toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: ComponentGroupsContextKey, + Resource: componentGroups, + }, + }) + resource, err := loader.GetComponentGroupsContainingComponentGroup(mockContext, nil, nil) + Expect(resource).To(Equal(componentGroups)) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("When calling GetNestedComponentGroupsForComponentGroup", func() { + It("returns resource and error from the context", func() { + componentGroups := []v1beta2.ComponentGroup{} + mockContext := toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: NestedComponentGroupsContextKey, + Resource: componentGroups, + }, + }) + resource, err := loader.GetNestedComponentGroupsForComponentGroup(mockContext, nil, nil) + Expect(resource).To(Equal(componentGroups)) + Expect(err).ToNot(HaveOccurred()) + }) + }) + }) diff --git a/loader/loader_test.go b/loader/loader_test.go index 0b9f606566..c42c6cac19 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -47,6 +47,7 @@ var _ = Describe("Loader", Ordered, func() { hasApp *applicationapiv1alpha1.Application hasComponentGroup1 *v1beta2.ComponentGroup hasComponentGroup2 *v1beta2.ComponentGroup + hasContainerCompGroup *v1beta2.ComponentGroup hasComp *applicationapiv1alpha1.Component integrationTestScenario *v1beta2.IntegrationTestScenario integrationTestScenarioOpt *v1beta2.IntegrationTestScenario @@ -125,6 +126,26 @@ var _ = Describe("Loader", Ordered, func() { } Expect(k8sClient.Create(ctx, hasComponentGroup2)).Should(Succeed()) + hasContainerCompGroup = &v1beta2.ComponentGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "container-componentgroup", + Namespace: "default", + }, + Spec: v1beta2.ComponentGroupSpec{ + Components: []v1beta2.ComponentReference{ + { + Name: "componentgroup-1", + Kind: "componentgroup", + }, + { + Name: "componentgroup-2", + Kind: "componentgroup", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, hasContainerCompGroup)).Should(Succeed()) + hasComp = &applicationapiv1alpha1.Component{ ObjectMeta: metav1.ObjectMeta{ Name: "component-sample", @@ -1311,7 +1332,6 @@ var _ = Describe("Loader", Ordered, func() { It("can get pull request component snapshot for specific component and PR number", func() { prNumber := "1" componentGroupNames := []string{hasCompGroup1.Name, hasCompGroup2.Name} - // TODO: create hasCompGroup1, hasCompGroup2, hasCGSnapshot1, hasCGSnapshot2 snapshots, err := loader.GetPRComponentSnapshotsForComponent(ctx, k8sClient, componentGroupNames, hasCGSnapshot1.Namespace, hasComp.Name, prNumber) sort.Slice(*snapshots, func(i, j int) bool { @@ -1534,4 +1554,25 @@ var _ = Describe("Loader", Ordered, func() { Expect(*snapshots).To(BeEmpty()) }) }) + + It("can get all componentGroups in the namespace", func() { + componentGroups, err := loader.GetAllComponentGroupsInNamespace(ctx, k8sClient, "default") + Expect(err).NotTo(HaveOccurred()) + Expect(componentGroups).To(HaveLen(5)) + }) + + It("can get componentGroups that contain another componentGroup", func() { + componentGroups, err := loader.GetComponentGroupsContainingComponentGroup(ctx, k8sClient, hasComponentGroup1) + Expect(err).NotTo(HaveOccurred()) + Expect(componentGroups).To(HaveLen(1)) + Expect((componentGroups)[0].Name).To(Equal(hasContainerCompGroup.Name)) + }) + + It("can get the componentGroups that are nested in a componentGroup", func() { + componentGroups, err := loader.GetNestedComponentGroupsForComponentGroup(ctx, k8sClient, hasContainerCompGroup) + Expect(err).NotTo(HaveOccurred()) + Expect(componentGroups).To(HaveLen(2)) + Expect((componentGroups)[0].Name).To(Equal(hasComponentGroup1.Name)) + }) + }) diff --git a/snapshot/components.go b/snapshot/components.go new file mode 100644 index 0000000000..d1d712177d --- /dev/null +++ b/snapshot/components.go @@ -0,0 +1,74 @@ +/* +Copyright 2022 Red Hat Inc. + +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 snapshot + +import ( + "fmt" + + applicationapiv1alpha1 "github.com/konflux-ci/application-api/api/v1alpha1" + "github.com/konflux-ci/integration-service/gitops" + "github.com/konflux-ci/integration-service/helpers" + tektonconsts "github.com/konflux-ci/integration-service/tekton/consts" +) + +// GetAllnewComponentsInSnapshot returns a slice of SnapshotComponents. Any SnapshotComponents in the Snapshot that did not +// come from the GCL are returned. This is one Component for Component Snapshots, many Components for Group Snapshots, and +// all Components for Override Snapshots +func GetAllNewComponentsInSnapshot(snapshot *applicationapiv1alpha1.Snapshot) ([]applicationapiv1alpha1.SnapshotComponent, error) { + snapshotComponents := []applicationapiv1alpha1.SnapshotComponent{} + if gitops.IsComponentSnapshot(snapshot) { + component := snapshot.Labels[gitops.SnapshotComponentLabel] + version := snapshot.Annotations[tektonconsts.PipelineRunComponentVersionAnnotation] + updatedComponent, err := getComponentFromSnapshotComponents(snapshot, component, version) + if err != nil { + return snapshotComponents, err + } + snapshotComponents = append(snapshotComponents, updatedComponent) + } else if gitops.IsGroupSnapshot(snapshot) { + var componentSnapshotInfos []*gitops.ComponentSnapshotInfo + var err error + if componentSnapshotInfoString, ok := snapshot.Annotations[gitops.GroupSnapshotInfoAnnotation]; ok { + componentSnapshotInfos, err = gitops.UnmarshalJSON([]byte(componentSnapshotInfoString)) + if err != nil { + return snapshotComponents, fmt.Errorf("failed to unmarshal JSON string: %+v", err) + } + } + for _, info := range componentSnapshotInfos { + updatedComponent, err := getComponentFromSnapshotComponents(snapshot, info.Component, info.Version) + if err != nil { + return snapshotComponents, fmt.Errorf("could not get component snapshot %s/%s referenced in group snapshot %s/%s", info.Namespace, info.Snapshot, info.Namespace, info.Component) + } + snapshotComponents = append(snapshotComponents, updatedComponent) + } + } else if gitops.IsOverrideSnapshot(snapshot) { + // Since all images will be promoted to the GCL, everything should take priority + // in parent snapshot creation + snapshotComponents = snapshot.Spec.Components + } + return snapshotComponents, nil +} + +func getComponentFromSnapshotComponents(snapshot *applicationapiv1alpha1.Snapshot, name, version string) (applicationapiv1alpha1.SnapshotComponent, error) { + for _, snapshotComponent := range snapshot.Spec.Components { + if snapshotComponent.Name == name { + if version == "" || snapshotComponent.Version == "" || snapshotComponent.Version == version { + return snapshotComponent, nil + } + } + } + return applicationapiv1alpha1.SnapshotComponent{}, fmt.Errorf("the snapshot '%s' does not contain the updated component/version '%s'", snapshot.Name, helpers.GetComponentVersionLogString(name, version)) +} diff --git a/snapshot/components_test.go b/snapshot/components_test.go new file mode 100644 index 0000000000..65fbde1043 --- /dev/null +++ b/snapshot/components_test.go @@ -0,0 +1,188 @@ +/* +Copyright 2022 Red Hat Inc. + +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 snapshot + +import ( + "encoding/json" + + applicationapiv1alpha1 "github.com/konflux-ci/application-api/api/v1alpha1" + "github.com/konflux-ci/integration-service/gitops" + tektonconsts "github.com/konflux-ci/integration-service/tekton/consts" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("Snapshot component functions", Ordered, func() { + var ( + componentSnapshot *applicationapiv1alpha1.Snapshot + overrideSnapshot *applicationapiv1alpha1.Snapshot + groupSnapshot *applicationapiv1alpha1.Snapshot + ) + const ( + componentName = "component-sample" + component2Name = "component-sample-2" + componentVersion = "v1" + componentURL = "https://github.com/devfile-samples/devfile-sample-go-basic" + builtDigest = "sha256:841328df1b9f8c4087adbdcfec6cc99ac8308805dea83f6d415d6fb8d40227c1" + builtImageWithoutDigest = "quay.io/konflux-ci/sample-image" + newCommit = "a2ba645d50e471d5f084b" + builtImageWithDigest = builtImageWithoutDigest + "@" + builtDigest + ) + + BeforeAll(func() { + componentSnapshot = &applicationapiv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "component-snapshot", + Namespace: "default", + Labels: map[string]string{ + gitops.SnapshotTypeLabel: "component", + gitops.SnapshotComponentLabel: componentName, + "build.appstudio.redhat.com/pipeline": "enterprise-contract", + gitops.PipelineAsCodeEventTypeLabel: "push", + }, + Annotations: map[string]string{ + gitops.PipelineAsCodeInstallationIDAnnotation: "123", + "build.appstudio.redhat.com/commit_sha": "6c65b2fcaea3e1a0a92476c8b5dc89e92a85f025", + "appstudio.redhat.com/updateComponentOnSuccess": "false", + tektonconsts.PipelineRunComponentVersionAnnotation: componentVersion, + }, + }, + Spec: applicationapiv1alpha1.SnapshotSpec{ + ComponentGroup: "component-group", + Components: []applicationapiv1alpha1.SnapshotComponent{ + { + Name: componentName, + Version: componentVersion, + ContainerImage: builtImageWithDigest, + Source: applicationapiv1alpha1.ComponentSource{ + ComponentSourceUnion: applicationapiv1alpha1.ComponentSourceUnion{ + GitSource: &applicationapiv1alpha1.GitSource{ + URL: componentURL, + Revision: newCommit, + }, + }, + }, + }, + { + Name: "second-component", + Version: componentVersion, + ContainerImage: builtImageWithDigest, + Source: applicationapiv1alpha1.ComponentSource{ + ComponentSourceUnion: applicationapiv1alpha1.ComponentSourceUnion{ + GitSource: &applicationapiv1alpha1.GitSource{ + URL: componentURL, + Revision: newCommit, + }, + }, + }, + }, + }, + }, + } + + overrideSnapshot = componentSnapshot.DeepCopy() + overrideSnapshot.Name = "override-snapshot" + overrideSnapshot.Labels[gitops.SnapshotTypeLabel] = "override" + + infos := []gitops.ComponentSnapshotInfo{ + { + Namespace: "default", + Component: componentName, + Version: componentVersion, + Snapshot: "comp-snapshot-1", + }, + { + Namespace: "default", + Component: component2Name, + Version: componentVersion, + Snapshot: "comp-snapshot-2", + }, + } + infoJSON, err := json.Marshal(infos) + Expect(err).NotTo(HaveOccurred()) + + groupSnapshot = &applicationapiv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "group-snapshot", + Namespace: "default", + Labels: map[string]string{ + gitops.SnapshotTypeLabel: gitops.SnapshotGroupType, + }, + Annotations: map[string]string{ + gitops.GroupSnapshotInfoAnnotation: string(infoJSON), + }, + }, + Spec: applicationapiv1alpha1.SnapshotSpec{ + Components: []applicationapiv1alpha1.SnapshotComponent{ + { + Name: componentName, + Version: componentVersion, + ContainerImage: builtImageWithDigest, + Source: applicationapiv1alpha1.ComponentSource{ + ComponentSourceUnion: applicationapiv1alpha1.ComponentSourceUnion{ + GitSource: &applicationapiv1alpha1.GitSource{ + URL: componentURL, + Revision: newCommit, + }, + }, + }, + }, + { + Name: component2Name, + Version: componentVersion, + ContainerImage: builtImageWithDigest, + Source: applicationapiv1alpha1.ComponentSource{ + ComponentSourceUnion: applicationapiv1alpha1.ComponentSourceUnion{ + GitSource: &applicationapiv1alpha1.GitSource{ + URL: componentURL, + Revision: newCommit, + }, + }, + }, + }, + }, + }, + } + + Expect(k8sClient.Create(ctx, componentSnapshot)).Should(Succeed()) + Expect(k8sClient.Create(ctx, overrideSnapshot)).Should(Succeed()) + }) + + It("Can get the new component in a component snapshot", func() { + newComponents, err := GetAllNewComponentsInSnapshot(componentSnapshot) + Expect(err).NotTo(HaveOccurred()) + Expect(newComponents).To(HaveLen(1)) + Expect(newComponents[0].Name).To(Equal(componentName)) + }) + + It("Returns all components when getting the new components in an override snapshot", func() { + newComponents, err := GetAllNewComponentsInSnapshot(overrideSnapshot) + Expect(err).NotTo(HaveOccurred()) + Expect(newComponents).To(Equal(overrideSnapshot.Spec.Components)) + }) + + It("Can get the new components in a group snapshot", func() { + // The group snapshot's Spec.Components contains both components referenced in its + // GroupSnapshotInfoAnnotation, so both should be returned. + newComponents, err := GetAllNewComponentsInSnapshot(groupSnapshot) + Expect(err).NotTo(HaveOccurred()) + Expect(newComponents).To(HaveLen(2)) + componentNames := []string{newComponents[0].Name, newComponents[1].Name} + Expect(componentNames).To(ConsistOf(componentName, component2Name)) + }) +}) diff --git a/snapshot/create.go b/snapshot/create.go index 51cf9c8241..eab9b85e87 100644 --- a/snapshot/create.go +++ b/snapshot/create.go @@ -20,15 +20,17 @@ import ( "context" "errors" "fmt" + "maps" "slices" "strconv" + "strings" "time" - "github.com/go-logr/logr" applicationapiv1alpha1 "github.com/konflux-ci/application-api/api/v1alpha1" "github.com/konflux-ci/integration-service/api/v1beta2" "github.com/konflux-ci/integration-service/gitops" "github.com/konflux-ci/integration-service/helpers" + "github.com/konflux-ci/integration-service/loader" "github.com/konflux-ci/integration-service/tekton" "github.com/konflux-ci/operator-toolkit/metadata" tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" @@ -39,12 +41,17 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" ) +const ( + // prevents stack overflow from recursion. Can be adjusted if users have deeply nested ComponentGroups + MaxDepth = 15 +) + // PrepareSnapshotForPipelineRun prepares the Snapshot for a given PipelineRun, // component and application. In case the Snapshot can't be created, an error will be returned. -func PrepareSnapshotForPipelineRun(ctx context.Context, adapterClient client.Client, pipelineRun *tektonv1.PipelineRun, componentName string, componentGroup *v1beta2.ComponentGroup) (*applicationapiv1alpha1.Snapshot, error) { - log := log.FromContext(ctx) +func PrepareSnapshotForPipelineRun(ctx context.Context, adapterClient client.Client, pipelineRun *tektonv1.PipelineRun, componentName string, componentGroup *v1beta2.ComponentGroup, loader loader.ObjectLoader) (*applicationapiv1alpha1.Snapshot, error) { + logger := helpers.IntegrationLogger{Logger: log.FromContext(ctx)} - newSnapshotComponent, err := getSnapshotComponentFromBuildPLR(pipelineRun, componentName, log) + newSnapshotComponent, err := GetSnapshotComponentFromBuildPLR(pipelineRun, componentName, logger) if err != nil { return nil, err } @@ -54,7 +61,7 @@ func PrepareSnapshotForPipelineRun(ctx context.Context, adapterClient client.Cli gitops.EnrichBuiltComponentSourceGitContext(&newSnapshotComponent.Source, &comp, newSnapshotComponent.Version) } - snapshot, err := PrepareSnapshot(ctx, adapterClient, componentGroup, newSnapshotComponent, log) + snapshot, err := PrepareSnapshot(ctx, adapterClient, componentGroup, newSnapshotComponent, loader, logger) if err != nil { return nil, err } @@ -129,6 +136,7 @@ func CreateSnapshotWithCollisionHandling(ctx context.Context, client client.Clie return err // Return original collision error } + // if pipelineRun is nil, this returns time.Now() timestampMillis := getPipelineRunStartTimeMillis(pipelineRun) // Regenerate name with suffix @@ -152,10 +160,15 @@ func CreateSnapshotWithCollisionHandling(ctx context.Context, client client.Clie // PrepareSnapshot prepares the Snapshot for a given componentGroup, components and the updated component (if any). // In case the Snapshot can't be created, an error will be returned. -func PrepareSnapshot(ctx context.Context, adapterClient client.Client, componentGroup *v1beta2.ComponentGroup, newSnapshotComponent applicationapiv1alpha1.SnapshotComponent, log logr.Logger) (*applicationapiv1alpha1.Snapshot, error) { +func PrepareSnapshot(ctx context.Context, adapterClient client.Client, componentGroup *v1beta2.ComponentGroup, newSnapshotComponent applicationapiv1alpha1.SnapshotComponent, loader loader.ObjectLoader, logger helpers.IntegrationLogger) (*applicationapiv1alpha1.Snapshot, error) { - snapshotComponents, invalidComponents := GetSnapshotComponentsFromGCL(componentGroup, log) - upsertNewComponentImage(&snapshotComponents, &invalidComponents, newSnapshotComponent, log) + // Get nested snapshotComponents + snapshotComponentsMap, invalidComponents, err := getAllNestedSnapshotComponents(componentGroup, []string{}, 0, loader, ctx, adapterClient, logger) + if err != nil { + return nil, fmt.Errorf("error getting nested snapshot components: %+v", err) + } + upsertNewComponentImage(snapshotComponentsMap, invalidComponents, newSnapshotComponent, logger) + snapshotComponents := flattenSnapshotComponentsMap(snapshotComponentsMap) if len(snapshotComponents) == 0 { return nil, helpers.NewMissingValidComponentError(joinInvalidComponentNamesAndVersions(invalidComponents)) @@ -174,9 +187,10 @@ func PrepareSnapshot(ctx context.Context, adapterClient client.Client, component if err := metadata.SetAnnotation(snapshot, helpers.CreateSnapshotAnnotationName, fmt.Sprintf("Component(s) '%s' is(are) not included in snapshot due to missing valid containerImage or git source", joinInvalidComponentNamesAndVersions(invalidComponents))); err != nil { return nil, fmt.Errorf("failed to set annotation %s: %w", gitops.SnapshotGitSourceRepoURLAnnotation, err) } + logger.Info("Some components were invalid", "invalidComponents", invalidComponents) } - err := ctrl.SetControllerReference(componentGroup, snapshot, adapterClient.Scheme()) + err = ctrl.SetControllerReference(componentGroup, snapshot, adapterClient.Scheme()) if err != nil { return nil, err } @@ -184,10 +198,108 @@ func PrepareSnapshot(ctx context.Context, adapterClient client.Client, component return snapshot, nil } +// PrepareParentSnapshot creates a snapshot from the parent componentGroup of childSnapshot. The snapshot gathers all GCL images with the parent GCL images overwriting the child GCL images if they conflict. +// It then replaces the GCL images with all new images in the child snapshot. This can be one or many depending on the type of snapshot. +func PrepareParentSnapshot(ctx context.Context, adapterClient client.Client, loader loader.ObjectLoader, logger helpers.IntegrationLogger, componentGroup *v1beta2.ComponentGroup, childSnapshot *applicationapiv1alpha1.Snapshot) (*applicationapiv1alpha1.Snapshot, error) { + // This should basically work the same as prepareSnapshot EXCEPT that it should use the snapshot's components rather than the component for the corresponding child componentGroup + + snapshotComponentsMap, invalidComponents, err := getAllNestedSnapshotComponents(componentGroup, []string{}, 0, loader, ctx, adapterClient, logger) + if err != nil { + return nil, fmt.Errorf("error getting nested snapshot components: %+v", err) + } + newSnapshotComponents, err := GetAllNewComponentsInSnapshot(childSnapshot) + if err != nil { + return nil, fmt.Errorf("error getting new components in snapshot: %+v", err) + } + upsertMultipleComponentImages(snapshotComponentsMap, invalidComponents, newSnapshotComponents, logger) + snapshotComponents := flattenSnapshotComponentsMap(snapshotComponentsMap) + + if len(snapshotComponents) == 0 { + return nil, helpers.NewMissingValidComponentError(joinInvalidComponentNamesAndVersions(invalidComponents)) + } + snapshot := NewSnapshot(componentGroup, &snapshotComponents) + + // expose the source repo URL and SHA in the snapshot as annotation so we don't have to do lookup in integration tests + // Only done on component snapshots + if len(newSnapshotComponents) == 1 { + if newSnapshotComponents[0].Source.GitSource != nil { + // NOTE: should we skip this annotation for override snaphots? + if err := metadata.SetAnnotation(snapshot, gitops.SnapshotGitSourceRepoURLAnnotation, newSnapshotComponents[0].Source.GitSource.URL); err != nil { + return nil, fmt.Errorf("failed to set annotation %s: %w", gitops.SnapshotGitSourceRepoURLAnnotation, err) + } + } + + } + + // Annotate snapshot with warning about invalid components + if len(invalidComponents) > 0 { + if err := metadata.SetAnnotation(snapshot, helpers.CreateSnapshotAnnotationName, fmt.Sprintf("Component(s) '%s' is(are) not included in snapshot due to missing valid containerImage or git source", joinInvalidComponentNamesAndVersions(invalidComponents))); err != nil { + return nil, fmt.Errorf("failed to set annotation %s: %w", gitops.SnapshotGitSourceRepoURLAnnotation, err) + } + logger.Info("Some components were invalid", "invalidComponents", invalidComponents) + } + + // Annotate the snapshot with child snapshot info + if err := metadata.SetAnnotation(snapshot, gitops.ChildSnapshotAnnotation, childSnapshot.Name); err != nil { + return nil, fmt.Errorf("failed to set annotation %s: %w", gitops.ChildSnapshotAnnotation, err) + } + + err = ctrl.SetControllerReference(componentGroup, snapshot, adapterClient.Scheme()) + if err != nil { + return nil, err + } + + return snapshot, nil +} + +// getAllNestedSnapshotComponents gets the GCL SnapshotComponents for a ComponentGroup. It recurses into the nested ComponentGroups to get their SnapshotComponents then builds upward from there. +// As a result, if a parent and child ComponentGroup share a ComponentVersion but have different images, the image in the parent will supersede the one in the child. +func getAllNestedSnapshotComponents(componentGroup *v1beta2.ComponentGroup, usedComponentGroups []string, depth int, loader loader.ObjectLoader, ctx context.Context, adapterClient client.Client, logger helpers.IntegrationLogger) (map[string]applicationapiv1alpha1.SnapshotComponent, map[v1beta2.ComponentState]InvalidComponentReason, error) { + snapshotComponents := make(map[string]applicationapiv1alpha1.SnapshotComponent) + invalidComponents := make(map[v1beta2.ComponentState]InvalidComponentReason) + + // Cycle detection since validation webhook is at risk of a race condition + if slices.Contains(usedComponentGroups, componentGroup.Name) { + usedComponentGroups = append(usedComponentGroups, componentGroup.Name) + return nil, nil, fmt.Errorf("cycle found in nested componentGroups: %+v", usedComponentGroups) + } + usedComponentGroups = append(usedComponentGroups, componentGroup.Name) + if depth > MaxDepth { + return nil, nil, fmt.Errorf("nested ComponentGroups exceeded max depth of %d", MaxDepth) + } + + nestedComponentGroups, err := loader.GetNestedComponentGroupsForComponentGroup(ctx, adapterClient, componentGroup) + if err != nil { + return nil, nil, err + } + + for _, nestedComponentGroup := range nestedComponentGroups { + valid, invalid, err := getAllNestedSnapshotComponents(&nestedComponentGroup, usedComponentGroups, depth+1, loader, ctx, adapterClient, logger) + if err != nil { + return nil, nil, err + } + maps.Copy(snapshotComponents, valid) + maps.Copy(invalidComponents, invalid) + } + + valid, invalid := GetSnapshotComponentsFromGCL(componentGroup, logger) + maps.Copy(snapshotComponents, valid) // bottom-up recursion means that parent componentGroup GCL will overwrite duplicates in children + maps.Copy(invalidComponents, invalid) + + return snapshotComponents, invalidComponents, nil +} + +func flattenSnapshotComponentsMap(snapshotComponentsMap map[string]applicationapiv1alpha1.SnapshotComponent) (snapshotComponents []applicationapiv1alpha1.SnapshotComponent) { + for _, snapshotComponent := range snapshotComponentsMap { + snapshotComponents = append(snapshotComponents, snapshotComponent) + } + return +} + // This prevents race conditions if EnsureGCLAlignedWithSpecComponents runs late -func GetSnapshotComponentsFromGCL(componentGroup *v1beta2.ComponentGroup, log logr.Logger) ([]applicationapiv1alpha1.SnapshotComponent, []v1beta2.ComponentState) { - var snapshotComponents []applicationapiv1alpha1.SnapshotComponent - var invalidComponents []v1beta2.ComponentState +func GetSnapshotComponentsFromGCL(componentGroup *v1beta2.ComponentGroup, logger helpers.IntegrationLogger) (map[string]applicationapiv1alpha1.SnapshotComponent, map[v1beta2.ComponentState]InvalidComponentReason) { + snapshotComponents := make(map[string]applicationapiv1alpha1.SnapshotComponent) + invalidComponents := make(map[v1beta2.ComponentState]InvalidComponentReason) specComponents := getSpecComponentsAndVersionsMap(componentGroup) for _, gclComponent := range componentGroup.Status.GlobalCandidateList { @@ -199,7 +311,7 @@ func GetSnapshotComponentsFromGCL(componentGroup *v1beta2.ComponentGroup, log lo // cleaned up GCL by the time snapshot is created specVersions, ok := specComponents[name] if !ok || !slices.Contains(specVersions, version) { - log.Info("componentVersion was deleted from spec.Components. Will not add to snapshot", "componentGroup", componentGroup.Name, "component.Name", name, "component.Version", version) + logger.Info("componentVersion was deleted from spec.Components. Will not add to snapshot", "componentGroup", componentGroup.Name, "component.Name", name, "component.Version", version) continue } @@ -209,27 +321,32 @@ func GetSnapshotComponentsFromGCL(componentGroup *v1beta2.ComponentGroup, log lo // including a component that is incomplete. if image == "" { // skip components that have not been added to GCL yet - log.Info("component cannot be added to snapshot for application due to missing containerImage", "component.Name", gclComponent.Name) - invalidComponents = append(invalidComponents, gclComponent) + logger.Info("component cannot be added to snapshot for application due to missing containerImage", "component.Name", gclComponent.Name) + invalidComponents[gclComponent] = InvalidComponentReason{ + ComponentGroup: componentGroup.Name, + Reason: "missing containerImage", + } continue } err := gitops.ValidateImageDigest(image) if err != nil { - log.Error(err, "component cannot be added to snapshot for ComponentGroup due to invalid digest in containerImage", "component.Name", gclComponent.Name) - invalidComponents = append(invalidComponents, gclComponent) + logger.Error(err, "component cannot be added to snapshot for ComponentGroup due to invalid digest in containerImage", "component.Name", gclComponent.Name) + invalidComponents[gclComponent] = InvalidComponentReason{ + ComponentGroup: componentGroup.Name, + Reason: "invalid digest in containerImage", + } continue } // Get ComponentSource for the component which is not built in this pipeline componentSource := GetComponentSourceFromGCLComponent(gclComponent) - snapshotComponents = append(snapshotComponents, applicationapiv1alpha1.SnapshotComponent{ + snapshotComponents[helpers.GetComponentVersionString(name, version)] = applicationapiv1alpha1.SnapshotComponent{ Name: name, Version: version, ContainerImage: image, Source: componentSource, - }, - ) + } } return snapshotComponents, invalidComponents } @@ -237,6 +354,9 @@ func GetSnapshotComponentsFromGCL(componentGroup *v1beta2.ComponentGroup, log lo func getSpecComponentsAndVersionsMap(componentGroup *v1beta2.ComponentGroup) map[string][]string { componentVersions := make(map[string][]string) for _, component := range componentGroup.Spec.Components { + if strings.EqualFold(component.Kind, "componentGroup") { + continue + } componentVersions[component.Name] = append(componentVersions[component.Name], component.ComponentVersion.Name) } return componentVersions @@ -245,47 +365,38 @@ func getSpecComponentsAndVersionsMap(componentGroup *v1beta2.ComponentGroup) map // Adds the updated Component to the list of snapshotComponents that will be added to the snapshot. If a SnapshotComponent with // a matching name and version already exists in the snapshotComponents list then it will be replaced with the updated component. // Otherwise the updated component will be appended to the list. -func upsertNewComponentImage(snapshotComponents *[]applicationapiv1alpha1.SnapshotComponent, invalidComponents *[]v1beta2.ComponentState, updatedComponent applicationapiv1alpha1.SnapshotComponent, log logr.Logger) { - for i, snapshotComponent := range *snapshotComponents { - if snapshotComponent.Name == updatedComponent.Name { - if snapshotComponent.Version == "" || snapshotComponent.Version == updatedComponent.Version { - // TODO: can this be removed? - err := gitops.ValidateImageDigest(updatedComponent.ContainerImage) - if err != nil { - log.Error(err, "component cannot be added to snapshot for ComponentGroup due to invalid digest in containerImage", "component.Name", updatedComponent.Name) - *invalidComponents = append(*invalidComponents, v1beta2.ComponentState{ - Name: updatedComponent.Name, - Version: updatedComponent.Version, - }) - continue +func upsertNewComponentImage(snapshotComponents map[string]applicationapiv1alpha1.SnapshotComponent, invalidComponents map[v1beta2.ComponentState]InvalidComponentReason, updatedComponent applicationapiv1alpha1.SnapshotComponent, logger helpers.IntegrationLogger) { + // Before we do anything else, try to validate the digest + err := gitops.ValidateImageDigest(updatedComponent.ContainerImage) + if err != nil { // the updated component is invalid + logger.Error(err, "component cannot be added to snapshot for ComponentGroup due to invalid digest in containerImage", "component.Name", updatedComponent.Name) + invalidComponents[v1beta2.ComponentState{ + Name: updatedComponent.Name, + Version: updatedComponent.Version, + }] = InvalidComponentReason{ + ComponentGroup: "", + Reason: "invalid digest in containerImage", + } + } else { // the updated component is valid + // add to snapshotComponents + snapshotComponents[helpers.GetComponentVersionString(updatedComponent.Name, updatedComponent.Version)] = updatedComponent + // remove from invalidComponents if it was there (it may have been added for a missing containerImage earlier) + maps.DeleteFunc(invalidComponents, func(K v1beta2.ComponentState, V InvalidComponentReason) bool { + if K.Name == updatedComponent.Name { + if K.Version == "" || K.Version == updatedComponent.Version { + return true } - - // replace snapshotComponent - *snapshotComponents = slices.Replace(*snapshotComponents, i, i+1, updatedComponent) - //(*snapshotComponents)[i] = updatedComponent - return } - } + return false + }) } +} - // if the component is replacing an invalid component, then we don't care that the old component was invalid - for i, invalidComponent := range *invalidComponents { - if invalidComponent.Name == updatedComponent.Name { - if invalidComponent.Version == "" || invalidComponent.Version == updatedComponent.Version { - // remove component from invalid list - *snapshotComponents = append(*snapshotComponents, updatedComponent) - // We don't care about the loop skipping problem here because we always exit - // immediately after deleting the component from the list - *invalidComponents = slices.Delete(*invalidComponents, i, i+1) - - return - } - } +// Accepts a dict of SnapshotComponents representing the current GCL. Updates multiple new SnapshotComponents whose ComponentVersion matches existing ComponentVersions. If no matches exist, the new SnapshotComponents are simply added to the dict +func upsertMultipleComponentImages(snapshotComponents map[string]applicationapiv1alpha1.SnapshotComponent, invalidComponents map[v1beta2.ComponentState]InvalidComponentReason, componentsToInsert []applicationapiv1alpha1.SnapshotComponent, logger helpers.IntegrationLogger) { + for _, component := range componentsToInsert { + upsertNewComponentImage(snapshotComponents, invalidComponents, component, logger) } - - // If the component is not in the list this is probably because the component has not been added to the GCL yet - // In this case we should append the component - *snapshotComponents = append(*snapshotComponents, updatedComponent) } // NewSnapshot creates a new snapshot based on the supplied ComponentGroup and components @@ -320,14 +431,14 @@ func GetComponentSourceFromGCLComponent(gclComponent v1beta2.ComponentState) app return componentSource } -func getSnapshotComponentFromBuildPLR(pipelineRun *tektonv1.PipelineRun, componentName string, log logr.Logger) (applicationapiv1alpha1.SnapshotComponent, error) { +func GetSnapshotComponentFromBuildPLR(pipelineRun *tektonv1.PipelineRun, componentName string, logger helpers.IntegrationLogger) (applicationapiv1alpha1.SnapshotComponent, error) { containerImage, err := tekton.GetImagePullSpecFromPipelineRun(pipelineRun) if err != nil { return applicationapiv1alpha1.SnapshotComponent{}, err } err = gitops.ValidateImageDigest(containerImage) if err != nil { - log.Error(err, "component cannot be added to snapshot for ComponentGroup due to invalid digest in containerImage", "component.Name", componentName) + logger.Error(err, "component cannot be added to snapshot for ComponentGroup due to invalid digest in containerImage", "component.Name", componentName) return applicationapiv1alpha1.SnapshotComponent{}, errors.Join(helpers.NewInvalidImageDigestError(componentName, containerImage), err) } componentSource, err := tekton.GetComponentSourceFromPipelineRun(pipelineRun) diff --git a/snapshot/create_test.go b/snapshot/create_test.go index 4578b79619..c58c15fd64 100644 --- a/snapshot/create_test.go +++ b/snapshot/create_test.go @@ -18,18 +18,20 @@ package snapshot import ( "bytes" + "context" "encoding/json" "fmt" "strconv" "time" - "github.com/go-logr/logr" applicationapiv1alpha1 "github.com/konflux-ci/application-api/api/v1alpha1" "github.com/konflux-ci/integration-service/api/v1beta2" "github.com/konflux-ci/integration-service/gitops" "github.com/konflux-ci/integration-service/helpers" + "github.com/konflux-ci/integration-service/loader" "github.com/konflux-ci/integration-service/tekton" tektonconsts "github.com/konflux-ci/integration-service/tekton/consts" + toolkit "github.com/konflux-ci/operator-toolkit/loader" "github.com/konflux-ci/operator-toolkit/metadata" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -39,9 +41,26 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "knative.dev/pkg/apis" v1 "knative.dev/pkg/apis/duck/v1" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" ) +// nestedGroupsLoader wraps an ObjectLoader and overrides GetNestedComponentGroupsForComponentGroup +// to return per-ComponentGroup results, allowing tests to control the nesting graph precisely. +type nestedGroupsLoader struct { + loader.ObjectLoader + nestedGroupsByParent map[string][]v1beta2.ComponentGroup + errByParent map[string]error +} + +func (l *nestedGroupsLoader) GetNestedComponentGroupsForComponentGroup(_ context.Context, _ client.Client, cg *v1beta2.ComponentGroup) ([]v1beta2.ComponentGroup, error) { + if err, ok := l.errByParent[cg.Name]; ok { + return nil, err + } + groups := l.nestedGroupsByParent[cg.Name] + return groups, nil +} + var _ = Describe("Snapshot creation functions", Ordered, func() { var ( buildPipelineRun *tektonv1.PipelineRun @@ -49,7 +68,8 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { hasCompGroup *v1beta2.ComponentGroup hasAppSample *applicationapiv1alpha1.Application hasCompSample *applicationapiv1alpha1.Component - logger logr.Logger + logger helpers.IntegrationLogger + mockCtx context.Context ) const ( SampleRepoLink = "https://github.com/devfile-samples/devfile-sample-java-springboot-basic" @@ -278,7 +298,14 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { } Expect(k8sClient.Status().Update(ctx, successfulTaskRun)).Should(Succeed()) - logger = log.FromContext(ctx) + logger = helpers.IntegrationLogger{Logger: log.FromContext(ctx)} + + mockCtx = toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: loader.NestedComponentGroupsContextKey, + Resource: []v1beta2.ComponentGroup{}, + }, + }) }) AfterAll(func() { @@ -294,7 +321,7 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { Context("Testing PrepareSnapshotForPipelineRun()", func() { It("ensures built component includes git context from Component CR", func() { - expectedSnapshot, err := PrepareSnapshotForPipelineRun(ctx, k8sClient, buildPipelineRun, componentName, hasCompGroup) + expectedSnapshot, err := PrepareSnapshotForPipelineRun(mockCtx, k8sClient, buildPipelineRun, componentName, hasCompGroup, loader.NewMockLoader()) Expect(err).ToNot(HaveOccurred()) Expect(expectedSnapshot).NotTo(BeNil()) var built *applicationapiv1alpha1.SnapshotComponent @@ -310,7 +337,7 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { }) It("ensures that snapshot has label pointing to build pipelinerun", func() { - expectedSnapshot, err := PrepareSnapshotForPipelineRun(ctx, k8sClient, buildPipelineRun, componentName, hasCompGroup) + expectedSnapshot, err := PrepareSnapshotForPipelineRun(mockCtx, k8sClient, buildPipelineRun, componentName, hasCompGroup, loader.NewMockLoader()) Expect(err).ToNot(HaveOccurred()) Expect(expectedSnapshot).NotTo(BeNil()) @@ -339,7 +366,7 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { buildPipelineRunNoStartTime := buildPipelineRun.DeepCopy() buildPipelineRunNoStartTime.Status.StartTime = nil - expectedSnapshot, err := PrepareSnapshotForPipelineRun(ctx, k8sClient, buildPipelineRunNoStartTime, componentName, hasCompGroup) + expectedSnapshot, err := PrepareSnapshotForPipelineRun(mockCtx, k8sClient, buildPipelineRunNoStartTime, componentName, hasCompGroup, loader.NewMockLoader()) Expect(err).ToNot(HaveOccurred()) Expect(expectedSnapshot).NotTo(BeNil()) @@ -360,7 +387,7 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { }) It("ensures that Labels and Annotations were copied to snapshot from pipelinerun", func() { - copyToSnapshot, err := PrepareSnapshotForPipelineRun(ctx, k8sClient, buildPipelineRun, componentName, hasCompGroup) + copyToSnapshot, err := PrepareSnapshotForPipelineRun(mockCtx, k8sClient, buildPipelineRun, componentName, hasCompGroup, loader.NewMockLoader()) Expect(err).ToNot(HaveOccurred()) Expect(copyToSnapshot).NotTo(BeNil()) @@ -383,7 +410,7 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { mergeQueueBuildPipelineRun.Labels[tektonconsts.PipelineAsCodePullRequestLabel] = "" mergeQueueBuildPipelineRun.Annotations[tektonconsts.PipelineAsCodePullRequestLabel] = "" mergeQueueBuildPipelineRun.Name = buildPipelineRun.Name + "-merge" - expectedSnapshot, err := PrepareSnapshotForPipelineRun(ctx, k8sClient, mergeQueueBuildPipelineRun, componentName, hasCompGroup) + expectedSnapshot, err := PrepareSnapshotForPipelineRun(mockCtx, k8sClient, mergeQueueBuildPipelineRun, componentName, hasCompGroup, loader.NewMockLoader()) Expect(err).ToNot(HaveOccurred()) Expect(expectedSnapshot).NotTo(BeNil()) @@ -430,7 +457,7 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { messageError := "Missing info IMAGE_DIGEST from pipelinerun pipelinerun-build-sample" var info map[string]string - expectedSnapshot, err := PrepareSnapshotForPipelineRun(ctx, k8sClient, buildPipelineRunNoSource, componentName, hasCompGroup) + expectedSnapshot, err := PrepareSnapshotForPipelineRun(mockCtx, k8sClient, buildPipelineRunNoSource, componentName, hasCompGroup, loader.NewMockLoader()) Expect(expectedSnapshot).To(BeNil()) Expect(err).To(HaveOccurred()) err = tekton.AnnotateBuildPipelineRunWithCreateSnapshotAnnotation(ctx, buildPipelineRun, k8sClient, err) @@ -443,7 +470,7 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { }) It("ensures pipelines as code labels and annotations are propagated to the snapshot", func() { - snapshot, err := PrepareSnapshotForPipelineRun(ctx, k8sClient, buildPipelineRun, componentName, hasCompGroup) + snapshot, err := PrepareSnapshotForPipelineRun(mockCtx, k8sClient, buildPipelineRun, componentName, hasCompGroup, loader.NewMockLoader()) Expect(err).ToNot(HaveOccurred()) Expect(snapshot).ToNot(BeNil()) annotation, found := snapshot.GetAnnotations()["pac.test.appstudio.openshift.io/on-target-branch"] @@ -455,7 +482,7 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { }) It("ensures non-pipelines as code labels and annotations are NOT propagated to the snapshot", func() { - snapshot, err := PrepareSnapshotForPipelineRun(ctx, k8sClient, buildPipelineRun, componentName, hasCompGroup) + snapshot, err := PrepareSnapshotForPipelineRun(mockCtx, k8sClient, buildPipelineRun, componentName, hasCompGroup, loader.NewMockLoader()) Expect(err).ToNot(HaveOccurred()) Expect(snapshot).ToNot(BeNil()) @@ -473,7 +500,7 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { }) It("ensures build labels and annotations prefixed with 'build.appstudio' are propagated to the snapshot", func() { - snapshot, err := PrepareSnapshotForPipelineRun(ctx, k8sClient, buildPipelineRun, componentName, hasCompGroup) + snapshot, err := PrepareSnapshotForPipelineRun(mockCtx, k8sClient, buildPipelineRun, componentName, hasCompGroup, loader.NewMockLoader()) Expect(err).ToNot(HaveOccurred()) Expect(snapshot).ToNot(BeNil()) @@ -487,7 +514,7 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { }) It("ensures build labels and annotations non-prefixed with 'build.appstudio' are NOT propagated to the snapshot", func() { - snapshot, err := PrepareSnapshotForPipelineRun(ctx, k8sClient, buildPipelineRun, componentName, hasCompGroup) + snapshot, err := PrepareSnapshotForPipelineRun(mockCtx, k8sClient, buildPipelineRun, componentName, hasCompGroup, loader.NewMockLoader()) Expect(err).ToNot(HaveOccurred()) Expect(snapshot).ToNot(BeNil()) @@ -506,7 +533,7 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { It("ensures integration workflow annotation is set to 'pull-request' for pr events", func() { // default buildPipelineRun already has event-type set to pull_request - snapshot, err := PrepareSnapshotForPipelineRun(ctx, k8sClient, buildPipelineRun, componentName, hasCompGroup) + snapshot, err := PrepareSnapshotForPipelineRun(mockCtx, k8sClient, buildPipelineRun, componentName, hasCompGroup, loader.NewMockLoader()) Expect(err).ToNot(HaveOccurred()) Expect(snapshot).ToNot(BeNil()) @@ -522,7 +549,7 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { pushPipelineRun.Labels["pipelinesascode.tekton.dev/event-type"] = "push" delete(pushPipelineRun.Labels, "pipelinesascode.tekton.dev/pull-request") - snapshot, err := PrepareSnapshotForPipelineRun(ctx, k8sClient, pushPipelineRun, componentName, hasCompGroup) + snapshot, err := PrepareSnapshotForPipelineRun(mockCtx, k8sClient, pushPipelineRun, componentName, hasCompGroup, loader.NewMockLoader()) Expect(err).ToNot(HaveOccurred()) Expect(snapshot).ToNot(BeNil()) @@ -536,56 +563,66 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { Context("testing creation of snapshotComponentsList", func() { It("Ensures valid and invalid snapshotComponents can be gathered from the GCL", func() { var buf bytes.Buffer - readableLog := buflogr.NewWithBuffer(&buf) + readableLog := helpers.IntegrationLogger{Logger: buflogr.NewWithBuffer(&buf)} snapshotComponents, invalidComponents := GetSnapshotComponentsFromGCL(hasCompGroup, readableLog) Expect(snapshotComponents).To(HaveLen(1)) - Expect(snapshotComponents[0].Name).To(Equal(componentName)) + Expect(snapshotComponents).To(HaveKey(helpers.GetComponentVersionString(componentName, "v1"))) Expect(invalidComponents).To(HaveLen(1)) - Expect(invalidComponents[0].Name).To(Equal(componentName2)) + var invalidName string + for k := range invalidComponents { + invalidName = k.Name + } + Expect(invalidName).To(Equal(componentName2)) Expect(buf.String()).To(ContainSubstring("componentVersion was deleted from spec.Components")) }) It("Ensures built component can replace existing snapshotComponent", func() { - newSnapshotComponent, err := getSnapshotComponentFromBuildPLR(buildPipelineRun, componentName, logger) + newSnapshotComponent, err := GetSnapshotComponentFromBuildPLR(buildPipelineRun, componentName, logger) Expect(err).NotTo(HaveOccurred()) snapshotComponents, invalidComponents := GetSnapshotComponentsFromGCL(hasCompGroup, logger) Expect(snapshotComponents).To(HaveLen(1)) Expect(invalidComponents).To(HaveLen(1)) - upsertNewComponentImage(&snapshotComponents, &invalidComponents, newSnapshotComponent, logger) + upsertNewComponentImage(snapshotComponents, invalidComponents, newSnapshotComponent, logger) - // The upserted image should replace the old image + // The upserted image should replace the old image (same name+version key) Expect(snapshotComponents).To(HaveLen(1)) - Expect(snapshotComponents[0].Name).To(Equal(componentName)) + Expect(snapshotComponents[helpers.GetComponentVersionString(componentName, "v1")].Name).To(Equal(componentName)) Expect(invalidComponents).To(HaveLen(1)) }) - It("Ensures built component can replace existing snapshotComponent when snapshotComponent has no version", func() { - newSnapshotComponent, err := getSnapshotComponentFromBuildPLR(buildPipelineRun, componentName, logger) + It("Ensures built component is added under its version key when an unversioned entry exists", func() { + newSnapshotComponent, err := GetSnapshotComponentFromBuildPLR(buildPipelineRun, componentName, logger) Expect(err).NotTo(HaveOccurred()) snapshotComponents, invalidComponents := GetSnapshotComponentsFromGCL(hasCompGroup, logger) Expect(snapshotComponents).To(HaveLen(1)) Expect(invalidComponents).To(HaveLen(1)) - snapshotComponents[0].Version = "" - upsertNewComponentImage(&snapshotComponents, &invalidComponents, newSnapshotComponent, logger) + // Simulate a version-less existing entry by re-keying the map + versionedKey := helpers.GetComponentVersionString(componentName, "v1") + comp := snapshotComponents[versionedKey] + comp.Version = "" + delete(snapshotComponents, versionedKey) + snapshotComponents[helpers.GetComponentVersionString(componentName, "")] = comp - // The upserted image should replace the old image - Expect(snapshotComponents).To(HaveLen(1)) - Expect(snapshotComponents[0].Name).To(Equal(componentName)) + upsertNewComponentImage(snapshotComponents, invalidComponents, newSnapshotComponent, logger) + + // The new versioned entry is added; the unversioned entry remains under its own key + Expect(snapshotComponents).To(HaveLen(2)) + Expect(snapshotComponents).To(HaveKey(helpers.GetComponentVersionString(componentName, "v1"))) Expect(invalidComponents).To(HaveLen(1)) }) It("Ensures built component can also be removed from invalidComponents", func() { // replace this with data from another-component-sample - newSnapshotComponent, err := getSnapshotComponentFromBuildPLR(buildPipelineRun, componentName, logger) + newSnapshotComponent, err := GetSnapshotComponentFromBuildPLR(buildPipelineRun, componentName, logger) Expect(err).NotTo(HaveOccurred()) newSnapshotComponent.Name = componentName2 @@ -593,13 +630,12 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { Expect(snapshotComponents).To(HaveLen(1)) Expect(invalidComponents).To(HaveLen(1)) - snapshotComponents[0].Version = "" - upsertNewComponentImage(&snapshotComponents, &invalidComponents, newSnapshotComponent, logger) + upsertNewComponentImage(snapshotComponents, invalidComponents, newSnapshotComponent, logger) - // The upserted image should exist in addition to the old image + // The upserted image should exist in addition to the existing component Expect(snapshotComponents).To(HaveLen(2)) - Expect(snapshotComponents[0].Name).To(Equal(componentName)) - Expect(snapshotComponents[1].Name).To(Equal(componentName2)) + Expect(snapshotComponents).To(HaveKey(helpers.GetComponentVersionString(componentName, "v1"))) + Expect(snapshotComponents).To(HaveKey(helpers.GetComponentVersionString(componentName2, "v1"))) Expect(invalidComponents).To(BeEmpty()) }) }) @@ -609,12 +645,157 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { Expect(true).To(BeTrue()) }) + Context("Testing flattenSnapshotComponentsMap()", func() { + It("returns an empty slice for an empty map", func() { + result := flattenSnapshotComponentsMap(map[string]applicationapiv1alpha1.SnapshotComponent{}) + Expect(result).To(BeEmpty()) + }) + + It("returns a slice with one entry for a single-element map", func() { + comp := applicationapiv1alpha1.SnapshotComponent{ + Name: componentName, + ContainerImage: fmt.Sprintf("%s@%s", SampleImageWithoutDigest, SampleDigest), + } + result := flattenSnapshotComponentsMap(map[string]applicationapiv1alpha1.SnapshotComponent{ + helpers.GetComponentVersionString(componentName, "v1"): comp, + }) + Expect(result).To(HaveLen(1)) + Expect(result).To(ContainElement(comp)) + }) + + It("returns all entries for a multi-element map", func() { + comp1 := applicationapiv1alpha1.SnapshotComponent{Name: componentName, ContainerImage: "image1"} + comp2 := applicationapiv1alpha1.SnapshotComponent{Name: componentName2, ContainerImage: "image2"} + result := flattenSnapshotComponentsMap(map[string]applicationapiv1alpha1.SnapshotComponent{ + helpers.GetComponentVersionString(componentName, "v1"): comp1, + helpers.GetComponentVersionString(componentName2, "v1"): comp2, + }) + Expect(result).To(HaveLen(2)) + Expect(result).To(ContainElements(comp1, comp2)) + }) + }) + + Context("Testing getAllNestedSnapshotComponents()", func() { + It("returns GCL components from the componentGroup itself when there are no nested groups", func() { + snapshotComponents, invalidComponents, err := getAllNestedSnapshotComponents(hasCompGroup, []string{}, 0, loader.NewMockLoader(), mockCtx, k8sClient, logger) + Expect(err).ToNot(HaveOccurred()) + Expect(snapshotComponents).To(HaveLen(1)) + Expect(snapshotComponents).To(HaveKey(helpers.GetComponentVersionString(componentName, "v1"))) + // componentName2 has no containerImage so it ends up as invalid + Expect(invalidComponents).To(HaveLen(1)) + }) + + It("returns an error when a cycle is detected in nested componentGroups", func() { + _, _, err := getAllNestedSnapshotComponents(hasCompGroup, []string{hasCompGroup.Name}, 0, loader.NewMockLoader(), mockCtx, k8sClient, logger) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cycle found")) + }) + + It("returns an error when the loader fails to get nested component groups", func() { + loaderErr := fmt.Errorf("simulated loader error") + errLoader := &nestedGroupsLoader{ + ObjectLoader: loader.NewMockLoader(), + nestedGroupsByParent: map[string][]v1beta2.ComponentGroup{}, + errByParent: map[string]error{hasCompGroup.Name: loaderErr}, + } + _, _, err := getAllNestedSnapshotComponents(hasCompGroup, []string{}, 0, errLoader, ctx, k8sClient, logger) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated loader error")) + }) + + It("merges components from a nested componentGroup with the parent's own GCL", func() { + validImage := fmt.Sprintf("%s@%s", SampleImageWithoutDigest, SampleDigest) + nestedCG := &v1beta2.ComponentGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nested-component-group", + Namespace: "default", + }, + Spec: v1beta2.ComponentGroupSpec{ + Components: []v1beta2.ComponentReference{ + { + Name: "nested-component", + ComponentVersion: v1beta2.ComponentVersionReference{Name: "v1"}, + }, + }, + }, + Status: v1beta2.ComponentGroupStatus{ + GlobalCandidateList: []v1beta2.ComponentState{ + { + Name: "nested-component", + Version: "v1", + URL: SampleRepoLink, + LastPromotedImage: validImage, + LastPromotedCommit: SampleCommit, + }, + }, + }, + } + nestedLoader := &nestedGroupsLoader{ + ObjectLoader: loader.NewMockLoader(), + nestedGroupsByParent: map[string][]v1beta2.ComponentGroup{ + hasCompGroup.Name: {*nestedCG}, + nestedCG.Name: {}, + }, + errByParent: map[string]error{}, + } + + snapshotComponents, _, err := getAllNestedSnapshotComponents(hasCompGroup, []string{}, 0, nestedLoader, ctx, k8sClient, logger) + Expect(err).ToNot(HaveOccurred()) + Expect(snapshotComponents).To(HaveKey(helpers.GetComponentVersionString(componentName, "v1"))) + Expect(snapshotComponents).To(HaveKey(helpers.GetComponentVersionString("nested-component", "v1"))) + }) + + It("parent componentGroup GCL overwrites duplicate entries from nested componentGroups", func() { + childImage := fmt.Sprintf("quay.io/child-image@%s", SampleDigest) + parentImage := fmt.Sprintf("%s@%s", SampleImageWithoutDigest, SampleDigest) + nestedCG := &v1beta2.ComponentGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nested-component-group-conflict", + Namespace: "default", + }, + Spec: v1beta2.ComponentGroupSpec{ + Components: []v1beta2.ComponentReference{ + { + Name: componentName, + ComponentVersion: v1beta2.ComponentVersionReference{Name: "v1"}, + }, + }, + }, + Status: v1beta2.ComponentGroupStatus{ + GlobalCandidateList: []v1beta2.ComponentState{ + { + Name: componentName, + Version: "v1", + URL: SampleRepoLink, + LastPromotedImage: childImage, + LastPromotedCommit: SampleCommit, + }, + }, + }, + } + nestedLoader := &nestedGroupsLoader{ + ObjectLoader: loader.NewMockLoader(), + nestedGroupsByParent: map[string][]v1beta2.ComponentGroup{ + hasCompGroup.Name: {*nestedCG}, + nestedCG.Name: {}, + }, + errByParent: map[string]error{}, + } + + snapshotComponents, _, err := getAllNestedSnapshotComponents(hasCompGroup, []string{}, 0, nestedLoader, ctx, k8sClient, logger) + Expect(err).ToNot(HaveOccurred()) + key := helpers.GetComponentVersionString(componentName, "v1") + Expect(snapshotComponents).To(HaveKey(key)) + Expect(snapshotComponents[key].ContainerImage).To(Equal(parentImage)) + }) + }) + Context("Testing PrepareSnapshot()", func() { It("ensures the Imagepullspec and ComponentSource from pipelinerun and prepare snapshot can be created", func() { - newSnapshotComponent, err := getSnapshotComponentFromBuildPLR(buildPipelineRun, componentName, logger) + newSnapshotComponent, err := GetSnapshotComponentFromBuildPLR(buildPipelineRun, componentName, logger) Expect(err).NotTo(HaveOccurred()) - snapshot, err := PrepareSnapshot(ctx, k8sClient, hasCompGroup, newSnapshotComponent, logger) + snapshot, err := PrepareSnapshot(mockCtx, k8sClient, hasCompGroup, newSnapshotComponent, loader.NewMockLoader(), logger) Expect(snapshot).NotTo(BeNil()) Expect(err).ToNot(HaveOccurred()) Expect(snapshot.Spec.Components).To(HaveLen(1), "One component should have been added to snapshot. Other component should have been omited due to empty ContainerImage field or missing valid digest") @@ -669,4 +850,218 @@ var _ = Describe("Snapshot creation functions", Ordered, func() { Expect(snapshot.Name).To(MatchRegexp(`^` + exactGroupName + `-\d{8}-\d{6}-\d{3}$`)) Expect(snapshot.Spec.ComponentGroup).To(Equal(exactGroupName)) }) + + Context("Testing upsertMultipleComponentImages()", func() { + var ( + existingSnapshotComponents map[string]applicationapiv1alpha1.SnapshotComponent + expectedNewSnapshotComponents map[string]applicationapiv1alpha1.SnapshotComponent + componentsToInsert []applicationapiv1alpha1.SnapshotComponent + ) + BeforeAll(func() { + existingSnapshotComponents = make(map[string]applicationapiv1alpha1.SnapshotComponent) + existingSnapshotComponents["component1/main"] = applicationapiv1alpha1.SnapshotComponent{ + Name: "component1", + Version: "main", + ContainerImage: "quay.io/konflux/component1-main:old", + Source: applicationapiv1alpha1.ComponentSource{ + ComponentSourceUnion: applicationapiv1alpha1.ComponentSourceUnion{ + GitSource: &applicationapiv1alpha1.GitSource{ + URL: SampleRepoLink, + Revision: SampleCommit, + }, + }, + }, + } + + newSnapshotComponent1 := applicationapiv1alpha1.SnapshotComponent{ + Name: "component1", + Version: "main", + ContainerImage: "quay.io/konflux/component1-main@sha256:841328df1b9f8c4087adbdcfec6cc99ac8308805dea83f6d415d6fb8d40227c1", + Source: applicationapiv1alpha1.ComponentSource{ + ComponentSourceUnion: applicationapiv1alpha1.ComponentSourceUnion{ + GitSource: &applicationapiv1alpha1.GitSource{ + URL: SampleRepoLink, + Revision: SampleCommit, + }, + }, + }, + } + newSnapshotComponent2 := applicationapiv1alpha1.SnapshotComponent{ + Name: "component2", + Version: "main", + ContainerImage: "quay.io/konflux/component2-main@sha256:841328df1b9f8c4087adbdcfec6cc99ac8308805dea83f6d415d6fb8d40227c1", + Source: applicationapiv1alpha1.ComponentSource{ + ComponentSourceUnion: applicationapiv1alpha1.ComponentSourceUnion{ + GitSource: &applicationapiv1alpha1.GitSource{ + URL: SampleRepoLink, + Revision: SampleCommit, + }, + }, + }, + } + componentsToInsert = []applicationapiv1alpha1.SnapshotComponent{newSnapshotComponent1, newSnapshotComponent2} + + expectedNewSnapshotComponents = make(map[string]applicationapiv1alpha1.SnapshotComponent) + expectedNewSnapshotComponents["component1/main"] = newSnapshotComponent1 + expectedNewSnapshotComponents["component2/main"] = newSnapshotComponent2 + }) + + It("Can upsert new componentVersions", func() { + invalidComponents := make(map[v1beta2.ComponentState]InvalidComponentReason) + upsertMultipleComponentImages(existingSnapshotComponents, invalidComponents, componentsToInsert, logger) + Expect(existingSnapshotComponents).To(Equal(expectedNewSnapshotComponents)) + Expect(invalidComponents).To(BeEmpty()) + }) + + It("Can upsert new componentVersions and replace invalid components", func() { + invalidComponents := make(map[v1beta2.ComponentState]InvalidComponentReason) + invalidComponents[v1beta2.ComponentState{ + Name: "component2", + Version: "main", + }] = InvalidComponentReason{ + ComponentGroup: "", + Reason: "invalid digest in containerImage", + } + upsertMultipleComponentImages(existingSnapshotComponents, invalidComponents, componentsToInsert, logger) + Expect(existingSnapshotComponents).To(Equal(expectedNewSnapshotComponents)) + Expect(invalidComponents).To(BeEmpty()) + }) + }) + + Context("Testing PrepareParentSnapshot()", func() { + var ( + childComponentSnapshot *applicationapiv1alpha1.Snapshot + childImageURL string + ) + + BeforeEach(func() { + childImageURL = fmt.Sprintf("quay.io/child-image@%s", SampleDigest) + childComponentSnapshot = &applicationapiv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "child-component-snapshot", + Namespace: "default", + Labels: map[string]string{ + gitops.SnapshotTypeLabel: gitops.SnapshotComponentType, + gitops.SnapshotComponentLabel: componentName, + }, + Annotations: map[string]string{ + tektonconsts.PipelineRunComponentVersionAnnotation: "v1", + }, + }, + Spec: applicationapiv1alpha1.SnapshotSpec{ + Application: hasAppSample.Name, + Components: []applicationapiv1alpha1.SnapshotComponent{ + { + Name: componentName, + Version: "v1", + ContainerImage: childImageURL, + Source: applicationapiv1alpha1.ComponentSource{ + ComponentSourceUnion: applicationapiv1alpha1.ComponentSourceUnion{ + GitSource: &applicationapiv1alpha1.GitSource{ + URL: SampleRepoLink, + Revision: SampleCommit, + }, + }, + }, + }, + }, + }, + } + }) + + It("returns a snapshot with the child component image overriding the GCL entry", func() { + snapshot, err := PrepareParentSnapshot(mockCtx, k8sClient, loader.NewMockLoader(), logger, hasCompGroup, childComponentSnapshot) + Expect(err).ToNot(HaveOccurred()) + Expect(snapshot).NotTo(BeNil()) + + var found *applicationapiv1alpha1.SnapshotComponent + for i := range snapshot.Spec.Components { + if snapshot.Spec.Components[i].Name == componentName { + found = &snapshot.Spec.Components[i] + break + } + } + Expect(found).NotTo(BeNil()) + Expect(found.ContainerImage).To(Equal(childImageURL)) + }) + + It("sets the SnapshotGitSourceRepoURLAnnotation from the child snapshot component's git source", func() { + snapshot, err := PrepareParentSnapshot(mockCtx, k8sClient, loader.NewMockLoader(), logger, hasCompGroup, childComponentSnapshot) + Expect(err).ToNot(HaveOccurred()) + Expect(snapshot).NotTo(BeNil()) + Expect(snapshot.Annotations).To(HaveKeyWithValue(gitops.SnapshotGitSourceRepoURLAnnotation, SampleRepoLink)) + }) + + It("sets the invalid component warning annotation when some GCL components remain invalid", func() { + snapshot, err := PrepareParentSnapshot(mockCtx, k8sClient, loader.NewMockLoader(), logger, hasCompGroup, childComponentSnapshot) + Expect(err).ToNot(HaveOccurred()) + Expect(snapshot).NotTo(BeNil()) + // componentName2 has an empty containerImage in the GCL and is not provided by the child snapshot + Expect(snapshot.Annotations).To(HaveKey(helpers.CreateSnapshotAnnotationName)) + Expect(snapshot.Annotations[helpers.CreateSnapshotAnnotationName]).To(ContainSubstring(componentName2)) + }) + + It("sets the controller reference on the snapshot to the componentGroup", func() { + snapshot, err := PrepareParentSnapshot(mockCtx, k8sClient, loader.NewMockLoader(), logger, hasCompGroup, childComponentSnapshot) + Expect(err).ToNot(HaveOccurred()) + Expect(snapshot).NotTo(BeNil()) + ownerRef := metav1.GetControllerOf(snapshot) + Expect(ownerRef).NotTo(BeNil()) + Expect(ownerRef.Name).To(Equal(hasCompGroup.Name)) + }) + + It("returns a wrapped error when the loader fails to get nested component groups", func() { + loaderErr := fmt.Errorf("simulated loader error") + errLoader := &nestedGroupsLoader{ + ObjectLoader: loader.NewMockLoader(), + nestedGroupsByParent: map[string][]v1beta2.ComponentGroup{}, + errByParent: map[string]error{hasCompGroup.Name: loaderErr}, + } + snapshot, err := PrepareParentSnapshot(ctx, k8sClient, errLoader, logger, hasCompGroup, childComponentSnapshot) + Expect(err).To(HaveOccurred()) + Expect(snapshot).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("error getting nested snapshot components:")) + Expect(err.Error()).To(ContainSubstring("simulated loader error")) + }) + + It("returns a MissingValidComponentError when no valid components remain after merging", func() { + // A group whose entire GCL has no valid images, and a child snapshot that contributes nothing + allInvalidGroup := &v1beta2.ComponentGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "all-invalid-prep-group", + Namespace: "default", + }, + Spec: v1beta2.ComponentGroupSpec{ + Components: []v1beta2.ComponentReference{ + { + Name: componentName2, + ComponentVersion: v1beta2.ComponentVersionReference{Name: "v1"}, + }, + }, + }, + Status: v1beta2.ComponentGroupStatus{ + GlobalCandidateList: []v1beta2.ComponentState{ + // empty LastPromotedImage → invalid, omitted from snapshot + {Name: componentName2, Version: "v1"}, + }, + }, + } + + // Child snapshot is not a component/group/override type, so GetAllNewComponentsInSnapshot returns empty + neutralChildSnapshot := &applicationapiv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "neutral-child-snapshot", + Namespace: "default", + }, + Spec: applicationapiv1alpha1.SnapshotSpec{ + Application: hasAppSample.Name, + }, + } + + snapshot, err := PrepareParentSnapshot(mockCtx, k8sClient, loader.NewMockLoader(), logger, allInvalidGroup, neutralChildSnapshot) + Expect(err).To(HaveOccurred()) + Expect(snapshot).To(BeNil()) + Expect(helpers.IsMissingValidComponentError(err)).To(BeTrue()) + }) + }) }) diff --git a/snapshot/gcl.go b/snapshot/gcl.go index 844fd389b6..736412614e 100644 --- a/snapshot/gcl.go +++ b/snapshot/gcl.go @@ -164,15 +164,17 @@ func UpdateGCLForOverrideSnapshot(ctx context.Context, adapterClient client.Clie return err } -func FetchSnapshotComponentFromGCL(componentName string, snapshotComponentsFromGCL []applicationapiv1alpha1.SnapshotComponent, invalidComponents []v1beta2.ComponentState) (*applicationapiv1alpha1.SnapshotComponent, error) { - for _, snapshotComponentFromGCL := range snapshotComponentsFromGCL { - if snapshotComponentFromGCL.Name == componentName { - return &snapshotComponentFromGCL, nil - } +func FetchSnapshotComponentFromGCL(componentName, componentVersion string, snapshotComponentsFromGCL map[string]applicationapiv1alpha1.SnapshotComponent, invalidComponents map[v1beta2.ComponentState]InvalidComponentReason) (*applicationapiv1alpha1.SnapshotComponent, error) { + componentVersionString := helpers.GetComponentVersionString(componentName, componentVersion) + if component, ok := snapshotComponentsFromGCL[componentVersionString]; ok { + return &component, nil } - for _, invalidComponent := range invalidComponents { + + for invalidComponent, reason := range invalidComponents { if invalidComponent.Name == componentName { - return nil, fmt.Errorf("component cannot be added to snapshot due to invalid digest in containerImage") + if invalidComponent.Version == "" || invalidComponent.Version == componentVersion { + return nil, fmt.Errorf("component cannot be added to snapshot due to %s", reason.Reason) + } } } return nil, nil diff --git a/snapshot/utils.go b/snapshot/utils.go index a309a06d76..193435a1d3 100644 --- a/snapshot/utils.go +++ b/snapshot/utils.go @@ -28,6 +28,11 @@ import ( tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" ) +type InvalidComponentReason struct { + ComponentGroup string + Reason string +} + // generateRandomSuffix generates a random 2-character alphanumeric suffix for collision handling func generateRandomSuffix() (string, error) { const charset = "0123456789abcdefghijklmnopqrstuvwxyz" @@ -43,9 +48,9 @@ func generateRandomSuffix() (string, error) { return string(suffix), nil } -func joinInvalidComponentNamesAndVersions(invalidComponents []v1beta2.ComponentState) string { +func joinInvalidComponentNamesAndVersions(invalidComponents map[v1beta2.ComponentState]InvalidComponentReason) string { var sb strings.Builder - for _, component := range invalidComponents { + for component := range invalidComponents { sb.WriteString(helpers.GetComponentVersionLogString(component.Name, component.Version)) } return sb.String() @@ -63,7 +68,7 @@ func snapshotComponentToComponentState(snapshotComponent applicationapiv1alpha1. } func getPipelineRunStartTimeMillis(pipelineRun *tektonv1.PipelineRun) int64 { - if pipelineRun.Status.StartTime != nil { + if pipelineRun != nil && pipelineRun.Status.StartTime != nil { return pipelineRun.Status.StartTime.UnixMilli() } return time.Now().UnixMilli() diff --git a/vendor/github.com/konflux-ci/application-api/api/v1alpha1/component_types.go b/vendor/github.com/konflux-ci/application-api/api/v1alpha1/component_types.go index a64362b6dc..3e8c12a728 100644 --- a/vendor/github.com/konflux-ci/application-api/api/v1alpha1/component_types.go +++ b/vendor/github.com/konflux-ci/application-api/api/v1alpha1/component_types.go @@ -114,28 +114,37 @@ type ComponentBuildPipeline struct { // Pipeline used for pull and push pipeline runs. // Can specify just one of: pipelinespec-from-bundle, pipelineref-by-name, pipelineref-by-git-resolver. // Optional. - PullAndPush PipelineDefinition `json:"pull-and-push,omitempty"` + // +optional + // +nullable + PullAndPush *PipelineDefinition `json:"pull-and-push,omitempty"` // Pipeline used for pull pipeline run. // Can specify just one of: pipelinespec-from-bundle, pipelineref-by-name, pipelineref-by-git-resolver. // Optional. - Pull PipelineDefinition `json:"pull,omitempty"` + // +optional + // +nullable + Pull *PipelineDefinition `json:"pull,omitempty"` // Pipeline used for push pipeline run. // Can specify just one of: pipelinespec-from-bundle, pipelineref-by-name, pipelineref-by-git-resolver. // Optional. - Push PipelineDefinition `json:"push,omitempty"` + // +optional + // +nullable + Push *PipelineDefinition `json:"push,omitempty"` } type PipelineDefinition struct { // Will be used to fill out PipelineRef in pipeline runs to user specific pipeline via git resolver, // specifying repository with a pipeline definition. // Optional. - PipelineRefGit PipelineRefGit `json:"pipelineref-by-git-resolver,omitempty"` + // +optional + // +nullable + PipelineRefGit *PipelineRefGit `json:"pipelineref-by-git-resolver,omitempty"` // Will be used to fill out PipelineRef in pipeline runs to user specific pipeline. // Such pipeline definition has to be in .tekton. // Optional. + // +optional PipelineRefName string `json:"pipelineref-by-name,omitempty"` // Will be used to fetch bundle and fill out PipelineSpec in pipeline runs. @@ -144,7 +153,9 @@ type PipelineDefinition struct { // When bundle is specified to specific image bundle, then that one will be used // and pipeline name will be used to fetch pipeline from that bundle. // Optional. - PipelineSpecFromBundle PipelineSpecFromBundle `json:"pipelinespec-from-bundle,omitempty"` + // +optional + // +nullable + PipelineSpecFromBundle *PipelineSpecFromBundle `json:"pipelinespec-from-bundle,omitempty"` } type PipelineSpecFromBundle struct { @@ -184,7 +195,9 @@ type ComponentVersion struct { // Used only when sending a PR with build pipeline configuration was requested via 'spec.actions.create-pipeline-configuration-pr'. // Pipeline used for the version; when omitted, the default pipeline will be used from 'spec.default-build-pipeline'. // Optional. - BuildPipeline ComponentBuildPipeline `json:"build-pipeline,omitempty"` + // +optional + // +nullable + BuildPipeline *ComponentBuildPipeline `json:"build-pipeline,omitempty"` // Context directory for the version. // Used only when sending a PR with build pipeline configuration was requested via 'spec.actions.create-pipeline-configuration-pr'. @@ -326,7 +339,8 @@ type ComponentSpec struct { // When omitted it has to be specified in all versions. // Optional. // +optional - DefaultBuildPipeline ComponentBuildPipeline `json:"default-build-pipeline,omitempty"` + // +nullable + DefaultBuildPipeline *ComponentBuildPipeline `json:"default-build-pipeline,omitempty"` } // ComponentStatus defines the observed state of Component diff --git a/vendor/github.com/konflux-ci/application-api/api/v1alpha1/snapshot_types.go b/vendor/github.com/konflux-ci/application-api/api/v1alpha1/snapshot_types.go index 9a20bb189b..f2bff799b6 100644 --- a/vendor/github.com/konflux-ci/application-api/api/v1alpha1/snapshot_types.go +++ b/vendor/github.com/konflux-ci/application-api/api/v1alpha1/snapshot_types.go @@ -81,6 +81,25 @@ type SnapshotStatus struct { // Conditions represent the latest available observations for the Snapshot // +optional Conditions []metav1.Condition `json:"conditions"` + + // ParentSnapshots contains a map of ComponentGroups that are parents of the + // ComponentGroup for which the snapshot was created and their corresponding + // snapshots + ParentSnapshots map[string]ParentSnapshotData `json:"parentSnapshots,omitempty"` +} + +type ParentSnapshotData struct { + // Name of the parent snapshot + // +optional + Name string `json:"name,omitempty"` + + // Whether the Snapshot has been created + Created bool `json:"created"` + + // If the snapshot could not be created, this will contain an error string + // If it was created, this will contain a success message + // +optional + Message string `json:"err,omitempty"` } //+kubebuilder:object:root=true diff --git a/vendor/github.com/konflux-ci/application-api/api/v1alpha1/zz_generated.deepcopy.go b/vendor/github.com/konflux-ci/application-api/api/v1alpha1/zz_generated.deepcopy.go index 32181089f2..0d91bcdad4 100644 --- a/vendor/github.com/konflux-ci/application-api/api/v1alpha1/zz_generated.deepcopy.go +++ b/vendor/github.com/konflux-ci/application-api/api/v1alpha1/zz_generated.deepcopy.go @@ -191,9 +191,21 @@ func (in *ComponentActions) DeepCopy() *ComponentActions { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ComponentBuildPipeline) DeepCopyInto(out *ComponentBuildPipeline) { *out = *in - out.PullAndPush = in.PullAndPush - out.Pull = in.Pull - out.Push = in.Push + if in.PullAndPush != nil { + in, out := &in.PullAndPush, &out.PullAndPush + *out = new(PipelineDefinition) + (*in).DeepCopyInto(*out) + } + if in.Pull != nil { + in, out := &in.Pull, &out.Pull + *out = new(PipelineDefinition) + (*in).DeepCopyInto(*out) + } + if in.Push != nil { + in, out := &in.Push, &out.Push + *out = new(PipelineDefinition) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ComponentBuildPipeline. @@ -426,7 +438,9 @@ func (in *ComponentSourceUnion) DeepCopyInto(out *ComponentSourceUnion) { if in.Versions != nil { in, out := &in.Versions, &out.Versions *out = make([]ComponentVersion, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } } @@ -464,7 +478,11 @@ func (in *ComponentSpec) DeepCopyInto(out *ComponentSpec) { } in.Actions.DeepCopyInto(&out.Actions) in.RepositorySettings.DeepCopyInto(&out.RepositorySettings) - out.DefaultBuildPipeline = in.DefaultBuildPipeline + if in.DefaultBuildPipeline != nil { + in, out := &in.DefaultBuildPipeline, &out.DefaultBuildPipeline + *out = new(ComponentBuildPipeline) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ComponentSpec. @@ -514,7 +532,11 @@ func (in *ComponentStatus) DeepCopy() *ComponentStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ComponentVersion) DeepCopyInto(out *ComponentVersion) { *out = *in - out.BuildPipeline = in.BuildPipeline + if in.BuildPipeline != nil { + in, out := &in.BuildPipeline, &out.BuildPipeline + *out = new(ComponentBuildPipeline) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ComponentVersion. @@ -572,11 +594,34 @@ func (in *GitSource) DeepCopy() *GitSource { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ParentSnapshotData) DeepCopyInto(out *ParentSnapshotData) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ParentSnapshotData. +func (in *ParentSnapshotData) DeepCopy() *ParentSnapshotData { + if in == nil { + return nil + } + out := new(ParentSnapshotData) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PipelineDefinition) DeepCopyInto(out *PipelineDefinition) { *out = *in - out.PipelineRefGit = in.PipelineRefGit - out.PipelineSpecFromBundle = in.PipelineSpecFromBundle + if in.PipelineRefGit != nil { + in, out := &in.PipelineRefGit, &out.PipelineRefGit + *out = new(PipelineRefGit) + **out = **in + } + if in.PipelineSpecFromBundle != nil { + in, out := &in.PipelineSpecFromBundle, &out.PipelineSpecFromBundle + *out = new(PipelineSpecFromBundle) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PipelineDefinition. @@ -767,6 +812,13 @@ func (in *SnapshotStatus) DeepCopyInto(out *SnapshotStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.ParentSnapshots != nil { + in, out := &in.ParentSnapshots, &out.ParentSnapshots + *out = make(map[string]ParentSnapshotData, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SnapshotStatus. diff --git a/vendor/modules.txt b/vendor/modules.txt index a5ef101751..8081a1dc2c 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -382,7 +382,7 @@ github.com/kevinburke/ssh_config # github.com/klauspost/cpuid/v2 v2.3.0 ## explicit; go 1.22 github.com/klauspost/cpuid/v2 -# github.com/konflux-ci/application-api v0.0.0-20260312190025-5154ad273e17 +# github.com/konflux-ci/application-api v0.0.0-20260603073049-dd8c9b1a64c2 ## explicit; go 1.19 github.com/konflux-ci/application-api/api/v1alpha1 # github.com/konflux-ci/coverport/instrumentation/go v0.0.0-20251127115143-b5207b335f8b