Skip to content
Open
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
5 changes: 5 additions & 0 deletions api/v1alpha1/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type RemediationStrategy struct {
AutoApply *bool `json:"autoApply,omitempty"`
ApplyWithoutPlanArtifact *bool `json:"applyWithoutPlanArtifact,omitempty"`
OnError OnErrorRemediationStrategy `json:"onError,omitempty"`
PlanApprovalRequired *bool `json:"planApprovalRequired,omitempty"`
}

type OnErrorRemediationStrategy struct {
Expand Down Expand Up @@ -138,6 +139,10 @@ func GetAutoApplyEnabled(repo *TerraformRepository, layer *TerraformLayer) bool
return chooseBool(repo.Spec.RemediationStrategy.AutoApply, layer.Spec.RemediationStrategy.AutoApply, false)
}

func GetPlanApprovalRequired(repo *TerraformRepository, layer *TerraformLayer) bool {
return chooseBool(repo.Spec.RemediationStrategy.PlanApprovalRequired, layer.Spec.RemediationStrategy.PlanApprovalRequired, false)
}

func isEnabled(enabled *bool) bool {
return enabled != nil && *enabled
}
Expand Down
5 changes: 5 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -4853,6 +4853,8 @@ spec:
maxRetries:
type: integer
type: object
planApprovalRequired:
type: boolean
type: object
repository:
properties:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4840,6 +4840,8 @@ spec:
maxRetries:
type: integer
type: object
planApprovalRequired:
type: boolean
type: object
repository:
properties:
Expand Down
11 changes: 6 additions & 5 deletions internal/annotations/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ const (
LastApplyDate string = "runner.terraform.padok.cloud/apply-date"
LastApplyCommit string = "runner.terraform.padok.cloud/apply-commit"
// LastApplyRun string = "runner.terraform.padok.cloud/apply-run"
LastPlanCommit string = "runner.terraform.padok.cloud/plan-commit"
LastPlanDate string = "runner.terraform.padok.cloud/plan-date"
LastPlanSum string = "runner.terraform.padok.cloud/plan-sum"
LastPlanRun string = "runner.terraform.padok.cloud/plan-run"
Lock string = "runner.terraform.padok.cloud/lock"
LastPlanApproved string = "runner.terraform.padok.cloud/plan-approved"
LastPlanCommit string = "runner.terraform.padok.cloud/plan-commit"
LastPlanDate string = "runner.terraform.padok.cloud/plan-date"
LastPlanSum string = "runner.terraform.padok.cloud/plan-sum"
LastPlanRun string = "runner.terraform.padok.cloud/plan-run"
Lock string = "runner.terraform.padok.cloud/lock"

LastBranchCommit string = "webhook.terraform.padok.cloud/branch-commit"
LastBranchCommitDate string = "webhook.terraform.padok.cloud/branch-commit-date"
Expand Down
39 changes: 39 additions & 0 deletions internal/controllers/terraformlayer/conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"path/filepath"
"strconv"
"strings"
"time"

Expand All @@ -15,6 +16,44 @@ import (
"k8s.io/apimachinery/pkg/types"
)

func (r *Reconciler) IsPlanApproved(t *configv1alpha1.TerraformLayer) (metav1.Condition, bool) {
condition := metav1.Condition{
Type: "IsPlanApproved",
ObservedGeneration: t.GetObjectMeta().GetGeneration(),
Status: metav1.ConditionUnknown,
LastTransitionTime: metav1.NewTime(time.Now()),
}

_, ok := t.Annotations[annotations.LastPlanDate]
if !ok {
condition.Reason = "NoPlanHasRunYet"
condition.Message = "No plan has run on this layer yet"
condition.Status = metav1.ConditionFalse
return condition, false
}

value, ok := t.Annotations[annotations.LastPlanApproved]
planApproved, err := strconv.ParseBool(value)
if err != nil {
condition.Reason = "InvalidPlanApprovedValue"
condition.Message = "plan-approved annotation value is invalid"
condition.Status = metav1.ConditionFalse
return condition, false
}

if !ok || !planApproved {
condition.Reason = "LastPlanNotApproved"
condition.Message = "Last Plan has not been approved"
condition.Status = metav1.ConditionFalse
return condition, false
}

condition.Reason = "LastPlanApproved"
condition.Message = "Last Plan has been approved"
condition.Status = metav1.ConditionTrue
return condition, true
}

func (r *Reconciler) IsRunning(t *configv1alpha1.TerraformLayer) (metav1.Condition, bool) {
condition := metav1.Condition{
Type: "IsRunning",
Expand Down
2 changes: 1 addition & 1 deletion internal/controllers/terraformlayer/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
r.Recorder.Event(layer, corev1.EventTypeWarning, "Reconciliation", err.Error())
return ctrl.Result{}, err
}
state, conditions := r.GetState(ctx, layer)
state, conditions := r.GetState(ctx, layer, repository)
lastResult := []byte("Layer has never been planned")
if layer.Status.LastRun.Name != "" {
lastResult, err = r.Datastore.GetPlan(layer.Namespace, layer.Name, layer.Status.LastRun.Name, "", "short")
Expand Down
75 changes: 75 additions & 0 deletions internal/controllers/terraformlayer/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,81 @@ var _ = Describe("Layer", func() {
Expect(len(runs.Items)).To(Equal(0))
})
})
Describe("When a TerraformLayer requires plan approval", Ordered, func() {
var layer *configv1alpha1.TerraformLayer
var reconcileError error
var err error
var result reconcile.Result
var name types.NamespacedName

BeforeAll(func() {
name = types.NamespacedName{
Name: "plan-approval-case-1",
Namespace: "default",
}
result, layer, reconcileError, err = getResult(name, reconciler)
})

It("should still exist", func() {
Expect(err).NotTo(HaveOccurred())
})

It("should not return an error", func() {
Expect(reconcileError).NotTo(HaveOccurred())
})

It("should be in PlanApprovalNeeded state", func() {
Expect(layer.Status.State).To(Equal("PlanApprovalNeeded"))
})

It("should set RequeueAfter to WaitAction", func() {
Expect(result.RequeueAfter).To(Equal(reconciler.Config.Controller.Timers.WaitAction))
})

It("should not have created an apply TerraformRun", func() {
runs, err := getLinkedRuns(k8sClient, layer)
Expect(err).NotTo(HaveOccurred())
Expect(len(runs.Items)).To(Equal(0))
})
})
Describe("When a TerraformLayer that requires plan approval is approved", Ordered, func() {
var layer *configv1alpha1.TerraformLayer
var reconcileError error
var err error
var result reconcile.Result
var name types.NamespacedName

BeforeAll(func() {
name = types.NamespacedName{
Name: "plan-approval-case-2",
Namespace: "default",
}
result, layer, reconcileError, err = getResult(name, reconciler)
})

It("should still exist", func() {
Expect(err).NotTo(HaveOccurred())
})

It("should not return an error", func() {
Expect(reconcileError).NotTo(HaveOccurred())
})

It("should be in ApplyNeeded state", func() {
Expect(layer.Status.State).To(Equal("ApplyNeeded"))
})

It("should set RequeueAfter to WaitAction", func() {
Expect(result.RequeueAfter).To(Equal(reconciler.Config.Controller.Timers.WaitAction))
})

It("should have created an apply 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("apply"))
})
})
})
})

