Skip to content

Commit

Permalink
Merge branch 'main' into update-ssh-key-reconciliation
Browse files Browse the repository at this point in the history
  • Loading branch information
arybolovlev authored Oct 18, 2024
2 parents fae95d4 + 14f9e5a commit 49f48cc
Show file tree
Hide file tree
Showing 16 changed files with 367 additions and 180 deletions.
5 changes: 5 additions & 0 deletions .changes/unreleased/ENHANCEMENTS-477-20240821-134240.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: ENHANCEMENTS
body: '`Workspace`: Update Notifications reconciliation logic to reduce the number of API calls.'
time: 2024-08-21T13:42:40.942036+02:00
custom:
PR: "477"
5 changes: 5 additions & 0 deletions .changes/unreleased/FEATURES-489-20241017-094021.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: FEATURES
body: '`Workspace`: Add the `destroy` deletion policy. The `spec.allowDestroyPlan` must be set to `true` for the controller to execute a destroy run.'
time: 2024-10-17T09:40:21.262822+02:00
custom:
PR: "489"
30 changes: 30 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,36 @@ USER 65532:65532

ENTRYPOINT ["/bin/sh", "-c", "/$BIN_NAME"]

# Red Hat UBI release image
# -----------------------------------
FROM registry.access.redhat.com/ubi9/ubi-micro:9.4-15 AS release-ubi

ARG BIN_NAME
ARG PRODUCT_VERSION
ARG PRODUCT_REVISION
ARG TARGETOS
ARG TARGETARCH

ENV BIN_NAME=$BIN_NAME

LABEL name="HCP Terraform Operator"
LABEL vendor="HashiCorp"
LABEL release=$PRODUCT_REVISION
LABEL summary="HCP Terraform Operator for Kubernetes allows managing HCP Terraform / Terraform Enterprise resources via Kubernetes Custom Resources"
LABEL description="HCP Terraform Operator for Kubernetes allows managing HCP Terraform / Terraform Enterprise resources via Kubernetes Custom Resources"

LABEL maintainer="Terraform Ecosystem - Hybrid Cloud Team <[email protected]>"
LABEL version=$PRODUCT_VERSION
LABEL revision=$PRODUCT_REVISION

WORKDIR /
COPY LICENSE /licenses/copyright.txt
COPY dist/$TARGETOS/$TARGETARCH/$BIN_NAME .

USER 65532:65532

ENTRYPOINT ["/bin/sh", "-c", "/$BIN_NAME"]

# ===================================
#
# Set default target to 'dev'.
Expand Down
2 changes: 1 addition & 1 deletion api/v1alpha2/module_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ type ModuleStatus struct {
Run *RunStatus `json:"run,omitempty"`
// Module Outputs status.
Output *OutputStatus `json:"output,omitempty"`
// Workspace Destroy Run status.
// Workspace Destroy Run ID.
//
//+optional
DestroyRunID string `json:"destroyRunID,omitempty"`
Expand Down
4 changes: 4 additions & 0 deletions api/v1alpha2/workspace_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,10 @@ type WorkspaceStatus struct {
//
//+optional
SSHKeyID string `json:"sshKeyID,omitempty"`
// Workspace Destroy Run ID.
//
//+optional
DestroyRunID string `json:"destroyRunID,omitempty"`
}

//+kubebuilder:object:root=true
Expand Down
16 changes: 16 additions & 0 deletions api/v1alpha2/workspace_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func (w *Workspace) ValidateSpec() error {
allErrs = append(allErrs, w.validateSpecProject()...)
allErrs = append(allErrs, w.validateSpecTerraformVariables()...)
allErrs = append(allErrs, w.validateSpecEnvironmentVariables()...)
allErrs = append(allErrs, w.validateSpecDeletionPolicy()...)

if len(allErrs) == 0 {
return nil
Expand Down Expand Up @@ -568,6 +569,21 @@ func (w *Workspace) validateSpecEnvironmentVariables() field.ErrorList {
return validateSpecVariables(field.NewPath("spec").Child("environmentVariables"), spec)
}

func (w *Workspace) validateSpecDeletionPolicy() field.ErrorList {
allErrs := field.ErrorList{}

f := field.NewPath("spec").Child("deletionPolicy")

if w.Spec.DeletionPolicy == DeletionPolicyDestroy && !w.Spec.AllowDestroyPlan {
allErrs = append(allErrs, field.Required(
f,
fmt.Sprintf("'spec.allowDestroyPlan' must be set to 'true' when 'spec.deletionPolicy' is set to %q", DeletionPolicyDestroy)),
)
}

return allErrs
}

// TODO:Validation
//
// + Tags duplicate: spec.tags[]
Expand Down
38 changes: 38 additions & 0 deletions api/v1alpha2/workspace_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1038,3 +1038,41 @@ func TestValidateWorkspaceSpecVariables(t *testing.T) {
})
}
}

