diff --git a/README.md b/README.md index d0714ab..d16bcac 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Aktuell enthalten: * `docker_stack_inventory_scan.yaml` * `build_select_options.py` * `linux_apt-upgrade.yaml` +* `hetzner-vm-provision.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. @@ -101,6 +102,42 @@ SSH_PRIVATE_KEY --- +### `hetzner-vm-provision.yaml` + +Kestra-Flow zum Provisionieren einer Hetzner Cloud VM via Terraform — mit KI-gestützter Plan-Analyse und manuellem Approval-Gate. + +**Namespace:** `hetzner` + +Der Flow läuft in vier Phasen: + +1. **Terraform Plan** — erstellt einen Plan für die neue VM (Ubuntu 24.04) im Hetzner-Datacenter; Terraform State wird im S3-Backend (Hetzner Object Storage) gespeichert +2. **LLM-Analyse** — GPT-4o-mini fasst den Plan auf Deutsch zusammen und bewertet ob er sicher aussieht +3. **Approval-Gate** — Kestra pausiert und wartet auf manuelle Freigabe +4. **Terraform Apply / Abbruch** — bei Freigabe wird die VM erstellt und die IPv4-Adresse ausgegeben; bei Ablehnung wird die Execution abgebrochen + +Der Flow erwartet folgende Eingaben: + +| Input | Typ | Beschreibung | +|---|---|---| +| `vm_name` | STRING | Eindeutiger VM-Name, z.B. `dev-server-01` | +| `server_type` | SELECT | `cx23` (2 vCPU / 4 GB) · `cx33` · `cx43` · `cx53` | +| `location` | SELECT | `nbg1` · `fsn1` · `hel1` | +| `team` | STRING | Label für die VM | + +Benötigte Kestra Secrets: + +```text +HCLOUD_TOKEN +SSH_KEY_NAME +AWS_ACCESS_KEY_ID +AWS_SECRET_ACCESS_KEY +TF_BACKEND_BUCKET +TF_BACKEND_ENDPOINT +OPENAI_API_KEY +``` + +--- + ### `.env.example` Beispiel-Datei für benötigte Umgebungsvariablen und Secrets. diff --git a/hetzner-vm-provision.yaml b/hetzner-vm-provision.yaml new file mode 100644 index 0000000..f6b41fd --- /dev/null +++ b/hetzner-vm-provision.yaml @@ -0,0 +1,240 @@ +id: hetzner-vm-provision +namespace: hetzner + +inputs: + - id: vm_name + type: STRING + displayName: "VM Name" + description: "Eindeutiger Name der VM, z.B. dev-server-01." + + - id: server_type + type: SELECT + displayName: "Server-Typ" + description: "cx23 = 2 vCPU 4 GB ~4 €/Mon | cx33 = 4 vCPU 8 GB ~9 €/Mon | cpx42 = 8 vCPU 16 GB ~30 €/Mon" + values: + - cx23 + - cx33 + - cx43 + - cx53 + defaults: cx23 + + - id: location + type: SELECT + displayName: "Standort" + description: "Hetzner-Datacenter" + values: + - nbg1 + - fsn1 + - hel1 + defaults: nbg1 + + - id: team + type: STRING + displayName: "Team" + description: "Wird als Label auf der VM gesetzt." + defaults: demo + +tasks: + - id: terraform-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 -no-color -out=tfplan \ + -var="vm_name={{ inputs.vm_name }}" \ + -var="server_type={{ inputs.server_type }}" \ + -var="location={{ inputs.location }}" \ + -var="team={{ inputs.team }}" \ + | tee plan-output.txt + + outputFiles: + - tfplan + - main.tf + - variables.tf + - plan-output.txt + + - id: llm-summary + type: io.kestra.plugin.openai.ChatCompletion + apiKey: "{{ secret('OPENAI_API_KEY') }}" + model: gpt-4o-mini + messages: + - role: system + content: "Du bist ein DevOps-Assistent. Fasse den folgenden Terraform Plan auf Deutsch in 3-5 Sätzen zusammen. Erkläre, was erstellt oder geändert wird und ob der Plan sicher aussieht." + - role: user + content: "{{ read(outputs['terraform-plan'].outputFiles['plan-output.txt']) }}" + + - id: log-summary + type: io.kestra.plugin.core.log.Log + message: | + 📋 KI-Analyse des Terraform Plans: + + {{ outputs['llm-summary'].choices[0].message.content }} + + ────────────────────────────────────────── + → Bitte prüfen und Execution fortsetzen oder ablehnen. + + - id: approval-gate + type: io.kestra.plugin.core.flow.Pause + onResume: + - id: approved + type: BOOLEAN + displayName: "Deployment genehmigen?" + description: "Ja = VM wird erstellt. Nein = Execution wird abgebrochen." + defaults: false + + - id: deploy-decision + type: io.kestra.plugin.core.flow.If + condition: "{{ outputs['approval-gate'].onResume.approved == true }}" + + then: + - id: terraform-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-plan'].outputFiles['main.tf'] }}" + variables.tf: "{{ outputs['terraform-plan'].outputFiles['variables.tf'] }}" + tfplan: "{{ outputs['terraform-plan'].outputFiles['tfplan'] }}" + + 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: + - terraform apply -auto-approve -no-color tfplan + - terraform output -raw vm_ipv4 | tr -d '\n' > vm_ipv4.txt + + outputFiles: + - vm_ipv4.txt + + - id: log-result + type: io.kestra.plugin.core.log.Log + message: | + ✅ VM erfolgreich bereitgestellt! + + Name: {{ inputs.vm_name }} + Typ: {{ inputs.server_type }} @ {{ inputs.location }} + Team: {{ inputs.team }} + IPv4: {{ read(outputs['terraform-apply'].outputFiles['vm_ipv4.txt']) }} + + SSH: ssh root@{{ read(outputs['terraform-apply'].outputFiles['vm_ipv4.txt']) }} + + else: + - id: log-aborted + type: io.kestra.plugin.core.log.Log + message: "❌ Deployment abgebrochen – Approval wurde verweigert."