diff --git a/controllers/pipelinerun_controller.go b/controllers/pipelinerun_controller.go new file mode 100644 index 00000000..0a2df762 --- /dev/null +++ b/controllers/pipelinerun_controller.go @@ -0,0 +1,217 @@ +package controllers + +import ( + "context" + "fmt" + + "github.com/shipwright-io/build/pkg/apis/build/v1alpha1" + "github.com/shipwright-io/triggers/pkg/constants" + "github.com/shipwright-io/triggers/pkg/filter" + "github.com/shipwright-io/triggers/pkg/inventory" + + tknv1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +// PipelineRunReconciler reconciles PipelineRun objects that may have triggers configured to generate +// a BuildRun based on the Pipeline state. +type PipelineRunReconciler struct { + client.Client // kubernetes client + Scheme *runtime.Scheme // shared scheme + Clock // local clock instance + + buildInventory *inventory.Inventory // local build triggers database +} + +//+kubebuilder:rbac:groups=shipwright.io,resources=builds,verbs=get;list;watch +//+kubebuilder:rbac:groups=shipwright.io,resources=buildruns,verbs=create;get;list;update;watch +//+kubebuilder:rbac:groups=tekton.dev,resources=pipelineruns,verbs=get;list;update;patch;watch + +// createBuildRun handles the actual BuildRun creation, uses the informed PipelineRun instance to +// establish ownership. Only returns the created object name and error. +func (r *PipelineRunReconciler) createBuildRun( + ctx context.Context, + pipelineRun *tknv1beta1.PipelineRun, + buildName string, +) (string, error) { + br := v1alpha1.BuildRun{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: pipelineRun.GetNamespace(), + GenerateName: fmt.Sprintf("%s-", buildName), + Annotations: map[string]string{ + filter.OwnedByTektonPipelineRun: pipelineRun.GetName(), + }, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: constants.TektonAPIv1beta1, + Kind: "PipelineRun", + Name: pipelineRun.GetName(), + UID: pipelineRun.GetUID(), + }}, + }, + Spec: v1alpha1.BuildRunSpec{ + BuildRef: &v1alpha1.BuildRef{ + Name: buildName, + }, + }, + } + if err := r.Client.Create(ctx, &br); err != nil { + return "", err + } + return br.GetName(), nil +} + +// issueBuildRunsForPipelineRun create the BuildRun instances for the informed objects, and updates +// the PipelineRun annotations to documented the created BuildRuns. +func (r *PipelineRunReconciler) issueBuildRunsForPipelineRun( + ctx context.Context, + pipelineRun *tknv1beta1.PipelineRun, + buildNames []string, +) ([]string, error) { + var created []string + for _, buildName := range buildNames { + buildRunName, err := r.createBuildRun(ctx, pipelineRun, buildName) + if err != nil { + return created, err + } + created = append(created, buildRunName) + } + return created, nil +} + +// Reconcile inspects the PipelineRun to extract the query parameters for the Build inventory search, +// and at the end creates the BuildRun instance(s). Before firing the BuildRuns it inspects the +// PipelineRun to assert the object is being referred by triggers and it's not part of a Custom-Task +// Pipeline. +func (r *PipelineRunReconciler) Reconcile( + ctx context.Context, + req ctrl.Request, +) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var pipelineRun tknv1beta1.PipelineRun + if err := r.Get(ctx, req.NamespacedName, &pipelineRun); err != nil { + if !errors.IsNotFound(err) { + logger.Error(err, "Unable to fetch PipelineRun") + } + return ctrl.Result{}, client.IgnoreNotFound(err) + } + // making sure a copy of the original object is available to patch the resource later on + originalPipelineRun := pipelineRun.DeepCopy() + + // creating a objectRef based on the informed PipelineRun, the instance is informed to the + // inventory query interface to list Shipwright Builds that should be triggered + objectRef, err := filter.PipelineRunToObjectRef(ctx, r.Clock.Now(), &pipelineRun) + if err != nil { + return ctrl.Result{}, err + } + logger.V(0).Info( + "Searching for Builds matching criteria", + "ref-name", objectRef.Name, + "ref-status", objectRef.Status, + "ref-selector", objectRef.Selector, + ) + + // search for Builds with Pipeline triggers matching current ObjectRef criteria + buildsToBeIssued := r.buildInventory.SearchForObjectRef(v1alpha1.PipelineTrigger, objectRef) + if len(buildsToBeIssued) == 0 { + return ctrl.Result{}, nil + } + + buildNames := inventory.ExtractBuildNames(buildsToBeIssued...) + logger.V(0).Info("Build names in the Inventory matching criteria", "build-names", buildNames) + + // during pipeline re-run a new PipelineRun is issued based on a existing object copying over all + // the elements, including annotations. To allow re-runs we annotate the current object's name + // and only check previously triggered builds when the name matches + var triggeredBuilds = []filter.TriggeredBuild{} + if filter.PipelineRunAnnotatedNameMatchesObject(&pipelineRun) { + // extracting existing triggered-builds from the annotation, information needed to detect if + // the BuildRuns have already beeing issued for the PipelineRun + triggeredBuilds, err = filter.PipelineRunExtractTriggeredBuildsSlice(&pipelineRun) + if err != nil { + logger.V(0).Error(err, "parsing triggered-builds annotation") + // in case of errors an empty slice takes place, may incur the side effect of issuing + // duplicated BuildRuns + triggeredBuilds = []filter.TriggeredBuild{} + } + + // filtering out the instances that have already been processed, the annotation extracted + // shows which build names and the objectRef employed + if filter.TriggereBuildsContainsObjectRef(triggeredBuilds, buildNames, objectRef) { + logger.V(0).Info("BuildRuns for PipelineRun have already been issued!") + return ctrl.Result{}, nil + } + } else { + logger.V(0).Info("PipelineRun annotated name does not match current object!") + } + logger.V(0).Info("PipelineRun previously triggered builds", "triggered-builds", triggeredBuilds) + + // firing the BuildRun instances for the informed Builds + buildRunsIssued, err := r.issueBuildRunsForPipelineRun(ctx, &pipelineRun, buildNames) + if err != nil { + logger.V(0).Error(err, "trying to issue BuildRun instances", "buildruns", buildRunsIssued) + return ctrl.Result{}, err + } + logger.V(0).Info("BuildRuns issued", "buildruns", buildRunsIssued) + + // updating annotation appending the current state which triggered BuildRuns instances, this + // annotation is later on checked to skip the conditions that already triggered builds + if err = filter.PipelineRunAppendTriggeredBuildsAnnotation( + &pipelineRun, + triggeredBuilds, + buildNames, + objectRef, + ); err != nil { + logger.V(0).Error(err, "trying to updated triggered-builds annotation") + return ctrl.Result{}, err + } + + // updating label registering all BuildRuns issued + filter.AppendIssuedBuildRunsLabel(&pipelineRun, buildRunsIssued) + // annotating object's current name + filter.PipelineRunAnnotateName(&pipelineRun) + + // patching the PipelineRun to reflect labels and annotations needed on the object + if err = r.Client.Patch(ctx, &pipelineRun, client.MergeFrom(originalPipelineRun)); err != nil { + logger.V(0).Error(err, "trying to update PipelineRun metadata") + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +// SetupWithManager uses the manager to watch over PipelineRuns. +func (r *PipelineRunReconciler) SetupWithManager(mgr ctrl.Manager) error { + if r.Clock == nil { + r.Clock = realClock{} + } + + return ctrl.NewControllerManagedBy(mgr). + For(&tknv1beta1.PipelineRun{}). + WithEventFilter(predicate.NewPredicateFuncs(filter.EventFilterPredicate)). + WithEventFilter(predicate.Funcs{ + DeleteFunc: func(e event.DeleteEvent) bool { + return !e.DeleteStateUnknown + }, + }). + Complete(r) +} + +// NewPipelineRunReconciler instantiate the PipelineRunReconciler. +func NewPipelineRunReconciler( + ctrlClient client.Client, + scheme *runtime.Scheme, + buildInventory *inventory.Inventory, +) *PipelineRunReconciler { + return &PipelineRunReconciler{ + Client: ctrlClient, + Scheme: scheme, + buildInventory: buildInventory, + } +} diff --git a/pkg/filter/annotation.go b/pkg/filter/annotation.go new file mode 100644 index 00000000..ff080d74 --- /dev/null +++ b/pkg/filter/annotation.go @@ -0,0 +1,143 @@ +package filter + +import ( + "bytes" + "encoding/json" + "reflect" + + "github.com/shipwright-io/build/pkg/apis/build/v1alpha1" + "github.com/shipwright-io/triggers/pkg/util" + tknv1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" +) + +// TriggeredBuild represents previously triggered builds by storing together the original build name +// and it's objectRef. Both are the criteria needed to find the Builds with matching triggers in the +// Inventory. +type TriggeredBuild struct { + BuildName string `json:"buildName"` + ObjectRef *v1alpha1.WhenObjectRef `json:"objectRef"` +} + +// PipelineRunGetAnnotations extract the annotations, return an empty map otherwise. +func PipelineRunGetAnnotations(pipelineRun *tknv1beta1.PipelineRun) map[string]string { + annotations := pipelineRun.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + return annotations +} + +func PipelineRunAnnotatedNameMatchesObject(pipelineRun *tknv1beta1.PipelineRun) bool { + annotations := PipelineRunGetAnnotations(pipelineRun) + value, ok := annotations[TektonPipelineRunName] + if !ok { + return false + } + return pipelineRun.GetName() == value +} + +func PipelineRunAnnotateName(pipelineRun *tknv1beta1.PipelineRun) { + annotations := PipelineRunGetAnnotations(pipelineRun) + annotations[TektonPipelineRunName] = pipelineRun.GetName() + pipelineRun.SetAnnotations(annotations) +} + +// UnmarshalIntoTriggeredAnnotationSlice executes the un-marshalling of the informed string payload +// into a slice of TriggeredBuild type. JSON validation is strict, returns error on unknown fields. +func UnmarshalIntoTriggeredAnnotationSlice(payload string) ([]TriggeredBuild, error) { + reader := bytes.NewReader([]byte(payload)) + dec := json.NewDecoder(reader) + dec.DisallowUnknownFields() + + var triggeredBuilds []TriggeredBuild + if err := dec.Decode(&triggeredBuilds); err != nil { + return nil, err + } + return triggeredBuilds, nil +} + +// PipelineRunExtractTriggeredBuildsSlice extracts the triggered-builds annotation and returns a +// valid slice of the type. When the annotation is empty, or not present, an empty slice is returned +// instead. +func PipelineRunExtractTriggeredBuildsSlice( + pipelineRun *tknv1beta1.PipelineRun, +) ([]TriggeredBuild, error) { + annotations := PipelineRunGetAnnotations(pipelineRun) + value, ok := annotations[TektonPipelineRunTriggeredBuilds] + if !ok { + return []TriggeredBuild{}, nil + } + return UnmarshalIntoTriggeredAnnotationSlice(value) +} + +// TriggereBuildsContainsObjectRef asserts if the slice contains the informed entry. +func TriggereBuildsContainsObjectRef( + triggeredBuilds []TriggeredBuild, + buildNames []string, + objectRef *v1alpha1.WhenObjectRef, +) bool { + for _, entry := range triggeredBuilds { + // first of all, the build name must be the same + if !util.StringSliceContains(buildNames, entry.BuildName) { + return false + } + + // making sure the objectRef is ready to be compared with incoming struct, and then when both + // entries are the same it asserts the informed objectRef is contained in the slice + if entry.ObjectRef != nil && entry.ObjectRef.Selector == nil { + entry.ObjectRef.Selector = map[string]string{} + } + if reflect.DeepEqual(entry.ObjectRef, objectRef) { + return true + } + } + return false +} + +// AppendIntoTriggeredBuildSliceAsAnnotation appends the build names with the objectRef into the +// informed triggered-builds slice, the payload returned is marshalled JSON which can emit errors. +func AppendIntoTriggeredBuildSliceAsAnnotation( + triggeredBuilds []TriggeredBuild, + buildNames []string, + objectRef *v1alpha1.WhenObjectRef, +) (string, error) { + for _, buildName := range buildNames { + entry := TriggeredBuild{ + BuildName: buildName, + ObjectRef: objectRef, + } + triggeredBuilds = append(triggeredBuilds, entry) + } + + annotationBytes, err := json.Marshal(triggeredBuilds) + if err != nil { + return "", err + } + return string(annotationBytes), nil +} + +// PipelineRunAppendTriggeredBuildsAnnotation set or update the triggered-builds annotation. +func PipelineRunAppendTriggeredBuildsAnnotation( + pipelineRun *tknv1beta1.PipelineRun, + triggeredBuilds []TriggeredBuild, + buildNames []string, + objectRef *v1alpha1.WhenObjectRef, +) error { + annotations := pipelineRun.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + + // annotating PipelineRun with the meta-information about which Builds have been triggered, and + // later on this information is used to filter out objects which have already been processed + triggeredBuildsAnnotation, err := AppendIntoTriggeredBuildSliceAsAnnotation( + triggeredBuilds, buildNames, objectRef) + if err != nil { + return err + } + annotations[TektonPipelineRunTriggeredBuilds] = triggeredBuildsAnnotation + + // updating the instance to reflect the annotations + pipelineRun.SetAnnotations(annotations) + return nil +} diff --git a/pkg/filter/annotation_test.go b/pkg/filter/annotation_test.go new file mode 100644 index 00000000..9c890c46 --- /dev/null +++ b/pkg/filter/annotation_test.go @@ -0,0 +1,180 @@ +package filter + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/shipwright-io/build/pkg/apis/build/v1alpha1" + "github.com/shipwright-io/triggers/test/stubs" + tknv1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" +) + +func TestPipelineRunExtractTriggeredBuildsSlice(t *testing.T) { + // PipelineRun with a bogus annotation payload, instead of valid JSON + pipelineRunWithBogusAnnotation := stubs.TektonPipelineRun("pipeline") + pipelineRunWithBogusAnnotation.SetAnnotations(map[string]string{ + TektonPipelineRunTriggeredBuilds: "bogus stuff", + }) + + // PipelineRun with valid triggered-builds annotation, JSON payload + pipelineRunWithTriggeredBuildsAnnotation := stubs.TektonPipelineRun("pipeline") + triggeredBuilds := []TriggeredBuild{{ + BuildName: "build", + ObjectRef: &v1alpha1.WhenObjectRef{}, + }} + triggeredBuildsAnnotationBytes, err := json.Marshal(triggeredBuilds) + if err != nil { + t.Errorf("failed to prepare triggered-builds JSON representation: %q", err.Error()) + } + pipelineRunWithTriggeredBuildsAnnotation.SetAnnotations(map[string]string{ + TektonPipelineRunTriggeredBuilds: string(triggeredBuildsAnnotationBytes), + }) + + tests := []struct { + name string + pipelineRun tknv1beta1.PipelineRun + want []TriggeredBuild + wantErr bool + }{{ + name: "PipelineRun without triggered-builds annotation", + pipelineRun: stubs.TektonPipelineRun("pipeline"), + want: []TriggeredBuild{}, + wantErr: false, + }, { + name: "PipelineRun with bogus annotation", + pipelineRun: pipelineRunWithBogusAnnotation, + want: nil, + wantErr: true, + }, { + name: "PipelineRun with triggered-builds annotation", + pipelineRun: pipelineRunWithTriggeredBuildsAnnotation, + want: triggeredBuilds, + wantErr: false, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := PipelineRunExtractTriggeredBuildsSlice(&tt.pipelineRun) + if (err != nil) != tt.wantErr { + t.Errorf("PipelineRunExtractTriggeredBuildsSlice() = %#v, error = %v, wantErr %v", + got, err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("PipelineRunExtractTriggeredBuildsSlice() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestTriggereBuildsContainsObjectRef(t *testing.T) { + tests := []struct { + name string + triggeredBuilds []TriggeredBuild + buildNames []string + objectRef *v1alpha1.WhenObjectRef + want bool + }{{ + name: "triggered builds contains objectRef", + triggeredBuilds: []TriggeredBuild{{ + BuildName: "build", + ObjectRef: stubs.TriggerWhenPipelineSucceeded.ObjectRef.DeepCopy(), + }}, + buildNames: []string{"build"}, + objectRef: stubs.TriggerWhenPipelineSucceeded.ObjectRef.DeepCopy(), + want: true, + }, { + name: "empty triggered builds does not contain objectRef", + triggeredBuilds: []TriggeredBuild{}, + buildNames: []string{"build"}, + objectRef: stubs.TriggerWhenPipelineSucceeded.ObjectRef.DeepCopy(), + want: false, + }, { + name: "triggered builds does not contain objectRef build name", + triggeredBuilds: []TriggeredBuild{{ + BuildName: "another-build", + ObjectRef: stubs.TriggerWhenPipelineSucceeded.ObjectRef.DeepCopy(), + }}, + buildNames: []string{"build"}, + objectRef: stubs.TriggerWhenPipelineSucceeded.ObjectRef.DeepCopy(), + want: false, + }, { + name: "triggered builds does not contain objectRef", + triggeredBuilds: []TriggeredBuild{{ + BuildName: "build", + ObjectRef: stubs.TriggerWhenPushToMain.ObjectRef.DeepCopy(), + }}, + buildNames: []string{"build"}, + objectRef: stubs.TriggerWhenPipelineSucceeded.ObjectRef.DeepCopy(), + want: false, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TriggereBuildsContainsObjectRef( + tt.triggeredBuilds, + tt.buildNames, + tt.objectRef, + ) + if got != tt.want { + t.Errorf("TriggereBuildsContainsObjectRef() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAppendIntoTriggeredBuildSliceAsAnnotation(t *testing.T) { + tests := []struct { + name string + triggeredBuilds []TriggeredBuild + buildNames []string + objectRef *v1alpha1.WhenObjectRef + want string + wantErr bool + }{{ + name: "empty inputs", + triggeredBuilds: []TriggeredBuild{}, + buildNames: []string{}, + objectRef: &v1alpha1.WhenObjectRef{}, + want: "[]", + wantErr: false, + }, { + name: "empty triggered-builds with a single build", + triggeredBuilds: []TriggeredBuild{}, + buildNames: []string{"build"}, + objectRef: &v1alpha1.WhenObjectRef{}, + want: "[{\"buildName\":\"build\",\"objectRef\":{}}]", + wantErr: false, + }, { + name: "single triggered-build with single build", + triggeredBuilds: []TriggeredBuild{{ + BuildName: "previous-build", + ObjectRef: &v1alpha1.WhenObjectRef{}, + }}, + buildNames: []string{"build"}, + objectRef: &v1alpha1.WhenObjectRef{}, + want: "[{\"buildName\":\"previous-build\",\"objectRef\":{}}," + + "{\"buildName\":\"build\",\"objectRef\":{}}]", + wantErr: false, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := AppendIntoTriggeredBuildSliceAsAnnotation( + tt.triggeredBuilds, + tt.buildNames, + tt.objectRef, + ) + if (err != nil) != tt.wantErr { + t.Errorf("AppendIntoTriggeredBuildSliceAsAnnotation() error = %v, wantErr %v", + err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("AppendIntoTriggeredBuildSliceAsAnnotation() = %v, want %v", + got, tt.want) + } + }) + } +} diff --git a/pkg/filter/label.go b/pkg/filter/label.go new file mode 100644 index 00000000..d3dc264d --- /dev/null +++ b/pkg/filter/label.go @@ -0,0 +1,36 @@ +package filter + +import ( + "strings" + + "github.com/shipwright-io/triggers/pkg/util" + tknv1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" +) + +// PipelineRunGetLabels extract labels from informed object, returns an empty map when `nil` labels. +func PipelineRunGetLabels(pipelineRun *tknv1beta1.PipelineRun) map[string]string { + labels := pipelineRun.GetLabels() + if labels == nil { + labels = map[string]string{} + } + return labels +} + +// AppendIssuedBuildRunsLabel update or add the label to document the BuildRuns issued for the +// PipelineRun instance informed. +func AppendIssuedBuildRunsLabel(pipelineRun *tknv1beta1.PipelineRun, buildRunsIssued []string) { + labels := PipelineRunGetLabels(pipelineRun) + + // contains all BuildRuns issued for the PipelineRun instance + pipelineRunBuildRunsIssued := []string{} + + // extracting existing label value to append the BuildRuns issued + label := labels[BuildRunsCreated] + if label != "" { + pipelineRunBuildRunsIssued = strings.Split(label, ",") + } + pipelineRunBuildRunsIssued = append(pipelineRunBuildRunsIssued, buildRunsIssued...) + + labels[BuildRunsCreated] = util.JoinReversedStringSliceForK8s(pipelineRunBuildRunsIssued) + pipelineRun.SetLabels(labels) +} diff --git a/pkg/filter/label_test.go b/pkg/filter/label_test.go new file mode 100644 index 00000000..48907fba --- /dev/null +++ b/pkg/filter/label_test.go @@ -0,0 +1,49 @@ +package filter + +import ( + "testing" + + "github.com/shipwright-io/triggers/test/stubs" + tknv1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" +) + +func TestAppendIssuedBuildRunsLabel(t *testing.T) { + pipelineRunLabeled := stubs.TektonPipelineRun("pipeline") + pipelineRunLabeled.SetLabels(map[string]string{ + BuildRunsCreated: "existing-buildrun", + }) + + tests := []struct { + name string + pipelineRun tknv1beta1.PipelineRun + buildRunsIssued []string + want string + }{{ + name: "PipelineRun without BuildRun labeled", + pipelineRun: stubs.TektonPipelineRun("pipeline"), + buildRunsIssued: []string{"buildrun"}, + want: "buildrun", + }, { + name: "PipelineRun with BuildRun labeled", + pipelineRun: pipelineRunLabeled, + buildRunsIssued: []string{"buildrun"}, + want: "buildrun,existing-buildrun", + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + AppendIssuedBuildRunsLabel(&tt.pipelineRun, tt.buildRunsIssued) + labels := PipelineRunGetLabels(&tt.pipelineRun) + got, ok := labels[BuildRunsCreated] + if !ok { + t.Errorf( + "AppendIssuedBuildRunsLabel() doesn't have the expected label %q", + BuildRunsCreated, + ) + } + if got != tt.want { + t.Errorf("AppendIssuedBuildRunsLabel() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/filter/pipelinerun.go b/pkg/filter/pipelinerun.go new file mode 100644 index 00000000..da8b9876 --- /dev/null +++ b/pkg/filter/pipelinerun.go @@ -0,0 +1,156 @@ +package filter + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/go-logr/logr" + "github.com/shipwright-io/build/pkg/apis/build/v1alpha1" + "github.com/shipwright-io/triggers/pkg/constants" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + tknv1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/clock" + "knative.dev/pkg/apis" +) + +// Prefix prefix used in all annotations. +const Prefix = "triggers.shipwright.io" + +var ( + // OwnedByTektonRun annotates the BuildRun as owned by Tekton Run. + OwnedByTektonRun = fmt.Sprintf("%s/owned-by-run", Prefix) + // OwnedByTektonPipelineRun lables the BuildRun as owned by Tekton PipelineRun. + OwnedByTektonPipelineRun = fmt.Sprintf("%s/owned-by-pipelinerun", Prefix) + // BuildRunsCreated annotates the PipelineRun with the BuildRuns created. + BuildRunsCreated = fmt.Sprintf("%s/buildrun-names", Prefix) + + // TektonPipelineRunName annotates PipelineRuns with its current name, avoid object reprocessing. + TektonPipelineRunName = fmt.Sprintf("%s/pipelinerun-name", Prefix) + // TektonPipelineRunTriggeredBuilds contains references for all Builds triggered, JSON formatted + TektonPipelineRunTriggeredBuilds = fmt.Sprintf("%s/pipelinerun-triggered-builds", Prefix) +) + +// searchBuildRunForRunOwner inspect the object owners for Tekton Run and returns it, otherwise nil. +func searchBuildRunForRunOwner(br *v1alpha1.BuildRun) *types.NamespacedName { + for _, ownerRef := range br.OwnerReferences { + if ownerRef.APIVersion == constants.TektonAPIv1alpha1 && ownerRef.Kind == "Run" { + return &types.NamespacedName{Namespace: br.GetNamespace(), Name: ownerRef.Name} + } + } + return nil +} + +// filterBuildRunOwnedByRun filter out BuildRuns objects not owned by Tekton Run. +func filterBuildRunOwnedByRun(obj interface{}) bool { + br, ok := obj.(*v1alpha1.BuildRun) + if !ok { + return false + } + return searchBuildRunForRunOwner(br) != nil +} + +// pipelineRunReferencesShipwright checks if the informed PipelineRun is reffering to a Shipwright +// resource via TaskRef. +func pipelineRunReferencesShipwright(pipelineRun *tknv1beta1.PipelineRun) bool { + if pipelineRun.Status.PipelineSpec == nil { + return false + } + for _, task := range pipelineRun.Status.PipelineSpec.Tasks { + if task.TaskRef == nil { + continue + } + if task.TaskRef.APIVersion == constants.ShipwrightAPIVersion { + return true + } + } + return false +} + +// EventFilterPredicate predicate filter for the basic inspections in the object, filtering only what +// needs to go through reconciliation. PipelineRun objects referencing Custom-Tasks are also skipped. +func EventFilterPredicate(obj client.Object) bool { + logger := logr.New(log.Log.GetSink()). + WithValues("namespace", obj.GetNamespace(), "name", obj.GetName()) + + pipelineRun, ok := obj.(*tknv1beta1.PipelineRun) + if !ok { + logger.V(0).Error(nil, "Unable to cast object as Tekton PipelineRun") + return false + } + + if !pipelineRun.ObjectMeta.DeletionTimestamp.IsZero() { + logger.V(0).Info("Marked for deletion") + return false + } + + if pipelineRun.Spec.PipelineRef == nil { + logger.V(0).Info("Skipping due nil .Spec.PipelineRef") + return false + } + + if pipelineRun.Status.PipelineSpec == nil { + logger.V(0).Info("Skipping due to nil .Status.PipelineSpec") + return false + } + + if pipelineRunReferencesShipwright(pipelineRun) { + logger.V(0).Info("Skipping due to being part of a Custom-Task") + return false + } + return true +} + +// ParsePipelineRunStatus parte the informed object status to extract its status. +func ParsePipelineRunStatus( + ctx context.Context, + now time.Time, + pipelineRun *tknv1beta1.PipelineRun, +) (string, error) { + switch { + case pipelineRun.IsDone(): + if pipelineRun.Status.GetCondition(apis.ConditionSucceeded).IsTrue() { + return tknv1beta1.PipelineRunReasonSuccessful.String(), nil + } + return tknv1beta1.PipelineRunReasonFailed.String(), nil + case pipelineRun.IsCancelled(): + return tknv1beta1.PipelineRunReasonCancelled.String(), nil + case pipelineRun.HasTimedOut(ctx, clock.NewFakePassiveClock(now)): + return "TimedOut", nil + case pipelineRun.HasStarted(): + return tknv1beta1.PipelineRunReasonStarted.String(), nil + default: + return "", fmt.Errorf("unable to parse pipelinerun %q current status", + pipelineRun.GetNamespacedName()) + } +} + +// PipelineRunToObjectRef transforms the informed PipelineRun instance to a ObjectRef. +func PipelineRunToObjectRef( + ctx context.Context, + now time.Time, + pipelineRun *tknv1beta1.PipelineRun, +) (*v1alpha1.WhenObjectRef, error) { + status, err := ParsePipelineRunStatus(ctx, now, pipelineRun) + if err != nil { + return nil, err + } + + // sanitizing label set to not use the labels added by triggers + labels := PipelineRunGetLabels(pipelineRun) + for k := range labels { + if strings.HasPrefix(k, Prefix) { + delete(labels, k) + } + } + + return &v1alpha1.WhenObjectRef{ + Name: pipelineRun.Spec.PipelineRef.Name, + Status: []string{status}, + Selector: labels, + }, nil +} diff --git a/pkg/filter/pipelinerun_test.go b/pkg/filter/pipelinerun_test.go new file mode 100644 index 00000000..5d6ee401 --- /dev/null +++ b/pkg/filter/pipelinerun_test.go @@ -0,0 +1,157 @@ +package filter + +import ( + "context" + "reflect" + "testing" + "time" + + "github.com/shipwright-io/build/pkg/apis/build/v1alpha1" + "github.com/shipwright-io/triggers/pkg/constants" + "github.com/shipwright-io/triggers/test/stubs" + + tknv1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func Test_searchBuildRunForRunOwner(t *testing.T) { + tests := []struct { + name string + br *v1alpha1.BuildRun + want *types.NamespacedName + }{{ + name: "buildrun not owned by tekton run", + br: &v1alpha1.BuildRun{ + ObjectMeta: metav1.ObjectMeta{ + OwnerReferences: []metav1.OwnerReference{}, + }, + }, + want: nil, + }, { + name: "buildrun owned by tekton run", + br: &v1alpha1.BuildRun{ + ObjectMeta: metav1.ObjectMeta{ + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: constants.TektonAPIv1alpha1, + Kind: "Run", + Name: "run", + }}, + Namespace: "namespace", + Name: "buildrun", + }, + }, + want: &types.NamespacedName{Namespace: "namespace", Name: "run"}, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := searchBuildRunForRunOwner(tt.br); !reflect.DeepEqual(got, tt.want) { + t.Errorf("searchBuildRunForRunOwner() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_pipelineRunReferencesShipwright(t *testing.T) { + tests := []struct { + name string + pipelineRun *tknv1beta1.PipelineRun + want bool + }{{ + name: "pipelinerun has status.pipelinespec nil", + pipelineRun: &tknv1beta1.PipelineRun{ + Status: tknv1beta1.PipelineRunStatus{ + PipelineRunStatusFields: tknv1beta1.PipelineRunStatusFields{ + PipelineSpec: nil, + }, + }, + }, + want: false, + }, { + name: "pipelinerun does not references shipwright build", + pipelineRun: &tknv1beta1.PipelineRun{ + Status: tknv1beta1.PipelineRunStatus{ + PipelineRunStatusFields: tknv1beta1.PipelineRunStatusFields{ + PipelineSpec: &tknv1beta1.PipelineSpec{ + Tasks: []tknv1beta1.PipelineTask{{}}, + }, + }, + }, + }, + want: false, + }, { + name: "pipelinerun references shipwright build", + pipelineRun: &tknv1beta1.PipelineRun{ + Status: tknv1beta1.PipelineRunStatus{ + PipelineRunStatusFields: tknv1beta1.PipelineRunStatusFields{ + PipelineSpec: &tknv1beta1.PipelineSpec{ + Tasks: []tknv1beta1.PipelineTask{{ + Name: "task", + TaskRef: &tknv1beta1.TaskRef{ + Name: "shipwright-build", + APIVersion: constants.ShipwrightAPIVersion, + Kind: "Build", + }, + }}, + }, + }, + }, + }, + want: true, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := pipelineRunReferencesShipwright(tt.pipelineRun); got != tt.want { + t.Errorf("pipelineRunReferencesShipwright() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParsePipelineRunStatus(t *testing.T) { + tests := []struct { + name string + pipelineRun tknv1beta1.PipelineRun + want string + wantErr bool + }{{ + name: "cancelled", + pipelineRun: stubs.TektonPipelineRunCanceled("name"), + want: "Cancelled", + wantErr: false, + }, { + name: "started", + pipelineRun: stubs.TektonPipelineRunRunning("name"), + want: "Started", + wantErr: false, + }, { + name: "timedout", + pipelineRun: stubs.TektonPipelineRunTimedOut("name"), + want: "TimedOut", + wantErr: false, + }, { + name: "succeeded", + pipelineRun: stubs.TektonPipelineRunSucceeded("name"), + want: "Succeeded", + wantErr: false, + }, { + name: "failed", + pipelineRun: stubs.TektonPipelineRunFailed("name"), + want: "Failed", + wantErr: false, + }} + + ctx := context.Background() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParsePipelineRunStatus(ctx, time.Now(), &tt.pipelineRun) + if (err != nil) != tt.wantErr { + t.Errorf("ParsePipelineRunStatus() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ParsePipelineRunStatus() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/test/stubs/tekton.go b/test/stubs/tekton.go new file mode 100644 index 00000000..90c16acb --- /dev/null +++ b/test/stubs/tekton.go @@ -0,0 +1,112 @@ +package stubs + +import ( + "fmt" + "time" + + "github.com/shipwright-io/triggers/pkg/constants" + + tknv1alpha1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1" + tknv1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var TektonPipelineRunStatusCustomTaskShipwright = &tknv1beta1.PipelineSpec{ + Tasks: []tknv1beta1.PipelineTask{TektonPipelineTaskRefToShipwright}, +} + +var TektonPipelineTaskRefToShipwright = tknv1beta1.PipelineTask{ + Name: "shipwright", + TaskRef: &tknv1beta1.TaskRef{ + APIVersion: constants.ShipwrightAPIVersion, + Name: "name", + }, +} + +var TektonTaskRefToShipwright = &tknv1beta1.TaskRef{ + APIVersion: constants.ShipwrightAPIVersion, + Kind: "Build", + Name: "shipwright-ex", +} + +var TektonTaskRefToTekton = &tknv1beta1.TaskRef{ + Name: "task-ex", +} + +func TektonRun(name string, ref *tknv1beta1.TaskRef) tknv1alpha1.Run { + return tknv1alpha1.Run{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: Namespace, + Name: name, + }, + Spec: tknv1alpha1.RunSpec{ + Ref: ref, + }, + } +} + +func TektonPipelineRunCanceled(name string) tknv1beta1.PipelineRun { + pipelineRun := TektonPipelineRun(name) + pipelineRun.Spec.Status = tknv1beta1.PipelineRunSpecStatus( + tknv1beta1.PipelineRunReasonCancelled, + ) + pipelineRun.Status.PipelineRunStatusFields = tknv1beta1.PipelineRunStatusFields{ + PipelineSpec: &tknv1beta1.PipelineSpec{Description: "testing"}, + } + return pipelineRun +} + +func TektonPipelineRunRunning(name string) tknv1beta1.PipelineRun { + pipelineRun := TektonPipelineRun(name) + pipelineRun.Status.StartTime = &metav1.Time{Time: time.Now()} + pipelineRun.Status.PipelineRunStatusFields = tknv1beta1.PipelineRunStatusFields{ + StartTime: &metav1.Time{Time: time.Now()}, + PipelineSpec: &tknv1beta1.PipelineSpec{Description: "testing"}, + } + return pipelineRun +} + +func TektonPipelineRunTimedOut(name string) tknv1beta1.PipelineRun { + pipelineRun := TektonPipelineRun(name) + pipelineRun.Spec.Timeout = &metav1.Duration{Duration: time.Second} + pipelineRun.Status.PipelineRunStatusFields = tknv1beta1.PipelineRunStatusFields{ + StartTime: &metav1.Time{ + Time: time.Date(1982, time.January, 1, 0, 0, 0, 0, time.Local), + }, + PipelineSpec: &tknv1beta1.PipelineSpec{Description: "testing"}, + } + return pipelineRun +} + +func TektonPipelineRunSucceeded(name string) tknv1beta1.PipelineRun { + pipelineRun := TektonPipelineRun(name) + pipelineRun.Status.MarkSucceeded("Succeeded", fmt.Sprintf("PipelineRun %q has succeeded", name)) + pipelineRun.Status.PipelineRunStatusFields = tknv1beta1.PipelineRunStatusFields{ + PipelineSpec: &tknv1beta1.PipelineSpec{Description: "testing"}, + } + return pipelineRun +} + +func TektonPipelineRunFailed(name string) tknv1beta1.PipelineRun { + pipelineRun := TektonPipelineRun(name) + pipelineRun.Status.MarkFailed("Failed", fmt.Sprintf("PipelineRun %q has failed", name)) + pipelineRun.Status.PipelineRunStatusFields = tknv1beta1.PipelineRunStatusFields{ + PipelineSpec: &tknv1beta1.PipelineSpec{Description: "testing"}, + } + return pipelineRun +} + +func TektonPipelineRun(name string) tknv1beta1.PipelineRun { + return tknv1beta1.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: Namespace, + Name: name, + }, + Spec: tknv1beta1.PipelineRunSpec{ + PipelineRef: &tknv1beta1.PipelineRef{ + Name: name, + }, + }, + Status: tknv1beta1.PipelineRunStatus{}, + } +}