Skip to content

Commit 1378bf1

Browse files
author
Guillaume Leccese
committed
feat: pull request polling
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.
1 parent 3147279 commit 1378bf1

27 files changed

Lines changed: 3000 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

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+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package terraformpullrequest
2+
3+
import (
4+
"testing"
5+
6+
configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1"
7+
"github.com/padok-team/burrito/internal/annotations"
8+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
9+
"sigs.k8s.io/controller-runtime/pkg/client"
10+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
11+
)
12+
13+
func TestIsLastCommitDiscoveredWhenDiscoveredCommitIsStale(t *testing.T) {
14+
pr := terraformPullRequest("default", "repo-1", "1", "feature", "new-sha")
15+
pr.Status.LastDiscoveredCommit = "old-sha"
16+
17+
condition, discovered := (&Reconciler{}).IsLastCommitDiscovered(pr)
18+
if discovered {
19+
t.Fatalf("expected stale discovered commit to require discovery")
20+
}
21+
if condition.Reason != "LastCommitNotDiscovered" {
22+
t.Fatalf("expected LastCommitNotDiscovered reason, got %q", condition.Reason)
23+
}
24+
}
25+
26+
func TestAreLayersStillPlanningCoversBranches(t *testing.T) {
27+
scheme := newTerraformPullRequestTestScheme(t)
28+
tests := []struct {
29+
name string
30+
pr *configv1alpha1.TerraformPullRequest
31+
objects []client.Object
32+
want bool
33+
reason string
34+
}{
35+
{
36+
name: "missing branch commit",
37+
pr: func() *configv1alpha1.TerraformPullRequest {
38+
pr := planningPullRequest("default", "repo-1", "1", "feature", "sha")
39+
delete(pr.Annotations, annotations.LastBranchCommit)
40+
return pr
41+
}(),
42+
want: true,
43+
reason: "NoBranchCommitOnPR",
44+
},
45+
{
46+
name: "no discovered commit",
47+
pr: planningPullRequest("default", "repo-1", "1", "feature", "sha"),
48+
want: true,
49+
reason: "NoCommitDiscovered",
50+
},
51+
{
52+
name: "discovery still needed",
53+
pr: planningPullRequestWithDiscoveredCommit("default", "repo-1", "1", "feature", "sha", "other"),
54+
want: true,
55+
reason: "StillNeedsDiscovery",
56+
},
57+
{
58+
name: "no linked layers",
59+
pr: planningPullRequestWithDiscoveredCommit("default", "repo-1", "1", "feature", "sha", "sha"),
60+
want: false,
61+
reason: "LayersNotPlanning",
62+
},
63+
{
64+
name: "layer missing plan commit",
65+
pr: planningPullRequestWithDiscoveredCommit("default", "repo-1", "1", "feature", "sha", "sha"),
66+
objects: []client.Object{
67+
planningLayer("default", "layer-1", managedByLabelValue(planningPullRequestWithDiscoveredCommit("default", "repo-1", "1", "feature", "sha", "sha")), nil, nil),
68+
},
69+
want: true,
70+
reason: "LayersStillPlanning",
71+
},
72+
{
73+
name: "layer missing relevant commit",
74+
pr: planningPullRequestWithDiscoveredCommit("default", "repo-1", "1", "feature", "sha", "sha"),
75+
objects: []client.Object{
76+
planningLayer("default", "layer-1", managedByLabelValue(planningPullRequestWithDiscoveredCommit("default", "repo-1", "1", "feature", "sha", "sha")), strPtr("plan-sha"), nil),
77+
},
78+
want: true,
79+
reason: "NoRelevantCommitOnLayer",
80+
},
81+
{
82+
name: "layer finished planning",
83+
pr: planningPullRequestWithDiscoveredCommit("default", "repo-1", "1", "feature", "sha", "sha"),
84+
objects: []client.Object{
85+
planningLayer("default", "layer-1", managedByLabelValue(planningPullRequestWithDiscoveredCommit("default", "repo-1", "1", "feature", "sha", "sha")), strPtr("plan-sha"), strPtr("plan-sha")),
86+
},
87+
want: false,
88+
reason: "LayersNotPlanning",
89+
},
90+
{
91+
name: "layer still planning",
92+
pr: planningPullRequestWithDiscoveredCommit("default", "repo-1", "1", "feature", "sha", "sha"),
93+
objects: []client.Object{
94+
planningLayer("default", "layer-1", managedByLabelValue(planningPullRequestWithDiscoveredCommit("default", "repo-1", "1", "feature", "sha", "sha")), strPtr("plan-sha"), strPtr("other-sha")),
95+
},
96+
want: true,
97+
reason: "LayersStillPlanning",
98+
},
99+
}
100+
101+
for _, tt := range tests {
102+
t.Run(tt.name, func(t *testing.T) {
103+
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(tt.objects...).Build()
104+
reconciler := &Reconciler{Client: cl}
105+
condition, got := reconciler.AreLayersStillPlanning(tt.pr)
106+
if got != tt.want {
107+
t.Fatalf("expected %t, got %t", tt.want, got)
108+
}
109+
if condition.Reason != tt.reason {
110+
t.Fatalf("expected reason %q, got %q", tt.reason, condition.Reason)
111+
}
112+
})
113+
}
114+
}
115+
116+
func planningPullRequest(namespace, name, id, branch, commit string) *configv1alpha1.TerraformPullRequest {
117+
pr := terraformPullRequest(namespace, name, id, branch, commit)
118+
pr.Status.LastDiscoveredCommit = ""
119+
return pr
120+
}
121+
122+
func planningPullRequestWithDiscoveredCommit(namespace, name, id, branch, commit, discoveredCommit string) *configv1alpha1.TerraformPullRequest {
123+
pr := terraformPullRequest(namespace, name, id, branch, commit)
124+
pr.Status.LastDiscoveredCommit = discoveredCommit
125+
return pr
126+
}
127+
128+
func planningLayer(namespace, name, managedLabel string, lastPlanCommit, lastRelevantCommit *string) *configv1alpha1.TerraformLayer {
129+
layer := &configv1alpha1.TerraformLayer{
130+
ObjectMeta: metav1.ObjectMeta{
131+
Name: name,
132+
Namespace: namespace,
133+
Labels: map[string]string{
134+
managedByLabel: managedLabel,
135+
},
136+
Annotations: map[string]string{},
137+
},
138+
}
139+
if lastPlanCommit != nil {
140+
layer.Annotations[annotations.LastPlanCommit] = *lastPlanCommit
141+
}
142+
if lastRelevantCommit != nil {
143+
layer.Annotations[annotations.LastRelevantCommit] = *lastRelevantCommit
144+
}
145+
return layer
146+
}
147+
148+
func strPtr(value string) *string {
149+
return &value
150+
}

0 commit comments

Comments
 (0)