func TestValidateSpecDeletionPolicy(t *testing.T) {
t.Parallel()

successCases := map[string]Workspace{
"DeletionPolicyDestroyAllowDestroyPlanTrue": {
Spec: WorkspaceSpec{
AllowDestroyPlan: true,
DeletionPolicy: DeletionPolicyDestroy,
},
},
}

for n, c := range successCases {
t.Run(n, func(t *testing.T) {
if errs := c.validateSpecDeletionPolicy(); len(errs) != 0 {
t.Errorf("Unexpected validation errors: %v", errs)
}
})
}

errorCases := map[string]Workspace{
"DeletionPolicyDestroyAllowDestroyPlanFalse": {
Spec: WorkspaceSpec{
AllowDestroyPlan: false,
DeletionPolicy: DeletionPolicyDestroy,
},
},
}

for n, c := range errorCases {
t.Run(n, func(t *testing.T) {
if errs := c.validateSpecDeletionPolicy(); len(errs) == 0 {
t.Error("Unexpected failure, at least one error is expected")
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ spec:
- status
type: object
destroyRunID:
description: Workspace Destroy Run status.
description: Workspace Destroy Run ID.
type: string
observedGeneration:
description: Real world state generation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,9 @@ spec:
defaultProjectID:
description: Default organization project ID.
type: string
destroyRunID:
description: Workspace Destroy Run ID.
type: string
observedGeneration:
description: Real world state generation.
format: int64
Expand Down
2 changes: 1 addition & 1 deletion config/crd/bases/app.terraform.io_modules.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ spec:
- status
type: object
destroyRunID:
description: Workspace Destroy Run status.
description: Workspace Destroy Run ID.
type: string
observedGeneration:
description: Real world state generation.
Expand Down
3 changes: 3 additions & 0 deletions config/crd/bases/app.terraform.io_workspaces.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,9 @@ spec:
defaultProjectID:
description: Default organization project ID.
type: string
destroyRunID:
description: Workspace Destroy Run ID.
type: string
observedGeneration:
description: Real world state generation.
format: int64
Expand Down
12 changes: 9 additions & 3 deletions controllers/module_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,16 @@ type moduleInstance struct {
}

var (
runCompleteStatus = map[tfc.RunStatus]struct{}{
runStatusComplete = map[tfc.RunStatus]struct{}{
tfc.RunApplied: {},
tfc.RunPlannedAndFinished: {},
}

runStatusUnsuccessful = map[tfc.RunStatus]struct{}{
tfc.RunCanceled: {},
tfc.RunDiscarded: {},
tfc.RunErrored: {},
}
)

// +kubebuilder:rbac:groups=app.terraform.io,resources=modules,verbs=get;list;watch;create;update;patch;delete
Expand Down Expand Up @@ -268,7 +274,7 @@ func (r *ModuleReconciler) deleteModule(ctx context.Context, m *moduleInstance)
}
if cr.IsDestroy {
m.log.Info("Delete Module", "msg", fmt.Sprintf("current run %s is destroy", cr.ID))
if _, ok := runCompleteStatus[cr.Status]; ok {
if _, ok := runStatusComplete[cr.Status]; ok {
m.log.Info("Delete Module", "msg", "current destroy run finished")
return r.removeFinalizer(ctx, m)
}
Expand Down Expand Up @@ -302,7 +308,7 @@ func (r *ModuleReconciler) deleteModule(ctx context.Context, m *moduleInstance)
}
m.log.Info("Reconcile Run", "msg", fmt.Sprintf("successfully got destroy run status: %s", run.Status))

if _, ok := runCompleteStatus[run.Status]; ok {
if _, ok := runStatusComplete[run.Status]; ok {
m.log.Info("Delete Module", "msg", "destroy run finished")
return r.removeFinalizer(ctx, m)
}
Expand Down
85 changes: 83 additions & 2 deletions controllers/workspace_controller_deletion_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,70 @@ func (r *WorkspaceReconciler) deleteWorkspace(ctx context.Context, w *workspaceI
w.log.Info("Reconcile Workspace", "msg", fmt.Sprintf("workspace ID %s has been deleted, remove finalizer", w.instance.Status.WorkspaceID))
return r.removeFinalizer(ctx, w)
case appv1alpha2.DeletionPolicyDestroy:
// TODO: Implement the destroy logic
return nil
if w.instance.Status.DestroyRunID == "" {
workspace, err := w.tfClient.Client.Workspaces.ReadByID(ctx, w.instance.Status.WorkspaceID)
if err != nil {
return r.handleWorkspaceErrorNotFound(ctx, w, err)
}
if workspace.CurrentRun == nil {
w.log.Info("Reconcile Workspace", "msg", "Workspace does not have runs, skipping destroy run, and remove finalizer")
if err := w.tfClient.Client.Workspaces.DeleteByID(ctx, w.instance.Status.WorkspaceID); err != nil {
return r.handleWorkspaceErrorNotFound(ctx, w, err)
}

w.log.Info("Reconcile Workspace", "msg", fmt.Sprintf("workspace ID %s has been deleted, remove finalizer", w.instance.Status.WorkspaceID))
return r.removeFinalizer(ctx, w)
}
w.log.Info("Destroy Run", "msg", "destroy on deletion, create a new destroy run")
run, err := w.tfClient.Client.Runs.Create(ctx, tfc.RunCreateOptions{
IsDestroy: tfc.Bool(true),
Message: tfc.String(runMessage),
Workspace: &tfc.Workspace{
ID: w.instance.Status.WorkspaceID,
},
})
if err != nil {
w.log.Error(err, "Destroy Run", "msg", "failed to create a new destroy run")
return err
}
w.log.Info("Destroy Run", "msg", fmt.Sprintf("successfully created a new destroy run: %s", run.ID))

w.instance.Status.DestroyRunID = run.ID
w.updateWorkspaceStatusRun(run)
return r.Status().Update(ctx, &w.instance)
}

w.log.Info("Destroy Run", "msg", fmt.Sprintf("get destroy run %s", w.instance.Status.DestroyRunID))
run, err := w.tfClient.Client.Runs.Read(ctx, w.instance.Status.DestroyRunID)
if err != nil {
if err == tfc.ErrResourceNotFound {
w.log.Info("Reconcile Workspace", "msg", "Destroy run was not found, check if the workspace exists")
if _, err := w.tfClient.Client.Workspaces.ReadByID(ctx, w.instance.Status.WorkspaceID); err != nil {
return r.handleWorkspaceErrorNotFound(ctx, w, err)
}
}
w.log.Info("Destroy Run", "msg", fmt.Sprintf("failed to get destroy run: %s", w.instance.Status.DestroyRunID))
return err
}

if _, ok := runStatusComplete[run.Status]; ok {
w.log.Info("Destroy Run", "msg", fmt.Sprintf("current destroy run %s is finished", run.ID))
if err := w.tfClient.Client.Workspaces.DeleteByID(ctx, w.instance.Status.WorkspaceID); err != nil {
return r.handleWorkspaceErrorNotFound(ctx, w, err)
}

w.log.Info("Reconcile Workspace", "msg", fmt.Sprintf("workspace ID %s has been deleted, remove finalizer", w.instance.Status.WorkspaceID))
return r.removeFinalizer(ctx, w)
}

if _, ok := runStatusUnsuccessful[run.Status]; ok {
w.log.Info("Destroy Run", "msg", fmt.Sprintf("destroy run %s is unsuccessful: %s", run.ID, run.Status))
return nil
}
w.log.Info("Destroy Run", "msg", fmt.Sprintf("destroy run %s is not finished", run.ID))

w.updateWorkspaceStatusRun(run)
return r.Status().Update(ctx, &w.instance)
case appv1alpha2.DeletionPolicyForce:
err := w.tfClient.Client.Workspaces.DeleteByID(ctx, w.instance.Status.WorkspaceID)
if err != nil {
Expand All @@ -63,3 +125,22 @@ func (r *WorkspaceReconciler) deleteWorkspace(ctx context.Context, w *workspaceI

return nil
}

func (r *WorkspaceReconciler) handleWorkspaceErrorNotFound(ctx context.Context, w *workspaceInstance, err error) error {
if err == tfc.ErrResourceNotFound {
w.log.Info("Reconcile Workspace", "msg", "Workspace was not found, remove finalizer")
return r.removeFinalizer(ctx, w)
}
w.log.Error(err, "Reconcile Workspace", "msg", fmt.Sprintf("failed to handle Workspace ID %s, retry later", w.instance.Status.WorkspaceID))
r.Recorder.Eventf(&w.instance, corev1.EventTypeWarning, "ReconcileWorkspace", "Failed to handle Workspace ID %s, retry later", w.instance.Status.WorkspaceID)
return err
}

func (w *workspaceInstance) updateWorkspaceStatusRun(run *tfc.Run) {
if w.instance.Status.Run == nil {
w.instance.Status.Run = &appv1alpha2.RunStatus{}
}
w.instance.Status.Run.ID = run.ID
w.instance.Status.Run.Status = string(run.Status)
w.instance.Status.Run.ConfigurationVersion = run.ConfigurationVersion.ID
}
58 changes: 57 additions & 1 deletion controllers/workspace_controller_deletion_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,64 @@ var _ = Describe("Workspace controller", Ordered, func() {
}).Should(BeTrue())
})
It("can destroy delete a workspace", func() {
// Not yet implemented
if cloudEndpoint != tfcDefaultAddress {
Skip("Does not run against TFC, skip this test")
}
instance.Spec.AllowDestroyPlan = true
instance.Spec.DeletionPolicy = appv1alpha2.DeletionPolicyDestroy
createWorkspace(instance)
workspaceID := instance.Status.WorkspaceID

cv := createAndUploadConfigurationVersion(instance, "hoi")
Eventually(func() bool {
listOpts := tfc.ListOptions{
PageNumber: 1,
PageSize: maxPageSize,
}
for listOpts.PageNumber != 0 {
runs, err := tfClient.Runs.List(ctx, workspaceID, &tfc.RunListOptions{
ListOptions: listOpts,
})
Expect(err).To(Succeed())
for _, r := range runs.Items {
if r.ConfigurationVersion.ID == cv.ID {
return r.Status == tfc.RunApplied
}
}
listOpts.PageNumber = runs.NextPage
}
return false
}).Should(BeTrue())

Expect(k8sClient.Delete(ctx, instance)).To(Succeed())

var destroyRunID string
Eventually(func() bool {
ws, err := tfClient.Workspaces.ReadByID(ctx, workspaceID)
Expect(err).To(Succeed())
Expect(ws).ToNot(BeNil())
Expect(ws.CurrentRun).ToNot(BeNil())
run, err := tfClient.Runs.Read(ctx, ws.CurrentRun.ID)
Expect(err).To(Succeed())
Expect(run).ToNot(BeNil())
destroyRunID = run.ID

return run.IsDestroy
}).Should(BeTrue())

Eventually(func() bool {
run, err := tfClient.Runs.Read(ctx, destroyRunID)
if err == tfc.ErrResourceNotFound || run.Status == tfc.RunApplied {
return true
}

return false
}).Should(BeTrue())

Eventually(func() bool {
_, err := tfClient.Workspaces.ReadByID(ctx, workspaceID)
return err == tfc.ErrResourceNotFound
}).Should(BeTrue())
})
It("can force delete a workspace", func() {
instance.Spec.DeletionPolicy = appv1alpha2.DeletionPolicyForce
Expand Down
Loading

0 comments on commit 49f48cc

Please sign in to comment.