Skip to content

Commit 68f576a

Browse files
DjinnSGuillaume Leccese
andauthored
feat: pull request polling (#936)
Keep TerraformPullRequest resources in sync with open remote pull/merge requests even without a webhook configured, by periodically listing them via the provider API (GitHub/GitLab) and reconciling the desired set against existing TerraformPullRequest objects (create/update/delete). Following review feedback on the initial implementation: - Polling now lives in the TerraformRepository controller (internal/controllers/terraformrepository/pullrequests.go), run best-effort on every repository Reconcile, instead of being bolted onto the TerraformPullRequest controller. This keeps each controller responsible for a single resource type and avoids conflating PR and Repository reconcile requests under the same watch/queue. - The TerraformPullRequest controller's Reconcile is back to handling only TerraformPullRequest objects. Also updates the git-webhook documentation to mention PR/MR polling outside of the polling-limitations warning block. Co-authored-by: Guillaume Leccese <guillaume.leccese@theodo.com>
1 parent 4b77172 commit 68f576a

28 files changed

Lines changed: 3036 additions & 80 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,4 @@ credentials/
5050
.aws/
5151

5252
test-repo/
53-
TIltfile
53+
TIltfile

api/v1alpha1/terraformrepository_types.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,20 @@ type TerraformRepositoryList struct {
113113
func init() {
114114
SchemeBuilder.Register(&TerraformRepository{}, &TerraformRepositoryList{})
115115
}
116+
117+
// Workaround needed for envtest which does not populate the TypeMeta structure
118+
// See https://github.com/kubernetes-sigs/controller-runtime/issues/1870
119+
func (repository *TerraformRepository) GetAPIVersion() string {
120+
if repository.APIVersion == "" {
121+
return "config.terraform.padok.cloud/v1alpha1"
122+
}
123+
return repository.APIVersion
124+
}
125+
126+
// Same as above
127+
func (repository *TerraformRepository) GetKind() string {
128+
if repository.Kind == "" {
129+
return "TerraformRepository"
130+
}
131+
return repository.Kind
132+
}

deploy/charts/burrito/values-dev.yaml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@ config:
33
runner:
44
image:
55
repository: burrito
6-
tag: DEV_TAG
6+
tag: "1783089222"
77
pullPolicy: Never
88
datastore:
99
storage:
1010
mock: true
11+
controller:
12+
timers:
13+
driftDetection: 10m
14+
repositorySync: 2m
1115
global:
1216
deployment:
1317
image:
1418
repository: burrito
15-
tag: DEV_TAG
19+
tag: "1783089222"
1620
pullPolicy: Never
1721
tenants:
1822
- namespace:

docs/operator-manual/git-webhook.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
22

33
Using the webhook feature, Burrito can automatically trigger actions based on events in your Git repository, such as pull requests or pushes. This allows for real-time updates and interactions with your infrastructure as code.
44

5-
Otherwise, Burrito will poll repositories for changes, which may not be as efficient or timely as using webhooks.
5+
Otherwise, Burrito will poll repositories for changes, which may not be as efficient or timely as using webhooks. Burrito also polls open Pull Requests and Merge Requests to keep `TerraformPullRequest` resources in sync.
66

77
!!! warning "Burrito polling limitations"
88
Burrito's automatic polling of repositories only works for changes on referenced branches in TerraformLayers.
9-
**Automatic polling of Pull Requests is not implemented yet.**
109

1110
## Expose Burrito server to the internet
1211

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
package comment
22

3+
import "strings"
4+
5+
// The hidden marker lets providers update Burrito's previous PR/MR comment
6+
// instead of creating duplicates when Kubernetes status persistence is retried.
7+
const managedCommentMarker = "<!-- burrito:pull-request-comment -->"
8+
39
type Comment interface {
410
Generate(string) (string, error)
511
}
12+
13+
func WithManagedMarker(body string) string {
14+
if HasManagedMarker(body) {
15+
return body
16+
}
17+
return body + "\n\n" + managedCommentMarker
18+
}
19+
20+
func HasManagedMarker(body string) bool {
21+
return strings.Contains(body, managedCommentMarker)
22+
}
Lines changed: 127 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,132 @@
11
package comment
22

33
import (
4-
_ "embed"
4+
"errors"
5+
"strings"
6+
"testing"
7+
8+
configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
510
)
611

7-
// func TestDefaultComment_Generate(t *testing.T) {
8-
// type fields struct {
9-
// layers []configv1alpha1.TerraformLayer
10-
// storage storage.Storage
11-
// }
12-
// tests := []struct {
13-
// name string
14-
// fields fields
15-
// want string
16-
// wantErr bool
17-
// }{
18-
// // TODO: Add test cases.
19-
// }
20-
// for _, tt := range tests {
21-
// t.Run(tt.name, func(t *testing.T) {
22-
// c := &DefaultComment{
23-
// layers: tt.fields.layers,
24-
// storage: tt.fields.storage,
25-
// }
26-
// got, err := c.Generate()
27-
// if (err != nil) != tt.wantErr {
28-
// t.Errorf("DefaultComment.Generate() error = %v, wantErr %v", err, tt.wantErr)
29-
// return
30-
// }
31-
// if got != tt.want {
32-
// t.Errorf("DefaultComment.Generate() = %v, want %v", got, tt.want)
33-
// }
34-
// })
35-
// }
36-
// }
12+
type fakeDatastore struct {
13+
plans map[string][]byte
14+
err error
15+
errByFormat map[string]error
16+
}
17+
18+
func (f *fakeDatastore) GetPlan(namespace string, layer string, run string, attempt string, format string) ([]byte, error) {
19+
if f.err != nil {
20+
return nil, f.err
21+
}
22+
if err, ok := f.errByFormat[format]; ok {
23+
return nil, err
24+
}
25+
return f.plans[format], nil
26+
}
27+
28+
func (f *fakeDatastore) PutPlan(namespace string, layer string, run string, attempt string, format string, content []byte) error {
29+
return nil
30+
}
31+
32+
func (f *fakeDatastore) GetLogs(namespace string, layer string, run string, attempt string) ([]string, error) {
33+
return nil, nil
34+
}
35+
36+
func (f *fakeDatastore) PutLogs(namespace string, layer string, run string, attempt string, content []byte) error {
37+
return nil
38+
}
39+
40+
func (f *fakeDatastore) PutGitBundle(namespace string, name string, ref string, revision string, bundle []byte) error {
41+
return nil
42+
}
43+
44+
func (f *fakeDatastore) CheckGitBundle(namespace string, name string, ref string, revision string) (bool, error) {
45+
return false, nil
46+
}
47+
48+
func (f *fakeDatastore) GetGitBundle(namespace string, name string, ref string, revision string) ([]byte, error) {
49+
return nil, nil
50+
}
51+
52+
func TestDefaultCommentGenerate(t *testing.T) {
53+
comment := NewDefaultComment([]configv1alpha1.TerraformLayer{
54+
{
55+
ObjectMeta: metav1.ObjectMeta{
56+
Name: "layer-a",
57+
Namespace: "default",
58+
},
59+
Spec: configv1alpha1.TerraformLayerSpec{
60+
Path: "terraform/",
61+
},
62+
Status: configv1alpha1.TerraformLayerStatus{
63+
LastRun: configv1alpha1.TerraformLayerRun{
64+
Name: "run-a",
65+
},
66+
},
67+
},
68+
}, &fakeDatastore{
69+
plans: map[string][]byte{
70+
"pretty": []byte("pretty plan"),
71+
"short": []byte("+ create"),
72+
},
73+
})
74+
75+
got, err := comment.Generate("abc123")
76+
if err != nil {
77+
t.Fatalf("Generate returned error: %v", err)
78+
}
79+
80+
for _, expected := range []string{"Burrito Report", "layer-a", "terraform/", "+ create", "pretty plan", "abc123"} {
81+
if !strings.Contains(got, expected) {
82+
t.Fatalf("expected generated comment to contain %q, got:\n%s", expected, got)
83+
}
84+
}
85+
}
86+
87+
func TestDefaultCommentGenerateReturnsDatastoreError(t *testing.T) {
88+
expectedErr := errors.New("datastore unavailable")
89+
comment := NewDefaultComment([]configv1alpha1.TerraformLayer{{}}, &fakeDatastore{err: expectedErr})
90+
91+
_, err := comment.Generate("abc123")
92+
if !errors.Is(err, expectedErr) {
93+
t.Fatalf("expected %v, got %v", expectedErr, err)
94+
}
95+
}
96+
97+
func TestDefaultCommentGenerateReturnsShortPlanError(t *testing.T) {
98+
expectedErr := errors.New("short plan unavailable")
99+
comment := NewDefaultComment([]configv1alpha1.TerraformLayer{{}}, &fakeDatastore{
100+
plans: map[string][]byte{
101+
"pretty": []byte("pretty plan"),
102+
},
103+
errByFormat: map[string]error{
104+
"short": expectedErr,
105+
},
106+
})
107+
108+
_, err := comment.Generate("abc123")
109+
if !errors.Is(err, expectedErr) {
110+
t.Fatalf("expected %v, got %v", expectedErr, err)
111+
}
112+
}
113+
114+
func TestManagedMarkerHelpers(t *testing.T) {
115+
body := "hello"
116+
withMarker := WithManagedMarker(body)
117+
if !HasManagedMarker(withMarker) {
118+
t.Fatalf("expected generated body to contain managed marker")
119+
}
120+
if got := WithManagedMarker(withMarker); got != withMarker {
121+
t.Fatalf("expected WithManagedMarker to be idempotent")
122+
}
123+
if HasManagedMarker(body) {
124+
t.Fatalf("did not expect unmanaged body to contain marker")
125+
}
126+
}
127+
128+
func TestNewInitialComment(t *testing.T) {
129+
if NewInitialComment() == nil {
130+
t.Fatalf("expected NewInitialComment to return a comment")
131+
}
132+
}

0 commit comments

Comments
 (0)