Skip to content

Commit

Permalink
feat: add manual layer sync (#321)
Browse files Browse the repository at this point in the history
  • Loading branch information
LucasMrqes authored Oct 8, 2024
1 parent 3984b21 commit 53eae91
Show file tree
Hide file tree
Showing 21 changed files with 659 additions and 49 deletions.
2 changes: 2 additions & 0 deletions internal/annotations/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const (

ForceApply string = "notifications.terraform.padok.cloud/force-apply"
AdditionnalTriggerPaths string = "config.terraform.padok.cloud/additionnal-trigger-paths"

SyncNow string = "api.terraform.padok.cloud/sync-now"
)

func Add(ctx context.Context, c client.Client, obj client.Object, annotations map[string]string) error {
Expand Down
26 changes: 26 additions & 0 deletions internal/controllers/terraformlayer/conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

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

func (r *Reconciler) IsSyncScheduled(t *configv1alpha1.TerraformLayer) (metav1.Condition, bool) {
condition := metav1.Condition{
Type: "IsSyncScheduled",
ObservedGeneration: t.GetObjectMeta().GetGeneration(),
Status: metav1.ConditionUnknown,
LastTransitionTime: metav1.NewTime(time.Now()),
}
// check if annotations.SyncNow is present
if _, ok := t.Annotations[annotations.SyncNow]; ok {
condition.Reason = "SyncScheduled"
condition.Message = "A sync has been manually scheduled"
condition.Status = metav1.ConditionTrue
// Remove the annotation to avoid running the sync again
err := annotations.Remove(context.Background(), r.Client, t, annotations.SyncNow)
if err != nil {
log.Errorf("Failed to remove annotation %s from layer %s: %s", annotations.SyncNow, t.Name, err)
}
return condition, true
}
condition.Reason = "NoSyncScheduled"
condition.Message = "No sync has been manually scheduled"
condition.Status = metav1.ConditionFalse
return condition, false
}

func LayerFilesHaveChanged(layer configv1alpha1.TerraformLayer, changedFiles []string) bool {
if len(changedFiles) == 0 {
return true
Expand Down
27 changes: 27 additions & 0 deletions internal/controllers/terraformlayer/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,33 @@ var _ = Describe("Layer", func() {
Expect(result.RequeueAfter).To(Equal(reconciler.Config.Controller.Timers.DriftDetection))
})
})
Describe("When a TerraformLayer is annotated to be manually synced", Ordered, func() {
BeforeAll(func() {
name = types.NamespacedName{
Name: "nominal-case-8",
Namespace: "default",
}
result, layer, reconcileError, err = getResult(name)
})
It("should still exists", func() {
Expect(err).NotTo(HaveOccurred())
})
It("should not return an error", func() {
Expect(reconcileError).NotTo(HaveOccurred())
})
It("should end in PlanNeeded state", func() {
Expect(layer.Status.State).To(Equal("PlanNeeded"))
})
It("should set RequeueAfter to WaitAction", func() {
Expect(result.RequeueAfter).To(Equal(reconciler.Config.Controller.Timers.WaitAction))
})
It("should have created a plan TerraformRun", func() {
runs, err := getLinkedRuns(k8sClient, layer)
Expect(err).NotTo(HaveOccurred())
Expect(len(runs.Items)).To(Equal(1))
Expect(runs.Items[0].Spec.Action).To(Equal("plan"))
})
})
})
Describe("When a TerraformLayer has errored on plan and is still before new DriftDetection tick", Ordered, func() {
BeforeAll(func() {
Expand Down
6 changes: 5 additions & 1 deletion internal/controllers/terraformlayer/states.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,18 @@ func (r *Reconciler) GetState(ctx context.Context, layer *configv1alpha1.Terrafo
c3, IsLastRelevantCommitPlanned := r.IsLastRelevantCommitPlanned(layer)
c4, HasLastPlanFailed := r.HasLastPlanFailed(layer)
c5, IsApplyUpToDate := r.IsApplyUpToDate(layer)
conditions := []metav1.Condition{c1, c2, c3, c4, c5}
c6, IsSyncScheduled := r.IsSyncScheduled(layer)
conditions := []metav1.Condition{c1, c2, c3, c4, c5, c6}
switch {
case IsRunning:
log.Infof("layer %s is running, waiting for the run to finish", layer.Name)
return &Idle{}, conditions
case IsLastPlanTooOld || !IsLastRelevantCommitPlanned:
log.Infof("layer %s has an outdated plan, creating a new run", layer.Name)
return &PlanNeeded{}, conditions
case IsSyncScheduled:
log.Infof("layer %s has a sync scheduled, creating a new run", layer.Name)
return &PlanNeeded{}, conditions
case !IsApplyUpToDate && !HasLastPlanFailed:
log.Infof("layer %s needs to be applied, creating a new run", layer.Name)
return &ApplyNeeded{}, conditions
Expand Down
23 changes: 23 additions & 0 deletions internal/controllers/terraformlayer/testdata/nominal-case.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,26 @@ status:
lastRun:
name: run-running
namespace: default
---
apiVersion: config.terraform.padok.cloud/v1alpha1
kind: TerraformLayer
metadata:
labels:
app.kubernetes.io/instance: in-cluster-burrito
annotations:
api.terraform.padok.cloud/sync-now: "true"
name: nominal-case-8
namespace: default
spec:
branch: main
path: nominal-case-eight/
remediationStrategy:
autoApply: true
repository:
name: burrito
namespace: default
terraform:
terragrunt:
enabled: true
version: 0.45.4
version: 1.3.1
59 changes: 31 additions & 28 deletions internal/server/api/layers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,26 @@ import (
"github.com/labstack/echo/v4"
configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1"
"github.com/padok-team/burrito/internal/annotations"
"github.com/padok-team/burrito/internal/server/utils"
log "github.com/sirupsen/logrus"
)

type layer struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
Repository string `json:"repository"`
Branch string `json:"branch"`
Path string `json:"path"`
State string `json:"state"`
RunCount int `json:"runCount"`
LastRun Run `json:"lastRun"`
LastRunAt string `json:"lastRunAt"`
LastResult string `json:"lastResult"`
IsRunning bool `json:"isRunning"`
IsPR bool `json:"isPR"`
LatestRuns []Run `json:"latestRuns"`
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
Repository string `json:"repository"`
Branch string `json:"branch"`
Path string `json:"path"`
State string `json:"state"`
RunCount int `json:"runCount"`
LastRun Run `json:"lastRun"`
LastRunAt string `json:"lastRunAt"`
LastResult string `json:"lastResult"`
IsRunning bool `json:"isRunning"`
IsPR bool `json:"isPR"`
LatestRuns []Run `json:"latestRuns"`
ManualSyncStatus utils.ManualSyncStatus `json:"manualSyncStatus"`
}

type Run struct {
Expand Down Expand Up @@ -83,20 +85,21 @@ func (a *API) LayersHandler(c echo.Context) error {
running = runStillRunning(run)
}
results = append(results, layer{
UID: string(l.UID),
Name: l.Name,
Namespace: l.Namespace,
Repository: fmt.Sprintf("%s/%s", l.Spec.Repository.Namespace, l.Spec.Repository.Name),
Branch: l.Spec.Branch,
Path: l.Spec.Path,
State: a.getLayerState(l),
RunCount: len(l.Status.LatestRuns),
LastRun: runAPI,
LastRunAt: l.Status.LastRun.Date.Format(time.RFC3339),
LastResult: l.Status.LastResult,
IsRunning: running,
IsPR: a.isLayerPR(l),
LatestRuns: transformLatestRuns(l.Status.LatestRuns),
UID: string(l.UID),
Name: l.Name,
Namespace: l.Namespace,
Repository: fmt.Sprintf("%s/%s", l.Spec.Repository.Namespace, l.Spec.Repository.Name),
Branch: l.Spec.Branch,
Path: l.Spec.Path,
State: a.getLayerState(l),
RunCount: len(l.Status.LatestRuns),
LastRun: runAPI,
LastRunAt: l.Status.LastRun.Date.Format(time.RFC3339),
LastResult: l.Status.LastResult,
IsRunning: running,
IsPR: a.isLayerPR(l),
LatestRuns: transformLatestRuns(l.Status.LatestRuns),
ManualSyncStatus: utils.GetManualSyncStatus(l),
})
}
return c.JSON(http.StatusOK, &layersResponse{
Expand Down
38 changes: 38 additions & 0 deletions internal/server/api/sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package api

import (
"context"
"net/http"

"github.com/labstack/echo/v4"
configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1"
"github.com/padok-team/burrito/internal/annotations"
"github.com/padok-team/burrito/internal/server/utils"
log "github.com/sirupsen/logrus"
"sigs.k8s.io/controller-runtime/pkg/client"
)

func (a *API) SyncLayerHandler(c echo.Context) error {
layer := &configv1alpha1.TerraformLayer{}
err := a.Client.Get(context.Background(), client.ObjectKey{
Namespace: c.Param("namespace"),
Name: c.Param("layer"),
}, layer)
if err != nil {
log.Errorf("could not get terraform layer: %s", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "An error occurred while getting the layer"})
}
syncStatus := utils.GetManualSyncStatus(*layer)
if syncStatus == utils.ManualSyncAnnotated || syncStatus == utils.ManualSyncPending {
return c.JSON(http.StatusConflict, map[string]string{"error": "Layer sync already triggered"})
}

err = annotations.Add(context.Background(), a.Client, layer, map[string]string{
annotations.SyncNow: "true",
})
if err != nil {
log.Errorf("could not update terraform layer annotations: %s", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "An error occurred while updating the layer annotations"})
}
return c.JSON(http.StatusOK, map[string]string{"status": "Layer sync triggered"})
}
1 change: 1 addition & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ func (s *Server) Exec() {
e.GET("/healthz", handleHealthz)
api.POST("/webhook", s.Webhook.GetHttpHandler())
api.GET("/layers", s.API.LayersHandler)
api.POST("/layers/:namespace/:layer/sync", s.API.SyncLayerHandler)
api.GET("/repositories", s.API.RepositoriesHandler)
api.GET("/logs/:namespace/:layer/:run/:attempt", s.API.GetLogsHandler)
api.GET("/run/:namespace/:layer/:run/attempts", s.API.GetAttemptsHandler)
Expand Down
27 changes: 27 additions & 0 deletions internal/server/utils/manual_sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package utils

import (
configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1"
"github.com/padok-team/burrito/internal/annotations"
)

type ManualSyncStatus string

const (
ManualSyncNone ManualSyncStatus = "none"
ManualSyncAnnotated ManualSyncStatus = "annotated"
ManualSyncPending ManualSyncStatus = "pending"
)

func GetManualSyncStatus(layer configv1alpha1.TerraformLayer) ManualSyncStatus {
if layer.Annotations[annotations.SyncNow] == "true" {
return ManualSyncAnnotated
}
// check the IsSyncScheduled condition on layer
for _, c := range layer.Status.Conditions {
if c.Type == "IsSyncScheduled" && c.Status == "True" {
return ManualSyncPending
}
}
return ManualSyncNone
}
1 change: 1 addition & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"axios": "^1.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-focus-lock": "^2.13.2",
"react-router-dom": "^6.16.0",
"react-tooltip": "^5.21.6",
"tailwind-merge": "^2.0.0"
Expand Down
7 changes: 7 additions & 0 deletions ui/src/clients/layers/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,10 @@ export const fetchLayers = async () => {
);
return response.data;
};

export const syncLayer = async (namespace: string, name: string) => {
const response = await axios.post(
`${import.meta.env.VITE_API_BASE_URL}/layers/${namespace}/${name}/sync`
);
return response;
}
2 changes: 2 additions & 0 deletions ui/src/clients/layers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ export type Layer = {
latestRuns: Run[];
lastResult: string;
isRunning: boolean;
manualSyncStatus: ManualSyncStatus;
isPR: boolean;
};

export type LayerState = "success" | "warning" | "error" | "disabled";
export type ManualSyncStatus = "none" | "annotated" | "pending";

export type Run = {
id: string;
Expand Down
54 changes: 54 additions & 0 deletions ui/src/components/buttons/GenericIconButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from "react";
import { twMerge } from "tailwind-merge";
import { Tooltip } from "react-tooltip";
export interface GenericIconButtonProps {
className?: string;
variant?: "light" | "dark";
disabled?: boolean;
tooltip?: string;
width?: number;
height?: number;
onClick?: () => void;
Icon: React.FC<React.SVGProps<SVGSVGElement>>;
}


const GenericIconButton: React.FC<GenericIconButtonProps> = ({
className,
variant,
disabled,
tooltip,
onClick,
width = 40,
height = 40,
Icon
}) => {
const hoverClass = !disabled ? (variant === "light" ? "hover:bg-primary-300" : "hover:bg-nuances-black") : "";
return (
<div style={{ width: `${width}px`, height: `${height}px` }}>
<Tooltip
opacity={1}
id="generic-button-tooltip"
variant={variant === "light" ? "dark" : "light"}
/>
<button
onClick={disabled ? undefined : onClick}
disabled={disabled}
className={twMerge(
`${hoverClass}
disabled:opacity-50
disabled:cursor-default
rounded-full
cursor-pointer
transition-colors
duration-300`,
className
)}
>
<Icon data-tooltip-id="generic-button-tooltip" data-tooltip-content={tooltip} className="p-2 fill-blue-500" width={width} height={height} />
</button>
</div>
);
};

export default GenericIconButton;
Loading

0 comments on commit 53eae91

Please sign in to comment.