Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -44,6 +44,7 @@ type RunHistoryPolicy struct {

type RemediationStrategy struct {
AutoApply *bool `json:"autoApply,omitempty"`
NonDestructiveApply *bool `json:"nonDestructiveApply,omitempty"`
ApplyWithoutPlanArtifact *bool `json:"applyWithoutPlanArtifact,omitempty"`
OnError OnErrorRemediationStrategy `json:"onError,omitempty"`
}
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 GetNonDestructiveApplyEnabled(repo *TerraformRepository, layer *TerraformLayer) bool {
return chooseBool(repo.Spec.RemediationStrategy.NonDestructiveApply, layer.Spec.RemediationStrategy.NonDestructiveApply, false)
}

func isEnabled(enabled *bool) bool {
return enabled != nil && *enabled
}
Expand Down
85 changes: 85 additions & 0 deletions api/v1alpha1/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,91 @@ func TestGetAutoApplyEnabled(t *testing.T) {
}
}

func TestGetNonDestructiveApplyEnabled(t *testing.T) {
tt := []struct {
name string
repository *configv1alpha1.TerraformRepository
layer *configv1alpha1.TerraformLayer
expected bool
}{
{
"EnabledInRepositoryDisabledInLayer",
&configv1alpha1.TerraformRepository{
Spec: configv1alpha1.TerraformRepositorySpec{
RemediationStrategy: configv1alpha1.RemediationStrategy{
NonDestructiveApply: &[]bool{true}[0],
},
},
},
&configv1alpha1.TerraformLayer{
Spec: configv1alpha1.TerraformLayerSpec{
RemediationStrategy: configv1alpha1.RemediationStrategy{
NonDestructiveApply: &[]bool{false}[0],
},
},
},
false,
},
{
"DisabledInRepositoryEnabledInLayer",
&configv1alpha1.TerraformRepository{
Spec: configv1alpha1.TerraformRepositorySpec{
RemediationStrategy: configv1alpha1.RemediationStrategy{
NonDestructiveApply: &[]bool{false}[0],
},
},
},
&configv1alpha1.TerraformLayer{
Spec: configv1alpha1.TerraformLayerSpec{
RemediationStrategy: configv1alpha1.RemediationStrategy{
NonDestructiveApply: &[]bool{true}[0],
},
},
},
true,
},
{
"OnlyRepositoryEnabling",
&configv1alpha1.TerraformRepository{
Spec: configv1alpha1.TerraformRepositorySpec{
RemediationStrategy: configv1alpha1.RemediationStrategy{
NonDestructiveApply: &[]bool{true}[0],
},
},
},
&configv1alpha1.TerraformLayer{},
true,
},
{
"OnlyLayerEnabling",
&configv1alpha1.TerraformRepository{},
&configv1alpha1.TerraformLayer{
Spec: configv1alpha1.TerraformLayerSpec{
RemediationStrategy: configv1alpha1.RemediationStrategy{
NonDestructiveApply: &[]bool{true}[0],
},
},
},
true,
},
{
"Default",
&configv1alpha1.TerraformRepository{},
&configv1alpha1.TerraformLayer{},
false,
},
}

for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
result := configv1alpha1.GetNonDestructiveApplyEnabled(tc.repository, tc.layer)
if tc.expected != result {
t.Errorf("different enabled status computed: expected %t go %t", tc.expected, result)
}
})
}
}