Expand Down
57 changes: 49 additions & 8 deletions internal/controllers/terraformlayer/states.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,20 @@ type State interface {
getHandler() Handler
}

func (r *Reconciler) GetState(ctx context.Context, layer *configv1alpha1.TerraformLayer) (State, []metav1.Condition) {
func (r *Reconciler) GetState(ctx context.Context, layer *configv1alpha1.TerraformLayer, repository *configv1alpha1.TerraformRepository) (State, []metav1.Condition) {
log := log.WithContext(ctx)

planApprovalRequired := configv1alpha1.GetPlanApprovalRequired(repository, layer)

c1, IsRunning := r.IsRunning(layer)
c2, IsLastPlanTooOld := r.IsLastPlanTooOld(layer)
c3, IsLastRelevantCommitPlanned := r.IsLastRelevantCommitPlanned(layer)
c4, HasLastPlanFailed := r.HasLastPlanFailed(layer)
c5, IsApplyUpToDate := r.IsApplyUpToDate(layer)
c6, IsSyncScheduled := r.IsSyncScheduled(layer)
conditions := []metav1.Condition{c1, c2, c3, c4, c5, c6}
c7, IsPlanApproved := r.IsPlanApproved(layer)
conditions := []metav1.Condition{c1, c2, c3, c4, c5, c6, c7}

switch {
case IsRunning:
log.Infof("layer %s is running, waiting for the run to finish", layer.Name)
Expand All @@ -40,8 +45,23 @@ func (r *Reconciler) GetState(ctx context.Context, layer *configv1alpha1.Terrafo
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
if planApprovalRequired {
if IsPlanApproved {
// Approval required and granted, so apply plan
log.Infof("layer %s last plan is approved, creating a new run", layer.Name)
return &ApplyNeeded{}, conditions
}

// Plan not approved, so move to `PlanApprovalNeeded` state
log.Infof("layer %s needs to be approved before application", layer.Name)
return &PlanApprovalNeeded{}, conditions
} else {
log.Infof("layer %s needs to be applied, creating a new run", layer.Name)
return &ApplyNeeded{}, conditions
}
case IsApplyUpToDate:
log.Infof("layer %s has applied the latest plan", layer.Name)
return &Idle{}, conditions
default:
log.Infof("layer %s is in an unknown state, defaulting to idle. If this happens please file an issue, this is not an intended behavior.", layer.Name)
return &Idle{}, conditions
Expand Down Expand Up @@ -83,29 +103,50 @@ func (s *PlanNeeded) getHandler() Handler {
}
}

type PlanApprovalNeeded struct{}

func (s *PlanApprovalNeeded) getHandler() Handler {
return func(ctx context.Context, r *Reconciler, layer *configv1alpha1.TerraformLayer, repository *configv1alpha1.TerraformRepository) (ctrl.Result, *configv1alpha1.TerraformRun) {
log := log.WithContext(ctx)

// Check for sync windows that would block the apply action
if isActionBlocked(r, layer, repository, syncwindow.PlanAction) {
return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.WaitAction}, nil
}

log.Infof("waiting for plan approval on layer %s", layer.Name)
r.Recorder.Event(layer, corev1.EventTypeNormal, "Reconciliation", "Waiting for plan approval")
return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.WaitAction}, nil
}
}

type ApplyNeeded struct{}

func (s *ApplyNeeded) getHandler() Handler {
return func(ctx context.Context, r *Reconciler, layer *configv1alpha1.TerraformLayer, repository *configv1alpha1.TerraformRepository) (ctrl.Result, *configv1alpha1.TerraformRun) {
log := log.WithContext(ctx)

autoApply := configv1alpha1.GetAutoApplyEnabled(repository, layer)
if !autoApply {
log.Infof("autoApply is disabled for layer %s, no apply action taken", layer.Name)
planApprovalRequired := configv1alpha1.GetPlanApprovalRequired(repository, layer)

if !autoApply && !planApprovalRequired {
log.Infof("autoApply and planApprovalRequired is disabled for layer %s, no apply action taken", layer.Name)
return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.DriftDetection}, nil
}

// Check for sync windows that would block the apply action
if isActionBlocked(r, layer, repository, syncwindow.ApplyAction) {
return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.WaitAction}, nil
}

revision, ok := layer.Annotations[annotations.LastRelevantCommit]
if !ok {
r.Recorder.Event(layer, corev1.EventTypeWarning, "Reconciliation", "Layer has no last relevant commit annotation, Apply run not created")
log.Errorf("layer %s has no last relevant commit annotation, run not created", layer.Name)
return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.OnError}, nil
}
run := r.getRun(layer, revision, ApplyAction)
err := r.Client.Create(ctx, &run)
if err != nil {
if err := r.Client.Create(ctx, &run); err != nil {
r.Recorder.Event(layer, corev1.EventTypeWarning, "Reconciliation", "Failed to create TerraformRun for Apply action")
log.Errorf("failed to create TerraformRun for Apply action on layer %s: %s", layer.Name, err)
return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.OnError}, nil
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
apiVersion: config.terraform.padok.cloud/v1alpha1
kind: TerraformLayer
metadata:
name: plan-approval-case-1
namespace: default
annotations:
runner.terraform.padok.cloud/plan-commit: ca9b6c80ac8fb5cd837ae9b374b79ff33f472558
runner.terraform.padok.cloud/plan-date: Sun May 8 11:21:53 UTC 2023
runner.terraform.padok.cloud/plan-run: run-succeeded/0
runner.terraform.padok.cloud/plan-sum: AuP6pMNxWsbSZKnxZvxD842wy0qaF9JCX8HW1nFeL1I=
spec:
branch: main
path: plan-approval-case/
remediationStrategy:
autoApply: false
planApprovalRequired: true
repository:
name: burrito
namespace: default
terraform:
enabled: true
version: 1.3.1
terragrunt:
enabled: true
version: 0.45.4
---
apiVersion: config.terraform.padok.cloud/v1alpha1
kind: TerraformLayer
metadata:
name: plan-approval-case-2
namespace: default
annotations:
runner.terraform.padok.cloud/plan-approved: "true"
runner.terraform.padok.cloud/plan-commit: ca9b6c80ac8fb5cd837ae9b374b79ff33f472558
runner.terraform.padok.cloud/plan-date: Sun May 8 11:21:53 UTC 2023
runner.terraform.padok.cloud/plan-run: run-succeeded/0
runner.terraform.padok.cloud/plan-sum: AuP6pMNxWsbSZKnxZvxD842wy0qaF9JCX8HW1nFeL1I=
webhook.terraform.padok.cloud/relevant-commit: cb9f15b90861c8c4364cdde63d17837c7a9ccca9
spec:
branch: main
path: plan-approval-case/
remediationStrategy:
autoApply: false
planApprovalRequired: true
repository:
name: burrito
namespace: default
terraform:
enabled: true
version: 1.3.1
terragrunt:
enabled: true
version: 0.45.4
1 change: 1 addition & 0 deletions internal/runner/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func (r *Runner) ExecAction() error {
ann[annotations.LastPlanRun] = fmt.Sprintf("%s/%s", r.Run.Name, strconv.Itoa(r.Run.Status.Retries))
ann[annotations.LastPlanSum] = sum
ann[annotations.LastPlanCommit] = r.Run.Spec.Layer.Revision
ann[annotations.LastPlanApproved] = "false"

case "apply":
sum, err := r.execApply()
Expand Down
Loading
Loading