From b4e6e023a15cbb4be975b69666ec929b956b5675 Mon Sep 17 00:00:00 2001 From: Joachim Hummel Date: Fri, 26 Jun 2026 18:06:22 +0200 Subject: [PATCH] add hetzner-vm-destroy flow and document it in README Co-Authored-By: Claude Sonnet 4.6 --- README.md | 30 ++++++ hetzner-vm-destroy.yaml | 211 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 hetzner-vm-destroy.yaml diff --git a/README.md b/README.md index 5621ee7..8bc6288 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Aktuell enthalten: * `hetzner-vm-provision.yaml` * `hetzner-server-type-location-update.yaml` * `hetzner-server-available-list.yaml` +* `hetzner-vm-destroy.yaml` * `.env.example` Der erste Flow scannt mehrere Docker-Hosts per SSH, sucht nach Docker-Compose-Stacks und schreibt daraus eine Dropdown-Liste in den Kestra KV-Store. @@ -208,6 +209,35 @@ TF_BACKEND_ENDPOINT --- +### `hetzner-vm-destroy.yaml` + +Kestra-Flow zum sicheren Löschen einer Hetzner Cloud VM via Terraform — mit Destroy-Plan, manuellem Approval-Gate und automatischer Inventar-Aktualisierung. + +**Namespace:** `hetzner` +**Trigger:** Manuell (kein Schedule) + +Der Flow läuft in vier Phasen: + +1. **Terraform Destroy Plan** — erstellt einen Destroy-Plan für die gewählte VM; lädt den Terraform State aus dem S3-Backend +2. **Plan-Ausgabe loggen** — zeigt den vollständigen Destroy-Plan im Kestra-Log zur manuellen Prüfung +3. **Approval-Gate** — Kestra pausiert und wartet auf manuelle Freigabe +4. **Destroy / Abbruch** — bei Freigabe wird die VM gelöscht und anschließend automatisch der Subflow `hetzner-server-available-list` ausgeführt, um Inventar und KV-Store zu aktualisieren; bei Ablehnung wird abgebrochen + +Die VM wird per dynamischem Dropdown aus dem KV-Key `hetzner_server_names` ausgewählt (befüllt durch `hetzner-server-available-list`). + +Benötigte Kestra Secrets: + +```text +HCLOUD_TOKEN +SSH_KEY_NAME +AWS_ACCESS_KEY_ID +AWS_SECRET_ACCESS_KEY +TF_BACKEND_BUCKET +TF_BACKEND_ENDPOINT +``` + +--- + ### `.env.example` Beispiel-Datei für benötigte Umgebungsvariablen und Secrets. diff --git a/hetzner-vm-destroy.yaml b/hetzner-vm-destroy.yaml new file mode 100644 index 0000000..48ec474 --- /dev/null +++ b/hetzner-vm-destroy.yaml @@ -0,0 +1,211 @@ +id: hetzner-vm-destroy +namespace: hetzner + +inputs: + - id: vm_name + type: SELECT + displayName: "VM auswählen" + description: "VM aus der aktuellen Hetzner-Serverliste auswählen. Vorher hetzner-server-available-list ausführen." + expression: "{{ kv('hetzner_server_names') }}" + +tasks: + - id: terraform-destroy-plan + type: io.kestra.plugin.terraform.cli.TerraformCLI + containerImage: hashicorp/terraform:latest + + env: + AWS_ACCESS_KEY_ID: "{{ secret('AWS_ACCESS_KEY_ID') }}" + AWS_SECRET_ACCESS_KEY: "{{ secret('AWS_SECRET_ACCESS_KEY') }}" + TF_BACKEND_BUCKET: "{{ secret('TF_BACKEND_BUCKET') }}" + TF_BACKEND_ENDPOINT: "{{ secret('TF_BACKEND_ENDPOINT') }}" + TF_VAR_hcloud_token: "{{ secret('HCLOUD_TOKEN') }}" + TF_VAR_ssh_key_name: "{{ secret('SSH_KEY_NAME') }}" + + inputFiles: + main.tf: | + terraform { + required_version = ">= 1.5" + + required_providers { + hcloud = { + source = "hetznercloud/hcloud" + version = "~> 1.49" + } + } + + backend "s3" {} + } + + provider "hcloud" { + token = var.hcloud_token + } + + data "hcloud_ssh_key" "default" { + name = var.ssh_key_name + } + + resource "hcloud_server" "vm" { + name = var.vm_name + image = "ubuntu-24.04" + server_type = var.server_type + location = var.location + ssh_keys = [data.hcloud_ssh_key.default.id] + + public_net { + ipv4_enabled = true + ipv6_enabled = false + } + + labels = { + managed_by = "terraform" + team = var.team + } + } + + output "vm_ipv4" { + value = hcloud_server.vm.ipv4_address + } + + variables.tf: | + variable "hcloud_token" { + type = string + sensitive = true + } + + variable "ssh_key_name" { + type = string + } + + variable "vm_name" { + type = string + } + + variable "server_type" { + type = string + default = "cx23" + } + + variable "location" { + type = string + default = "nbg1" + } + + variable "team" { + type = string + default = "demo" + } + + beforeCommands: + - | + terraform init -reconfigure \ + -backend-config="bucket=$TF_BACKEND_BUCKET" \ + -backend-config="endpoints={s3=\"$TF_BACKEND_ENDPOINT\"}" \ + -backend-config="key=vms/{{ inputs.vm_name }}/terraform.tfstate" \ + -backend-config="region=us-east-1" \ + -backend-config="skip_requesting_account_id=true" \ + -backend-config="skip_credentials_validation=true" \ + -backend-config="skip_metadata_api_check=true" \ + -backend-config="skip_region_validation=true" \ + -backend-config="use_path_style=true" + + commands: + - | + set -eo pipefail + + terraform plan -destroy -no-color -out=tfdestroyplan \ + -var="vm_name={{ inputs.vm_name }}" \ + | tee destroy-plan-output.txt + + outputFiles: + - tfdestroyplan + - main.tf + - variables.tf + - destroy-plan-output.txt + + - id: log-destroy-plan + type: io.kestra.plugin.core.log.Log + message: | + 🧨 Terraform Destroy Plan für VM '{{ inputs.vm_name }}': + + {{ read(outputs['terraform-destroy-plan'].outputFiles['destroy-plan-output.txt']) }} + + ────────────────────────────────────────── + → Bitte genau prüfen und Execution fortsetzen oder abbrechen. + + - id: approval-gate + type: io.kestra.plugin.core.flow.Pause + onResume: + - id: approved + type: BOOLEAN + displayName: "VM wirklich löschen?" + description: "True = VM wird gelöscht. False = Execution wird abgebrochen." + defaults: false + + - id: destroy-decision + type: io.kestra.plugin.core.flow.If + condition: "{{ outputs['approval-gate'].onResume.approved == true }}" + + then: + - id: terraform-destroy-apply + type: io.kestra.plugin.terraform.cli.TerraformCLI + containerImage: hashicorp/terraform:latest + + env: + AWS_ACCESS_KEY_ID: "{{ secret('AWS_ACCESS_KEY_ID') }}" + AWS_SECRET_ACCESS_KEY: "{{ secret('AWS_SECRET_ACCESS_KEY') }}" + TF_BACKEND_BUCKET: "{{ secret('TF_BACKEND_BUCKET') }}" + TF_BACKEND_ENDPOINT: "{{ secret('TF_BACKEND_ENDPOINT') }}" + TF_VAR_hcloud_token: "{{ secret('HCLOUD_TOKEN') }}" + TF_VAR_ssh_key_name: "{{ secret('SSH_KEY_NAME') }}" + + inputFiles: + main.tf: "{{ outputs['terraform-destroy-plan'].outputFiles['main.tf'] }}" + variables.tf: "{{ outputs['terraform-destroy-plan'].outputFiles['variables.tf'] }}" + tfdestroyplan: "{{ outputs['terraform-destroy-plan'].outputFiles['tfdestroyplan'] }}" + + beforeCommands: + - | + terraform init -reconfigure \ + -backend-config="bucket=$TF_BACKEND_BUCKET" \ + -backend-config="endpoints={s3=\"$TF_BACKEND_ENDPOINT\"}" \ + -backend-config="key=vms/{{ inputs.vm_name }}/terraform.tfstate" \ + -backend-config="region=us-east-1" \ + -backend-config="skip_requesting_account_id=true" \ + -backend-config="skip_credentials_validation=true" \ + -backend-config="skip_metadata_api_check=true" \ + -backend-config="skip_region_validation=true" \ + -backend-config="use_path_style=true" + + commands: + - | + set -eo pipefail + terraform apply -auto-approve -no-color tfdestroyplan + + - id: update_server_inventory_after_destroy + type: io.kestra.plugin.core.flow.Subflow + namespace: hetzner + flowId: hetzner-server-available-list + wait: true + + - id: log-result + type: io.kestra.plugin.core.log.Log + message: | + 🗑️ VM '{{ inputs.vm_name }}' wurde erfolgreich gelöscht. + + Terraform-State wurde über MinIO/S3 aktualisiert: + vms/{{ inputs.vm_name }}/terraform.tfstate + + Die Hetzner-Serverliste wurde danach ebenfalls aktualisiert: + inventory/hetzner-server-available.json + inventory/hetzner-server-names.json + + Kestra KV: + hetzner_server_names + + else: + - id: log-aborted + type: io.kestra.plugin.core.log.Log + message: | + ❌ Löschvorgang abgebrochen. + + VM '{{ inputs.vm_name }}' wurde nicht gelöscht.