diff --git a/.goreleaser.yaml b/.goreleaser.yaml index aee224001..fba022da8 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -11,7 +11,6 @@ builds: - -X ${PACKAGE}/internal/version.BuildTimestamp=${BUILD_TIMESTAMP} goos: - linux - - darwin main: ./ binary: burrito archives: diff --git a/Makefile b/Makefile index dfbaea4ec..6955789b9 100644 --- a/Makefile +++ b/Makefile @@ -180,7 +180,7 @@ ENVTEST ?= $(LOCALBIN)/setup-envtest ## Tool Versions KUSTOMIZE_VERSION ?= v3.8.7 -CONTROLLER_TOOLS_VERSION ?= v0.10.0 +CONTROLLER_TOOLS_VERSION ?= v0.11.2 KUSTOMIZE_INSTALL_SCRIPT ?= "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" .PHONY: kustomize diff --git a/api/v1alpha1/terraformlayer_types.go b/api/v1alpha1/terraformlayer_types.go index b0185ef17..531e1151b 100644 --- a/api/v1alpha1/terraformlayer_types.go +++ b/api/v1alpha1/terraformlayer_types.go @@ -33,12 +33,10 @@ type TerraformLayerSpec struct { TerraformConfig TerraformConfig `json:"terraform,omitempty"` Repository TerraformLayerRepository `json:"repository,omitempty"` RemediationStrategy RemediationStrategy `json:"remediationStrategy,omitempty"` - PlanOnPullRequest bool `json:"planOnPullRequest,omitempty"` OverrideRunnerSpec OverrideRunnerSpec `json:"overrideRunnerSpec,omitempty"` } type TerraformLayerRepository struct { - Kind string `json:"kind,omitempty"` Name string `json:"name,omitempty"` Namespace string `json:"namespace,omitempty"` } diff --git a/api/v1alpha1/terraformpullrequest_types.go b/api/v1alpha1/terraformpullrequest_types.go new file mode 100644 index 000000000..1d3730cf3 --- /dev/null +++ b/api/v1alpha1/terraformpullrequest_types.go @@ -0,0 +1,69 @@ +/* +Copyright 2022. + +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 v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// TerraformPullRequestSpec defines the desired state of TerraformPullRequest +type TerraformPullRequestSpec struct { + Provider string `json:"provider,omitempty"` + Branch string `json:"branch,omitempty"` + Base string `json:"base,omitempty"` + ID string `json:"id,omitempty"` + Repository TerraformLayerRepository `json:"repository,omitempty"` +} + +// TerraformPullRequestStatus defines the observed state of TerraformPullRequest +type TerraformPullRequestStatus struct { + Conditions []metav1.Condition `json:"conditions,omitempty"` + State string `json:"state,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:shortName=pr;prs;pullrequest;pullrequests; +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="ID",type=string,JSONPath=`.spec.id` +// +kubebuilder:printcolumn:name="State",type=string,JSONPath=`.status.state` +// +kubebuilder:printcolumn:name="Provider",type=string,JSONPath=`.spec.provider` +// +kubebuilder:printcolumn:name="Base",type=string,JSONPath=`.spec.base` +// +kubebuilder:printcolumn:name="Branch",type=string,JSONPath=`.spec.branch` +// TerraformPullRequest is the Schema for the TerraformPullRequests API +type TerraformPullRequest struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec TerraformPullRequestSpec `json:"spec,omitempty"` + Status TerraformPullRequestStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// TerraformPullRequestList contains a list of TerraformPullRequest +type TerraformPullRequestList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []TerraformPullRequest `json:"items"` +} + +func init() { + SchemeBuilder.Register(&TerraformPullRequest{}, &TerraformPullRequestList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ee1987074..304aa2650 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -250,6 +250,103 @@ func (in *TerraformLayerStatus) DeepCopy() *TerraformLayerStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TerraformPullRequest) DeepCopyInto(out *TerraformPullRequest) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerraformPullRequest. +func (in *TerraformPullRequest) DeepCopy() *TerraformPullRequest { + if in == nil { + return nil + } + out := new(TerraformPullRequest) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TerraformPullRequest) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TerraformPullRequestList) DeepCopyInto(out *TerraformPullRequestList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]TerraformPullRequest, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerraformPullRequestList. +func (in *TerraformPullRequestList) DeepCopy() *TerraformPullRequestList { + if in == nil { + return nil + } + out := new(TerraformPullRequestList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TerraformPullRequestList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TerraformPullRequestSpec) DeepCopyInto(out *TerraformPullRequestSpec) { + *out = *in + out.Repository = in.Repository +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerraformPullRequestSpec. +func (in *TerraformPullRequestSpec) DeepCopy() *TerraformPullRequestSpec { + if in == nil { + return nil + } + out := new(TerraformPullRequestSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TerraformPullRequestStatus) DeepCopyInto(out *TerraformPullRequestStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerraformPullRequestStatus. +func (in *TerraformPullRequestStatus) DeepCopy() *TerraformPullRequestStatus { + if in == nil { + return nil + } + out := new(TerraformPullRequestStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TerraformRepository) DeepCopyInto(out *TerraformRepository) { *out = *in diff --git a/cmd/controllers/start.go b/cmd/controllers/start.go index 846eb56c2..2505073b0 100644 --- a/cmd/controllers/start.go +++ b/cmd/controllers/start.go @@ -27,7 +27,7 @@ func buildControllersStartCmd(app *burrito.App) *cobra.Command { defaultOnErrorTimer, _ := time.ParseDuration("1m") defaultWaitActionTimer, _ := time.ParseDuration("1m") - cmd.Flags().StringSliceVar(&app.Config.Controller.Types, "types", []string{"layer", "repository"}, "list of controllers to start") + cmd.Flags().StringSliceVar(&app.Config.Controller.Types, "types", []string{"layer", "repository", "pullrequest"}, "list of controllers to start") cmd.Flags().DurationVar(&app.Config.Controller.Timers.DriftDetection, "drift-detection-period", defaultDriftDetectionTimer, "period between two plans. Must end with s, m or h.") cmd.Flags().DurationVar(&app.Config.Controller.Timers.OnError, "on-error-period", defaultOnErrorTimer, "period between two runners launch when an error occurred. Must end with s, m or h.") diff --git a/config/crd/bases/config.terraform.padok.cloud_terraformlayers.yaml b/config/crd/bases/config.terraform.padok.cloud_terraformlayers.yaml index 1275c204b..3c9ff5237 100644 --- a/config/crd/bases/config.terraform.padok.cloud_terraformlayers.yaml +++ b/config/crd/bases/config.terraform.padok.cloud_terraformlayers.yaml @@ -259,7 +259,9 @@ spec: - name type: object type: array - x-kubernetes-list-type: set + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map limits: additionalProperties: anyOf: @@ -965,7 +967,9 @@ spec: - name type: object type: array - x-kubernetes-list-type: set + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map limits: additionalProperties: anyOf: @@ -1983,8 +1987,6 @@ spec: type: object path: type: string - planOnPullRequest: - type: boolean remediationStrategy: enum: - dry @@ -1992,8 +1994,6 @@ spec: type: string repository: properties: - kind: - type: string name: type: string namespace: diff --git a/config/crd/bases/config.terraform.padok.cloud_terraformpullrequests.yaml b/config/crd/bases/config.terraform.padok.cloud_terraformpullrequests.yaml new file mode 100644 index 000000000..d9747bfe3 --- /dev/null +++ b/config/crd/bases/config.terraform.padok.cloud_terraformpullrequests.yaml @@ -0,0 +1,155 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: terraformpullrequests.config.terraform.padok.cloud +spec: + group: config.terraform.padok.cloud + names: + kind: TerraformPullRequest + listKind: TerraformPullRequestList + plural: terraformpullrequests + shortNames: + - pr + - prs + - pullrequest + - pullrequests + singular: terraformpullrequest + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.id + name: ID + type: string + - jsonPath: .status.state + name: State + type: string + - jsonPath: .spec.provider + name: Provider + type: string + - jsonPath: .spec.base + name: Base + type: string + - jsonPath: .spec.branch + name: Branch + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: TerraformPullRequest is the Schema for the TerraformPullRequests + API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: TerraformPullRequestSpec defines the desired state of TerraformPullRequest + properties: + base: + type: string + branch: + type: string + id: + type: string + provider: + type: string + repository: + properties: + name: + type: string + namespace: + type: string + type: object + type: object + status: + description: TerraformPullRequestStatus defines the observed state of + TerraformPullRequest + properties: + conditions: + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + state: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/config.terraform.padok.cloud_terraformrepositories.yaml b/config/crd/bases/config.terraform.padok.cloud_terraformrepositories.yaml index 34e1a610a..a69bc5338 100644 --- a/config/crd/bases/config.terraform.padok.cloud_terraformrepositories.yaml +++ b/config/crd/bases/config.terraform.padok.cloud_terraformrepositories.yaml @@ -247,7 +247,9 @@ spec: - name type: object type: array - x-kubernetes-list-type: set + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map limits: additionalProperties: anyOf: @@ -953,7 +955,9 @@ spec: - name type: object type: array - x-kubernetes-list-type: set + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map limits: additionalProperties: anyOf: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 693979b67..4988a78a8 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -31,6 +31,32 @@ rules: - get - patch - update +- apiGroups: + - config.terraform.padok.cloud + resources: + - terraformpullrequests + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - config.terraform.padok.cloud + resources: + - terraformpullrequests/finalizers + verbs: + - update +- apiGroups: + - config.terraform.padok.cloud + resources: + - terraformpullrequests/status + verbs: + - get + - patch + - update - apiGroups: - config.terraform.padok.cloud resources: diff --git a/go.mod b/go.mod index 07d49841a..1036a214b 100644 --- a/go.mod +++ b/go.mod @@ -3,25 +3,28 @@ module github.com/padok-team/burrito go 1.19 require ( - github.com/go-playground/webhooks/v6 v6.0.1 - github.com/onsi/ginkgo/v2 v2.6.0 - github.com/onsi/gomega v1.24.1 + github.com/hashicorp/go-multierror v1.1.1 + github.com/hashicorp/terraform-json v0.14.0 + github.com/onsi/ginkgo/v2 v2.6.1 + github.com/onsi/gomega v1.24.2 github.com/sirupsen/logrus v1.8.1 - k8s.io/apimachinery v0.26.0 - k8s.io/client-go v0.26.0 + k8s.io/apimachinery v0.26.1 + k8s.io/client-go v0.26.1 sigs.k8s.io/controller-runtime v0.14.0 ) require ( github.com/Microsoft/go-winio v0.5.2 // indirect - github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect github.com/acomagu/bufpipe v1.0.3 // indirect github.com/cloudflare/circl v1.1.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.0 // indirect github.com/go-git/go-billy/v5 v5.3.1 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/terraform-json v0.14.0 // indirect + github.com/hashicorp/go-retryablehttp v0.7.2 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/pjbgf/sha1cd v0.2.3 // indirect @@ -46,12 +49,14 @@ require ( github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/swag v0.19.14 // indirect + github.com/go-playground/webhooks v5.17.0+incompatible github.com/go-redis/redis/v8 v8.11.5 github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect github.com/google/gnostic v0.5.7-v3refs // indirect github.com/google/go-cmp v0.5.9 // indirect + github.com/google/go-github/v50 v50.2.0 github.com/google/gofuzz v1.1.0 // indirect github.com/google/uuid v1.3.0 // indirect github.com/hashicorp/go-version v1.6.0 @@ -83,26 +88,27 @@ require ( github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.14.0 github.com/subosito/gotenv v1.4.1 // indirect + github.com/xanzy/go-gitlab v0.81.0 go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.8.0 // indirect go.uber.org/zap v1.24.0 // indirect - golang.org/x/crypto v0.3.0 // indirect - golang.org/x/net v0.3.1-0.20221206200815-1e63c2f08a10 // indirect - golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 // indirect - golang.org/x/sys v0.3.0 // indirect - golang.org/x/term v0.3.0 // indirect - golang.org/x/text v0.5.0 // indirect + golang.org/x/crypto v0.7.0 // indirect + golang.org/x/net v0.8.0 // indirect + golang.org/x/oauth2 v0.6.0 + golang.org/x/sys v0.6.0 // indirect + golang.org/x/term v0.6.0 // indirect + golang.org/x/text v0.8.0 // indirect golang.org/x/time v0.3.0 // indirect gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/protobuf v1.28.1 // indirect + google.golang.org/protobuf v1.29.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.26.0 - k8s.io/apiextensions-apiserver v0.26.0 // indirect - k8s.io/component-base v0.26.0 // indirect + k8s.io/api v0.26.1 + k8s.io/apiextensions-apiserver v0.26.1 // indirect + k8s.io/component-base v0.26.1 // indirect k8s.io/klog/v2 v2.80.1 // indirect k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 // indirect k8s.io/utils v0.0.0-20221128185143-99ec85e7a448 // indirect diff --git a/go.sum b/go.sum index 0f60e61c9..a8dd384c8 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,9 @@ github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugX github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= -github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4 h1:ra2OtmuW0AE5csawV4YXMNGNQQXvLRps3z2Z59OPO+I= github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4/go.mod h1:UBYPn8k0D56RtnR8RFQMjmh4KrZzWJ5o7Z9SYjossQ8= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk= github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -102,6 +103,7 @@ github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMi github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= @@ -145,12 +147,11 @@ github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXym github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng= github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= -github.com/go-playground/webhooks/v6 v6.0.1 h1:ssqgU7vZ+xK+/Uwx4zkf5tfmzOHnLBpzSp5bJ4cX3rg= -github.com/go-playground/webhooks/v6 v6.0.1/go.mod h1:GCocmfMtpJdkEOM1uG9p2nXzg1kY5X/LtvQgtPHUaaA= +github.com/go-playground/webhooks v5.17.0+incompatible h1:Ea3zLJXlnlIFweIujDxdneq512xO4k9cYwAuZ3VuPJo= +github.com/go-playground/webhooks v5.17.0+incompatible/go.mod h1:rMsxoY7bQzIPF9Ni55rTCyLG2af55f9IWgJ1ao3JiZA= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/gogits/go-gogs-client v0.0.0-20200905025246-8bb8a50cb355/go.mod h1:cY2AIrMgHm6oOHmR7jY+9TtjzSjQ3iG7tURJG3Y6XH0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -183,8 +184,9 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/gnostic v0.5.7-v3refs h1:FhTMOKj2VhjpouxvWJAV1TL304uMlb9zcDqkl6cEI54= @@ -202,6 +204,10 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v50 v50.2.0 h1:j2FyongEHlO9nxXLc+LP3wuBSVU9mVxfpdYUexMpIfk= +github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -225,12 +231,18 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXcJdM= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0= +github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.5.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= @@ -295,6 +307,8 @@ github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.2 h1:hAHbPm5IJGijwng3PWk09JkG9WeqChjprR5s9bBZ+OM= github.com/matttproud/golang_protobuf_extensions v1.0.2/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= @@ -317,10 +331,10 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRW github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo/v2 v2.6.0 h1:9t9b9vRUbFq3C4qKFCGkVuq/fIHji802N1nrtkh1mNc= -github.com/onsi/ginkgo/v2 v2.6.0/go.mod h1:63DOGlLAH8+REH8jUGdL3YpCpu7JODesutUjdENfUAc= -github.com/onsi/gomega v1.24.1 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E= -github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= +github.com/onsi/ginkgo/v2 v2.6.1 h1:1xQPCjcqYw/J5LchOcp4/2q/jzJFjiAOc25chhnDw+Q= +github.com/onsi/ginkgo/v2 v2.6.1/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= +github.com/onsi/gomega v1.24.2 h1:J/tulyYK6JwBldPViHJReihxxZ+22FHs0piGjQAvoUE= +github.com/onsi/gomega v1.24.2/go.mod h1:gs3J10IS7Z7r7eXRoNJIrNqU4ToQukCJhFtKrWgHWnk= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.0.5 h1:ipoSadvV8oGUjnUbMub59IDPPwfxF694nG/jwbMiyQg= @@ -406,6 +420,8 @@ github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNG github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/xanzy/go-gitlab v0.81.0 h1:ofbhZ5ZY9AjHATWQie4qd2JfncdUmvcSA/zfQB767Dk= +github.com/xanzy/go-gitlab v0.81.0/go.mod h1:VMbY3JIWdZ/ckvHbQqkyd3iYk2aViKrNIQ23IbFMQDo= github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= @@ -451,8 +467,9 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -529,8 +546,8 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.3.1-0.20221206200815-1e63c2f08a10 h1:Frnccbp+ok2GkUS2tC84yAq/U9Vg+0sIO7aRL3T4Xnc= -golang.org/x/net v0.3.1-0.20221206200815-1e63c2f08a10/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -542,8 +559,8 @@ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 h1:nt+Q6cXKz4MosCSpnbMtqiQ8Oz0pxTef2B4Vca2lvfk= -golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= +golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -613,14 +630,14 @@ golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= -golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= -golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -631,8 +648,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM= -golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -787,8 +804,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= -google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.29.0 h1:44S3JjaKmLEE4YIkjzexaP+NzZsudE3Zin5Njn/pYX0= +google.golang.org/protobuf v1.29.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -825,16 +842,16 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -k8s.io/api v0.26.0 h1:IpPlZnxBpV1xl7TGk/X6lFtpgjgntCg8PJ+qrPHAC7I= -k8s.io/api v0.26.0/go.mod h1:k6HDTaIFC8yn1i6pSClSqIwLABIcLV9l5Q4EcngKnQg= -k8s.io/apiextensions-apiserver v0.26.0 h1:Gy93Xo1eg2ZIkNX/8vy5xviVSxwQulsnUdQ00nEdpDo= -k8s.io/apiextensions-apiserver v0.26.0/go.mod h1:7ez0LTiyW5nq3vADtK6C3kMESxadD51Bh6uz3JOlqWQ= -k8s.io/apimachinery v0.26.0 h1:1feANjElT7MvPqp0JT6F3Ss6TWDwmcjLypwoPpEf7zg= -k8s.io/apimachinery v0.26.0/go.mod h1:tnPmbONNJ7ByJNz9+n9kMjNP8ON+1qoAIIC70lztu74= -k8s.io/client-go v0.26.0 h1:lT1D3OfO+wIi9UFolCrifbjUUgu7CpLca0AD8ghRLI8= -k8s.io/client-go v0.26.0/go.mod h1:I2Sh57A79EQsDmn7F7ASpmru1cceh3ocVT9KlX2jEZg= -k8s.io/component-base v0.26.0 h1:0IkChOCohtDHttmKuz+EP3j3+qKmV55rM9gIFTXA7Vs= -k8s.io/component-base v0.26.0/go.mod h1:lqHwlfV1/haa14F/Z5Zizk5QmzaVf23nQzCwVOQpfC8= +k8s.io/api v0.26.1 h1:f+SWYiPd/GsiWwVRz+NbFyCgvv75Pk9NK6dlkZgpCRQ= +k8s.io/api v0.26.1/go.mod h1:xd/GBNgR0f707+ATNyPmQ1oyKSgndzXij81FzWGsejg= +k8s.io/apiextensions-apiserver v0.26.1 h1:cB8h1SRk6e/+i3NOrQgSFij1B2S0Y0wDoNl66bn8RMI= +k8s.io/apiextensions-apiserver v0.26.1/go.mod h1:AptjOSXDGuE0JICx/Em15PaoO7buLwTs0dGleIHixSM= +k8s.io/apimachinery v0.26.1 h1:8EZ/eGJL+hY/MYCNwhmDzVqq2lPl3N3Bo8rvweJwXUQ= +k8s.io/apimachinery v0.26.1/go.mod h1:tnPmbONNJ7ByJNz9+n9kMjNP8ON+1qoAIIC70lztu74= +k8s.io/client-go v0.26.1 h1:87CXzYJnAMGaa/IDDfRdhTzxk/wzGZ+/HUQpqgVSZXU= +k8s.io/client-go v0.26.1/go.mod h1:IWNSglg+rQ3OcvDkhY6+QLeasV4OYHDjdqeWkDQZwGE= +k8s.io/component-base v0.26.1 h1:4ahudpeQXHZL5kko+iDHqLj/FSGAEUnSVO0EBbgDd+4= +k8s.io/component-base v0.26.1/go.mod h1:VHrLR0b58oC035w6YQiBSbtsf0ThuSwXP+p5dD/kAWU= k8s.io/klog/v2 v2.80.1 h1:atnLQ121W371wYYFawwYx1aEY2eUfs4l3J72wtgAwV4= k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 h1:+70TFaan3hfJzs+7VK2o+OGxg8HsuBr/5f6tVAjDu6E= diff --git a/internal/annotations/annotations.go b/internal/annotations/annotations.go index f90a5b09e..0fb230e02 100644 --- a/internal/annotations/annotations.go +++ b/internal/annotations/annotations.go @@ -3,8 +3,6 @@ package annotations import ( "context" - configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" - "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -22,22 +20,31 @@ const ( LastRelevantCommit string = "webhook.terraform.padok.cloud/relevant-commit" ForceApply string = "notifications.terraform.padok.cloud/force-apply" + + LastDiscoveredCommit string = "pullrequest.terraform.padok.cloud/last-discovered-commit" + LastCommentedCommit string = "pullrequest.terraform.padok.cloud/last-commented-commit" ) -func Add(ctx context.Context, c client.Client, obj configv1alpha1.TerraformLayer, annotations map[string]string) error { - patch := client.MergeFrom(obj.DeepCopy()) +func Add(ctx context.Context, c client.Client, obj client.Object, annotations map[string]string) error { + newObj := obj.DeepCopyObject().(client.Object) + patch := client.MergeFrom(newObj) currentAnnotations := obj.GetAnnotations() + if currentAnnotations == nil { + currentAnnotations = make(map[string]string) + } for k, v := range annotations { currentAnnotations[k] = v } + obj.SetAnnotations(currentAnnotations) - return c.Patch(ctx, &obj, patch) + return c.Patch(ctx, obj, patch) } -func Remove(ctx context.Context, c client.Client, obj configv1alpha1.TerraformLayer, annotation string) error { - patch := client.MergeFrom(obj.DeepCopy()) +func Remove(ctx context.Context, c client.Client, obj client.Object, annotation string) error { + newObj := obj.DeepCopyObject().(client.Object) + patch := client.MergeFrom(newObj) annotations := obj.GetAnnotations() delete(annotations, annotation) obj.SetAnnotations(annotations) - return c.Patch(ctx, &obj, patch) + return c.Patch(ctx, obj, patch) } diff --git a/internal/burrito/config/config.go b/internal/burrito/config/config.go index d715b1bca..033e000de 100644 --- a/internal/burrito/config/config.go +++ b/internal/burrito/config/config.go @@ -29,7 +29,9 @@ type WebhookGithubConfig struct { } type WebhookGitlabConfig struct { - Secret string `yaml:"secret"` + URL string `yaml:"url"` + Secret string `yaml:"secret"` + APIToken string `yaml:"token"` } type ControllerConfig struct { @@ -40,6 +42,17 @@ type ControllerConfig struct { MetricsBindAddress string `yaml:"metricsBindAddress"` HealthProbeBindAddress string `yaml:"healthProbeBindAddress"` KubernetesWehbookPort int `yaml:"kubernetesWebhookPort"` + GithubConfig GithubConfig `yaml:"githubConfig"` + GitlabConfig GitlabConfig `yaml:"gitlabConfig"` +} + +type GithubConfig struct { + APIToken string `yaml:"apiToken"` +} + +type GitlabConfig struct { + APIToken string `yaml:"token"` + URL string `yaml:"url"` } type LeaderElectionConfig struct { @@ -49,8 +62,8 @@ type LeaderElectionConfig struct { type ControllerTimers struct { DriftDetection time.Duration `yaml:"driftDetection"` - OnError time.Duration `yaml:"waitAction"` - WaitAction time.Duration `yaml:"onError"` + OnError time.Duration `yaml:"onError"` + WaitAction time.Duration `yaml:"waitAction"` } type RepositoryConfig struct { diff --git a/internal/controllers/manager.go b/internal/controllers/manager.go index d181a6f8f..6b0e90cae 100644 --- a/internal/controllers/manager.go +++ b/internal/controllers/manager.go @@ -30,6 +30,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/padok-team/burrito/internal/controllers/terraformlayer" + "github.com/padok-team/burrito/internal/controllers/terraformpullrequest" "github.com/padok-team/burrito/internal/controllers/terraformrepository" "github.com/padok-team/burrito/internal/storage/redis" @@ -99,6 +100,15 @@ func (c *Controllers) Exec() { log.Fatalf("unable to create repository controller: %s", err) } log.Infof("repository controller started successfully") + case "pullrequest": + if err = (&terraformpullrequest.Reconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Config: c.config, + }).SetupWithManager(mgr); err != nil { + log.Fatalf("unable to create pullrequest controller: %s", err) + } + log.Infof("pullrequest controller started successfully") default: log.Infof("unrecognized controller type %s, ignoring", ctrlType) } diff --git a/internal/controllers/terraformlayer/conditions.go b/internal/controllers/terraformlayer/conditions.go index 8f7ee4e64..ff8530ff4 100644 --- a/internal/controllers/terraformlayer/conditions.go +++ b/internal/controllers/terraformlayer/conditions.go @@ -72,13 +72,7 @@ func (r *Reconciler) IsLastRelevantCommitPlanned(t *configv1alpha1.TerraformLaye condition.Status = metav1.ConditionTrue return condition, true } - if lastBranchCommit != lastRelevantCommit { - condition.Reason = "CommitAlreadyHadnled" - condition.Message = "The last relevant commit should already have been planned" - condition.Status = metav1.ConditionTrue - return condition, true - } - if lastPlannedCommit == lastBranchCommit { + if lastPlannedCommit == lastBranchCommit || lastPlannedCommit == lastRelevantCommit { condition.Reason = "LastRelevantCommitPlanned" condition.Message = "The last relevant commit has already been planned" condition.Status = metav1.ConditionTrue diff --git a/internal/controllers/terraformlayer/controller.go b/internal/controllers/terraformlayer/controller.go index 67baa0787..639c02918 100644 --- a/internal/controllers/terraformlayer/controller.go +++ b/internal/controllers/terraformlayer/controller.go @@ -27,6 +27,8 @@ import ( "k8s.io/apimachinery/pkg/types" 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/predicate" log "github.com/sirupsen/logrus" @@ -109,5 +111,19 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&configv1alpha1.TerraformLayer{}). + WithEventFilter(ignorePredicate()). Complete(r) } + +func ignorePredicate() predicate.Predicate { + return predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + // Ignore updates to CR status in which case metadata.Generation does not change + return e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration() + }, + DeleteFunc: func(e event.DeleteEvent) bool { + // Evaluates to false if the object has been confirmed deleted. + return !e.DeleteStateUnknown + }, + } +} diff --git a/internal/controllers/terraformlayer/pod.go b/internal/controllers/terraformlayer/pod.go index 1d9cc5704..b87f2a8e7 100644 --- a/internal/controllers/terraformlayer/pod.go +++ b/internal/controllers/terraformlayer/pod.go @@ -72,6 +72,7 @@ func (r *Reconciler) getPod(layer *configv1alpha1.TerraformLayer, repository *co } overrideSpec := configv1alpha1.GetOverrideRunnerSpec(repository, layer) + defaultSpec.Tolerations = overrideSpec.Tolerations defaultSpec.NodeSelector = overrideSpec.NodeSelector defaultSpec.Containers[0].Env = append(defaultSpec.Containers[0].Env, overrideSpec.Env...) @@ -79,10 +80,15 @@ func (r *Reconciler) getPod(layer *configv1alpha1.TerraformLayer, repository *co defaultSpec.Containers[0].VolumeMounts = append(defaultSpec.Containers[0].VolumeMounts, overrideSpec.VolumeMounts...) defaultSpec.Containers[0].Resources = overrideSpec.Resources defaultSpec.Containers[0].EnvFrom = append(defaultSpec.Containers[0].EnvFrom, overrideSpec.EnvFrom...) - defaultSpec.Containers[0].Image = overrideSpec.Image - defaultSpec.ServiceAccountName = overrideSpec.ServiceAccountName defaultSpec.ImagePullSecrets = append(defaultSpec.ImagePullSecrets, overrideSpec.ImagePullSecrets...) + if len(overrideSpec.ServiceAccountName) > 0 { + defaultSpec.ServiceAccountName = overrideSpec.ServiceAccountName + } + if len(overrideSpec.Image) > 0 { + defaultSpec.Containers[0].Image = overrideSpec.Image + } + pod := corev1.Pod{ Spec: defaultSpec, ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/controllers/terraformlayer/states.go b/internal/controllers/terraformlayer/states.go index 0242ae003..e94df8c6c 100644 --- a/internal/controllers/terraformlayer/states.go +++ b/internal/controllers/terraformlayer/states.go @@ -12,8 +12,10 @@ import ( ctrl "sigs.k8s.io/controller-runtime" ) +type Handler func(context.Context, *Reconciler, *configv1alpha1.TerraformLayer, *configv1alpha1.TerraformRepository) ctrl.Result + type State interface { - getHandler() func(ctx context.Context, r *Reconciler, layer *configv1alpha1.TerraformLayer, repository *configv1alpha1.TerraformRepository) ctrl.Result + getHandler() Handler } func (r *Reconciler) GetState(ctx context.Context, layer *configv1alpha1.TerraformLayer) (State, []metav1.Condition) { @@ -24,7 +26,7 @@ func (r *Reconciler) GetState(ctx context.Context, layer *configv1alpha1.Terrafo // c3, hasFailed := HasFailed(r) conditions := []metav1.Condition{c1, c2, c3} switch { - case isPlanArtifactUpToDate && isApplyUpToDate: + case isPlanArtifactUpToDate && isApplyUpToDate && isLastRelevantCommitPlanned: log.Infof("layer %s is up to date, waiting for a new drift detection cycle", layer.Name) return &Idle{}, conditions case !isPlanArtifactUpToDate || !isLastRelevantCommitPlanned: @@ -41,7 +43,7 @@ func (r *Reconciler) GetState(ctx context.Context, layer *configv1alpha1.Terrafo type Idle struct{} -func (s *Idle) getHandler() func(ctx context.Context, r *Reconciler, layer *configv1alpha1.TerraformLayer, repository *configv1alpha1.TerraformRepository) ctrl.Result { +func (s *Idle) getHandler() Handler { return func(ctx context.Context, r *Reconciler, layer *configv1alpha1.TerraformLayer, repository *configv1alpha1.TerraformRepository) ctrl.Result { return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.DriftDetection} } @@ -49,7 +51,7 @@ func (s *Idle) getHandler() func(ctx context.Context, r *Reconciler, layer *conf type PlanNeeded struct{} -func (s *PlanNeeded) getHandler() func(ctx context.Context, r *Reconciler, layer *configv1alpha1.TerraformLayer, repository *configv1alpha1.TerraformRepository) ctrl.Result { +func (s *PlanNeeded) getHandler() Handler { return func(ctx context.Context, r *Reconciler, layer *configv1alpha1.TerraformLayer, repository *configv1alpha1.TerraformRepository) ctrl.Result { log := log.WithContext(ctx) err := lock.CreateLock(ctx, r.Client, layer) @@ -70,7 +72,7 @@ func (s *PlanNeeded) getHandler() func(ctx context.Context, r *Reconciler, layer type ApplyNeeded struct{} -func (s *ApplyNeeded) getHandler() func(ctx context.Context, r *Reconciler, layer *configv1alpha1.TerraformLayer, repository *configv1alpha1.TerraformRepository) ctrl.Result { +func (s *ApplyNeeded) getHandler() Handler { return func(ctx context.Context, r *Reconciler, layer *configv1alpha1.TerraformLayer, repository *configv1alpha1.TerraformRepository) ctrl.Result { log := log.WithContext(ctx) remediationStrategy := getRemediationStrategy(repository, layer) diff --git a/internal/controllers/terraformpullrequest/comment/common.go b/internal/controllers/terraformpullrequest/comment/common.go new file mode 100644 index 000000000..4478ffd33 --- /dev/null +++ b/internal/controllers/terraformpullrequest/comment/common.go @@ -0,0 +1,5 @@ +package comment + +type Comment interface { + Generate(string) (string, error) +} diff --git a/internal/controllers/terraformpullrequest/comment/default.go b/internal/controllers/terraformpullrequest/comment/default.go new file mode 100644 index 000000000..c8123ecba --- /dev/null +++ b/internal/controllers/terraformpullrequest/comment/default.go @@ -0,0 +1,71 @@ +package comment + +import ( + "bytes" + "text/template" + + configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" + "github.com/padok-team/burrito/internal/storage" + + _ "embed" +) + +var ( + //go:embed templates/comment.md + defaultTemplateRaw string + defaultTemplate = template.Must(template.New("report").Parse(defaultTemplateRaw)) +) + +type ReportedLayer struct { + ShortDiff string + Path string + PrettyPlan string +} + +type DefaultComment struct { + layers []configv1alpha1.TerraformLayer + storage storage.Storage +} + +type DefaultCommentInput struct { +} + +func NewDefaultComment(layers []configv1alpha1.TerraformLayer, storage storage.Storage) *DefaultComment { + return &DefaultComment{ + layers: layers, + storage: storage, + } +} + +func (c *DefaultComment) Generate(commit string) (string, error) { + var reportedLayers []ReportedLayer + for _, layer := range c.layers { + prettyPlanKey := storage.GenerateKey(storage.LastPrettyPlan, &layer) + plan, err := c.storage.Get(prettyPlanKey) + if err != nil { + return "", err + } + shortDiffKey := storage.GenerateKey(storage.LastPlanResult, &layer) + shortDiff, err := c.storage.Get(shortDiffKey) + if err != nil { + return "", err + } + reportedLayer := ReportedLayer{ + Path: layer.Spec.Path, + ShortDiff: string(shortDiff), + PrettyPlan: string(plan), + } + reportedLayers = append(reportedLayers, reportedLayer) + + } + data := struct { + Commit string + Layers []ReportedLayer + }{ + Commit: commit, + Layers: reportedLayers, + } + comment := bytes.NewBufferString("") + defaultTemplate.Execute(comment, data) + return comment.String(), nil +} diff --git a/internal/controllers/terraformpullrequest/comment/default_test.go b/internal/controllers/terraformpullrequest/comment/default_test.go new file mode 100644 index 000000000..3bce9fc33 --- /dev/null +++ b/internal/controllers/terraformpullrequest/comment/default_test.go @@ -0,0 +1,36 @@ +package comment + +import ( + _ "embed" +) + +// func TestDefaultComment_Generate(t *testing.T) { +// type fields struct { +// layers []configv1alpha1.TerraformLayer +// storage storage.Storage +// } +// tests := []struct { +// name string +// fields fields +// want string +// wantErr bool +// }{ +// // TODO: Add test cases. +// } +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// c := &DefaultComment{ +// layers: tt.fields.layers, +// storage: tt.fields.storage, +// } +// got, err := c.Generate() +// if (err != nil) != tt.wantErr { +// t.Errorf("DefaultComment.Generate() error = %v, wantErr %v", err, tt.wantErr) +// return +// } +// if got != tt.want { +// t.Errorf("DefaultComment.Generate() = %v, want %v", got, tt.want) +// } +// }) +// } +// } diff --git a/internal/controllers/terraformpullrequest/comment/initial.go b/internal/controllers/terraformpullrequest/comment/initial.go new file mode 100644 index 000000000..4677840ab --- /dev/null +++ b/internal/controllers/terraformpullrequest/comment/initial.go @@ -0,0 +1,8 @@ +package comment + +type InitialComment struct { +} + +func NewInitialComment() *InitialComment { + return &InitialComment{} +} diff --git a/internal/controllers/terraformpullrequest/comment/templates/comment.md b/internal/controllers/terraformpullrequest/comment/templates/comment.md new file mode 100644 index 000000000..f2032438c --- /dev/null +++ b/internal/controllers/terraformpullrequest/comment/templates/comment.md @@ -0,0 +1,17 @@ +## :burrito: Burrito Report + +{{ len .Layers }} layer(s) affected with {{ .Commit }} commit. + +{{- range .Layers }} +### Layer {{ .Path }} + +`{{ .ShortDiff }}` + +
+Plan + +``` +{{ .PrettyPlan }} +``` +
+{{- end }} diff --git a/internal/controllers/terraformpullrequest/conditions.go b/internal/controllers/terraformpullrequest/conditions.go new file mode 100644 index 000000000..5b83cd6af --- /dev/null +++ b/internal/controllers/terraformpullrequest/conditions.go @@ -0,0 +1,159 @@ +package terraformpullrequest + +import ( + "context" + "time" + + configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" + "github.com/padok-team/burrito/internal/annotations" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func (r *Reconciler) IsLastCommitDiscovered(pr *configv1alpha1.TerraformPullRequest) (metav1.Condition, bool) { + condition := metav1.Condition{ + Type: "IsLastCommitDiscovered", + ObservedGeneration: pr.GetObjectMeta().GetGeneration(), + Status: metav1.ConditionUnknown, + LastTransitionTime: metav1.NewTime(time.Now()), + } + lastDiscorveredCommit, ok := pr.Annotations[annotations.LastDiscoveredCommit] + if !ok { + condition.Reason = "NoCommitDiscovered" + condition.Message = "Controller hasn't discovered any commit yet." + condition.Status = metav1.ConditionFalse + return condition, false + } + lastBranchCommit, ok := pr.Annotations[annotations.LastBranchCommit] + if !ok { + condition.Reason = "UnknownLastBranchCommit" + condition.Message = "This should have happened" + condition.Status = metav1.ConditionFalse + return condition, false + } + if lastDiscorveredCommit == lastBranchCommit { + condition.Reason = "LastCommitDiscovered" + condition.Message = "The last commit has been discovered." + condition.Status = metav1.ConditionTrue + return condition, true + } + condition.Reason = "LastCommitNotDiscovered" + condition.Message = "Last received commit is not the last discovered commit." + condition.Status = metav1.ConditionFalse + return condition, false +} + +func (r *Reconciler) AreLayersStillPlanning(pr *configv1alpha1.TerraformPullRequest) (metav1.Condition, bool) { + condition := metav1.Condition{ + Type: "AreLayersStillPlanning", + ObservedGeneration: pr.GetObjectMeta().GetGeneration(), + Status: metav1.ConditionUnknown, + LastTransitionTime: metav1.NewTime(time.Now()), + } + layers, err := getLinkedLayers(r.Client, pr) + + lastDiscoveredCommit, okDiscoveredCommit := pr.Annotations[annotations.LastDiscoveredCommit] + prLastBranchCommit, okPRBranchCommit := pr.Annotations[annotations.LastBranchCommit] + + if !okPRBranchCommit { + condition.Reason = "NoBranchCommitOnPR" + condition.Message = "This should not have happened, report this as an issue" + condition.Status = metav1.ConditionUnknown + return condition, true + } + + if !okDiscoveredCommit { + condition.Reason = "NoCommitDiscovered" + condition.Message = "Controller hasn't discovered any commit yet." + condition.Status = metav1.ConditionTrue + return condition, true + } + + if lastDiscoveredCommit != prLastBranchCommit { + condition.Reason = "StillNeedsDiscovery" + condition.Message = "Controller hasn't discovered the latest commit yet." + condition.Status = metav1.ConditionTrue + return condition, true + } + + if err != nil { + condition.Reason = "ErrorListingLayers" + condition.Message = err.Error() + condition.Status = metav1.ConditionTrue + return condition, true + } + + for _, layer := range layers { + lastRelevantCommit, okRelevantCommit := layer.Annotations[annotations.LastRelevantCommit] + lastPlanCommit, okPlanCommit := layer.Annotations[annotations.LastPlanCommit] + condition.Reason = "LayersStillPlanning" + condition.Message = "Linked layers are still planning." + condition.Status = metav1.ConditionTrue + if !okPlanCommit { + return condition, true + } + if !okRelevantCommit { + condition.Reason = "NoRelevantCommitOnLayer" + condition.Message = "This should not have happened, report this as an issue" + condition.Status = metav1.ConditionUnknown + return condition, true + } + if lastPlanCommit == lastRelevantCommit { + continue + } + return condition, true + } + condition.Reason = "LayersNotPlanning" + condition.Message = "Linked layers are not planning." + condition.Status = metav1.ConditionFalse + return condition, false +} + +func (r *Reconciler) IsCommentUpToDate(pr *configv1alpha1.TerraformPullRequest) (metav1.Condition, bool) { + condition := metav1.Condition{ + Type: "IsCommentUpToDate", + ObservedGeneration: pr.GetObjectMeta().GetGeneration(), + Status: metav1.ConditionUnknown, + LastTransitionTime: metav1.NewTime(time.Now()), + } + lasCommentedCommit, ok := pr.Annotations[annotations.LastCommentedCommit] + if !ok { + condition.Reason = "NoCommentSent" + condition.Message = "No comment has ever been sent" + condition.Status = metav1.ConditionFalse + return condition, false + } + lastDiscorveredCommit, ok := pr.Annotations[annotations.LastDiscoveredCommit] + if !ok { + condition.Reason = "Unknown" + condition.Message = "This should not have happened" + condition.Status = metav1.ConditionUnknown + return condition, true + } + if lasCommentedCommit != lastDiscorveredCommit { + condition.Reason = "CommentOutdated" + condition.Message = "The comment is outdated." + condition.Status = metav1.ConditionFalse + return condition, false + } + condition.Reason = "CommentUpToDate" + condition.Message = "The comment is up to date." + condition.Status = metav1.ConditionTrue + return condition, true +} + +func getLinkedLayers(cl client.Client, pr *configv1alpha1.TerraformPullRequest) ([]configv1alpha1.TerraformLayer, error) { + layers := configv1alpha1.TerraformLayerList{} + requirement, err := labels.NewRequirement("burrito/managed-by", selection.Equals, []string{pr.Name}) + if err != nil { + return nil, err + } + selector := labels.NewSelector().Add(*requirement) + err = cl.List(context.TODO(), &layers, client.MatchingLabelsSelector{Selector: selector}) + if err != nil { + return nil, err + } + return layers.Items, nil +} diff --git a/internal/controllers/terraformpullrequest/controller.go b/internal/controllers/terraformpullrequest/controller.go new file mode 100644 index 000000000..25ad8139a --- /dev/null +++ b/internal/controllers/terraformpullrequest/controller.go @@ -0,0 +1,128 @@ +package terraformpullrequest + +import ( + "context" + "fmt" + "strings" + + "github.com/padok-team/burrito/internal/burrito/config" + "github.com/padok-team/burrito/internal/controllers/terraformpullrequest/comment" + "github.com/padok-team/burrito/internal/controllers/terraformpullrequest/github" + "github.com/padok-team/burrito/internal/storage" + "github.com/padok-team/burrito/internal/storage/redis" + + "github.com/padok-team/burrito/internal/controllers/terraformpullrequest/gitlab" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + 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/predicate" + + log "github.com/sirupsen/logrus" + + configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" +) + +type Provider interface { + Init(*config.Config) error + IsFromProvider(*configv1alpha1.TerraformPullRequest) bool + GetChanges(*configv1alpha1.TerraformRepository, *configv1alpha1.TerraformPullRequest) ([]string, error) + Comment(*configv1alpha1.TerraformRepository, *configv1alpha1.TerraformPullRequest, comment.Comment) error +} + +// Reconciler reconciles a TerraformPullRequest object +type Reconciler struct { + client.Client + Scheme *runtime.Scheme + Config *config.Config + Providers []Provider + Storage storage.Storage +} + +//+kubebuilder:rbac:groups=config.terraform.padok.cloud,resources=terraformpullrequests,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=config.terraform.padok.cloud,resources=terraformpullrequests/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=config.terraform.padok.cloud,resources=terraformpullrequests/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// TODO(user): Modify the Reconcile function to compare the state specified by +// the TerraformLayer object against the actual cluster state, and then +// perform operations to make the cluster state reflect the state specified by +// the user. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.13.0/pkg/reconcile +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + + log := log.WithContext(ctx) + log.Infof("starting reconciliation...") + pr := &configv1alpha1.TerraformPullRequest{} + err := r.Client.Get(ctx, req.NamespacedName, pr) + if errors.IsNotFound(err) { + log.Errorf("resource not found. Ignoring since object must be deleted: %s", err) + return ctrl.Result{}, nil + } + if err != nil { + log.Errorf("failed to get TerraformPullRequest: %s", err) + return ctrl.Result{}, err + } + repository := &configv1alpha1.TerraformRepository{} + err = r.Client.Get(ctx, types.NamespacedName{ + Name: pr.Spec.Repository.Name, + Namespace: pr.Spec.Repository.Namespace, + }, repository) + if errors.IsNotFound(err) { + log.Errorf("repository not found. object must not be configured correctly: %s", err) + return ctrl.Result{}, nil + } + if err != nil { + log.Errorf("failed to get TerraformRepository: %s", err) + return ctrl.Result{}, err + } + state, conditions := r.GetState(ctx, pr) + pr.Status = configv1alpha1.TerraformPullRequestStatus{Conditions: conditions, State: getStateString(state)} + + result := state.getHandler()(ctx, r, repository, pr) + err = r.Client.Status().Update(ctx, pr) + if err != nil { + log.Errorf("could not update pull request %s status: %s", pr.Name, err) + } + log.Infof("finished reconciliation cycle for pull request %s", pr.Name) + return result, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + providers := []Provider{} + for _, p := range []Provider{&github.Github{}, &gitlab.Gitlab{}} { + name := strings.Split(fmt.Sprintf("%T", p), ".") + err := p.Init(r.Config) + if err != nil { + log.Warnf("could not initialize provider %s: %s", name, err) + continue + } + log.Infof("provider %s successfully initialized", name) + providers = append(providers, p) + } + r.Providers = providers + r.Storage = redis.New(r.Config.Redis.URL, r.Config.Redis.Password, r.Config.Redis.Database) + return ctrl.NewControllerManagedBy(mgr). + For(&configv1alpha1.TerraformPullRequest{}). + WithEventFilter(ignorePredicate()). + Complete(r) +} + +func ignorePredicate() predicate.Predicate { + return predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + // Ignore updates to CR status in which case metadata.Generation does not change + return e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration() + }, + DeleteFunc: func(e event.DeleteEvent) bool { + // Evaluates to false if the object has been confirmed deleted. + return !e.DeleteStateUnknown + }, + } +} diff --git a/internal/controllers/terraformpullrequest/github/provider.go b/internal/controllers/terraformpullrequest/github/provider.go new file mode 100644 index 000000000..f4e4cfe45 --- /dev/null +++ b/internal/controllers/terraformpullrequest/github/provider.go @@ -0,0 +1,118 @@ +package github + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/google/go-github/v50/github" + configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" + "github.com/padok-team/burrito/internal/annotations" + "github.com/padok-team/burrito/internal/burrito/config" + "github.com/padok-team/burrito/internal/controllers/terraformpullrequest/comment" + log "github.com/sirupsen/logrus" + "golang.org/x/oauth2" +) + +type Github struct { + *github.Client +} + +func (g *Github) IsConfigPresent(c *config.Config) bool { + if &c.Controller.GithubConfig == nil { + return false + } + if c.Controller.GithubConfig.APIToken == "" { + return false + } + return true +} + +func (g *Github) Init(c *config.Config) error { + ctx := context.Background() + if !g.IsConfigPresent(c) { + return errors.New("github config is not present") + } + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: c.Controller.GithubConfig.APIToken}, + ) + tc := oauth2.NewClient(ctx, ts) + + g.Client = github.NewClient(tc) + return nil +} + +func (g *Github) IsFromProvider(pr *configv1alpha1.TerraformPullRequest) bool { + return pr.Spec.Provider == "github" +} + +func (g *Github) GetChanges(repository *configv1alpha1.TerraformRepository, pr *configv1alpha1.TerraformPullRequest) ([]string, error) { + owner, repoName := parseGithubUrl(repository.Spec.Repository.Url) + id, err := strconv.Atoi(pr.Spec.ID) + if err != nil { + log.Errorf("Error while parsing Github pull request ID: %s", err) + return []string{}, err + } + // Per page is 30 by default, max is 100 + opts := &github.RepositoryListByOrgOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + // Get all pull request files from Github + var allChangedFiles []string + for { + changedFiles, resp, err := g.Client.PullRequests.ListFiles(context.TODO(), owner, repoName, id, nil) + if err != nil { + return []string{}, err + } + for _, file := range changedFiles { + if *file.Status != "unchanged" { + allChangedFiles = append(allChangedFiles, *file.Filename) + } + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return allChangedFiles, nil +} + +func (g *Github) Comment(repository *configv1alpha1.TerraformRepository, pr *configv1alpha1.TerraformPullRequest, comment comment.Comment) error { + body, err := comment.Generate(pr.Annotations[annotations.LastBranchCommit]) + if err != nil { + log.Errorf("Error while generating comment: %s", err) + return err + } + owner, repoName := parseGithubUrl(repository.Spec.Repository.Url) + id, err := strconv.Atoi(pr.Spec.ID) + if err != nil { + log.Errorf("Error while parsing Github pull request ID: %s", err) + return err + } + _, _, err = g.Client.Issues.CreateComment(context.TODO(), owner, repoName, id, &github.IssueComment{ + Body: &body, + }) + return err +} + +func parseGithubUrl(url string) (string, string) { + normalizedUrl := normalizeUrl(url) + // nomalized url are "https://padok.github.com/owner/repo" + // we remove "https://" then split on "/" + split := strings.Split(normalizedUrl[8:], "/") + return split[1], split[2] +} + +func normalizeUrl(url string) string { + if strings.Contains(url, "https://") { + return url + } + // All SSH URL from GitHub are like "git@padok.github.com:/.git" + // We split on ":" then remove ".git" by removing the last characters + // To handle enterprise GitHub, we dynamically get "padok.github.com" + // By removing "git@" at the beginning of the string + split := strings.Split(url, ":") + return fmt.Sprintf("https://%s/%s", split[0][4:], split[1][:len(split[1])-4]) +} diff --git a/internal/controllers/terraformpullrequest/gitlab/provider.go b/internal/controllers/terraformpullrequest/gitlab/provider.go new file mode 100644 index 000000000..01f1d8df6 --- /dev/null +++ b/internal/controllers/terraformpullrequest/gitlab/provider.go @@ -0,0 +1,104 @@ +package gitlab + +import ( + "fmt" + "strconv" + "strings" + + configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" + "github.com/padok-team/burrito/internal/annotations" + "github.com/padok-team/burrito/internal/burrito/config" + "github.com/padok-team/burrito/internal/controllers/terraformpullrequest/comment" + log "github.com/sirupsen/logrus" + "github.com/xanzy/go-gitlab" +) + +type Gitlab struct { + *gitlab.Client +} + +func (g *Gitlab) IsConfigPresent(c *config.Config) bool { + if &c.Controller.GitlabConfig == nil { + return false + } + if c.Controller.GitlabConfig.APIToken == "" { + return false + } + return true +} + +func (g *Gitlab) Init(c *config.Config) error { + if !g.IsConfigPresent(c) { + return fmt.Errorf("gitlab config is not present") + } + client, err := gitlab.NewClient(c.Controller.GitlabConfig.APIToken, gitlab.WithBaseURL(c.Controller.GitlabConfig.URL)) + if err != nil { + return err + } + g.Client = client + return nil +} + +func (g *Gitlab) IsFromProvider(pr *configv1alpha1.TerraformPullRequest) bool { + return pr.Spec.Provider == "gitlab" +} + +func (g *Gitlab) GetChanges(repository *configv1alpha1.TerraformRepository, pr *configv1alpha1.TerraformPullRequest) ([]string, error) { + id, err := strconv.Atoi(pr.Spec.ID) + if err != nil { + log.Errorf("Error while parsing Gitlab merge request ID: %s", err) + return []string{}, err + } + getOpts := gitlab.GetMergeRequestChangesOptions{ + AccessRawDiffs: gitlab.Bool(true), + } + + mr, _, err := g.Client.MergeRequests.GetMergeRequestChanges(getGitlabNamespacedName(repository.Spec.Repository.Url), id, &getOpts) + if err != nil { + log.Errorf("Error while getting merge request changes: %s", err) + return []string{}, err + } + var changes []string + for _, change := range mr.Changes { + changes = append(changes, change.NewPath) + } + return changes, nil +} + +func (g *Gitlab) Comment(repository *configv1alpha1.TerraformRepository, pr *configv1alpha1.TerraformPullRequest, comment comment.Comment) error { + body, err := comment.Generate(pr.Annotations[annotations.LastBranchCommit]) + if err != nil { + log.Errorf("Error while generating comment: %s", err) + return err + } + id, err := strconv.Atoi(pr.Spec.ID) + if err != nil { + log.Errorf("Error while parsing Gitlab merge request ID: %s", err) + return err + } + _, _, err = g.Client.Notes.CreateMergeRequestNote(getGitlabNamespacedName(repository.Spec.Repository.Url), id, &gitlab.CreateMergeRequestNoteOptions{ + Body: gitlab.String(body), + }) + if err != nil { + log.Errorf("Error while creating merge request note: %s", err) + return err + } + return nil +} + +func getGitlabNamespacedName(url string) string { + normalizedUrl := normalizeUrl(url) + return strings.Join(strings.Split(normalizedUrl[8:], "/")[1:], "/") +} + +func normalizeUrl(url string) string { + if strings.Contains(url, "https://") { + return url + } + // All SSH URL from GitLab are like "git@:/.git" + // We split on ":" then remove ".git" by removing the last characters + // To handle enterprise GitLab on premise, we dynamically get "padok.gitlab.com" + // By removing "git@" at the beginning of the string + split := strings.Split(url, ":") + return fmt.Sprintf("https://%s/%s", split[0][4:], split[1][:len(split[1])-4]) +} diff --git a/internal/controllers/terraformpullrequest/layer.go b/internal/controllers/terraformpullrequest/layer.go new file mode 100644 index 000000000..1cd07fe98 --- /dev/null +++ b/internal/controllers/terraformpullrequest/layer.go @@ -0,0 +1,109 @@ +package terraformpullrequest + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" + "github.com/padok-team/burrito/internal/annotations" +) + +func (r *Reconciler) getAffectedLayers(repository *configv1alpha1.TerraformRepository, pr *configv1alpha1.TerraformPullRequest) ([]configv1alpha1.TerraformLayer, error) { + var layers configv1alpha1.TerraformLayerList + err := r.Client.List(context.Background(), &layers) + if err != nil { + return nil, err + } + var provider Provider + for _, p := range r.Providers { + if p.IsFromProvider(pr) { + provider = p + break + } + } + if provider == nil { + return nil, fmt.Errorf("could not find provider for pull request %s", pr.Name) + } + changes, err := provider.GetChanges(repository, pr) + if err != nil { + return nil, err + } + affectedLayers := []configv1alpha1.TerraformLayer{} + for _, layer := range layers.Items { + if layer.Spec.Repository != pr.Spec.Repository { + continue + } + if layer.Spec.Branch != pr.Spec.Base { + continue + } + if layerFilesHaveChanged(layer, changes) { + affectedLayers = append(affectedLayers, layer) + } + } + + return affectedLayers, nil +} + +func generateTempLayers(pr *configv1alpha1.TerraformPullRequest, layers []configv1alpha1.TerraformLayer) []configv1alpha1.TerraformLayer { + list := []configv1alpha1.TerraformLayer{} + for _, layer := range layers { + new := configv1alpha1.TerraformLayer{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: layer.ObjectMeta.Namespace, + GenerateName: fmt.Sprintf("%s-%s-", layer.Name, pr.Spec.ID), + Annotations: map[string]string{ + annotations.LastBranchCommit: pr.Annotations[annotations.LastBranchCommit], + annotations.LastRelevantCommit: pr.Annotations[annotations.LastBranchCommit], + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: pr.APIVersion, + Kind: pr.Kind, + Name: pr.Name, + UID: pr.UID, + }, + }, + Labels: map[string]string{ + "burrito/managed-by": pr.Name, + }, + }, + Spec: configv1alpha1.TerraformLayerSpec{ + Path: layer.Spec.Path, + Branch: pr.Spec.Branch, + TerraformConfig: layer.Spec.TerraformConfig, + Repository: layer.Spec.Repository, + RemediationStrategy: "dry", + OverrideRunnerSpec: layer.Spec.OverrideRunnerSpec, + }, + } + list = append(list, new) + } + return list +} + +func layerFilesHaveChanged(layer configv1alpha1.TerraformLayer, changedFiles []string) bool { + if len(changedFiles) == 0 { + return true + } + + // At last one changed file must be under refresh path + for _, f := range changedFiles { + f = ensureAbsPath(f) + if strings.Contains(f, layer.Spec.Path) { + return true + } + } + + return false +} + +func ensureAbsPath(input string) string { + if !filepath.IsAbs(input) { + return string(filepath.Separator) + input + } + return input +} diff --git a/internal/controllers/terraformpullrequest/states.go b/internal/controllers/terraformpullrequest/states.go new file mode 100644 index 000000000..2518155b6 --- /dev/null +++ b/internal/controllers/terraformpullrequest/states.go @@ -0,0 +1,129 @@ +package terraformpullrequest + +import ( + "context" + "fmt" + "strings" + + configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" + "github.com/padok-team/burrito/internal/annotations" + "github.com/padok-team/burrito/internal/controllers/terraformpullrequest/comment" + log "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" +) + +type State interface { + getHandler() func(ctx context.Context, r *Reconciler, repository *configv1alpha1.TerraformRepository, pr *configv1alpha1.TerraformPullRequest) ctrl.Result +} + +func (r *Reconciler) GetState(ctx context.Context, pr *configv1alpha1.TerraformPullRequest) (State, []metav1.Condition) { + log := log.WithContext(ctx) + c1, isLastCommitDiscovered := r.IsLastCommitDiscovered(pr) + c2, areLayersStillPlanning := r.AreLayersStillPlanning(pr) + c3, isCommentUpToDate := r.IsCommentUpToDate(pr) + conditions := []metav1.Condition{c1, c2, c3} + switch { + case !isLastCommitDiscovered: + log.Infof("pull request %s needs to be discovered", pr.Name) + return &DiscoveryNeeded{}, conditions + case isLastCommitDiscovered && isCommentUpToDate: + log.Infof("pull request %s comment is up to date", pr.Name) + return &Idle{}, conditions + case isLastCommitDiscovered && areLayersStillPlanning: + log.Infof("pull request %s layers are still planning, waiting", pr.Name) + return &Idle{}, conditions + case isLastCommitDiscovered && !areLayersStillPlanning && !isCommentUpToDate: + log.Infof("pull request %s layers have finished, posting comment", pr.Name) + return &CommentNeeded{}, conditions + default: + log.Infof("pull request %s is in an unknown state, defaulting to idle. If this happens please file an issue, this is not an intended behavior.", pr.Name) + return &Idle{}, conditions + } +} + +type Idle struct{} + +func (s *Idle) getHandler() func(ctx context.Context, r *Reconciler, repository *configv1alpha1.TerraformRepository, pr *configv1alpha1.TerraformPullRequest) ctrl.Result { + return func(ctx context.Context, r *Reconciler, repository *configv1alpha1.TerraformRepository, pr *configv1alpha1.TerraformPullRequest) ctrl.Result { + return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.WaitAction} + } +} + +type DiscoveryNeeded struct{} + +func (s *DiscoveryNeeded) getHandler() func(ctx context.Context, r *Reconciler, repository *configv1alpha1.TerraformRepository, pr *configv1alpha1.TerraformPullRequest) ctrl.Result { + return func(ctx context.Context, r *Reconciler, repository *configv1alpha1.TerraformRepository, pr *configv1alpha1.TerraformPullRequest) ctrl.Result { + layers, err := r.getAffectedLayers(repository, pr) + if err != nil { + log.Errorf("failed to get affected layers for pull request %s: %s", pr.Name, err) + return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.OnError} + } + newLayers := generateTempLayers(pr, layers) + for _, layer := range newLayers { + err := r.Client.Create(ctx, &layer) + if errors.IsAlreadyExists(err) { + log.Infof("layer %s already exists, updating it", layer.Name) + err = r.Client.Update(ctx, &layer) + if err != nil { + log.Errorf("failed to update layer %s: %s", layer.Name, err) + return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.OnError} + } + } + if err != nil { + log.Errorf("failed to create layer %s: %s", layer.Name, err) + return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.OnError} + } + } + err = annotations.Add(ctx, r.Client, pr, map[string]string{annotations.LastDiscoveredCommit: pr.Annotations[annotations.LastBranchCommit]}) + if err != nil { + log.Errorf("failed to add annotation %s to pull request %s: %s", annotations.LastDiscoveredCommit, pr.Name, err) + return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.OnError} + } + return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.WaitAction} + } +} + +type CommentNeeded struct{} + +func (s *CommentNeeded) getHandler() func(ctx context.Context, r *Reconciler, repository *configv1alpha1.TerraformRepository, pr *configv1alpha1.TerraformPullRequest) ctrl.Result { + return func(ctx context.Context, r *Reconciler, repository *configv1alpha1.TerraformRepository, pr *configv1alpha1.TerraformPullRequest) ctrl.Result { + layers, err := getLinkedLayers(r.Client, pr) + if err != nil { + log.Errorf("failed to get linked layers for pull request %s: %s", pr.Name, err) + return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.OnError} + } + + var provider Provider + found := false + for _, p := range r.Providers { + if p.IsFromProvider(pr) { + provider = p + found = true + } + } + if !found { + log.Infof("failed to get pull request provider. Requeuing") + return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.WaitAction} + } + + comment := comment.NewDefaultComment(layers, r.Storage) + err = provider.Comment(repository, pr, comment) + if err != nil { + log.Errorf("an error occured while commenting pull request: %s", err) + return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.OnError} + } + err = annotations.Add(ctx, r.Client, pr, map[string]string{annotations.LastCommentedCommit: pr.Annotations[annotations.LastDiscoveredCommit]}) + if err != nil { + log.Errorf("failed to add annotation %s to pull request %s: %s", annotations.LastCommentedCommit, pr.Name, err) + return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.OnError} + } + return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.WaitAction} + } +} + +func getStateString(state State) string { + t := strings.Split(fmt.Sprintf("%T", state), ".") + return t[len(t)-1] +} diff --git a/internal/e2e/testdata/terraform/random-pets/test-plan b/internal/e2e/testdata/terraform/random-pets/test-plan new file mode 100644 index 000000000..998f1cd5f Binary files /dev/null and b/internal/e2e/testdata/terraform/random-pets/test-plan differ diff --git a/internal/runner/runner.go b/internal/runner/runner.go index f9e4e0a9a..066b49217 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -52,7 +52,7 @@ type TerraformExec interface { Init(string) error Plan() error Apply() error - Show() ([]byte, error) + Show(string) ([]byte, error) } func New(c *config.Config) *Runner { @@ -61,13 +61,15 @@ func New(c *config.Config) *Runner { } } +func (r *Runner) unlock() { + err := lock.DeleteLock(context.TODO(), r.client, r.layer) + if err != nil { + log.Fatalf("could not remove lease lock for terraform layer %s: %s", r.layer.Name, err) + } +} + func (r *Runner) Exec() { - defer func() { - err := lock.DeleteLock(context.TODO(), r.client, r.layer) - if err != nil { - log.Fatalf("could not remove lease lock for terraform layer %s: %s", r.layer.Name, err) - } - }() + defer r.unlock() var sum string err := r.init() ann := map[string]string{} @@ -106,7 +108,7 @@ func (r *Runner) Exec() { number++ ann[annotations.Failure] = strconv.Itoa(number) } - err = annotations.Add(context.TODO(), r.client, *r.layer, ann) + err = annotations.Add(context.TODO(), r.client, r.layer, ann) if err != nil { log.Errorf("could not update terraform layer annotations: %s", err) } @@ -217,11 +219,22 @@ func (r *Runner) plan() (string, error) { log.Errorf("error executing terraform plan: %s", err) return "", err } - planJsonBytes, err := r.exec.Show() + planJsonBytes, err := r.exec.Show("json") if err != nil { log.Errorf("error getting terraform plan json: %s", err) return "", err } + prettyPlan, err := r.exec.Show("pretty") + if err != nil { + log.Errorf("error getting terraform pretty plan: %s", err) + return "", err + } + prettyPlanKey := storage.GenerateKey(storage.LastPrettyPlan, r.layer) + log.Infof("setting pretty plan into storage at key %s", prettyPlanKey) + err = r.storage.Set(prettyPlanKey, prettyPlan, 3600) + if err != nil { + log.Errorf("could not put pretty plan in cache: %s", err) + } plan := &tfjson.Plan{} err = json.Unmarshal(planJsonBytes, plan) if err != nil { diff --git a/internal/runner/terraform/terraform.go b/internal/runner/terraform/terraform.go index 736aac61a..9b7531d3e 100644 --- a/internal/runner/terraform/terraform.go +++ b/internal/runner/terraform/terraform.go @@ -3,6 +3,7 @@ package terraform import ( "context" "encoding/json" + "errors" "io" "os" @@ -74,17 +75,28 @@ func (t *Terraform) Apply() error { return nil } -func (t *Terraform) Show() ([]byte, error) { +func (t *Terraform) Show(mode string) ([]byte, error) { t.silent() - planJson, err := t.exec.ShowPlanFile(context.TODO(), t.planArtifactPath) - if err != nil { - return nil, err - } - planJsonBytes, err := json.Marshal(planJson) - if err != nil { - return nil, err + switch mode { + case "json": + planJson, err := t.exec.ShowPlanFile(context.TODO(), t.planArtifactPath) + if err != nil { + return nil, err + } + planJsonBytes, err := json.Marshal(planJson) + if err != nil { + return nil, err + } + return planJsonBytes, nil + case "pretty": + plan, err := t.exec.ShowPlanFileRaw(context.TODO(), t.planArtifactPath) + if err != nil { + return nil, err + } + return []byte(plan), nil + default: + return nil, errors.New("invalid mode") } - return planJsonBytes, nil } func (t *Terraform) silent() { diff --git a/internal/runner/terragrunt/terragrunt.go b/internal/runner/terragrunt/terragrunt.go index a745bc492..1aa70703f 100644 --- a/internal/runner/terragrunt/terragrunt.go +++ b/internal/runner/terragrunt/terragrunt.go @@ -1,6 +1,7 @@ package terragrunt import ( + "errors" "fmt" "io" "net/http" @@ -53,6 +54,7 @@ func (t *Terragrunt) getDefaultOptions(command string) []string { t.terraform.ExecPath, "--terragrunt-working-dir", t.workingDir, + "-no-color", } } @@ -89,15 +91,24 @@ func (t *Terragrunt) Apply() error { return nil } -func (t *Terragrunt) Show() ([]byte, error) { - options := append(t.getDefaultOptions("show"), "-json", t.planArtifactPath) +func (t *Terragrunt) Show(mode string) ([]byte, error) { + options := t.getDefaultOptions("show") + switch mode { + case "json": + options = append(options, "-json", t.planArtifactPath) + case "pretty": + options = append(options, t.planArtifactPath) + default: + return nil, errors.New("invalid mode") + } cmd := exec.Command(t.execPath, options...) cmd.Dir = t.workingDir - jsonBytes, err := cmd.Output() + output, err := cmd.Output() + if err != nil { return nil, err } - return jsonBytes, nil + return output, nil } func downloadTerragrunt(version string) (string, error) { diff --git a/internal/storage/common.go b/internal/storage/common.go index b858ef206..653e58272 100644 --- a/internal/storage/common.go +++ b/internal/storage/common.go @@ -35,6 +35,7 @@ const ( RunMessage Prefix = "runMessage" LastPlannedArtifactJson Prefix = "plannedArtifactJson" LastPlanResult Prefix = "planResult" + LastPrettyPlan Prefix = "prettyPlan" ) type Storage interface { @@ -44,23 +45,8 @@ type Storage interface { } func GenerateKey(prefix Prefix, layer *configv1alpha1.TerraformLayer) string { - var toHash string - switch prefix { - case LastPlannedArtifactBin: - toHash = layer.Spec.Repository.Name + layer.Spec.Repository.Namespace + layer.Spec.Path + layer.Spec.Branch - return fmt.Sprintf("%s-%d", prefix, hash(toHash)) - case LastPlannedArtifactJson: - toHash = layer.Spec.Repository.Name + layer.Spec.Repository.Namespace + layer.Spec.Path + layer.Spec.Branch - return fmt.Sprintf("%s-%d", prefix, hash(toHash)) - case LastPlanResult: - toHash = layer.Spec.Repository.Name + layer.Spec.Repository.Namespace + layer.Spec.Path + layer.Spec.Branch - return fmt.Sprintf("%s-%d", prefix, hash(toHash)) - case RunMessage: - toHash = layer.Spec.Repository.Name + layer.Spec.Repository.Namespace + layer.Spec.Path + layer.Spec.Branch - return fmt.Sprintf("%s-%d", prefix, hash(toHash)) - default: - return "" - } + toHash := layer.Spec.Repository.Name + layer.Spec.Repository.Namespace + layer.Spec.Path + layer.Spec.Branch + return fmt.Sprintf("%s-%d", prefix, hash(toHash)) } func hash(s string) uint32 { diff --git a/internal/webhook/event/common.go b/internal/webhook/event/common.go new file mode 100644 index 000000000..2a522a1d9 --- /dev/null +++ b/internal/webhook/event/common.go @@ -0,0 +1,81 @@ +package event + +import ( + "fmt" + "path/filepath" + "strings" + + configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const PullRequestOpened = "opened" +const PullRequestClosed = "closed" + +type ChangeInfo struct { + ShaBefore string + ShaAfter string +} + +type Event interface { + Handle(client.Client) error +} + +// Normalize a Github/Gitlab URL (SSH or HTTPS) to a HTTPS URL +func NormalizeUrl(url string) string { + if strings.Contains(url, "https://") { + return url + } + // All SSH URL from GitHub are like "git@padok.github.com:/.git" + // We split on ":" then remove ".git" by removing the last characters + // To handle enterprise GitHub, we dynamically get "padok.github.com" + // By removing "git@" at the beginning of the string + split := strings.Split(url, ":") + return fmt.Sprintf("https://%s/%s", split[0][4:], split[1][:len(split[1])-4]) +} + +func ParseRevision(ref string) string { + refParts := strings.SplitN(ref, "/", 3) + return refParts[len(refParts)-1] +} + +func isLayerLinkedToAnyRepositories(repositories []configv1alpha1.TerraformRepository, layer configv1alpha1.TerraformLayer) bool { + for _, r := range repositories { + if r.Name == layer.Spec.Repository.Name && r.Namespace == layer.Spec.Repository.Namespace { + return true + } + } + return false +} + +func layerFilesHaveChanged(layer configv1alpha1.TerraformLayer, changedFiles []string) bool { + if len(changedFiles) == 0 { + return true + } + + // At last one changed file must be under refresh path + for _, f := range changedFiles { + f = ensureAbsPath(f) + if strings.Contains(f, layer.Spec.Path) { + return true + } + } + + return false +} + +func isPRLinkedToAnyRepositories(pr configv1alpha1.TerraformPullRequest, repos []configv1alpha1.TerraformRepository) bool { + for _, r := range repos { + if r.Name == pr.Spec.Repository.Name && r.Namespace == pr.Spec.Repository.Namespace { + return true + } + } + return false +} + +func ensureAbsPath(input string) string { + if !filepath.IsAbs(input) { + return string(filepath.Separator) + input + } + return input +} diff --git a/internal/webhook/event/event_test.go b/internal/webhook/event/event_test.go new file mode 100644 index 000000000..5dfbc1a62 --- /dev/null +++ b/internal/webhook/event/event_test.go @@ -0,0 +1 @@ +package event_test diff --git a/internal/webhook/event/pullrequest.go b/internal/webhook/event/pullrequest.go new file mode 100644 index 000000000..80a4c9240 --- /dev/null +++ b/internal/webhook/event/pullrequest.go @@ -0,0 +1,111 @@ +package event + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" + "github.com/padok-team/burrito/internal/annotations" + log "github.com/sirupsen/logrus" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/hashicorp/go-multierror" +) + +type PullRequestEvent struct { + Provider string + URL string + Revision string + Base string + Action string + ID string + Commit string +} + +func (e *PullRequestEvent) Handle(c client.Client) error { + repositories := &configv1alpha1.TerraformRepositoryList{} + err := c.List(context.Background(), repositories) + if err != nil { + log.Errorf("could not list terraform repositories: %s", err) + return err + } + affectedRepositories := e.getAffectedRepositories(repositories.Items) + if len(affectedRepositories) == 0 { + log.Infof("no affected repositories found for pull request event") + return nil + } + prs := e.generateTerraformPullRequests(affectedRepositories) + switch e.Action { + case PullRequestOpened: + return batchCreatePullRequests(context.TODO(), c, prs) + case PullRequestClosed: + return batchDeletePullRequests(context.TODO(), c, prs) + default: + log.Infof("action %s not supported", e.Action) + } + return nil +} + +func batchCreatePullRequests(ctx context.Context, c client.Client, prs []configv1alpha1.TerraformPullRequest) error { + var errResult error + for _, pr := range prs { + err := c.Create(ctx, &pr) + if err != nil { + errResult = multierror.Append(errResult, err) + } + } + return errResult +} + +func batchDeletePullRequests(ctx context.Context, c client.Client, prs []configv1alpha1.TerraformPullRequest) error { + var errResult error + for _, pr := range prs { + err := c.Delete(ctx, &pr) + if err != nil { + errResult = multierror.Append(errResult, err) + } + } + return errResult +} + +func (e *PullRequestEvent) generateTerraformPullRequests(repositories []configv1alpha1.TerraformRepository) []configv1alpha1.TerraformPullRequest { + prs := []configv1alpha1.TerraformPullRequest{} + for _, repository := range repositories { + pr := configv1alpha1.TerraformPullRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s", repository.Name, e.ID), + Namespace: repository.Namespace, + Annotations: map[string]string{ + annotations.LastBranchCommit: e.Commit, + }, + }, + Spec: configv1alpha1.TerraformPullRequestSpec{ + Provider: e.Provider, + Branch: e.Revision, + ID: e.ID, + Base: e.Base, + Repository: configv1alpha1.TerraformLayerRepository{ + Name: repository.Name, + Namespace: repository.Namespace, + }, + }, + } + prs = append(prs, pr) + } + return prs +} + +// Function that checks if any TerraformRepository is linked to a PullRequestEvent +func (e *PullRequestEvent) getAffectedRepositories(repositories []configv1alpha1.TerraformRepository) []configv1alpha1.TerraformRepository { + affectedRepositories := []configv1alpha1.TerraformRepository{} + for _, repo := range repositories { + log.Infof("evaluating terraform repository %s for url %s", repo.Name, repo.Spec.Repository.Url) + log.Infof("comparing noramlized url %s with received URL from paylaod %s", NormalizeUrl(repo.Spec.Repository.Url), e.URL) + if e.URL == NormalizeUrl(repo.Spec.Repository.Url) { + affectedRepositories = append(affectedRepositories, repo) + } + } + return affectedRepositories +} diff --git a/internal/webhook/event/push.go b/internal/webhook/event/push.go new file mode 100644 index 000000000..265b09ab6 --- /dev/null +++ b/internal/webhook/event/push.go @@ -0,0 +1,109 @@ +package event + +import ( + "context" + + configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" + "github.com/padok-team/burrito/internal/annotations" + log "github.com/sirupsen/logrus" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type PushEvent struct { + URL string + Revision string + ChangeInfo + Changes []string +} + +func (e *PushEvent) Handle(c client.Client) error { + repositories := &configv1alpha1.TerraformRepositoryList{} + err := c.List(context.Background(), repositories) + if err != nil { + log.Errorf("could not list terraform repositories: %s", err) + return err + } + layers := &configv1alpha1.TerraformLayerList{} + err = c.List(context.Background(), layers) + if err != nil { + log.Errorf("could not list terraform layers: %s", err) + return err + } + prs := &configv1alpha1.TerraformPullRequestList{} + err = c.List(context.Background(), prs) + if err != nil { + log.Errorf("could not list terraform prs: %s", err) + return err + } + affectedRepositories := e.getAffectedRepositories(repositories.Items) + for _, repo := range affectedRepositories { + ann := map[string]string{} + ann[annotations.LastBranchCommit] = e.ChangeInfo.ShaAfter + err := annotations.Add(context.TODO(), c, &repo, ann) + if err != nil { + log.Errorf("could not add annotation to terraform repository %s", err) + return err + } + } + + for _, layer := range e.getAffectedLayers(layers.Items, repositories.Items) { + ann := map[string]string{} + log.Printf("evaluating terraform layer %s for revision %s", layer.Name, e.Revision) + if layer.Spec.Branch != e.Revision { + log.Infof("branch %s for terraform layer %s not matching revision %s", layer.Spec.Branch, layer.Name, e.Revision) + continue + } + ann[annotations.LastBranchCommit] = e.ChangeInfo.ShaAfter + if layerFilesHaveChanged(layer, e.Changes) { + ann[annotations.LastRelevantCommit] = e.ChangeInfo.ShaAfter + } + + err := annotations.Add(context.TODO(), c, &layer, ann) + if err != nil { + log.Errorf("could not add annotation to terraform layer %s", err) + return err + } + } + + for _, pr := range e.getAffectedPullRequests(prs.Items, affectedRepositories) { + ann := map[string]string{} + ann[annotations.LastBranchCommit] = e.ChangeInfo.ShaAfter + err := annotations.Add(context.TODO(), c, &pr, ann) + if err != nil { + log.Errorf("could not add annotation to terraform pr %s", err) + return err + } + } + return nil +} + +func (e *PushEvent) getAffectedRepositories(repositories []configv1alpha1.TerraformRepository) []configv1alpha1.TerraformRepository { + affectedRepositories := []configv1alpha1.TerraformRepository{} + for _, repo := range repositories { + if e.URL == NormalizeUrl(repo.Spec.Repository.Url) { + affectedRepositories = append(affectedRepositories, repo) + continue + } + } + return affectedRepositories +} + +func (e *PushEvent) getAffectedLayers(allLayers []configv1alpha1.TerraformLayer, affectedRepositories []configv1alpha1.TerraformRepository) []configv1alpha1.TerraformLayer { + layers := []configv1alpha1.TerraformLayer{} + for _, layer := range allLayers { + if isLayerLinkedToAnyRepositories(affectedRepositories, layer) { + layers = append(layers, layer) + } + } + return layers +} + +func (e *PushEvent) getAffectedPullRequests(prs []configv1alpha1.TerraformPullRequest, affectedRepositories []configv1alpha1.TerraformRepository) []configv1alpha1.TerraformPullRequest { + affectedPRs := []configv1alpha1.TerraformPullRequest{} + for _, pr := range prs { + if isPRLinkedToAnyRepositories(pr, affectedRepositories) && pr.Spec.Branch == e.Revision { + affectedPRs = append(affectedPRs, pr) + } + } + return affectedPRs +} diff --git a/internal/webhook/github/provider.go b/internal/webhook/github/provider.go new file mode 100644 index 000000000..cc732ddf4 --- /dev/null +++ b/internal/webhook/github/provider.go @@ -0,0 +1,76 @@ +package github + +import ( + "errors" + "net/http" + "strconv" + + "github.com/go-playground/webhooks/github" + "github.com/padok-team/burrito/internal/burrito/config" + "github.com/padok-team/burrito/internal/webhook/event" + + log "github.com/sirupsen/logrus" +) + +type Github struct { + github *github.Webhook +} + +func (g *Github) Init(c *config.Config) error { + githubWebhook, err := github.New(github.Options.Secret(c.Server.Webhook.Github.Secret)) + if err != nil { + return err + } + g.github = githubWebhook + return nil +} + +func (g *Github) IsFromProvider(r *http.Request) bool { + return r.Header.Get("X-GitHub-Event") != "" +} + +func (g *Github) GetEvent(r *http.Request) (event.Event, error) { + p, err := g.github.Parse(r, github.PushEvent, github.PingEvent, github.PullRequestEvent) + if errors.Is(err, github.ErrHMACVerificationFailed) { + log.Errorf("GitHub webhook HMAC verification failed: %s", err) + return nil, err + } + var e event.Event + switch payload := p.(type) { + case github.PushPayload: + log.Infof("parsing Github push event payload") + changedFiles := []string{} + for _, commit := range payload.Commits { + changedFiles = append(changedFiles, commit.Added...) + changedFiles = append(changedFiles, commit.Modified...) + changedFiles = append(changedFiles, commit.Removed...) + } + e = &event.PushEvent{ + URL: event.NormalizeUrl(payload.Repository.HTMLURL), + Revision: event.ParseRevision(payload.Ref), + ChangeInfo: event.ChangeInfo{ + ShaBefore: payload.Before, + ShaAfter: payload.After, + }, + Changes: changedFiles, + } + case github.PullRequestPayload: + log.Infof("parsing Github pull request event payload") + if err != nil { + log.Warnf("could not retrieve pull request from Github API: %s", err) + return nil, err + } + e = &event.PullRequestEvent{ + Provider: "github", + ID: strconv.FormatInt(payload.PullRequest.Number, 10), + URL: event.NormalizeUrl(payload.Repository.HTMLURL), + Revision: event.ParseRevision(payload.PullRequest.Head.Ref), + Action: payload.Action, + Base: payload.PullRequest.Base.Ref, + Commit: payload.PullRequest.Head.Sha, + } + default: + return nil, errors.New("unsupported Event") + } + return e, nil +} diff --git a/internal/webhook/gitlab/provider.go b/internal/webhook/gitlab/provider.go new file mode 100644 index 000000000..4d3398352 --- /dev/null +++ b/internal/webhook/gitlab/provider.go @@ -0,0 +1,81 @@ +package gitlab + +import ( + "errors" + "net/http" + "strconv" + + "github.com/go-playground/webhooks/gitlab" + "github.com/padok-team/burrito/internal/burrito/config" + "github.com/padok-team/burrito/internal/webhook/event" + log "github.com/sirupsen/logrus" +) + +type Gitlab struct { + gitlab *gitlab.Webhook +} + +func (g *Gitlab) Init(c *config.Config) error { + gitlabWebhook, err := gitlab.New(gitlab.Options.Secret(c.Server.Webhook.Gitlab.Secret)) + if err != nil { + return err + } + g.gitlab = gitlabWebhook + return nil +} + +func (g *Gitlab) IsFromProvider(r *http.Request) bool { + return r.Header.Get("X-Gitlab-Event") != "" +} + +func (g *Gitlab) GetEvent(r *http.Request) (event.Event, error) { + var e event.Event + p, err := g.gitlab.Parse(r, gitlab.PushEvents, gitlab.TagEvents, gitlab.MergeRequestEvents) + if errors.Is(err, gitlab.ErrGitLabTokenVerificationFailed) { + log.Errorf("GitLab webhook token verification failed: %s", err) + } + switch payload := p.(type) { + case gitlab.PushEventPayload: + log.Infof("parsing Gitlab push event payload") + changedFiles := []string{} + for _, commit := range payload.Commits { + changedFiles = append(changedFiles, commit.Added...) + changedFiles = append(changedFiles, commit.Modified...) + changedFiles = append(changedFiles, commit.Removed...) + } + e = &event.PushEvent{ + URL: event.NormalizeUrl(payload.Project.WebURL), + Revision: event.ParseRevision(payload.Ref), + ChangeInfo: event.ChangeInfo{ + ShaBefore: payload.Before, + ShaAfter: payload.After, + }, + Changes: changedFiles, + } + case gitlab.MergeRequestEventPayload: + log.Infof("parsing Gitlab merge request event payload") + e = &event.PullRequestEvent{ + Provider: "gitlab", + ID: strconv.Itoa(int(payload.ObjectAttributes.ID)), + URL: event.NormalizeUrl(payload.Project.WebURL), + Revision: event.ParseRevision(payload.ObjectAttributes.Ref), + Action: getNormalizedAction(payload.ObjectAttributes.Action), + Base: payload.ObjectAttributes.TargetBranch, + Commit: payload.ObjectAttributes.LastCommit.ID, + } + default: + return nil, errors.New("unsupported event") + } + return e, nil +} + +func getNormalizedAction(action string) string { + switch action { + case "open": + return event.PullRequestOpened + case "close": + return event.PullRequestClosed + default: + return action + } +} diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go index 22d5d25b6..5746e4dbb 100644 --- a/internal/webhook/webhook.go +++ b/internal/webhook/webhook.go @@ -1,28 +1,24 @@ package webhook import ( - "context" - "errors" "fmt" "html" "net/http" - "path/filepath" - "strings" log "github.com/sirupsen/logrus" - "github.com/go-playground/webhooks/v6/github" - "github.com/go-playground/webhooks/v6/gitlab" - "github.com/padok-team/burrito/internal/annotations" "github.com/padok-team/burrito/internal/burrito/config" - "k8s.io/apimachinery/pkg/runtime" + "github.com/padok-team/burrito/internal/webhook/event" + "github.com/padok-team/burrito/internal/webhook/github" + "github.com/padok-team/burrito/internal/webhook/gitlab" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" + "k8s.io/apimachinery/pkg/runtime" configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" ) type Handler interface { @@ -31,9 +27,8 @@ type Handler interface { type Webhook struct { client.Client - config *config.Config - github *github.Webhook - gitlab *gitlab.Webhook + config *config.Config + providers []Provider } func New(c *config.Config) *Webhook { @@ -42,6 +37,12 @@ func New(c *config.Config) *Webhook { } } +type Provider interface { + Init(*config.Config) error + IsFromProvider(*http.Request) bool + GetEvent(*http.Request) (event.Event, error) +} + func (w *Webhook) Init() error { scheme := runtime.NewScheme() utilruntime.Must(clientgoscheme.AddToScheme(scheme)) @@ -53,171 +54,58 @@ func (w *Webhook) Init() error { return err } w.Client = cl - githubWebhook, err := github.New(github.Options.Secret(w.config.Server.Webhook.Github.Secret)) - if err != nil { - return err + providers := []Provider{} + for _, p := range []Provider{&github.Github{}, &gitlab.Gitlab{}} { + err = p.Init(w.config) + if err != nil { + log.Warnf("failed to initialize webhook provider: %s", err) + continue + } + providers = append(providers, p) } - w.github = githubWebhook - gitlabWebhook, err := gitlab.New(gitlab.Options.Secret(w.config.Server.Webhook.Gitlab.Secret)) - if err != nil { - return err + if len(providers) == 0 { + log.Warnf("no webhook provider initialized, every event will be considered as unknown") } - w.gitlab = gitlabWebhook + w.providers = providers return nil } func (w *Webhook) GetHttpHandler() func(http.ResponseWriter, *http.Request) { log.Infof("webhook event received...") return func(writer http.ResponseWriter, r *http.Request) { - var payload interface{} var err error - - switch { - case r.Header.Get("X-GitHub-Event") != "": - log.Infof("webhook has detected a GitHub event") - payload, err = w.github.Parse(r, github.PushEvent, github.PingEvent) - if errors.Is(err, github.ErrHMACVerificationFailed) { - log.Errorf("GitHub webhook HMAC verification failed: %s", err) - } - case r.Header.Get("X-Gitlab-Event") != "": - log.Infof("webhook has detected a GitLab event") - payload, err = w.gitlab.Parse(r, gitlab.PushEvents, gitlab.TagEvents) - if errors.Is(err, gitlab.ErrGitLabTokenVerificationFailed) { - log.Errorf("GitLab webhook token verification failed: %s", err) + var event event.Event + for _, p := range w.providers { + if p.IsFromProvider(r) { + event, err = p.GetEvent(r) + break } - default: - log.Infof("ignoring unknown webhook event") - http.Error(writer, "Unknown webhook event", http.StatusBadRequest) - return } - if err != nil { log.Errorf("webhook processing failed: %s", err) status := http.StatusBadRequest if r.Method != "POST" { status = http.StatusMethodNotAllowed } - http.Error(writer, fmt.Sprintf("Webhook processing failed: %s", html.EscapeString(err.Error())), status) + http.Error(writer, fmt.Sprintf("webhook processing failed: %s", html.EscapeString(err.Error())), status) return } - - w.Handle(payload) - } -} - -func (w *Webhook) Handle(payload interface{}) { - webUrls, sshUrls, revision, change, touchedHead, changedFiles := affectedRevisionInfo(payload) - allUrls := append(webUrls, sshUrls...) - - if len(allUrls) == 0 { - log.Infof("ignoring webhook event") - return - } - for _, url := range allUrls { - log.Infof("received event repo: %s, revision: %s, touchedHead: %v", url, revision, touchedHead) - } - - repositories := &configv1alpha1.TerraformRepositoryList{} - err := w.Client.List(context.TODO(), repositories) - if err != nil { - log.Errorf("could not get terraform repositories: %s", err) - } - - for _, url := range allUrls { - for _, repo := range repositories.Items { - log.Infof("evaluating terraform repository %s for url %s", repo.Name, url) - if repo.Spec.Repository.Url != url { - log.Infof("evaluating terraform repository %s url %s not matching %s", repo.Name, repo.Spec.Repository.Url, url) - continue - } - layers := &configv1alpha1.TerraformLayerList{} - err := w.Client.List(context.TODO(), layers, &client.ListOptions{}) - if err != nil { - log.Errorf("could not get terraform layers: %s", err) - } - for _, layer := range layers.Items { - ann := map[string]string{} - log.Printf("evaluating terraform layer %s for revision %s", layer.Name, revision) - if layer.Spec.Branch != revision { - log.Infof("branch %s for terraform layer %s not matching revision %s", layer.Spec.Branch, layer.Name, revision) - continue - } - ann[annotations.LastBranchCommit] = change.shaAfter - if layerFilesHaveChanged(&layer, changedFiles) { - ann[annotations.LastRelevantCommit] = change.shaAfter - } - err = annotations.Add(context.TODO(), w.Client, layer, ann) - if err != nil { - log.Errorf("could not add annotation to terraform layer %s", err) - } - } - } - } - return -} - -type changeInfo struct { - shaBefore string - shaAfter string -} - -func parseRevision(ref string) string { - refParts := strings.SplitN(ref, "/", 3) - return refParts[len(refParts)-1] -} - -func affectedRevisionInfo(payloadIf interface{}) (webUrls []string, sshUrls []string, revision string, change changeInfo, touchedHead bool, changedFiles []string) { - switch payload := payloadIf.(type) { - case github.PushPayload: - log.Infof("parsing GitHub push event payload") - webUrls = append(webUrls, payload.Repository.HTMLURL) - sshUrls = append(sshUrls, payload.Repository.SSHURL) - revision = parseRevision(payload.Ref) - change.shaAfter = parseRevision(payload.After) - change.shaBefore = parseRevision(payload.Before) - touchedHead = bool(payload.Repository.DefaultBranch == revision) - for _, commit := range payload.Commits { - changedFiles = append(changedFiles, commit.Added...) - changedFiles = append(changedFiles, commit.Modified...) - changedFiles = append(changedFiles, commit.Removed...) - } - case gitlab.PushEventPayload: - log.Infof("parsing GitLab push event payload") - webUrls = append(webUrls, payload.Project.WebURL) - revision = parseRevision(payload.Ref) - change.shaAfter = parseRevision(payload.After) - change.shaBefore = parseRevision(payload.Before) - touchedHead = bool(payload.Project.DefaultBranch == revision) - for _, commit := range payload.Commits { - changedFiles = append(changedFiles, commit.Added...) - changedFiles = append(changedFiles, commit.Modified...) - changedFiles = append(changedFiles, commit.Removed...) + if event == nil { + log.Infof("ignoring unknown webhook event") + http.Error(writer, "Unknown webhook event", http.StatusBadRequest) } - default: - log.Infof("event not handled") - } - return webUrls, sshUrls, revision, change, touchedHead, changedFiles -} -func layerFilesHaveChanged(layer *configv1alpha1.TerraformLayer, changedFiles []string) bool { - if len(changedFiles) == 0 { - return true - } - - // At last one changed file must be under refresh path - for _, f := range changedFiles { - f = ensureAbsPath(f) - if strings.Contains(f, layer.Spec.Path) { - return true + err = w.Handle(event) + if err != nil { + log.Errorf("webhook processing worked but errored during event handling: %s", err) } } - - return false } -func ensureAbsPath(input string) string { - if !filepath.IsAbs(input) { - return string(filepath.Separator) + input +func (w *Webhook) Handle(e event.Event) error { + err := e.Handle(w.Client) + if err != nil { + return err } - return input + return nil } diff --git a/manifests/install.yaml b/manifests/install.yaml index a6e451b7b..9955eeb3e 100644 --- a/manifests/install.yaml +++ b/manifests/install.yaml @@ -1,6 +1,161 @@ --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: terraformpullrequests.config.terraform.padok.cloud +spec: + group: config.terraform.padok.cloud + names: + kind: TerraformPullRequest + listKind: TerraformPullRequestList + plural: terraformpullrequests + shortNames: + - pr + - prs + - pullrequest + - pullrequests + singular: terraformpullrequest + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.id + name: ID + type: string + - jsonPath: .status.state + name: State + type: string + - jsonPath: .spec.provider + name: Provider + type: string + - jsonPath: .spec.base + name: Base + type: string + - jsonPath: .spec.branch + name: Branch + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: TerraformPullRequest is the Schema for the TerraformPullRequests + API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: TerraformPullRequestSpec defines the desired state of TerraformPullRequest + properties: + base: + type: string + branch: + type: string + id: + type: string + provider: + type: string + repository: + properties: + name: + type: string + namespace: + type: string + type: object + type: object + status: + description: TerraformPullRequestStatus defines the observed state of + TerraformPullRequest + properties: + conditions: + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + state: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.10.0 @@ -247,7 +402,9 @@ spec: - name type: object type: array - x-kubernetes-list-type: set + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map limits: additionalProperties: anyOf: @@ -953,7 +1110,9 @@ spec: - name type: object type: array - x-kubernetes-list-type: set + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map limits: additionalProperties: anyOf: @@ -2332,7 +2491,9 @@ spec: - name type: object type: array - x-kubernetes-list-type: set + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map limits: additionalProperties: anyOf: @@ -3038,7 +3199,9 @@ spec: - name type: object type: array - x-kubernetes-list-type: set + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map limits: additionalProperties: anyOf: @@ -4056,8 +4219,6 @@ spec: type: object path: type: string - planOnPullRequest: - type: boolean remediationStrategy: enum: - dry @@ -4065,8 +4226,6 @@ spec: type: string repository: properties: - kind: - type: string name: type: string namespace: @@ -4290,65 +4449,31 @@ rules: resources: - terraformlayers verbs: - - create - - delete - get - list - patch - update - watch -- apiGroups: - - config.terraform.padok.cloud - resources: - - terraformlayers/finalizers - verbs: - - update -- apiGroups: - - config.terraform.padok.cloud - resources: - - terraformlayers/status - verbs: - - get - - patch - - update - apiGroups: - config.terraform.padok.cloud resources: - terraformrepositories verbs: - - create - - delete - get - list - - patch - - update - watch - apiGroups: - config.terraform.padok.cloud resources: - - terraformrepositories/finalizers - verbs: - - update -- apiGroups: - - config.terraform.padok.cloud - resources: - - terraformrepositories/status + - terraformpullrequests verbs: + - create + - delete - get + - list - patch - update -- apiGroups: - - "coordination.k8s.io" - resources: - - leases - verbs: - - get - - list - watch - - create - - update - - patch - - delete --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -4419,6 +4544,32 @@ rules: - terraformrepositories/finalizers verbs: - update +- apiGroups: + - config.terraform.padok.cloud + resources: + - terraformpullrequests + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - config.terraform.padok.cloud + resources: + - terraformpullrequests/finalizers + verbs: + - update +- apiGroups: + - config.terraform.padok.cloud + resources: + - terraformpullrequests/status + verbs: + - get + - patch + - update - apiGroups: - config.terraform.padok.cloud resources: @@ -4647,7 +4798,7 @@ metadata: data: known_hosts: |- bitbucket.org ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAubiN81eDcafrgMeLzaFPsw2kNvEcqTKl/VqLat/MaB33pZy0y3rJZtnqwR2qOOvbwKZYKiEO1O6VqNEBxKvJJelCq0dTXWT5pbO2gDXC6h6QDXCaHo6pOHGPUy+YBaGQRGuSusMEASYiWunYN0vCAI8QaXnWMXNMdFP3jHAJH0eDsoiGnLPBlBp4TNm6rYI74nMzgz3B9IikW4WVK+dc8KZJZWYjAuORU3jc1c/NPskD2ASinf8v3xnfXeukU0sJ5N6m5E8VLjObPEO+mN2t/FZTMZLiFqPWc/ALSqnMnnhwrNi2rbfg/rd/IpL8Le3pSBne8+seeFVBoGqzHM9yXw== - github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ== + github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk= gitlab.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY= gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf gitlab.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9