Automate immutable production deployments
This commit is contained in:
@@ -33,9 +33,12 @@ chmod 600 terraform.tfvars
|
||||
```
|
||||
|
||||
Auch `terraform.tfstate` bleibt lokal und wird nicht nach Git oder auf den
|
||||
Anwendungsserver übertragen. Nach dem ersten Apply sollte die State-Datei
|
||||
verschlüsselt gesichert werden, weil OpenTofu sie für spätere Änderungen an
|
||||
denselben Ressourcen benötigt.
|
||||
Anwendungsserver übertragen. Nach jedem Import oder Apply muss die State-Datei
|
||||
mit Dateimodus `0600` verschlüsselt gesichert werden, weil OpenTofu sie für
|
||||
spätere Änderungen an denselben Ressourcen benötigt. Ist ein älterer State
|
||||
verloren, werden vorhandene Ressourcen vor jedem Apply anhand ihrer
|
||||
Hetzner-IDs importiert; ein paralleles Neuerzeugen gleichnamiger Ressourcen ist
|
||||
nicht zulässig.
|
||||
|
||||
## Infrastruktur erzeugen
|
||||
|
||||
@@ -44,10 +47,15 @@ cd infra/opentofu
|
||||
tofu init
|
||||
tofu fmt -check
|
||||
tofu validate
|
||||
umask 077
|
||||
tofu plan -out=watermaps.tfplan
|
||||
tofu apply watermaps.tfplan
|
||||
```
|
||||
|
||||
Gespeicherte Plan-Dateien enthalten trotz als sensibel markierter Variablen
|
||||
unter Umständen den Hetzner-Token. Sie müssen deshalb wie `terraform.tfvars`
|
||||
und der State immer Modus `0600` haben und dürfen nicht weitergegeben werden.
|
||||
|
||||
Die feste IPv4-Adresse und den erforderlichen manuellen DNS-Eintrag zeigt
|
||||
OpenTofu anschließend an:
|
||||
|
||||
@@ -79,3 +87,25 @@ Der Status des ersten Starts lässt sich so prüfen:
|
||||
ssh deploy@"$(tofu output -raw server_ipv4)" \
|
||||
"cloud-init status --wait && systemctl status watermaps-volume-setup --no-pager"
|
||||
```
|
||||
|
||||
Cloud-init bindet ausschließlich das von OpenTofu erzeugte Volume über den
|
||||
stabilen Hetzner-Gerätepfad
|
||||
`/dev/disk/by-id/scsi-0HC_Volume_<volume-id>` ein. Es wird nicht heuristisch
|
||||
das erste Blockgerät mit dem Modellnamen `Volume` ausgewählt. Nach dem Mount
|
||||
wird zusätzlich die UUID des tatsächlich eingehängten Geräts geprüft. Ein
|
||||
Volume ohne lesbares Dateisystem wird standardmäßig nicht formatiert; nur bei
|
||||
einem garantiert neuen, leeren Volume darf
|
||||
`initialize_empty_routing_volume = true` für die einmalige Initialisierung
|
||||
gesetzt werden. Bei Importen und Serverwechseln bleibt der Wert `false`.
|
||||
|
||||
Der Löschschutz ist getrennt konfiguriert:
|
||||
|
||||
- `enable_server_protection` für den austauschbaren Host,
|
||||
- `enable_primary_ip_protection` für die feste öffentliche Adresse,
|
||||
- `enable_volume_protection` für Routing-, Zertifikats- und PostGIS-Daten.
|
||||
|
||||
Bei einem kontrollierten Servertausch bleiben IP- und Volume-Schutz immer
|
||||
aktiv. Ein Plan, der `hcloud_primary_ip.main` oder
|
||||
`hcloud_volume.routing_data` ersetzen beziehungsweise löschen will, darf nicht
|
||||
angewendet werden. Zusätzlich verhindert OpenTofu für beide Ressourcen mit
|
||||
`prevent_destroy` eine versehentliche Löschung durch den IaC-Plan.
|
||||
|
||||
@@ -50,36 +50,45 @@ write_files:
|
||||
set -euo pipefail
|
||||
|
||||
mount_path="/srv/watermaps-data"
|
||||
device="/dev/disk/by-id/scsi-0HC_Volume_${routing_volume_id}"
|
||||
allow_initial_format="${initialize_empty_routing_volume}"
|
||||
install -d -m 0755 "$mount_path"
|
||||
|
||||
if ! mountpoint -q "$mount_path"; then
|
||||
device=""
|
||||
for attempt in $(seq 1 120); do
|
||||
device="$(lsblk -dnpo NAME,MODEL | awk '$2 == "Volume" { print $1; exit }')"
|
||||
if [ -n "$device" ] && [ -b "$device" ]; then
|
||||
break
|
||||
fi
|
||||
device=""
|
||||
sleep 5
|
||||
done
|
||||
for attempt in $(seq 1 120); do
|
||||
if [ -b "$device" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ -z "$device" ]; then
|
||||
echo "Kein angehängtes Hetzner Cloud Volume gefunden." >&2
|
||||
if [ ! -b "$device" ]; then
|
||||
echo "Hetzner Cloud Volume ${routing_volume_id} wurde nicht unter $device gefunden." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
filesystem="$(blkid -o value -s TYPE "$device" || true)"
|
||||
if [ -z "$filesystem" ]; then
|
||||
signatures="$(wipefs --no-act --output TYPE --noheadings "$device" | tr -d '[:space:]')"
|
||||
if [ -n "$signatures" ]; then
|
||||
echo "Volume enthält unbekannte Signaturen und wird nicht verändert." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
filesystem="$(blkid -o value -s TYPE "$device" || true)"
|
||||
if [ -z "$filesystem" ]; then
|
||||
if [ "$allow_initial_format" = "true" ]; then
|
||||
mkfs.ext4 -F "$device"
|
||||
filesystem="ext4"
|
||||
fi
|
||||
|
||||
if [ "$filesystem" != "ext4" ]; then
|
||||
echo "Unerwartetes Dateisystem auf $device: $filesystem" >&2
|
||||
else
|
||||
echo "Volume hat kein lesbares Dateisystem; automatische Formatierung ist deaktiviert." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
uuid="$(blkid -o value -s UUID "$device")"
|
||||
if [ "$filesystem" != "ext4" ]; then
|
||||
echo "Unerwartetes Dateisystem auf $device: $filesystem" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
uuid="$(blkid -o value -s UUID "$device")"
|
||||
if ! mountpoint -q "$mount_path"; then
|
||||
if ! grep -q "^UUID=$uuid " /etc/fstab; then
|
||||
printf '%s\n' \
|
||||
"UUID=$uuid $mount_path ext4 defaults,nofail,x-systemd.device-timeout=30 0 2" \
|
||||
@@ -89,6 +98,13 @@ write_files:
|
||||
mount "$mount_path"
|
||||
fi
|
||||
|
||||
mounted_source="$(findmnt --noheadings --output SOURCE --target "$mount_path")"
|
||||
mounted_uuid="$(blkid -o value -s UUID "$mounted_source" || true)"
|
||||
if [ "$mounted_uuid" != "$uuid" ]; then
|
||||
echo "Unter $mount_path ist nicht das erwartete Volume ${routing_volume_id} eingehängt." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -d -m 0755 -o ${deploy_user} -g ${deploy_user} \
|
||||
"$mount_path/geofabrik" \
|
||||
"$mount_path/local" \
|
||||
|
||||
+16
-7
@@ -16,8 +16,12 @@ resource "hcloud_primary_ip" "main" {
|
||||
location = var.location
|
||||
type = "ipv4"
|
||||
auto_delete = false
|
||||
delete_protection = var.enable_resource_protection
|
||||
delete_protection = var.enable_primary_ip_protection
|
||||
labels = local.common_labels
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "hcloud_firewall" "main" {
|
||||
@@ -75,8 +79,10 @@ resource "hcloud_server" "main" {
|
||||
firewall_ids = [hcloud_firewall.main.id]
|
||||
|
||||
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
|
||||
deploy_user = var.deploy_user
|
||||
ssh_public_key = jsonencode(trimspace(var.ssh_public_key))
|
||||
deploy_user = var.deploy_user
|
||||
ssh_public_key = jsonencode(trimspace(var.ssh_public_key))
|
||||
routing_volume_id = hcloud_volume.routing_data.id
|
||||
initialize_empty_routing_volume = var.initialize_empty_routing_volume
|
||||
})
|
||||
|
||||
public_net {
|
||||
@@ -85,8 +91,8 @@ resource "hcloud_server" "main" {
|
||||
ipv6_enabled = false
|
||||
}
|
||||
|
||||
delete_protection = var.enable_resource_protection
|
||||
rebuild_protection = var.enable_resource_protection
|
||||
delete_protection = var.enable_server_protection
|
||||
rebuild_protection = var.enable_server_protection
|
||||
shutdown_before_deletion = true
|
||||
|
||||
labels = local.common_labels
|
||||
@@ -96,9 +102,12 @@ resource "hcloud_volume" "routing_data" {
|
||||
name = "${var.server_name}-routing-data"
|
||||
location = var.location
|
||||
size = var.routing_volume_size_gb
|
||||
format = "ext4"
|
||||
delete_protection = var.enable_resource_protection
|
||||
delete_protection = var.enable_volume_protection
|
||||
labels = local.common_labels
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "hcloud_volume_attachment" "routing_data" {
|
||||
|
||||
@@ -19,5 +19,12 @@ server_type = "cx33"
|
||||
deploy_user = "deploy"
|
||||
routing_volume_size_gb = 40
|
||||
|
||||
# Schützt Ressourcen vor versehentlicher Löschung über Console/API.
|
||||
enable_resource_protection = true
|
||||
# Nur für ein neu angelegtes, garantiert leeres Volume einmalig auf true
|
||||
# setzen. Bei Import/Migration eines bestehenden Volumes immer false lassen.
|
||||
initialize_empty_routing_volume = false
|
||||
|
||||
# Der Server-Schutz kann für einen kontrollierten Hosttausch separat gelöst
|
||||
# werden, ohne jemals IP- oder Volume-Schutz zu deaktivieren.
|
||||
enable_server_protection = true
|
||||
enable_primary_ip_protection = true
|
||||
enable_volume_protection = true
|
||||
|
||||
@@ -115,8 +115,26 @@ variable "routing_volume_size_gb" {
|
||||
}
|
||||
}
|
||||
|
||||
variable "enable_resource_protection" {
|
||||
description = "Aktiviert Hetzner-Löschschutz für Server, Primary IP und Volume."
|
||||
variable "initialize_empty_routing_volume" {
|
||||
description = "Erlaubt genau bei einem nachweislich leeren Volume die initiale ext4-Formatierung. Für importierte oder bestehende Volumes muss dies false bleiben."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "enable_server_protection" {
|
||||
description = "Aktiviert Hetzner-Lösch- und Rebuild-Schutz für den austauschbaren Server."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "enable_primary_ip_protection" {
|
||||
description = "Aktiviert Hetzner-Löschschutz für die persistente öffentliche IPv4."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "enable_volume_protection" {
|
||||
description = "Aktiviert Hetzner-Löschschutz für das persistente Datenvolume."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user