diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index aee22400..fba022da 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/api/v1alpha1/terraformlayer_types.go b/api/v1alpha1/terraformlayer_types.go
index b0185ef1..531e1151 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 00000000..1d3730cf
--- /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 ee198707..304aa265 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 846eb56c..2505073b 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/go.mod b/go.mod
index 9678169a..1aa4edb2 100644
--- a/go.mod
+++ b/go.mod
@@ -3,11 +3,12 @@ module github.com/padok-team/burrito
go 1.19
require (
- github.com/go-playground/webhooks/v6 v6.0.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
+ github.com/stretchr/testify v1.8.1
k8s.io/apimachinery v0.26.1
k8s.io/client-go v0.26.1
sigs.k8s.io/controller-runtime v0.14.0
@@ -21,10 +22,14 @@ require (
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/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
+ github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.2.0 // indirect
github.com/skeema/knownhosts v1.1.0 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
@@ -46,12 +51,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.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,12 +90,13 @@ 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.7.0 // indirect
golang.org/x/net v0.8.0 // indirect
- golang.org/x/oauth2 v0.6.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
diff --git a/go.sum b/go.sum
index 89a7246b..dd8e4cd2 100644
--- a/go.sum
+++ b/go.sum
@@ -103,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=
@@ -146,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=
@@ -204,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=
@@ -227,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=
@@ -297,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=
@@ -394,6 +406,7 @@ github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -403,11 +416,14 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
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=
diff --git a/internal/annotations/annotations.go b/internal/annotations/annotations.go
index f90a5b09..0fb230e0 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 d715b1bc..033e000d 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 d181a6f8..6b0e90ca 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 8f7ee4e6..ff8530ff 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 67baa078..639c0291 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 9d3d3362..b87f2a8e 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...)
diff --git a/internal/controllers/terraformlayer/states.go b/internal/controllers/terraformlayer/states.go
index 0242ae00..e94df8c6 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 00000000..4478ffd3
--- /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 00000000..c8123ecb
--- /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 00000000..3bce9fc3
--- /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 00000000..4677840a
--- /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 00000000..f2032438
--- /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 00000000..5b83cd6a
--- /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 00000000..25ad8139
--- /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 00000000..f4e4cfe4
--- /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 00000000..01f1d8df
--- /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 00000000..1cd07fe9
--- /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 00000000..2518155b
--- /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 00000000..998f1cd5
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 f9e4e0a9..066b4921 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 736aac61..9b7531d3 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 a745bc49..1aa70703 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 b858ef20..653e5827 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 00000000..7b328432
--- /dev/null
+++ b/internal/webhook/event/common.go
@@ -0,0 +1,84 @@
+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
+ }
+ if strings.Contains(url, "http://") {
+ return "https://" + url[7:]
+ }
+ // 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 00000000..5dfbc1a6
--- /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 00000000..80a4c924
--- /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 00000000..265b09ab
--- /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 00000000..a17bca06
--- /dev/null
+++ b/internal/webhook/github/provider.go
@@ -0,0 +1,81 @@
+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
+ }
+ if err != nil {
+ log.Errorf("an error occured during request parsing: %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/github/provider_test.go b/internal/webhook/github/provider_test.go
new file mode 100644
index 00000000..eaf960c9
--- /dev/null
+++ b/internal/webhook/github/provider_test.go
@@ -0,0 +1,152 @@
+package github_test
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/sha1"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "os"
+
+ "net/http"
+ "testing"
+
+ "github.com/padok-team/burrito/internal/burrito/config"
+ "github.com/padok-team/burrito/internal/webhook/event"
+ "github.com/padok-team/burrito/internal/webhook/github"
+
+ webhook "github.com/go-playground/webhooks/github"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGithub_IsFromProvider(t *testing.T) {
+ github := github.Github{}
+
+ req, err := http.NewRequest("GET", "/", nil)
+ assert.NoError(t, err)
+ req.Header.Set("X-GitHub-Event", "test")
+ assert.True(t, github.IsFromProvider(req))
+
+ req, err = http.NewRequest("GET", "/", nil)
+ assert.NoError(t, err)
+ req.Header.Set("X-GitLab-Event", "test")
+ assert.False(t, github.IsFromProvider(req))
+}
+
+func TestGithub_GetEvent_PushEvent(t *testing.T) {
+ payloadFile, err := os.Open("../testdata/github-push-main-event.json")
+ if err != nil {
+ t.Fatalf("failed to open payload file: %v", err)
+ }
+ defer payloadFile.Close()
+
+ payloadBytes, err := ioutil.ReadAll(payloadFile)
+ if err != nil {
+ t.Fatalf("failed to read payload file: %v", err)
+ }
+
+ var payload webhook.PushPayload
+ err = json.Unmarshal(payloadBytes, &payload)
+ if err != nil {
+ t.Fatalf("failed to unmarshal payload: %v", err)
+ }
+
+ req, err := http.NewRequest("POST", "/", bytes.NewBuffer(payloadBytes))
+ if err != nil {
+ t.Fatalf("failed to create request: %v", err)
+ }
+
+ secret := "test-secret"
+ github := github.Github{}
+ config := &config.Config{
+ Server: config.Server{
+ Webhook: config.WebhookConfig{
+ Github: config.WebhookGithubConfig{
+ Secret: secret,
+ },
+ },
+ },
+ }
+ err = github.Init(config)
+ assert.NoError(t, err)
+
+ req.Header.Set("X-GitHub-Event", "push")
+
+ mac := hmac.New(sha1.New, []byte(secret))
+ _, err = mac.Write(payloadBytes)
+ assert.NoError(t, err)
+ expectedMac := hex.EncodeToString(mac.Sum(nil))
+ req.Header.Set("X-Hub-Signature", fmt.Sprintf("sha1=%s", expectedMac))
+
+ evt, err := github.GetEvent(req)
+ assert.NoError(t, err)
+ assert.IsType(t, &event.PushEvent{}, evt)
+
+ pushEvt := evt.(*event.PushEvent)
+ assert.Equal(t, "https://github.com/padok-team/burrito-examples", pushEvt.URL)
+ assert.Equal(t, "main", pushEvt.Revision)
+ assert.Equal(t, "6f51b4ffd5e3adadfc3ee649d5ea2499472ea33b", pushEvt.ShaBefore)
+ assert.Equal(t, "ca9b6c80ac8fb5cd837ae9b374b79ff33f472558", pushEvt.ShaAfter)
+ assert.ElementsMatch(t, []string{"modules/random-pets/main.tf", "terragrunt/random-pets/test/inputs.hcl", "modules/random-pets/variables.tf"}, pushEvt.Changes)
+}
+
+func TestGithub_GetEvent_PullRequestEvent(t *testing.T) {
+ payloadFile, err := os.Open("../testdata/github-open-pull-request-event.json")
+ if err != nil {
+ t.Fatalf("failed to open payload file: %v", err)
+ }
+ defer payloadFile.Close()
+
+ payloadBytes, err := ioutil.ReadAll(payloadFile)
+ if err != nil {
+ t.Fatalf("failed to read payload file: %v", err)
+ }
+
+ var payload webhook.PullRequestPayload
+ err = json.Unmarshal(payloadBytes, &payload)
+ if err != nil {
+ t.Fatalf("failed to unmarshal payload: %v", err)
+ }
+
+ req, err := http.NewRequest("POST", "/", bytes.NewBuffer(payloadBytes))
+ if err != nil {
+ t.Fatalf("failed to create request: %v", err)
+ }
+
+ secret := "test-secret"
+ github := github.Github{}
+ config := &config.Config{
+ Server: config.Server{
+ Webhook: config.WebhookConfig{
+ Github: config.WebhookGithubConfig{
+ Secret: secret,
+ },
+ },
+ },
+ }
+ err = github.Init(config)
+ assert.NoError(t, err)
+
+ req.Header.Set("X-GitHub-Event", "pull_request")
+
+ mac := hmac.New(sha1.New, []byte(secret))
+ _, err = mac.Write(payloadBytes)
+ assert.NoError(t, err)
+ expectedMac := hex.EncodeToString(mac.Sum(nil))
+ req.Header.Set("X-Hub-Signature", fmt.Sprintf("sha1=%s", expectedMac))
+
+ evt, err := github.GetEvent(req)
+ assert.NoError(t, err)
+ assert.IsType(t, &event.PullRequestEvent{}, evt)
+
+ pullRequestEvt := evt.(*event.PullRequestEvent)
+ assert.Equal(t, "20", pullRequestEvt.ID)
+ assert.Equal(t, "github", pullRequestEvt.Provider)
+ assert.Equal(t, "https://github.com/padok-team/burrito-examples", pullRequestEvt.URL)
+ assert.Equal(t, "demo", pullRequestEvt.Revision)
+ assert.Equal(t, "main", pullRequestEvt.Base)
+ assert.Equal(t, "faf5e25402a9bd10f7318c8a2cd984af576c687f", pullRequestEvt.Commit)
+ assert.Equal(t, "opened", pullRequestEvt.Action)
+}
diff --git a/internal/webhook/gitlab/provider.go b/internal/webhook/gitlab/provider.go
new file mode 100644
index 00000000..bcad443a
--- /dev/null
+++ b/internal/webhook/gitlab/provider.go
@@ -0,0 +1,86 @@
+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)
+ return nil, err
+ }
+ if err != nil {
+ log.Errorf("an error occured during event parsing: %s", err)
+ return nil, 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.SourceBranch),
+ 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/gitlab/provider_test.go b/internal/webhook/gitlab/provider_test.go
new file mode 100644
index 00000000..2d85d511
--- /dev/null
+++ b/internal/webhook/gitlab/provider_test.go
@@ -0,0 +1,138 @@
+package gitlab_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "io/ioutil"
+ "os"
+
+ "net/http"
+ "testing"
+
+ "github.com/padok-team/burrito/internal/burrito/config"
+ "github.com/padok-team/burrito/internal/webhook/event"
+ "github.com/padok-team/burrito/internal/webhook/gitlab"
+
+ webhook "github.com/go-playground/webhooks/gitlab"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGilab_IsFromProvider(t *testing.T) {
+ github := gitlab.Gitlab{}
+
+ req, err := http.NewRequest("GET", "/", nil)
+ assert.NoError(t, err)
+ req.Header.Set("X-GitHub-Event", "test")
+ assert.False(t, github.IsFromProvider(req))
+
+ req, err = http.NewRequest("GET", "/", nil)
+ assert.NoError(t, err)
+ req.Header.Set("X-GitLab-Event", "test")
+ assert.True(t, github.IsFromProvider(req))
+}
+
+func TestGitlab_GetEvent_PushEvent(t *testing.T) {
+ payloadFile, err := os.Open("../testdata/gitlab-push-main-event.json")
+ if err != nil {
+ t.Fatalf("failed to open payload file: %v", err)
+ }
+ defer payloadFile.Close()
+
+ payloadBytes, err := ioutil.ReadAll(payloadFile)
+ if err != nil {
+ t.Fatalf("failed to read payload file: %v", err)
+ }
+
+ var payload webhook.PushEventPayload
+ err = json.Unmarshal(payloadBytes, &payload)
+ if err != nil {
+ t.Fatalf("failed to unmarshal payload: %v", err)
+ }
+
+ req, err := http.NewRequest("POST", "/", bytes.NewBuffer(payloadBytes))
+ if err != nil {
+ t.Fatalf("failed to create request: %v", err)
+ }
+
+ secret := "test-secret"
+ gitlab := gitlab.Gitlab{}
+ config := &config.Config{
+ Server: config.Server{
+ Webhook: config.WebhookConfig{
+ Gitlab: config.WebhookGitlabConfig{
+ Secret: secret,
+ },
+ },
+ },
+ }
+ err = gitlab.Init(config)
+ assert.NoError(t, err)
+
+ req.Header.Set("X-GitLab-Event", "Push Hook")
+ req.Header.Set("X-Gitlab-Token", secret)
+
+ evt, err := gitlab.GetEvent(req)
+ assert.NoError(t, err)
+ assert.IsType(t, &event.PushEvent{}, evt)
+
+ pushEvt := evt.(*event.PushEvent)
+ assert.Equal(t, "https://gitlab.com/burrito/examples", pushEvt.URL)
+ assert.Equal(t, "main", pushEvt.Revision)
+ assert.Equal(t, "95790bf891e76fee5e1747ab589903a6a1f80f22", pushEvt.ShaBefore)
+ assert.Equal(t, "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", pushEvt.ShaAfter)
+ assert.ElementsMatch(t, []string{"test.hcl", "layer-1/prod.hcl", "layer-2/staging.hcl"}, pushEvt.Changes)
+}
+
+func TestGitlab_GetEvent_MergeRequestEvent(t *testing.T) {
+ payloadFile, err := os.Open("../testdata/gitlab-open-merge-request-event.json")
+ if err != nil {
+ t.Fatalf("failed to open payload file: %v", err)
+ }
+ defer payloadFile.Close()
+
+ payloadBytes, err := ioutil.ReadAll(payloadFile)
+ if err != nil {
+ t.Fatalf("failed to read payload file: %v", err)
+ }
+
+ var payload webhook.MergeRequestEventPayload
+ err = json.Unmarshal(payloadBytes, &payload)
+ if err != nil {
+ t.Fatalf("failed to unmarshal payload: %v", err)
+ }
+
+ req, err := http.NewRequest("POST", "/", bytes.NewBuffer(payloadBytes))
+ if err != nil {
+ t.Fatalf("failed to create request: %v", err)
+ }
+
+ secret := "test-secret"
+ gitlab := gitlab.Gitlab{}
+ config := &config.Config{
+ Server: config.Server{
+ Webhook: config.WebhookConfig{
+ Gitlab: config.WebhookGitlabConfig{
+ Secret: secret,
+ },
+ },
+ },
+ }
+ err = gitlab.Init(config)
+ assert.NoError(t, err)
+
+ req.Header.Set("X-GitLab-Event", "Merge Request Hook")
+ req.Header.Set("X-Gitlab-Token", secret)
+
+ evt, err := gitlab.GetEvent(req)
+ assert.NoError(t, err)
+ assert.IsType(t, &event.PullRequestEvent{}, evt)
+
+ pullRequestEvt := evt.(*event.PullRequestEvent)
+ assert.Equal(t, "99", pullRequestEvt.ID)
+ assert.Equal(t, "gitlab", pullRequestEvt.Provider)
+ assert.Equal(t, "https://example.com/gitlabhq/gitlab-test", pullRequestEvt.URL)
+ assert.Equal(t, "demo", pullRequestEvt.Revision)
+ assert.Equal(t, "main", pullRequestEvt.Base)
+ assert.Equal(t, "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", pullRequestEvt.Commit)
+ assert.Equal(t, "opened", pullRequestEvt.Action)
+}
diff --git a/internal/webhook/testdata/github-open-pull-request-event.json b/internal/webhook/testdata/github-open-pull-request-event.json
new file mode 100644
index 00000000..d21df81c
--- /dev/null
+++ b/internal/webhook/testdata/github-open-pull-request-event.json
@@ -0,0 +1,520 @@
+{
+ "action": "opened",
+ "number": 20,
+ "pull_request": {
+ "url": "https://api.github.com/repos/padok-team/burrito-examples/pulls/20",
+ "id": 1324375711,
+ "node_id": "PR_kwDOJYQOH85O8F6f",
+ "html_url": "https://github.com/padok-team/burrito-examples/pull/20",
+ "diff_url": "https://github.com/padok-team/burrito-examples/pull/20.diff",
+ "patch_url": "https://github.com/padok-team/burrito-examples/pull/20.patch",
+ "issue_url": "https://api.github.com/repos/padok-team/burrito-examples/issues/20",
+ "number": 20,
+ "state": "open",
+ "locked": false,
+ "title": "feat: add a new random pet",
+ "user": {
+ "login": "spoukke",
+ "id": 32708678,
+ "node_id": "MDQ6VXNlcjMyNzA4Njc4",
+ "avatar_url": "https://avatars.githubusercontent.com/u/32708678?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/spoukke",
+ "html_url": "https://github.com/spoukke",
+ "followers_url": "https://api.github.com/users/spoukke/followers",
+ "following_url": "https://api.github.com/users/spoukke/following{/other_user}",
+ "gists_url": "https://api.github.com/users/spoukke/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/spoukke/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/spoukke/subscriptions",
+ "organizations_url": "https://api.github.com/users/spoukke/orgs",
+ "repos_url": "https://api.github.com/users/spoukke/repos",
+ "events_url": "https://api.github.com/users/spoukke/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/spoukke/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "body": null,
+ "created_at": "2023-04-23T13:20:57Z",
+ "updated_at": "2023-04-23T13:20:57Z",
+ "closed_at": null,
+ "merged_at": null,
+ "merge_commit_sha": null,
+ "assignee": null,
+ "assignees": [],
+ "requested_reviewers": [],
+ "requested_teams": [],
+ "labels": [],
+ "milestone": null,
+ "draft": false,
+ "commits_url": "https://api.github.com/repos/padok-team/burrito-examples/pulls/20/commits",
+ "review_comments_url": "https://api.github.com/repos/padok-team/burrito-examples/pulls/20/comments",
+ "review_comment_url": "https://api.github.com/repos/padok-team/burrito-examples/pulls/comments{/number}",
+ "comments_url": "https://api.github.com/repos/padok-team/burrito-examples/issues/20/comments",
+ "statuses_url": "https://api.github.com/repos/padok-team/burrito-examples/statuses/faf5e25402a9bd10f7318c8a2cd984af576c687f",
+ "head": {
+ "label": "padok-team:demo",
+ "ref": "demo",
+ "sha": "faf5e25402a9bd10f7318c8a2cd984af576c687f",
+ "user": {
+ "login": "padok-team",
+ "id": 46325765,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ2MzI1NzY1",
+ "avatar_url": "https://avatars.githubusercontent.com/u/46325765?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/padok-team",
+ "html_url": "https://github.com/padok-team",
+ "followers_url": "https://api.github.com/users/padok-team/followers",
+ "following_url": "https://api.github.com/users/padok-team/following{/other_user}",
+ "gists_url": "https://api.github.com/users/padok-team/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/padok-team/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/padok-team/subscriptions",
+ "organizations_url": "https://api.github.com/users/padok-team/orgs",
+ "repos_url": "https://api.github.com/users/padok-team/repos",
+ "events_url": "https://api.github.com/users/padok-team/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/padok-team/received_events",
+ "type": "Organization",
+ "site_admin": false
+ },
+ "repo": {
+ "id": 629411359,
+ "node_id": "R_kgDOJYQOHw",
+ "name": "burrito-examples",
+ "full_name": "padok-team/burrito-examples",
+ "private": true,
+ "owner": {
+ "login": "padok-team",
+ "id": 46325765,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ2MzI1NzY1",
+ "avatar_url": "https://avatars.githubusercontent.com/u/46325765?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/padok-team",
+ "html_url": "https://github.com/padok-team",
+ "followers_url": "https://api.github.com/users/padok-team/followers",
+ "following_url": "https://api.github.com/users/padok-team/following{/other_user}",
+ "gists_url": "https://api.github.com/users/padok-team/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/padok-team/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/padok-team/subscriptions",
+ "organizations_url": "https://api.github.com/users/padok-team/orgs",
+ "repos_url": "https://api.github.com/users/padok-team/repos",
+ "events_url": "https://api.github.com/users/padok-team/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/padok-team/received_events",
+ "type": "Organization",
+ "site_admin": false
+ },
+ "html_url": "https://github.com/padok-team/burrito-examples",
+ "description": "A repository with examples on how to use burrito.",
+ "fork": false,
+ "url": "https://api.github.com/repos/padok-team/burrito-examples",
+ "forks_url": "https://api.github.com/repos/padok-team/burrito-examples/forks",
+ "keys_url": "https://api.github.com/repos/padok-team/burrito-examples/keys{/key_id}",
+ "collaborators_url": "https://api.github.com/repos/padok-team/burrito-examples/collaborators{/collaborator}",
+ "teams_url": "https://api.github.com/repos/padok-team/burrito-examples/teams",
+ "hooks_url": "https://api.github.com/repos/padok-team/burrito-examples/hooks",
+ "issue_events_url": "https://api.github.com/repos/padok-team/burrito-examples/issues/events{/number}",
+ "events_url": "https://api.github.com/repos/padok-team/burrito-examples/events",
+ "assignees_url": "https://api.github.com/repos/padok-team/burrito-examples/assignees{/user}",
+ "branches_url": "https://api.github.com/repos/padok-team/burrito-examples/branches{/branch}",
+ "tags_url": "https://api.github.com/repos/padok-team/burrito-examples/tags",
+ "blobs_url": "https://api.github.com/repos/padok-team/burrito-examples/git/blobs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/padok-team/burrito-examples/git/tags{/sha}",
+ "git_refs_url": "https://api.github.com/repos/padok-team/burrito-examples/git/refs{/sha}",
+ "trees_url": "https://api.github.com/repos/padok-team/burrito-examples/git/trees{/sha}",
+ "statuses_url": "https://api.github.com/repos/padok-team/burrito-examples/statuses/{sha}",
+ "languages_url": "https://api.github.com/repos/padok-team/burrito-examples/languages",
+ "stargazers_url": "https://api.github.com/repos/padok-team/burrito-examples/stargazers",
+ "contributors_url": "https://api.github.com/repos/padok-team/burrito-examples/contributors",
+ "subscribers_url": "https://api.github.com/repos/padok-team/burrito-examples/subscribers",
+ "subscription_url": "https://api.github.com/repos/padok-team/burrito-examples/subscription",
+ "commits_url": "https://api.github.com/repos/padok-team/burrito-examples/commits{/sha}",
+ "git_commits_url": "https://api.github.com/repos/padok-team/burrito-examples/git/commits{/sha}",
+ "comments_url": "https://api.github.com/repos/padok-team/burrito-examples/comments{/number}",
+ "issue_comment_url": "https://api.github.com/repos/padok-team/burrito-examples/issues/comments{/number}",
+ "contents_url": "https://api.github.com/repos/padok-team/burrito-examples/contents/{+path}",
+ "compare_url": "https://api.github.com/repos/padok-team/burrito-examples/compare/{base}...{head}",
+ "merges_url": "https://api.github.com/repos/padok-team/burrito-examples/merges",
+ "archive_url": "https://api.github.com/repos/padok-team/burrito-examples/{archive_format}{/ref}",
+ "downloads_url": "https://api.github.com/repos/padok-team/burrito-examples/downloads",
+ "issues_url": "https://api.github.com/repos/padok-team/burrito-examples/issues{/number}",
+ "pulls_url": "https://api.github.com/repos/padok-team/burrito-examples/pulls{/number}",
+ "milestones_url": "https://api.github.com/repos/padok-team/burrito-examples/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/padok-team/burrito-examples/notifications{?since,all,participating}",
+ "labels_url": "https://api.github.com/repos/padok-team/burrito-examples/labels{/name}",
+ "releases_url": "https://api.github.com/repos/padok-team/burrito-examples/releases{/id}",
+ "deployments_url": "https://api.github.com/repos/padok-team/burrito-examples/deployments",
+ "created_at": "2023-04-18T09:00:53Z",
+ "updated_at": "2023-04-18T09:10:54Z",
+ "pushed_at": "2023-04-23T13:20:58Z",
+ "git_url": "git://github.com/padok-team/burrito-examples.git",
+ "ssh_url": "git@github.com:padok-team/burrito-examples.git",
+ "clone_url": "https://github.com/padok-team/burrito-examples.git",
+ "svn_url": "https://github.com/padok-team/burrito-examples",
+ "homepage": null,
+ "size": 8,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "language": "HCL",
+ "has_issues": true,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_wiki": false,
+ "has_pages": false,
+ "has_discussions": false,
+ "forks_count": 0,
+ "mirror_url": null,
+ "archived": false,
+ "disabled": false,
+ "open_issues_count": 1,
+ "license": {
+ "key": "apache-2.0",
+ "name": "Apache License 2.0",
+ "spdx_id": "Apache-2.0",
+ "url": "https://api.github.com/licenses/apache-2.0",
+ "node_id": "MDc6TGljZW5zZTI="
+ },
+ "allow_forking": false,
+ "is_template": false,
+ "web_commit_signoff_required": false,
+ "topics": [],
+ "visibility": "private",
+ "forks": 0,
+ "open_issues": 1,
+ "watchers": 0,
+ "default_branch": "main",
+ "allow_squash_merge": true,
+ "allow_merge_commit": true,
+ "allow_rebase_merge": true,
+ "allow_auto_merge": false,
+ "delete_branch_on_merge": false,
+ "allow_update_branch": false,
+ "use_squash_pr_title_as_default": false,
+ "squash_merge_commit_message": "COMMIT_MESSAGES",
+ "squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
+ "merge_commit_message": "PR_TITLE",
+ "merge_commit_title": "MERGE_MESSAGE"
+ }
+ },
+ "base": {
+ "label": "padok-team:main",
+ "ref": "main",
+ "sha": "ca9b6c80ac8fb5cd837ae9b374b79ff33f472558",
+ "user": {
+ "login": "padok-team",
+ "id": 46325765,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ2MzI1NzY1",
+ "avatar_url": "https://avatars.githubusercontent.com/u/46325765?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/padok-team",
+ "html_url": "https://github.com/padok-team",
+ "followers_url": "https://api.github.com/users/padok-team/followers",
+ "following_url": "https://api.github.com/users/padok-team/following{/other_user}",
+ "gists_url": "https://api.github.com/users/padok-team/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/padok-team/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/padok-team/subscriptions",
+ "organizations_url": "https://api.github.com/users/padok-team/orgs",
+ "repos_url": "https://api.github.com/users/padok-team/repos",
+ "events_url": "https://api.github.com/users/padok-team/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/padok-team/received_events",
+ "type": "Organization",
+ "site_admin": false
+ },
+ "repo": {
+ "id": 629411359,
+ "node_id": "R_kgDOJYQOHw",
+ "name": "burrito-examples",
+ "full_name": "padok-team/burrito-examples",
+ "private": true,
+ "owner": {
+ "login": "padok-team",
+ "id": 46325765,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ2MzI1NzY1",
+ "avatar_url": "https://avatars.githubusercontent.com/u/46325765?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/padok-team",
+ "html_url": "https://github.com/padok-team",
+ "followers_url": "https://api.github.com/users/padok-team/followers",
+ "following_url": "https://api.github.com/users/padok-team/following{/other_user}",
+ "gists_url": "https://api.github.com/users/padok-team/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/padok-team/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/padok-team/subscriptions",
+ "organizations_url": "https://api.github.com/users/padok-team/orgs",
+ "repos_url": "https://api.github.com/users/padok-team/repos",
+ "events_url": "https://api.github.com/users/padok-team/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/padok-team/received_events",
+ "type": "Organization",
+ "site_admin": false
+ },
+ "html_url": "https://github.com/padok-team/burrito-examples",
+ "description": "A repository with examples on how to use burrito.",
+ "fork": false,
+ "url": "https://api.github.com/repos/padok-team/burrito-examples",
+ "forks_url": "https://api.github.com/repos/padok-team/burrito-examples/forks",
+ "keys_url": "https://api.github.com/repos/padok-team/burrito-examples/keys{/key_id}",
+ "collaborators_url": "https://api.github.com/repos/padok-team/burrito-examples/collaborators{/collaborator}",
+ "teams_url": "https://api.github.com/repos/padok-team/burrito-examples/teams",
+ "hooks_url": "https://api.github.com/repos/padok-team/burrito-examples/hooks",
+ "issue_events_url": "https://api.github.com/repos/padok-team/burrito-examples/issues/events{/number}",
+ "events_url": "https://api.github.com/repos/padok-team/burrito-examples/events",
+ "assignees_url": "https://api.github.com/repos/padok-team/burrito-examples/assignees{/user}",
+ "branches_url": "https://api.github.com/repos/padok-team/burrito-examples/branches{/branch}",
+ "tags_url": "https://api.github.com/repos/padok-team/burrito-examples/tags",
+ "blobs_url": "https://api.github.com/repos/padok-team/burrito-examples/git/blobs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/padok-team/burrito-examples/git/tags{/sha}",
+ "git_refs_url": "https://api.github.com/repos/padok-team/burrito-examples/git/refs{/sha}",
+ "trees_url": "https://api.github.com/repos/padok-team/burrito-examples/git/trees{/sha}",
+ "statuses_url": "https://api.github.com/repos/padok-team/burrito-examples/statuses/{sha}",
+ "languages_url": "https://api.github.com/repos/padok-team/burrito-examples/languages",
+ "stargazers_url": "https://api.github.com/repos/padok-team/burrito-examples/stargazers",
+ "contributors_url": "https://api.github.com/repos/padok-team/burrito-examples/contributors",
+ "subscribers_url": "https://api.github.com/repos/padok-team/burrito-examples/subscribers",
+ "subscription_url": "https://api.github.com/repos/padok-team/burrito-examples/subscription",
+ "commits_url": "https://api.github.com/repos/padok-team/burrito-examples/commits{/sha}",
+ "git_commits_url": "https://api.github.com/repos/padok-team/burrito-examples/git/commits{/sha}",
+ "comments_url": "https://api.github.com/repos/padok-team/burrito-examples/comments{/number}",
+ "issue_comment_url": "https://api.github.com/repos/padok-team/burrito-examples/issues/comments{/number}",
+ "contents_url": "https://api.github.com/repos/padok-team/burrito-examples/contents/{+path}",
+ "compare_url": "https://api.github.com/repos/padok-team/burrito-examples/compare/{base}...{head}",
+ "merges_url": "https://api.github.com/repos/padok-team/burrito-examples/merges",
+ "archive_url": "https://api.github.com/repos/padok-team/burrito-examples/{archive_format}{/ref}",
+ "downloads_url": "https://api.github.com/repos/padok-team/burrito-examples/downloads",
+ "issues_url": "https://api.github.com/repos/padok-team/burrito-examples/issues{/number}",
+ "pulls_url": "https://api.github.com/repos/padok-team/burrito-examples/pulls{/number}",
+ "milestones_url": "https://api.github.com/repos/padok-team/burrito-examples/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/padok-team/burrito-examples/notifications{?since,all,participating}",
+ "labels_url": "https://api.github.com/repos/padok-team/burrito-examples/labels{/name}",
+ "releases_url": "https://api.github.com/repos/padok-team/burrito-examples/releases{/id}",
+ "deployments_url": "https://api.github.com/repos/padok-team/burrito-examples/deployments",
+ "created_at": "2023-04-18T09:00:53Z",
+ "updated_at": "2023-04-18T09:10:54Z",
+ "pushed_at": "2023-04-23T13:20:58Z",
+ "git_url": "git://github.com/padok-team/burrito-examples.git",
+ "ssh_url": "git@github.com:padok-team/burrito-examples.git",
+ "clone_url": "https://github.com/padok-team/burrito-examples.git",
+ "svn_url": "https://github.com/padok-team/burrito-examples",
+ "homepage": null,
+ "size": 8,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "language": "HCL",
+ "has_issues": true,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_wiki": false,
+ "has_pages": false,
+ "has_discussions": false,
+ "forks_count": 0,
+ "mirror_url": null,
+ "archived": false,
+ "disabled": false,
+ "open_issues_count": 1,
+ "license": {
+ "key": "apache-2.0",
+ "name": "Apache License 2.0",
+ "spdx_id": "Apache-2.0",
+ "url": "https://api.github.com/licenses/apache-2.0",
+ "node_id": "MDc6TGljZW5zZTI="
+ },
+ "allow_forking": false,
+ "is_template": false,
+ "web_commit_signoff_required": false,
+ "topics": [],
+ "visibility": "private",
+ "forks": 0,
+ "open_issues": 1,
+ "watchers": 0,
+ "default_branch": "main",
+ "allow_squash_merge": true,
+ "allow_merge_commit": true,
+ "allow_rebase_merge": true,
+ "allow_auto_merge": false,
+ "delete_branch_on_merge": false,
+ "allow_update_branch": false,
+ "use_squash_pr_title_as_default": false,
+ "squash_merge_commit_message": "COMMIT_MESSAGES",
+ "squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
+ "merge_commit_message": "PR_TITLE",
+ "merge_commit_title": "MERGE_MESSAGE"
+ }
+ },
+ "_links": {
+ "self": {
+ "href": "https://api.github.com/repos/padok-team/burrito-examples/pulls/20"
+ },
+ "html": {
+ "href": "https://github.com/padok-team/burrito-examples/pull/20"
+ },
+ "issue": {
+ "href": "https://api.github.com/repos/padok-team/burrito-examples/issues/20"
+ },
+ "comments": {
+ "href": "https://api.github.com/repos/padok-team/burrito-examples/issues/20/comments"
+ },
+ "review_comments": {
+ "href": "https://api.github.com/repos/padok-team/burrito-examples/pulls/20/comments"
+ },
+ "review_comment": {
+ "href": "https://api.github.com/repos/padok-team/burrito-examples/pulls/comments{/number}"
+ },
+ "commits": {
+ "href": "https://api.github.com/repos/padok-team/burrito-examples/pulls/20/commits"
+ },
+ "statuses": {
+ "href": "https://api.github.com/repos/padok-team/burrito-examples/statuses/faf5e25402a9bd10f7318c8a2cd984af576c687f"
+ }
+ },
+ "author_association": "CONTRIBUTOR",
+ "auto_merge": null,
+ "active_lock_reason": null,
+ "merged": false,
+ "mergeable": null,
+ "rebaseable": null,
+ "mergeable_state": "unknown",
+ "merged_by": null,
+ "comments": 0,
+ "review_comments": 0,
+ "maintainer_can_modify": false,
+ "commits": 1,
+ "additions": 1,
+ "deletions": 1,
+ "changed_files": 1
+ },
+ "repository": {
+ "id": 629411359,
+ "node_id": "R_kgDOJYQOHw",
+ "name": "burrito-examples",
+ "full_name": "padok-team/burrito-examples",
+ "private": true,
+ "owner": {
+ "login": "padok-team",
+ "id": 46325765,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ2MzI1NzY1",
+ "avatar_url": "https://avatars.githubusercontent.com/u/46325765?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/padok-team",
+ "html_url": "https://github.com/padok-team",
+ "followers_url": "https://api.github.com/users/padok-team/followers",
+ "following_url": "https://api.github.com/users/padok-team/following{/other_user}",
+ "gists_url": "https://api.github.com/users/padok-team/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/padok-team/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/padok-team/subscriptions",
+ "organizations_url": "https://api.github.com/users/padok-team/orgs",
+ "repos_url": "https://api.github.com/users/padok-team/repos",
+ "events_url": "https://api.github.com/users/padok-team/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/padok-team/received_events",
+ "type": "Organization",
+ "site_admin": false
+ },
+ "html_url": "https://github.com/padok-team/burrito-examples",
+ "description": "A repository with examples on how to use burrito.",
+ "fork": false,
+ "url": "https://api.github.com/repos/padok-team/burrito-examples",
+ "forks_url": "https://api.github.com/repos/padok-team/burrito-examples/forks",
+ "keys_url": "https://api.github.com/repos/padok-team/burrito-examples/keys{/key_id}",
+ "collaborators_url": "https://api.github.com/repos/padok-team/burrito-examples/collaborators{/collaborator}",
+ "teams_url": "https://api.github.com/repos/padok-team/burrito-examples/teams",
+ "hooks_url": "https://api.github.com/repos/padok-team/burrito-examples/hooks",
+ "issue_events_url": "https://api.github.com/repos/padok-team/burrito-examples/issues/events{/number}",
+ "events_url": "https://api.github.com/repos/padok-team/burrito-examples/events",
+ "assignees_url": "https://api.github.com/repos/padok-team/burrito-examples/assignees{/user}",
+ "branches_url": "https://api.github.com/repos/padok-team/burrito-examples/branches{/branch}",
+ "tags_url": "https://api.github.com/repos/padok-team/burrito-examples/tags",
+ "blobs_url": "https://api.github.com/repos/padok-team/burrito-examples/git/blobs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/padok-team/burrito-examples/git/tags{/sha}",
+ "git_refs_url": "https://api.github.com/repos/padok-team/burrito-examples/git/refs{/sha}",
+ "trees_url": "https://api.github.com/repos/padok-team/burrito-examples/git/trees{/sha}",
+ "statuses_url": "https://api.github.com/repos/padok-team/burrito-examples/statuses/{sha}",
+ "languages_url": "https://api.github.com/repos/padok-team/burrito-examples/languages",
+ "stargazers_url": "https://api.github.com/repos/padok-team/burrito-examples/stargazers",
+ "contributors_url": "https://api.github.com/repos/padok-team/burrito-examples/contributors",
+ "subscribers_url": "https://api.github.com/repos/padok-team/burrito-examples/subscribers",
+ "subscription_url": "https://api.github.com/repos/padok-team/burrito-examples/subscription",
+ "commits_url": "https://api.github.com/repos/padok-team/burrito-examples/commits{/sha}",
+ "git_commits_url": "https://api.github.com/repos/padok-team/burrito-examples/git/commits{/sha}",
+ "comments_url": "https://api.github.com/repos/padok-team/burrito-examples/comments{/number}",
+ "issue_comment_url": "https://api.github.com/repos/padok-team/burrito-examples/issues/comments{/number}",
+ "contents_url": "https://api.github.com/repos/padok-team/burrito-examples/contents/{+path}",
+ "compare_url": "https://api.github.com/repos/padok-team/burrito-examples/compare/{base}...{head}",
+ "merges_url": "https://api.github.com/repos/padok-team/burrito-examples/merges",
+ "archive_url": "https://api.github.com/repos/padok-team/burrito-examples/{archive_format}{/ref}",
+ "downloads_url": "https://api.github.com/repos/padok-team/burrito-examples/downloads",
+ "issues_url": "https://api.github.com/repos/padok-team/burrito-examples/issues{/number}",
+ "pulls_url": "https://api.github.com/repos/padok-team/burrito-examples/pulls{/number}",
+ "milestones_url": "https://api.github.com/repos/padok-team/burrito-examples/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/padok-team/burrito-examples/notifications{?since,all,participating}",
+ "labels_url": "https://api.github.com/repos/padok-team/burrito-examples/labels{/name}",
+ "releases_url": "https://api.github.com/repos/padok-team/burrito-examples/releases{/id}",
+ "deployments_url": "https://api.github.com/repos/padok-team/burrito-examples/deployments",
+ "created_at": "2023-04-18T09:00:53Z",
+ "updated_at": "2023-04-18T09:10:54Z",
+ "pushed_at": "2023-04-23T13:20:58Z",
+ "git_url": "git://github.com/padok-team/burrito-examples.git",
+ "ssh_url": "git@github.com:padok-team/burrito-examples.git",
+ "clone_url": "https://github.com/padok-team/burrito-examples.git",
+ "svn_url": "https://github.com/padok-team/burrito-examples",
+ "homepage": null,
+ "size": 8,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "language": "HCL",
+ "has_issues": true,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_wiki": false,
+ "has_pages": false,
+ "has_discussions": false,
+ "forks_count": 0,
+ "mirror_url": null,
+ "archived": false,
+ "disabled": false,
+ "open_issues_count": 1,
+ "license": {
+ "key": "apache-2.0",
+ "name": "Apache License 2.0",
+ "spdx_id": "Apache-2.0",
+ "url": "https://api.github.com/licenses/apache-2.0",
+ "node_id": "MDc6TGljZW5zZTI="
+ },
+ "allow_forking": false,
+ "is_template": false,
+ "web_commit_signoff_required": false,
+ "topics": [],
+ "visibility": "private",
+ "forks": 0,
+ "open_issues": 1,
+ "watchers": 0,
+ "default_branch": "main"
+ },
+ "organization": {
+ "login": "padok-team",
+ "id": 46325765,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ2MzI1NzY1",
+ "url": "https://api.github.com/orgs/padok-team",
+ "repos_url": "https://api.github.com/orgs/padok-team/repos",
+ "events_url": "https://api.github.com/orgs/padok-team/events",
+ "hooks_url": "https://api.github.com/orgs/padok-team/hooks",
+ "issues_url": "https://api.github.com/orgs/padok-team/issues",
+ "members_url": "https://api.github.com/orgs/padok-team/members{/member}",
+ "public_members_url": "https://api.github.com/orgs/padok-team/public_members{/member}",
+ "avatar_url": "https://avatars.githubusercontent.com/u/46325765?v=4",
+ "description": ""
+ },
+ "sender": {
+ "login": "spoukke",
+ "id": 32708678,
+ "node_id": "MDQ6VXNlcjMyNzA4Njc4",
+ "avatar_url": "https://avatars.githubusercontent.com/u/32708678?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/spoukke",
+ "html_url": "https://github.com/spoukke",
+ "followers_url": "https://api.github.com/users/spoukke/followers",
+ "following_url": "https://api.github.com/users/spoukke/following{/other_user}",
+ "gists_url": "https://api.github.com/users/spoukke/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/spoukke/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/spoukke/subscriptions",
+ "organizations_url": "https://api.github.com/users/spoukke/orgs",
+ "repos_url": "https://api.github.com/users/spoukke/repos",
+ "events_url": "https://api.github.com/users/spoukke/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/spoukke/received_events",
+ "type": "User",
+ "site_admin": false
+ }
+}
diff --git a/internal/webhook/testdata/github-push-main-event.json b/internal/webhook/testdata/github-push-main-event.json
new file mode 100644
index 00000000..cf928f99
--- /dev/null
+++ b/internal/webhook/testdata/github-push-main-event.json
@@ -0,0 +1,213 @@
+{
+ "ref": "refs/heads/main",
+ "before": "6f51b4ffd5e3adadfc3ee649d5ea2499472ea33b",
+ "after": "ca9b6c80ac8fb5cd837ae9b374b79ff33f472558",
+ "repository": {
+ "id": 629411359,
+ "node_id": "R_kgDOJYQOHw",
+ "name": "burrito-examples",
+ "full_name": "padok-team/burrito-examples",
+ "private": true,
+ "owner": {
+ "name": "padok-team",
+ "email": null,
+ "login": "padok-team",
+ "id": 46325765,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ2MzI1NzY1",
+ "avatar_url": "https://avatars.githubusercontent.com/u/46325765?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/padok-team",
+ "html_url": "https://github.com/padok-team",
+ "followers_url": "https://api.github.com/users/padok-team/followers",
+ "following_url": "https://api.github.com/users/padok-team/following{/other_user}",
+ "gists_url": "https://api.github.com/users/padok-team/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/padok-team/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/padok-team/subscriptions",
+ "organizations_url": "https://api.github.com/users/padok-team/orgs",
+ "repos_url": "https://api.github.com/users/padok-team/repos",
+ "events_url": "https://api.github.com/users/padok-team/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/padok-team/received_events",
+ "type": "Organization",
+ "site_admin": false
+ },
+ "html_url": "https://github.com/padok-team/burrito-examples",
+ "description": "A repository with examples on how to use burrito.",
+ "fork": false,
+ "url": "https://github.com/padok-team/burrito-examples",
+ "forks_url": "https://api.github.com/repos/padok-team/burrito-examples/forks",
+ "keys_url": "https://api.github.com/repos/padok-team/burrito-examples/keys{/key_id}",
+ "collaborators_url": "https://api.github.com/repos/padok-team/burrito-examples/collaborators{/collaborator}",
+ "teams_url": "https://api.github.com/repos/padok-team/burrito-examples/teams",
+ "hooks_url": "https://api.github.com/repos/padok-team/burrito-examples/hooks",
+ "issue_events_url": "https://api.github.com/repos/padok-team/burrito-examples/issues/events{/number}",
+ "events_url": "https://api.github.com/repos/padok-team/burrito-examples/events",
+ "assignees_url": "https://api.github.com/repos/padok-team/burrito-examples/assignees{/user}",
+ "branches_url": "https://api.github.com/repos/padok-team/burrito-examples/branches{/branch}",
+ "tags_url": "https://api.github.com/repos/padok-team/burrito-examples/tags",
+ "blobs_url": "https://api.github.com/repos/padok-team/burrito-examples/git/blobs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/padok-team/burrito-examples/git/tags{/sha}",
+ "git_refs_url": "https://api.github.com/repos/padok-team/burrito-examples/git/refs{/sha}",
+ "trees_url": "https://api.github.com/repos/padok-team/burrito-examples/git/trees{/sha}",
+ "statuses_url": "https://api.github.com/repos/padok-team/burrito-examples/statuses/{sha}",
+ "languages_url": "https://api.github.com/repos/padok-team/burrito-examples/languages",
+ "stargazers_url": "https://api.github.com/repos/padok-team/burrito-examples/stargazers",
+ "contributors_url": "https://api.github.com/repos/padok-team/burrito-examples/contributors",
+ "subscribers_url": "https://api.github.com/repos/padok-team/burrito-examples/subscribers",
+ "subscription_url": "https://api.github.com/repos/padok-team/burrito-examples/subscription",
+ "commits_url": "https://api.github.com/repos/padok-team/burrito-examples/commits{/sha}",
+ "git_commits_url": "https://api.github.com/repos/padok-team/burrito-examples/git/commits{/sha}",
+ "comments_url": "https://api.github.com/repos/padok-team/burrito-examples/comments{/number}",
+ "issue_comment_url": "https://api.github.com/repos/padok-team/burrito-examples/issues/comments{/number}",
+ "contents_url": "https://api.github.com/repos/padok-team/burrito-examples/contents/{+path}",
+ "compare_url": "https://api.github.com/repos/padok-team/burrito-examples/compare/{base}...{head}",
+ "merges_url": "https://api.github.com/repos/padok-team/burrito-examples/merges",
+ "archive_url": "https://api.github.com/repos/padok-team/burrito-examples/{archive_format}{/ref}",
+ "downloads_url": "https://api.github.com/repos/padok-team/burrito-examples/downloads",
+ "issues_url": "https://api.github.com/repos/padok-team/burrito-examples/issues{/number}",
+ "pulls_url": "https://api.github.com/repos/padok-team/burrito-examples/pulls{/number}",
+ "milestones_url": "https://api.github.com/repos/padok-team/burrito-examples/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/padok-team/burrito-examples/notifications{?since,all,participating}",
+ "labels_url": "https://api.github.com/repos/padok-team/burrito-examples/labels{/name}",
+ "releases_url": "https://api.github.com/repos/padok-team/burrito-examples/releases{/id}",
+ "deployments_url": "https://api.github.com/repos/padok-team/burrito-examples/deployments",
+ "created_at": 1681808453,
+ "updated_at": "2023-04-18T09:10:54Z",
+ "pushed_at": 1681986432,
+ "git_url": "git://github.com/padok-team/burrito-examples.git",
+ "ssh_url": "git@github.com:padok-team/burrito-examples.git",
+ "clone_url": "https://github.com/padok-team/burrito-examples.git",
+ "svn_url": "https://github.com/padok-team/burrito-examples",
+ "homepage": null,
+ "size": 6,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "language": "HCL",
+ "has_issues": true,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_wiki": false,
+ "has_pages": false,
+ "has_discussions": false,
+ "forks_count": 0,
+ "mirror_url": null,
+ "archived": false,
+ "disabled": false,
+ "open_issues_count": 0,
+ "license": {
+ "key": "apache-2.0",
+ "name": "Apache License 2.0",
+ "spdx_id": "Apache-2.0",
+ "url": "https://api.github.com/licenses/apache-2.0",
+ "node_id": "MDc6TGljZW5zZTI="
+ },
+ "allow_forking": false,
+ "is_template": false,
+ "web_commit_signoff_required": false,
+ "topics": [],
+ "visibility": "private",
+ "forks": 0,
+ "open_issues": 0,
+ "watchers": 0,
+ "default_branch": "main",
+ "stargazers": 0,
+ "master_branch": "main",
+ "organization": "padok-team"
+ },
+ "pusher": {
+ "name": "spoukke",
+ "email": "32708678+spoukke@users.noreply.github.com"
+ },
+ "organization": {
+ "login": "padok-team",
+ "id": 46325765,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ2MzI1NzY1",
+ "url": "https://api.github.com/orgs/padok-team",
+ "repos_url": "https://api.github.com/orgs/padok-team/repos",
+ "events_url": "https://api.github.com/orgs/padok-team/events",
+ "hooks_url": "https://api.github.com/orgs/padok-team/hooks",
+ "issues_url": "https://api.github.com/orgs/padok-team/issues",
+ "members_url": "https://api.github.com/orgs/padok-team/members{/member}",
+ "public_members_url": "https://api.github.com/orgs/padok-team/public_members{/member}",
+ "avatar_url": "https://avatars.githubusercontent.com/u/46325765?v=4",
+ "description": ""
+ },
+ "sender": {
+ "login": "spoukke",
+ "id": 32708678,
+ "node_id": "MDQ6VXNlcjMyNzA4Njc4",
+ "avatar_url": "https://avatars.githubusercontent.com/u/32708678?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/spoukke",
+ "html_url": "https://github.com/spoukke",
+ "followers_url": "https://api.github.com/users/spoukke/followers",
+ "following_url": "https://api.github.com/users/spoukke/following{/other_user}",
+ "gists_url": "https://api.github.com/users/spoukke/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/spoukke/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/spoukke/subscriptions",
+ "organizations_url": "https://api.github.com/users/spoukke/orgs",
+ "repos_url": "https://api.github.com/users/spoukke/repos",
+ "events_url": "https://api.github.com/users/spoukke/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/spoukke/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "created": false,
+ "deleted": false,
+ "forced": false,
+ "base_ref": null,
+ "compare": "https://github.com/padok-team/burrito-examples/compare/6f51b4ffd5e3...ca9b6c80ac8f",
+ "commits": [
+ {
+ "id": "ca9b6c80ac8fb5cd837ae9b374b79ff33f472558",
+ "tree_id": "71b9af3583497628b7c275296d5f8e7b17bbfa1b",
+ "distinct": true,
+ "message": "feat: use pets var",
+ "timestamp": "2023-04-20T12:27:09+02:00",
+ "url": "https://github.com/padok-team/burrito-examples/commit/ca9b6c80ac8fb5cd837ae9b374b79ff33f472558",
+ "author": {
+ "name": "spoukke",
+ "email": "sacha.bernheim@hey.com",
+ "username": "spoukke"
+ },
+ "committer": {
+ "name": "spoukke",
+ "email": "sacha.bernheim@hey.com",
+ "username": "spoukke"
+ },
+ "added": [
+ "modules/random-pets/variables.tf"
+ ],
+ "removed": [],
+ "modified": [
+ "modules/random-pets/main.tf",
+ "terragrunt/random-pets/test/inputs.hcl"
+ ]
+ }
+ ],
+ "head_commit": {
+ "id": "ca9b6c80ac8fb5cd837ae9b374b79ff33f472558",
+ "tree_id": "71b9af3583497628b7c275296d5f8e7b17bbfa1b",
+ "distinct": true,
+ "message": "feat: use pets var",
+ "timestamp": "2023-04-20T12:27:09+02:00",
+ "url": "https://github.com/padok-team/burrito-examples/commit/ca9b6c80ac8fb5cd837ae9b374b79ff33f472558",
+ "author": {
+ "name": "spoukke",
+ "email": "sacha.bernheim@hey.com",
+ "username": "spoukke"
+ },
+ "committer": {
+ "name": "spoukke",
+ "email": "sacha.bernheim@hey.com",
+ "username": "spoukke"
+ },
+ "added": [
+ "modules/random-pets/variables.tf"
+ ],
+ "removed": [],
+ "modified": [
+ "modules/random-pets/main.tf",
+ "terragrunt/random-pets/test/inputs.hcl"
+ ]
+ }
+}
diff --git a/internal/webhook/testdata/gitlab-open-merge-request-event.json b/internal/webhook/testdata/gitlab-open-merge-request-event.json
new file mode 100644
index 00000000..fca2d445
--- /dev/null
+++ b/internal/webhook/testdata/gitlab-open-merge-request-event.json
@@ -0,0 +1,207 @@
+{
+ "object_kind": "merge_request",
+ "event_type": "merge_request",
+ "user": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon",
+ "email": "admin@example.com"
+ },
+ "project": {
+ "id": 1,
+ "name": "Gitlab Test",
+ "description": "Aut reprehenderit ut est.",
+ "web_url": "http://example.com/gitlabhq/gitlab-test",
+ "avatar_url": null,
+ "git_ssh_url": "git@example.com:gitlabhq/gitlab-test.git",
+ "git_http_url": "http://example.com/gitlabhq/gitlab-test.git",
+ "namespace": "GitlabHQ",
+ "visibility_level": 20,
+ "path_with_namespace": "gitlabhq/gitlab-test",
+ "default_branch": "master",
+ "ci_config_path": "",
+ "homepage": "http://example.com/gitlabhq/gitlab-test",
+ "url": "http://example.com/gitlabhq/gitlab-test.git",
+ "ssh_url": "git@example.com:gitlabhq/gitlab-test.git",
+ "http_url": "http://example.com/gitlabhq/gitlab-test.git"
+ },
+ "repository": {
+ "name": "Gitlab Test",
+ "url": "http://example.com/gitlabhq/gitlab-test.git",
+ "description": "Aut reprehenderit ut est.",
+ "homepage": "http://example.com/gitlabhq/gitlab-test"
+ },
+ "object_attributes": {
+ "id": 99,
+ "iid": 1,
+ "target_branch": "main",
+ "source_branch": "demo",
+ "source_project_id": 14,
+ "author_id": 51,
+ "assignee_ids": [
+ 6
+ ],
+ "assignee_id": 6,
+ "reviewer_ids": [
+ 6
+ ],
+ "title": "MS-Viewport",
+ "created_at": "2013-12-03T17:23:34Z",
+ "updated_at": "2013-12-03T17:23:34Z",
+ "last_edited_at": "2013-12-03T17:23:34Z",
+ "last_edited_by_id": 1,
+ "milestone_id": null,
+ "state_id": 1,
+ "state": "opened",
+ "blocking_discussions_resolved": true,
+ "work_in_progress": false,
+ "first_contribution": true,
+ "merge_status": "unchecked",
+ "target_project_id": 14,
+ "description": "",
+ "total_time_spent": 1800,
+ "time_change": 30,
+ "human_total_time_spent": "30m",
+ "human_time_change": "30s",
+ "human_time_estimate": "30m",
+ "url": "http://example.com/diaspora/merge_requests/1",
+ "source": {
+ "name": "Awesome Project",
+ "description": "Aut reprehenderit ut est.",
+ "web_url": "http://example.com/awesome_space/awesome_project",
+ "avatar_url": null,
+ "git_ssh_url": "git@example.com:awesome_space/awesome_project.git",
+ "git_http_url": "http://example.com/awesome_space/awesome_project.git",
+ "namespace": "Awesome Space",
+ "visibility_level": 20,
+ "path_with_namespace": "awesome_space/awesome_project",
+ "default_branch": "master",
+ "homepage": "http://example.com/awesome_space/awesome_project",
+ "url": "http://example.com/awesome_space/awesome_project.git",
+ "ssh_url": "git@example.com:awesome_space/awesome_project.git",
+ "http_url": "http://example.com/awesome_space/awesome_project.git"
+ },
+ "target": {
+ "name": "Awesome Project",
+ "description": "Aut reprehenderit ut est.",
+ "web_url": "http://example.com/awesome_space/awesome_project",
+ "avatar_url": null,
+ "git_ssh_url": "git@example.com:awesome_space/awesome_project.git",
+ "git_http_url": "http://example.com/awesome_space/awesome_project.git",
+ "namespace": "Awesome Space",
+ "visibility_level": 20,
+ "path_with_namespace": "awesome_space/awesome_project",
+ "default_branch": "master",
+ "homepage": "http://example.com/awesome_space/awesome_project",
+ "url": "http://example.com/awesome_space/awesome_project.git",
+ "ssh_url": "git@example.com:awesome_space/awesome_project.git",
+ "http_url": "http://example.com/awesome_space/awesome_project.git"
+ },
+ "last_commit": {
+ "id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
+ "message": "fixed readme",
+ "title": "Update file README.md",
+ "timestamp": "2012-01-03T23:36:29+02:00",
+ "url": "http://example.com/awesome_space/awesome_project/commits/da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
+ "author": {
+ "name": "GitLab dev user",
+ "email": "gitlabdev@dv6700.(none)"
+ }
+ },
+ "labels": [
+ {
+ "id": 206,
+ "title": "API",
+ "color": "#ffffff",
+ "project_id": 14,
+ "created_at": "2013-12-03T17:15:43Z",
+ "updated_at": "2013-12-03T17:15:43Z",
+ "template": false,
+ "description": "API related issues",
+ "type": "ProjectLabel",
+ "group_id": 41
+ }
+ ],
+ "action": "open",
+ "detailed_merge_status": "mergeable"
+ },
+ "labels": [
+ {
+ "id": 206,
+ "title": "API",
+ "color": "#ffffff",
+ "project_id": 14,
+ "created_at": "2013-12-03T17:15:43Z",
+ "updated_at": "2013-12-03T17:15:43Z",
+ "template": false,
+ "description": "API related issues",
+ "type": "ProjectLabel",
+ "group_id": 41
+ }
+ ],
+ "changes": {
+ "updated_by_id": {
+ "previous": null,
+ "current": 1
+ },
+ "updated_at": {
+ "previous": "2017-09-15 16:50:55 UTC",
+ "current": "2017-09-15 16:52:00 UTC"
+ },
+ "labels": {
+ "previous": [
+ {
+ "id": 206,
+ "title": "API",
+ "color": "#ffffff",
+ "project_id": 14,
+ "created_at": "2013-12-03T17:15:43Z",
+ "updated_at": "2013-12-03T17:15:43Z",
+ "template": false,
+ "description": "API related issues",
+ "type": "ProjectLabel",
+ "group_id": 41
+ }
+ ],
+ "current": [
+ {
+ "id": 205,
+ "title": "Platform",
+ "color": "#123123",
+ "project_id": 14,
+ "created_at": "2013-12-03T17:15:43Z",
+ "updated_at": "2013-12-03T17:15:43Z",
+ "template": false,
+ "description": "Platform related issues",
+ "type": "ProjectLabel",
+ "group_id": 41
+ }
+ ]
+ },
+ "last_edited_at": {
+ "previous": null,
+ "current": "2023-03-15 00:00:10 UTC"
+ },
+ "last_edited_by_id": {
+ "previous": null,
+ "current": 3278533
+ }
+ },
+ "assignees": [
+ {
+ "id": 6,
+ "name": "User1",
+ "username": "user1",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
+ }
+ ],
+ "reviewers": [
+ {
+ "id": 6,
+ "name": "User1",
+ "username": "user1",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/internal/webhook/testdata/gitlab-push-main-event.json b/internal/webhook/testdata/gitlab-push-main-event.json
new file mode 100644
index 00000000..ebb262f7
--- /dev/null
+++ b/internal/webhook/testdata/gitlab-push-main-event.json
@@ -0,0 +1,77 @@
+{
+ "object_kind": "push",
+ "before": "95790bf891e76fee5e1747ab589903a6a1f80f22",
+ "after": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
+ "ref": "refs/heads/main",
+ "checkout_sha": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
+ "user_id": 4,
+ "user_name": "spoukke",
+ "user_username": "spoukke",
+ "user_email": "sachab@padok.fr",
+ "user_avatar": "https://www.gravatar.com/avatar/123",
+ "project_id": 15,
+ "project": {
+ "id": 15,
+ "name": "Burrito examples",
+ "description": "",
+ "web_url": "https://gitlab.com/burrito/examples",
+ "git_ssh_url": "git@gitlab.com:burrito/examples.git",
+ "git_http_url": "https://gitlab.com/burrito/examples.git",
+ "namespace": "burrito",
+ "visibility_level": 0,
+ "avatar_url": null,
+ "star_count": 0,
+ "forks_count": 0,
+ "last_activity_at": "2021-09-22T14:15:22.000Z",
+ "namespace_id": 3,
+ "namespace_name": "burrito",
+ "path_with_namespace": "burrito/examples",
+ "default_branch": "main",
+ "homepage": "https://gitlab.com/burrito/examples",
+ "url": "git@gitlab.com:burrito/examples.git",
+ "ssh_url": "git@example.com:example-project.git",
+ "http_url": "https://gitlab.com/burrito/examples.git"
+ },
+ "commits": [
+ {
+ "id": "b6568db..",
+ "message": "changed file",
+ "timestamp": "2021-09-22T14:15:22-07:00",
+ "url": "http://example.com/example-project/commit/b6568db...",
+ "author": {
+ "name": "spoukke",
+ "email": "sachab@padok.fr"
+ },
+ "added": [
+ "test.hcl"
+ ]
+ },
+ {
+ "id": "6dcb09b...",
+ "message": "Add new file",
+ "timestamp": "2021-09-22T14:15:22-07:00",
+ "url": "http://example.com/example-project/commit/6dcb09b...",
+ "author": {
+ "name": "spoukke",
+ "email": "sachab@padok.fr"
+ },
+ "modified": [
+ "layer-1/prod.hcl"
+ ]
+ },
+ {
+ "id": "6dcb09b...",
+ "message": "Add new file",
+ "timestamp": "2021-09-22T14:15:22-07:00",
+ "url": "http://example.com/example-project/commit/6dcb09b...",
+ "author": {
+ "name": "spoukke",
+ "email": "sachab@padok.fr"
+ },
+ "removed": [
+ "layer-2/staging.hcl"
+ ]
+ }
+ ],
+ "total_commits_count": 3
+}
diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go
index 22d5d25b..5746e4db 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/internal/webhook/webhook_test.go b/internal/webhook/webhook_test.go
new file mode 100644
index 00000000..19473924
--- /dev/null
+++ b/internal/webhook/webhook_test.go
@@ -0,0 +1,26 @@
+package webhook_test
+
+// import (
+// "testing"
+
+// "github.com/padok-team/burrito/internal/burrito/config"
+// "github.com/padok-team/burrito/internal/webhook"
+// "github.com/stretchr/testify/assert"
+// )
+
+// func TestWebhook_Init(t *testing.T) {
+// secret := "test-secret"
+// config := &config.Config{
+// Server: config.Server{
+// Webhook: config.WebhookConfig{
+// Github: config.WebhookGithubConfig{
+// Secret: secret,
+// },
+// },
+// },
+// }
+
+// w := webhook.New(config)
+// err := w.Init()
+// assert.NoError(t, err)
+// }
diff --git a/manifests/crds/config.terraform.padok.cloud_terraformlayers.yaml b/manifests/crds/config.terraform.padok.cloud_terraformlayers.yaml
index 0e896d61..6834545c 100644
--- a/manifests/crds/config.terraform.padok.cloud_terraformlayers.yaml
+++ b/manifests/crds/config.terraform.padok.cloud_terraformlayers.yaml
@@ -1987,8 +1987,6 @@ spec:
type: object
path:
type: string
- planOnPullRequest:
- type: boolean
remediationStrategy:
enum:
- dry
@@ -1996,8 +1994,6 @@ spec:
type: string
repository:
properties:
- kind:
- type: string
name:
type: string
namespace:
diff --git a/manifests/crds/config.terraform.padok.cloud_terraformpullrequests.yaml b/manifests/crds/config.terraform.padok.cloud_terraformpullrequests.yaml
new file mode 100644
index 00000000..09af6b70
--- /dev/null
+++ b/manifests/crds/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.11.2
+ 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/manifests/crds/kustomization.yaml b/manifests/crds/kustomization.yaml
index 8e529307..57d39e2d 100644
--- a/manifests/crds/kustomization.yaml
+++ b/manifests/crds/kustomization.yaml
@@ -2,5 +2,6 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
+ - config.terraform.padok.cloud_terraformpullrequests.yaml
- config.terraform.padok.cloud_terraformrepositories.yaml
- config.terraform.padok.cloud_terraformlayers.yaml
diff --git a/manifests/install.yaml b/manifests/install.yaml
index 89588b67..508c454a 100644
--- a/manifests/install.yaml
+++ b/manifests/install.yaml
@@ -1238,8 +1238,6 @@ spec:
type: object
path:
type: string
- planOnPullRequest:
- type: boolean
remediationStrategy:
enum:
- dry
@@ -1247,8 +1245,6 @@ spec:
type: string
repository:
properties:
- kind:
- type: string
name:
type: string
namespace:
@@ -1326,6 +1322,130 @@ spec:
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.11.2
+ 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.11.2