func TestOverrideRunnerSpec(t *testing.T) {
tt := []struct {
name string
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 @@ -4867,6 +4867,8 @@ spec:
type: boolean
autoApply:
type: boolean
nonDestructiveApply:
type: boolean
onError:
properties:
maxRetries:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4854,6 +4854,8 @@ spec:
type: boolean
autoApply:
type: boolean
nonDestructiveApply:
type: boolean
onError:
properties:
maxRetries:
Expand Down
13 changes: 9 additions & 4 deletions docs/user-guide/remediation-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@ The configuration of the `TerraformLayer` will take precedence.

## `spec.remediationStrategy` API reference

| Field | Type | Default | Effect |
| :------------------: | :-----: | :-------------------------------------------: | :-----------------------------------------------------------------------: |
| `autoApply` | Boolean | `false` | If `true` when a `plan` shows drift, it will run an `apply`. |
| `onError.maxRetries` | Integer | `5` or value defined in Burrito configuration | How many times Burrito should retry a `plan`/`apply` when a runner fails. |
| Field | Type | Default | Effect |
| :-------------------: | :-----: | :-------------------------------------------: | :-----------------------------------------------------------------------------------------: |
| `autoApply` | Boolean | `false` | If `true` when a `plan` shows drift, it will run an `apply`. |
| `nonDestructiveApply` | Boolean | `false` | If `true`, `apply` runs are skipped when the latest `plan` includes resources to destroy. |
| `onError.maxRetries` | Integer | `5` or value defined in Burrito configuration | How many times Burrito should retry a `plan`/`apply` when a runner fails. |

!!! warning
This operator is still experimental. Use `spec.remediationStrategy.autoApply: true` at your own risk.

!!! note
`nonDestructiveApply` only applies when `autoApply` is enabled. If the latest plan summary includes resources to delete, Burrito will keep the layer in `ApplyNeeded` and wait for the next drift detection instead of creating an `apply` run.

## Example

With this example configuration, Burrito will create `apply` runs for this layer, with a maximum of 3 retries.
Expand All @@ -28,6 +32,7 @@ metadata:
spec:
remediationStrategy:
autoApply: true
nonDestructiveApply: true
onError:
maxRetries: 3
# ... snipped ...
Expand Down
13 changes: 13 additions & 0 deletions internal/controllers/terraformlayer/states.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package terraformlayer
import (
"context"
"fmt"
"regexp"
"strings"

configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1"
Expand Down Expand Up @@ -90,6 +91,8 @@ func (s *PlanNeeded) getHandler() Handler {

type ApplyNeeded struct{}

var deleteChangesRegex = regexp.MustCompile(`\b[1-9][0-9]* to delete\b`)

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)
Expand All @@ -98,6 +101,12 @@ func (s *ApplyNeeded) getHandler() Handler {
log.Infof("autoApply is disabled for layer %s, no apply action taken", layer.Name)
return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.DriftDetection}, nil
}
nonDestructiveApply := configv1alpha1.GetNonDestructiveApplyEnabled(repository, layer)
if nonDestructiveApply && hasDestructiveChanges(layer.Status.LastResult) {
Comment thread
therunnas marked this conversation as resolved.
log.Infof("nonDestructiveApply is enabled for layer %s and the plan contains delete actions, no apply action taken", layer.Name)
r.Recorder.Event(layer, corev1.EventTypeNormal, "Reconciliation", "nonDestructiveApply is enabled and the plan contains delete actions, Apply run not created")
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
Expand All @@ -120,6 +129,10 @@ func (s *ApplyNeeded) getHandler() Handler {
}
}

func hasDestructiveChanges(lastResult string) bool {
return deleteChangesRegex.MatchString(lastResult)
}

type MaxRetriesReached struct{}

func (s *MaxRetriesReached) getHandler() Handler {
Expand Down
64 changes: 64 additions & 0 deletions internal/controllers/terraformlayer/states_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,70 @@ func TestApplyNeededEventIncludesCreateError(t *testing.T) {
assertEventContains(t, recorder, "Failed to create TerraformRun for Apply action: "+createErr.Error())
}

func TestApplyNeededBlocksDestructivePlanWhenNonDestructiveApplyEnabled(t *testing.T) {
recorder := record.NewFakeRecorder(1)
reconciler := &Reconciler{
Client: createErrorClient{err: errors.New("unexpected create")},
Recorder: recorder,
Config: config.TestConfig(),
}
autoApply := true
nonDestructiveApply := true
layer := &configv1alpha1.TerraformLayer{
ObjectMeta: metav1.ObjectMeta{
Name: "test-layer",
Namespace: "default",
Annotations: map[string]string{
annotations.LastRelevantCommit: "abc123",
annotations.LastPlanRun: "plan-run/0",
},
},
Status: configv1alpha1.TerraformLayerStatus{
LastResult: "Plan: 0 to create, 1 to update, 2 to delete",
},
}
repository := &configv1alpha1.TerraformRepository{
Spec: configv1alpha1.TerraformRepositorySpec{
RemediationStrategy: configv1alpha1.RemediationStrategy{
AutoApply: &autoApply,
NonDestructiveApply: &nonDestructiveApply,
},
},
}

result, run := (&ApplyNeeded{}).getHandler()(context.Background(), reconciler, layer, repository)

if run != nil {
t.Fatalf("expected no run when nonDestructiveApply blocks destructive changes")
}
if result.RequeueAfter != reconciler.Config.Controller.Timers.DriftDetection {
t.Fatalf("expected DriftDetection requeue, got %s", result.RequeueAfter)
}
assertEventContains(t, recorder, "nonDestructiveApply is enabled and the plan contains delete actions, Apply run not created")
}

func TestHasDestructiveChanges(t *testing.T) {
tt := []struct {
name string
lastResult string
expected bool
}{
{"NoDelete", "Plan: 3 to create, 1 to update, 0 to delete", false},
{"OneDelete", "Plan: 0 to create, 0 to update, 1 to delete", true},
{"MultipleDeletes", "Plan: 0 to create, 1 to update, 12 to delete", true},
{"NoPlan", "Layer has never been planned", false},
}

for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
result := hasDestructiveChanges(tc.lastResult)
if result != tc.expected {
t.Fatalf("expected %t, got %t", tc.expected, result)
}
})
}
}

func assertEventContains(t *testing.T, recorder *record.FakeRecorder, want string) {
t.Helper()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4866,6 +4866,8 @@ spec:
type: boolean
autoApply:
type: boolean
nonDestructiveApply:
type: boolean
onError:
properties:
maxRetries:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4853,6 +4853,8 @@ spec:
type: boolean
autoApply:
type: boolean
nonDestructiveApply:
type: boolean
onError:
properties:
maxRetries:
Expand Down
4 changes: 4 additions & 0 deletions manifests/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4866,6 +4866,8 @@ spec:
type: boolean
autoApply:
type: boolean
nonDestructiveApply:
type: boolean
onError:
properties:
maxRetries:
Expand Down Expand Up @@ -10006,6 +10008,8 @@ spec:
type: boolean
autoApply:
type: boolean
nonDestructiveApply:
type: boolean
onError:
properties:
maxRetries:
Expand Down