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