Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add manual layer sync #321

Merged
merged 12 commits into from
Oct 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
Comment on lines +259 to +263
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the annotation right away is causing another reconciliation right?
Does the layer end in a NoSyncScheduled status just a few seconds later? Does it impact the Server API in its SyncLayerHandler ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The layer controller creates a run, and the run controller locks the layer related to a this new run until the run succeeds or fails. Since the layer is locked during the whole duration of the run, this should not cause any issue.

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
Loading