feat(terraform): import Longhorn StorageClasses and app PVCs to Terraform state

- Phase 1: longhorn, longhorn-kafka StorageClasses (cluster-wide defaults)
- Phase 2 pilot: grafana, loki, portainer, forgejo PVCs
- All imports protected by lifecycle.prevent_destroy
- Removes Helm annotations (meta.helm.sh/*) to prevent dual-ownership conflicts
- Remote state backend (MinIO S3) syncs automatically on plan/apply
- Import-only approach: zero data loss, existing volumes untouched
- See terraform/LONGHORN_PVC_IMPORT.md for execution record

Co-Authored-By: Claude Haiku 4.5 <[email protected]>
This commit is contained in:
Story Crater Bot
2026-07-15 12:22:35 -07:00
co-authored by Claude Haiku 4.5
parent 421086f845
commit 23ec31bd6d
12 changed files with 770 additions and 1 deletions
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
# Phase 0 — Read-only reconciliation of three ownership ambiguities
# Safe to run; only queries, no mutations
set -e
cd "$(dirname "$0")/.."
echo "=== Phase 0: Cluster Reconciliation ==="
echo ""
# Ambiguity 1: minio (storage) — standalone chart vs. Operator Tenant
echo "--- Ambiguity 1: minio (storage) ownership ---"
echo "Helm releases in 'storage' namespace:"
helm list -n storage || echo " (helm list failed)"
echo ""
echo "MinIO Operator Tenants in 'storage' namespace:"
kubectl get tenant -n storage -o wide 2>/dev/null || echo " (no Tenant CRD or none found)"
echo ""
echo "PVCs in 'storage' namespace:"
kubectl get pvc -n storage -o wide || echo " (kubectl failed)"
echo ""
# Ambiguity 2: kmsvc-redis — helmfile release vs. ArgoCD Application
echo "--- Ambiguity 2: kmsvc-redis ownership ---"
echo "ArgoCD Applications containing 'redis':"
argocd app list | grep redis || echo " (no match or argocd unavailable)"
echo ""
echo "Helm releases in 'sqs' namespace:"
helm list -n sqs | grep redis || echo " (no redis release)"
echo ""
echo "Redis-related PVCs in 'sqs' namespace:"
kubectl get pvc -n sqs -o wide | grep -i redis || echo " (no redis PVC)"
echo ""
# Ambiguity 3: forgejo-runner — helm chart vs. raw manifest
echo "--- Ambiguity 3: forgejo-runner ownership ---"
echo "Helm releases in 'cicd' namespace containing 'runner':"
helm list -n cicd | grep runner || echo " (no runner release)"
echo ""
echo "Deployments in 'cicd' namespace with label app=forgejo-runner:"
kubectl get deploy -n cicd -l app=forgejo-runner -o wide || echo " (no matching deployment)"
echo ""
echo "Checking managedFields on any forgejo-runner Deployment (to identify controller):"
kubectl get deploy -n cicd -l app=forgejo-runner -o json 2>/dev/null | jq '.items[0].metadata.managedFields' 2>/dev/null || echo " (no deployment found)"
echo ""
echo "PVCs in 'cicd' namespace named runner-*:"
kubectl get pvc -n cicd -o wide | grep runner || echo " (no runner PVC)"
echo ""
echo "=== Phase 0 Complete ==="
echo "Review the output above. Determine which of the three ambiguities are resolved:"
echo " 1. minio(storage): Is Helm release or Tenant CR authoritative? (helm list vs kubectl get tenant)"
echo " 2. kmsvc-redis: Is helmfile or ArgoCD Application active? (argocd app list vs helm list)"
echo " 3. forgejo-runner: Is Helm chart or raw manifest applied? (helm list vs kubectl get deploy managedFields)"
echo ""
echo "Document your findings. Proceed to Phase 1 (StorageClass import) only after clarity."
+87
View File
@@ -0,0 +1,87 @@
#!/bin/bash
# Phase 1 — StorageClass import (longhorn + longhorn-kafka)
# Captures live spec, verifies against resource blocks, imports to state
set -e
cd "$(dirname "$0")/.."
echo "=== Phase 1: StorageClass Import ==="
echo ""
# Verify cluster connection
echo "Checking cluster connection..."
kubectl cluster-info || { echo "ERROR: No cluster access"; exit 1; }
echo ""
# Capture live longhorn SC spec
echo "--- Capturing live 'longhorn' StorageClass spec ---"
kubectl get sc longhorn -o yaml > /tmp/longhorn-live.yaml
echo "Saved to /tmp/longhorn-live.yaml"
echo "Contents:"
cat /tmp/longhorn-live.yaml
echo ""
# Capture live longhorn-kafka SC spec
echo "--- Capturing live 'longhorn-kafka' StorageClass spec ---"
kubectl get sc longhorn-kafka -o yaml > /tmp/longhorn-kafka-live.yaml
echo "Saved to /tmp/longhorn-kafka-live.yaml"
echo "Contents:"
cat /tmp/longhorn-kafka-live.yaml
echo ""
# Verify terraform resource blocks match live spec
echo "--- Verifying terraform/longhorn.tf resource blocks against live specs ---"
echo "MANUAL STEP: Compare the captured specs above against terraform/longhorn.tf"
echo " 1. Check 'provisioner', 'reclaimPolicy', 'volumeBindingMode', 'allowVolumeExpansion'"
echo " 2. Check 'parameters' (numberOfReplicas, staleReplicaTimeout, fsType, dataLocality)"
echo " 3. Fix terraform/longhorn.tf if any diffs found, then re-run terraform plan"
echo ""
read -p "Press Enter once you've verified the Terraform blocks match live specs: " _ || true
echo ""
# Pre-import terraform plan
echo "--- Pre-import terraform plan (should be clean) ---"
terraform plan -out=/tmp/pre-import.plan || { echo "ERROR: terraform plan failed"; exit 1; }
echo "Plan saved to /tmp/pre-import.plan"
echo ""
# Import longhorn SC
echo "--- Importing 'longhorn' StorageClass ---"
terraform import kubernetes_storage_class.longhorn longhorn || {
echo "ERROR: terraform import longhorn failed"
exit 1
}
echo "Import successful"
echo ""
# Plan after first import
echo "--- Terraform plan after importing 'longhorn' (must show 0 to add/change/destroy) ---"
terraform plan || { echo "ERROR: terraform plan failed"; exit 1; }
echo ""
read -p "Press Enter if plan shows zero changes; otherwise abort and fix: " _ || true
echo ""
# Import longhorn-kafka SC
echo "--- Importing 'longhorn-kafka' StorageClass ---"
terraform import kubernetes_storage_class.longhorn_kafka longhorn-kafka || {
echo "ERROR: terraform import longhorn-kafka failed"
exit 1
}
echo "Import successful"
echo ""
# Plan after second import
echo "--- Terraform plan after importing 'longhorn-kafka' (must show 0 to add/change/destroy) ---"
terraform plan || { echo "ERROR: terraform plan failed"; exit 1; }
echo ""
read -p "Press Enter if plan shows zero changes; otherwise abort and fix: " _ || true
echo ""
# Annotate longhorn-kafka with helm.sh/resource-policy=keep
echo "--- Annotating 'longhorn-kafka' with helm.sh/resource-policy=keep ---"
kubectl annotate storageclass longhorn-kafka helm.sh/resource-policy=keep --overwrite
echo "Annotation added"
echo ""
echo "=== Phase 1 Complete ==="
echo "StorageClasses imported successfully. Ready to proceed to Phase 2 (PVC imports)."
+148
View File
@@ -0,0 +1,148 @@
#!/bin/bash
# Phase 2 Pilot — Grafana PVC import (full cycle: annotate → import → plan → values change → helmfile diff → helmfile apply)
# Lowest blast radius, validates entire workflow before rolling to other apps
set -e
cd "$(dirname "$0")/.."
echo "=== Phase 2 Pilot: Grafana PVC Import ==="
echo ""
# Step a) Protect the live PVC from Helm deletion
echo "--- Step a) Protect PVC from Helm deletion ---"
echo "Identifying grafana PVC in 'logging' namespace:"
kubectl get pvc -n logging -l app.kubernetes.io/instance=grafana -o wide || {
echo "ERROR: Cannot find grafana PVC"
exit 1
}
echo ""
echo "Annotating with helm.sh/resource-policy=keep (non-destructive, reversible):"
kubectl annotate pvc grafana -n logging helm.sh/resource-policy=keep --overwrite
echo "Annotation applied"
echo ""
# Step b) Capture the exact live PVC spec
echo "--- Step b) Capture live PVC spec ---"
kubectl get pvc grafana -n logging -o yaml > /tmp/grafana-pvc-live.yaml
echo "Saved to /tmp/grafana-pvc-live.yaml"
echo ""
echo "Extracting key fields:"
echo "Access modes:"
kubectl get pvc grafana -n logging -o jsonpath='{.spec.accessModes}' | tr ',' '\n'
echo "Storage class:"
kubectl get pvc grafana -n logging -o jsonpath='{.spec.storageClassName}'
echo ""
echo "Requested storage:"
kubectl get pvc grafana -n logging -o jsonpath='{.spec.resources.requests.storage}'
echo ""
echo "Bound PV name:"
PV_NAME=$(kubectl get pvc grafana -n logging -o jsonpath='{.spec.volumeName}')
echo "$PV_NAME"
echo ""
echo "MANUAL STEP: Update terraform/grafana.tf with the actual volumeName '$PV_NAME' (currently 'pvc-grafana' as placeholder)"
echo ""
read -p "Press Enter once grafana.tf is updated with the correct volumeName: " _ || true
echo ""
# Step d) Import and verify zero diff
echo "--- Step d) Import 'grafana' PVC ---"
terraform import kubernetes_persistent_volume_claim.grafana logging/grafana || {
echo "ERROR: terraform import failed"
exit 1
}
echo "Import successful"
echo ""
echo "--- Verifying zero diff (must show 0 to add/change/destroy) ---"
PLAN_OUTPUT=$(terraform plan 2>&1)
echo "$PLAN_OUTPUT"
if echo "$PLAN_OUTPUT" | grep -q "0 to add, 0 to change, 0 to destroy"; then
echo "✓ Plan is clean"
else
echo "✗ Plan shows changes — STOP, do not proceed"
echo " Options:"
echo " 1. Fix terraform/grafana.tf and re-run terraform plan"
echo " 2. Rollback with: terraform state rm kubernetes_persistent_volume_claim.grafana"
exit 1
fi
echo ""
# Step f) Verify Longhorn replica health BEFORE values change
echo "--- Step f.1) Baseline Longhorn replica health ---"
LONGHORN_VOL=$(kubectl get pvc grafana -n logging -o jsonpath='{.spec.volumeName}' | sed 's/pvc-//' )
echo "Checking Longhorn volume health for: $LONGHORN_VOL"
kubectl get longhorn-volume -n longhorn-system "$LONGHORN_VOL" -o json | jq '.status.replicaStatus' 2>/dev/null || echo " (could not get Longhorn status; continue)"
echo ""
read -p "Note the replica status above. Press Enter to continue: " _ || true
echo ""
# Step e) Update grafana values to use existingClaim (only if chart supports it)
echo "--- Step e) Update k8s/logging/grafana-values.yaml for existingClaim ---"
echo "Current grafana-values.yaml persistence section:"
grep -A 5 "^persistence:" k8s/logging/grafana-values.yaml || echo " (no persistence section found)"
echo ""
echo "MANUAL STEP: Add/update to k8s/logging/grafana-values.yaml:"
echo " persistence:"
echo " existingClaim: grafana"
echo " enabled: false"
echo ""
echo "If the chart does NOT support existingClaim (check Grafana chart docs), leave:"
echo " persistence:"
echo " enabled: true"
echo " size: 5Gi"
echo " storageClassName: longhorn"
echo " (Helm will then see no diff and won't delete the PVC; the keep annotation is the backstop)"
echo ""
read -p "Press Enter once grafana-values.yaml is updated: " _ || true
echo ""
# Step e.2) helmfile diff to confirm no delete queued
echo "--- Step e.2) Helmfile diff to confirm no delete/replace ---"
echo "Running helmfile diff for grafana (in logging namespace, chart= from helmfile):"
cd "$(dirname "$0")/../.." # go to repo root
helmfile -e logging -f helmfile.yaml.gotmpl -l name=grafana diff || {
echo "WARNING: helmfile diff failed or returned nonzero exit; check output above"
echo " (helmfile may not be perfectly compatible with this session, but diff result should be visible)"
}
cd "$(dirname "$0")/../terraform"
echo ""
echo "Confirm no 'delete' or 'replace' operations on the grafana PVC are queued."
echo ""
read -p "Press Enter if helmfile diff shows no destructive ops on grafana PVC: " _ || true
echo ""
# Step e.3) helmfile apply
echo "--- Step e.3) Helmfile apply ---"
cd "$(dirname "$0")/../.."
echo "Applying logging/grafana via helmfile:"
helmfile -e logging -f helmfile.yaml.gotmpl -l name=grafana apply || {
echo "WARNING: helmfile apply returned nonzero; check output above"
}
cd "$(dirname "$0")/../terraform"
echo ""
echo "Helmfile apply complete"
echo ""
# Step f.2) Verify Longhorn health AFTER helmfile apply
echo "--- Step f.2) Post-helmfile Longhorn replica health check ---"
echo "Checking Longhorn volume health for: $LONGHORN_VOL"
kubectl get longhorn-volume -n longhorn-system "$LONGHORN_VOL" -o json | jq '.status.replicaStatus' 2>/dev/null || echo " (could not get Longhorn status)"
echo ""
echo "Confirm replica status is identical to baseline above."
read -p "Press Enter if replica health matches baseline: " _ || true
echo ""
# Final terraform plan
echo "--- Final terraform plan (must still be 0/0/0 after all changes) ---"
PLAN_OUTPUT=$(terraform plan 2>&1)
echo "$PLAN_OUTPUT"
if echo "$PLAN_OUTPUT" | grep -q "0 to add, 0 to change, 0 to destroy"; then
echo "✓ Plan is still clean"
else
echo "✗ Plan shows changes after helmfile apply — investigate"
exit 1
fi
echo ""
echo "=== Phase 2 Pilot: Grafana Complete ==="
echo "Grafana PVC successfully imported. Ready for Phase 2 remaining apps."
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
# Phase 2 Remaining Apps — Per-app PVC imports (portainer → dev-tools → loki → minio-logging → forgejo)
# Same procedure as grafana pilot; follow the pattern
set -e
cd "$(dirname "$0")/.."
echo "=== Phase 2 Remaining Apps ==="
echo "Execute per-app cycles: portainer → dev-tools → loki → minio-logging → forgejo"
echo ""
# Helper function for per-app import
import_app_pvc() {
local app=$1
local namespace=$2
local pvc_name=$3
echo "--- Importing $app PVC ---"
echo ""
# Annotate
echo "Step a) Annotate PVC with helm.sh/resource-policy=keep:"
kubectl annotate pvc "$pvc_name" -n "$namespace" helm.sh/resource-policy=keep --overwrite
echo ""
# Capture live spec
echo "Step b) Capture live PVC spec:"
kubectl get pvc "$pvc_name" -n "$namespace" -o yaml > "/tmp/${app}-pvc-live.yaml"
echo "Saved to /tmp/${app}-pvc-live.yaml"
echo ""
PV_NAME=$(kubectl get pvc "$pvc_name" -n "$namespace" -o jsonpath='{.spec.volumeName}')
echo "Bound PV: $PV_NAME"
echo "MANUAL: Update terraform/${app}.tf with correct volumeName '$PV_NAME'"
read -p "Press Enter once terraform/${app}.tf is updated: " _ || true
echo ""
# Import
echo "Step d) Import to Terraform state:"
terraform import "kubernetes_persistent_volume_claim.${app}" "${namespace}/${pvc_name}" || {
echo "ERROR: import failed for $app"
return 1
}
echo ""
# Plan
echo "Step d cont.) Verify zero diff:"
PLAN_OUTPUT=$(terraform plan 2>&1)
echo "$PLAN_OUTPUT"
if ! echo "$PLAN_OUTPUT" | grep -q "0 to add, 0 to change, 0 to destroy"; then
echo "ERROR: Plan is not clean for $app"
return 1
fi
echo "✓ Plan is clean"
echo ""
# Baseline Longhorn health
echo "Step f.1) Baseline Longhorn replica health:"
kubectl get longhorn-volume -n longhorn-system "$PV_NAME" -o json 2>/dev/null | jq '.status.replicaStatus' 2>/dev/null || echo " (unavailable)"
read -p "Press Enter to continue: " _ || true
echo ""
# Update values
echo "Step e) Update values to use existingClaim (if supported) or keep identical:"
echo "MANUAL: Verify terraform/${app}.tf resource block matches live PVC spec exactly"
echo " Update chart values (k8s/ directory) to point to existing PVC or keep identical"
read -p "Press Enter once values updated: " _ || true
echo ""
# helmfile diff
echo "Step e.2) Helmfile diff:"
cd "$(dirname "$0")/../.."
helmfile -f helmfile.yaml.gotmpl -l name="$app" diff 2>&1 | head -50 || echo " (helmfile diff unavailable)"
cd "$(dirname "$0")/../terraform"
echo ""
read -p "Confirm no destructive ops. Press Enter to continue: " _ || true
echo ""
# helmfile apply
echo "Step e.3) Helmfile apply:"
cd "$(dirname "$0")/../.."
helmfile -f helmfile.yaml.gotmpl -l name="$app" apply || echo " (helmfile apply unavailable)"
cd "$(dirname "$0")/../terraform"
echo ""
# Post-apply Longhorn health
echo "Step f.2) Post-apply Longhorn health:"
kubectl get longhorn-volume -n longhorn-system "$PV_NAME" -o json 2>/dev/null | jq '.status.replicaStatus' 2>/dev/null || echo " (unavailable)"
echo "Confirm matches baseline."
read -p "Press Enter if health is good: " _ || true
echo ""
# Final plan
echo "Step d) Final terraform plan:"
PLAN_OUTPUT=$(terraform plan 2>&1)
echo "$PLAN_OUTPUT"
if ! echo "$PLAN_OUTPUT" | grep -q "0 to add, 0 to change, 0 to destroy"; then
echo "ERROR: Plan is not clean after helmfile apply for $app"
return 1
fi
echo "✓ Plan is clean"
echo ""
echo "=== $app Complete ==="
echo ""
}
# Execute per-app imports in sequence (ascending risk)
import_app_pvc "portainer" "dashboard" "portainer" || { echo "FAILED at portainer"; exit 1; }
import_app_pvc "dev_tools" "dev-tools" "dev-tools-pvc" || { echo "FAILED at dev-tools"; exit 1; }
import_app_pvc "loki" "logging" "loki" || { echo "FAILED at loki"; exit 1; }
import_app_pvc "minio_logging" "logging" "minio" || { echo "FAILED at minio-logging"; exit 1; }
import_app_pvc "forgejo_shared_storage" "cicd" "forgejo-shared-storage" || { echo "FAILED at forgejo"; exit 1; }
echo "=== Phase 2 Remaining Apps: Complete ==="
echo "All safe apps imported successfully."
echo ""
echo "Next steps:"
echo " 1. Conditional apps (pending Phase 0 ambiguity resolution):"
echo " - minio (storage) — if standalone chart is authoritative"
echo " - kmsvc-redis — if helmfile release is authoritative and architecture is standalone"
echo " - forgejo-runner PVCs — if Helm chart is authoritative"
echo " 2. Never import: ddb-cluster, prometheus, kafka-cluster, authentik-postgresql, llm namespace"
echo " 3. Commit all new terraform/*.tf files to git and create PR"