Skip to content

Commit 53eae91

Browse files
authored
feat: add manual layer sync (#321)
1 parent 3984b21 commit 53eae91

File tree

21 files changed

+659
-49
lines changed

21 files changed

+659
-49
lines changed

internal/annotations/annotations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ const (
2424

2525
ForceApply string = "notifications.terraform.padok.cloud/force-apply"
2626
AdditionnalTriggerPaths string = "config.terraform.padok.cloud/additionnal-trigger-paths"
27+
28+
SyncNow string = "api.terraform.padok.cloud/sync-now"
2729
)
2830

2931
func Add(ctx context.Context, c client.Client, obj client.Object, annotations map[string]string) error {

internal/controllers/terraformlayer/conditions.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1"
1111
"github.com/padok-team/burrito/internal/annotations"
12+
log "github.com/sirupsen/logrus"
1213
"k8s.io/apimachinery/pkg/api/errors"
1314
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1415
"k8s.io/apimachinery/pkg/types"
@@ -243,6 +244,31 @@ func (r *Reconciler) IsApplyUpToDate(t *configv1alpha1.TerraformLayer) (metav1.C
243244
return condition, true
244245
}
245246

247+
func (r *Reconciler) IsSyncScheduled(t *configv1alpha1.TerraformLayer) (metav1.Condition, bool) {
248+
condition := metav1.Condition{
249+
Type: "IsSyncScheduled",
250+
ObservedGeneration: t.GetObjectMeta().GetGeneration(),
251+
Status: metav1.ConditionUnknown,
252+
LastTransitionTime: metav1.NewTime(time.Now()),
253+
}
254+
// check if annotations.SyncNow is present
255+
if _, ok := t.Annotations[annotations.SyncNow]; ok {
256+
condition.Reason = "SyncScheduled"
257+
condition.Message = "A sync has been manually scheduled"
258+
condition.Status = metav1.ConditionTrue
259+
// Remove the annotation to avoid running the sync again
260+
err := annotations.Remove(context.Background(), r.Client, t, annotations.SyncNow)
261+
if err != nil {
262+
log.Errorf("Failed to remove annotation %s from layer %s: %s", annotations.SyncNow, t.Name, err)
263+
}
264+
return condition, true
265+
}
266+
condition.Reason = "NoSyncScheduled"
267+
condition.Message = "No sync has been manually scheduled"
268+
condition.Status = metav1.ConditionFalse
269+
return condition, false
270+
}
271+
246272
func LayerFilesHaveChanged(layer configv1alpha1.TerraformLayer, changedFiles []string) bool {
247273
if len(changedFiles) == 0 {
248274
return true

internal/controllers/terraformlayer/controller_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,33 @@ var _ = Describe("Layer", func() {
313313
Expect(result.RequeueAfter).To(Equal(reconciler.Config.Controller.Timers.DriftDetection))
314314
})
315315
})
316+
Describe("When a TerraformLayer is annotated to be manually synced", Ordered, func() {
317+
BeforeAll(func() {
318+
name = types.NamespacedName{
319+
Name: "nominal-case-8",
320+
Namespace: "default",
321+
}
322+
result, layer, reconcileError, err = getResult(name)
323+
})
324+
It("should still exists", func() {
325+
Expect(err).NotTo(HaveOccurred())
326+
})
327+
It("should not return an error", func() {
328+
Expect(reconcileError).NotTo(HaveOccurred())
329+
})
330+
It("should end in PlanNeeded state", func() {
331+
Expect(layer.Status.State).To(Equal("PlanNeeded"))
332+
})
333+
It("should set RequeueAfter to WaitAction", func() {
334+
Expect(result.RequeueAfter).To(Equal(reconciler.Config.Controller.Timers.WaitAction))
335+
})
336+
It("should have created a plan TerraformRun", func() {
337+
runs, err := getLinkedRuns(k8sClient, layer)
338+
Expect(err).NotTo(HaveOccurred())
339+
Expect(len(runs.Items)).To(Equal(1))
340+
Expect(runs.Items[0].Spec.Action).To(Equal("plan"))
341+
})
342+
})
316343
})
317344
Describe("When a TerraformLayer has errored on plan and is still before new DriftDetection tick", Ordered, func() {
318345
BeforeAll(func() {

internal/controllers/terraformlayer/states.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,18 @@ func (r *Reconciler) GetState(ctx context.Context, layer *configv1alpha1.Terrafo
2525
c3, IsLastRelevantCommitPlanned := r.IsLastRelevantCommitPlanned(layer)
2626
c4, HasLastPlanFailed := r.HasLastPlanFailed(layer)
2727
c5, IsApplyUpToDate := r.IsApplyUpToDate(layer)
28-
conditions := []metav1.Condition{c1, c2, c3, c4, c5}
28+
c6, IsSyncScheduled := r.IsSyncScheduled(layer)
29+
conditions := []metav1.Condition{c1, c2, c3, c4, c5, c6}
2930
switch {
3031
case IsRunning:
3132
log.Infof("layer %s is running, waiting for the run to finish", layer.Name)
3233
return &Idle{}, conditions
3334
case IsLastPlanTooOld || !IsLastRelevantCommitPlanned:
3435
log.Infof("layer %s has an outdated plan, creating a new run", layer.Name)
3536
return &PlanNeeded{}, conditions
37+
case IsSyncScheduled:
38+
log.Infof("layer %s has a sync scheduled, creating a new run", layer.Name)
39+
return &PlanNeeded{}, conditions
3640
case !IsApplyUpToDate && !HasLastPlanFailed:
3741
log.Infof("layer %s needs to be applied, creating a new run", layer.Name)
3842
return &ApplyNeeded{}, conditions

internal/controllers/terraformlayer/testdata/nominal-case.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,26 @@ status:
201201
lastRun:
202202
name: run-running
203203
namespace: default
204+
---
205+
apiVersion: config.terraform.padok.cloud/v1alpha1
206+
kind: TerraformLayer
207+
metadata:
208+
labels:
209+
app.kubernetes.io/instance: in-cluster-burrito
210+
annotations:
211+
api.terraform.padok.cloud/sync-now: "true"
212+
name: nominal-case-8
213+
namespace: default
214+
spec:
215+
branch: main
216+
path: nominal-case-eight/
217+
remediationStrategy:
218+
autoApply: true
219+
repository:
220+
name: burrito
221+
namespace: default
222+
terraform:
223+
terragrunt:
224+
enabled: true
225+
version: 0.45.4
226+
version: 1.3.1

internal/server/api/layers.go

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,26 @@ import (
99
"github.com/labstack/echo/v4"
1010
configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1"
1111
"github.com/padok-team/burrito/internal/annotations"
12+
"github.com/padok-team/burrito/internal/server/utils"
1213
log "github.com/sirupsen/logrus"
1314
)
1415

1516
type layer struct {
16-
UID string `json:"uid"`
17-
Name string `json:"name"`
18-
Namespace string `json:"namespace"`
19-
Repository string `json:"repository"`
20-
Branch string `json:"branch"`
21-
Path string `json:"path"`
22-
State string `json:"state"`
23-
RunCount int `json:"runCount"`
24-
LastRun Run `json:"lastRun"`
25-
LastRunAt string `json:"lastRunAt"`
26-
LastResult string `json:"lastResult"`
27-
IsRunning bool `json:"isRunning"`
28-
IsPR bool `json:"isPR"`
29-
LatestRuns []Run `json:"latestRuns"`
17+
UID string `json:"uid"`
18+
Name string `json:"name"`
19+
Namespace string `json:"namespace"`
20+
Repository string `json:"repository"`
21+
Branch string `json:"branch"`
22+
Path string `json:"path"`
23+
State string `json:"state"`
24+
RunCount int `json:"runCount"`
25+
LastRun Run `json:"lastRun"`
26+
LastRunAt string `json:"lastRunAt"`
27+
LastResult string `json:"lastResult"`
28+
IsRunning bool `json:"isRunning"`
29+
IsPR bool `json:"isPR"`
30+
LatestRuns []Run `json:"latestRuns"`
31+
ManualSyncStatus utils.ManualSyncStatus `json:"manualSyncStatus"`
3032
}
3133

3234
type Run struct {
@@ -83,20 +85,21 @@ func (a *API) LayersHandler(c echo.Context) error {
8385
running = runStillRunning(run)
8486
}
8587
results = append(results, layer{
86-
UID: string(l.UID),
87-
Name: l.Name,
88-
Namespace: l.Namespace,
89-
Repository: fmt.Sprintf("%s/%s", l.Spec.Repository.Namespace, l.Spec.Repository.Name),
90-
Branch: l.Spec.Branch,
91-
Path: l.Spec.Path,
92-
State: a.getLayerState(l),
93-
RunCount: len(l.Status.LatestRuns),
94-
LastRun: runAPI,
95-
LastRunAt: l.Status.LastRun.Date.Format(time.RFC3339),
96-
LastResult: l.Status.LastResult,
97-
IsRunning: running,
98-
IsPR: a.isLayerPR(l),
99-
LatestRuns: transformLatestRuns(l.Status.LatestRuns),
88+
UID: string(l.UID),
89+
Name: l.Name,
90+
Namespace: l.Namespace,
91+
Repository: fmt.Sprintf("%s/%s", l.Spec.Repository.Namespace, l.Spec.Repository.Name),
92+
Branch: l.Spec.Branch,
93+
Path: l.Spec.Path,
94+
State: a.getLayerState(l),
95+
RunCount: len(l.Status.LatestRuns),
96+
LastRun: runAPI,
97+
LastRunAt: l.Status.LastRun.Date.Format(time.RFC3339),
98+
LastResult: l.Status.LastResult,
99+
IsRunning: running,
100+
IsPR: a.isLayerPR(l),
101+
LatestRuns: transformLatestRuns(l.Status.LatestRuns),
102+
ManualSyncStatus: utils.GetManualSyncStatus(l),
100103
})
101104
}
102105
return c.JSON(http.StatusOK, &layersResponse{

internal/server/api/sync.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"net/http"
6+
7+
"github.com/labstack/echo/v4"
8+
configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1"
9+
"github.com/padok-team/burrito/internal/annotations"
10+
"github.com/padok-team/burrito/internal/server/utils"
11+
log "github.com/sirupsen/logrus"
12+
"sigs.k8s.io/controller-runtime/pkg/client"
13+
)
14+
15+
func (a *API) SyncLayerHandler(c echo.Context) error {
16+
layer := &configv1alpha1.TerraformLayer{}
17+
err := a.Client.Get(context.Background(), client.ObjectKey{
18+
Namespace: c.Param("namespace"),
19+
Name: c.Param("layer"),
20+
}, layer)
21+
if err != nil {
22+
log.Errorf("could not get terraform layer: %s", err)
23+
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "An error occurred while getting the layer"})
24+
}
25+
syncStatus := utils.GetManualSyncStatus(*layer)
26+
if syncStatus == utils.ManualSyncAnnotated || syncStatus == utils.ManualSyncPending {
27+
return c.JSON(http.StatusConflict, map[string]string{"error": "Layer sync already triggered"})
28+
}
29+
30+
err = annotations.Add(context.Background(), a.Client, layer, map[string]string{
31+
annotations.SyncNow: "true",
32+
})
33+
if err != nil {
34+
log.Errorf("could not update terraform layer annotations: %s", err)
35+
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "An error occurred while updating the layer annotations"})
36+
}
37+
return c.JSON(http.StatusOK, map[string]string{"status": "Layer sync triggered"})
38+
}

internal/server/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ func (s *Server) Exec() {
8282
e.GET("/healthz", handleHealthz)
8383
api.POST("/webhook", s.Webhook.GetHttpHandler())
8484
api.GET("/layers", s.API.LayersHandler)
85+
api.POST("/layers/:namespace/:layer/sync", s.API.SyncLayerHandler)
8586
api.GET("/repositories", s.API.RepositoriesHandler)
8687
api.GET("/logs/:namespace/:layer/:run/:attempt", s.API.GetLogsHandler)
8788
api.GET("/run/:namespace/:layer/:run/attempts", s.API.GetAttemptsHandler)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package utils
2+
3+
import (
4+
configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1"
5+
"github.com/padok-team/burrito/internal/annotations"
6+
)
7+
8+
type ManualSyncStatus string
9+
10+
const (
11+
ManualSyncNone ManualSyncStatus = "none"
12+
ManualSyncAnnotated ManualSyncStatus = "annotated"
13+
ManualSyncPending ManualSyncStatus = "pending"
14+
)
15+
16+
func GetManualSyncStatus(layer configv1alpha1.TerraformLayer) ManualSyncStatus {
17+
if layer.Annotations[annotations.SyncNow] == "true" {
18+
return ManualSyncAnnotated
19+
}
20+
// check the IsSyncScheduled condition on layer
21+
for _, c := range layer.Status.Conditions {
22+
if c.Type == "IsSyncScheduled" && c.Status == "True" {
23+
return ManualSyncPending
24+
}
25+
}
26+
return ManualSyncNone
27+
}

ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"axios": "^1.5.1",
1717
"react": "^18.2.0",
1818
"react-dom": "^18.2.0",
19+
"react-focus-lock": "^2.13.2",
1920
"react-router-dom": "^6.16.0",
2021
"react-tooltip": "^5.21.6",
2122
"tailwind-merge": "^2.0.0"

0 commit comments

Comments
 (0)