# migrate-yaml-auth-keys.sh

# Migrates YAML-managed ingestion auth keys (Kubernetes secret + ingester ConfigMap) to the UI-managed (Postgres-backed) form by calling config-mgmt-service's GraphQL API.

# What this script does:
# 1. Reads tokens from the auth secret (default: kfuse-auth-ingest).
# Each data. is a base64-encoded token.
# 2. Reads authKeyAdditionalLabels from the ingester ConfigMap (default: ingester, key config.yaml).
# Display names are lowercased and trimmed to match the ingester's Viper-loaded semantics.
# 3. Joins each token with its label entry (by display name) and calls `createAuthKeyLabel` against config-mgmt-service. The token is reused as-is so existing agents keep working.
# 4. Skips display names that already have a UI-managed entry (idempotent re-runs).

# What this script does NOT do:
# - It does not delete the Kubernetes secret or remove authKeyAdditionalLabels from custom_values.yaml. Do that manually after verifying ingestion is healthy.
# - It does not restart config-mgmt-service. Do that after removing the YAML config so it stops surfacing the now-deleted yaml entries.

# Prerequisites:
# - kubectl configured for the target cluster.
# - jq, base64, curl.
# - python3 with PyYAML installed (`pip3 install PyYAML` if missing).
# - config-mgmt-service port-forwarded: kubectl port-forward -n svc/config-mgmt-service 8080:8080

# Usage:
# ./migrate-yaml-auth-keys.sh -n [options]

# Options:
# -n, --namespace NAME Kloudfuse namespace (default: kfuse)
# --secret NAME Auth secret (default: kfuse-auth-ingest)
# --configmap NAME Ingester ConfigMap (default: ingester)
# --configmap-key KEY Key inside the ConfigMap (default: config.yaml)
# --port PORT Local port for config-mgmt-service (default: 8080)
# --admin-email EMAIL Email recorded as updatedBy (default: yaml-migration@kloudfuse.local)
# --token-name NAME Name for the imported token (default: yaml-imported)
# --dry-run Print what would be created and exit
# -h, --help Show this help

set -euo pipefail

NAMESPACE="kfuse"
SECRET="kfuse-auth-ingest"
CONFIGMAP="ingester"
CONFIGMAP_KEY="config.yaml"
PORT="8080"
ADMIN_EMAIL="yaml-migration@kloudfuse.local"
TOKEN_NAME="yaml-imported"
DRY_RUN=0

usage() { sed -n '/^# Usage:/,/^# \-h,/p' "$0" | sed 's/^# \{0,1\}//'; exit 0; }

while [[ $# -gt 0 ]]; do
 case "$1" in
 -n|--namespace) NAMESPACE="$2"; shift 2 ;;  
 --secret) SECRET="$2"; shift 2 ;;  
 --configmap) CONFIGMAP="$2"; shift 2 ;;  
 --configmap-key) CONFIGMAP_KEY="$2"; shift 2 ;;  
 --port) PORT="$2"; shift 2 ;;  
 --admin-email) ADMIN_EMAIL="$2"; shift 2 ;;  
 --token-name) TOKEN_NAME="$2"; shift 2 ;;  
 --dry-run) DRY_RUN=1; shift ;;  
 -h|--help) usage ;;  
 *) echo "Unknown argument: $1" >&2; exit 2 ;;  
 esac
done

GQL_URL="http://localhost:${PORT}/config/graph/api"

red() { printf '\033[31m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
bold() { printf '\033[1m%s\033[0m\n' "$*"; }

# ---------------------------------------------------------------------------
# Read tokens from the secret. Each .data. is a base64-encoded token. We collapse displayName to lowercase + trimmed, matching the loader.
# Output: TSV "displayName\tplaintextToken" (one per line).
# ---------------------------------------------------------------------------
read_secret_tokens() {
 local raw
 if ! raw=$(kubectl get secret "$SECRET" -n "$NAMESPACE" -o json 2>/dev/null); then
 echo "ERROR: cannot read secret $SECRET in namespace $NAMESPACE" >&2
 exit 1
 fi
 echo "$raw" | python3 -c '
import json, sys, base64
 doc = json.load(sys.stdin)
 data = doc.get("data") or {}
 for name, b64 in data.items():
 name_clean = name.strip().lower()
 if not name_clean:
 continue
 try:
 token = base64.b64decode(b64).decode("utf-8").strip()
 except Exception as e:
 print(f"WARN: skipping {name}: cannot base64-decode ({e})", file=sys.stderr)
 continue
 if not token:
 continue
 print(f"{name_clean}\t{token}")
'
}

# ---------------------------------------------------------------------------
# Read authKeyAdditionalLabels from the ConfigMap. We accept either top-level (matching how config-mgmt-service mounts it) or nested under ingester.config (matching how helm/custom_values.yaml is typically structured).
# Output: JSON object keyed by lowercased displayName, value is an array of {name, value} entries. Empty {} if no labels.
# ---------------------------------------------------------------------------

read_configmap_labels() {
 local raw
 if ! raw=$(kubectl get cm "$CONFIGMAP" -n "$NAMESPACE" -o json 2>/dev/null); then
 echo "WARN: cannot read configmap $CONFIGMAP in namespace $NAMESPACE — proceeding with no labels" >&2
 echo "{}"
 return
 fi
 local content
 content=$(echo "$raw" | jq -r --arg k "$CONFIGMAP_KEY" '.data[$k] // empty')
 if [[ -z "$content" ]]; then
 echo "WARN: configmap $CONFIGMAP has no key $CONFIGMAP_KEY — proceeding with no labels" >&2
 echo "{}"
 return
 fi
 echo "$content" | python3 -c '
import sys, json, yaml
 doc = yaml.safe_load(sys.stdin) or {}
 candidates = [
 doc.get("authKeyAdditionalLabels"),
 (doc.get("ingester") or {}).get("config", {}).get("authKeyAdditionalLabels") if isinstance(doc.get("ingester"), dict) else None,
 (doc.get("config") or {}).get("authKeyAdditionalLabels") if isinstance(doc.get("config"), dict) else None,
 ]
 labels = next((c for c in candidates if isinstance(c, dict)), {}) or {}
 out = {}
 for name, entries in labels.items():
 name_clean = (name or "").strip().lower()
 if not name_clean or not isinstance(entries, list):
 continue
 cleaned = []
 for e in entries:
 if not isinstance(e, dict):
 continue
 n = str(e.get("name", "")).strip()
 if not n:
 continue
 cleaned.append({"name": n, "value": str(e.get("value", ""))})
 out[name_clean] = cleaned
 print(json.dumps(out))
'
}

# ---------------------------------------------------------------------------
# GraphQL helpers. RBAC headers spoof an admin caller.
# ---------------------------------------------------------------------------

gql() {
 local query="$1" vars="${2:-{}}"
 local payload
 payload=$(python3 -c '
import json, sys
 print(json.dumps({"query": sys.argv[1], "variables": json.loads(sys.argv[2])}))
' "$query" "$vars")
 curl -sS -X POST "$GQL_URL" \
 -H 'Content-Type: application/json' \
 -H 'x-auth-request-role: admin' \
 -H "x-auth-request-email: ${ADMIN_EMAIL}" \
 -d "$payload"
}

list_existing_display_names() {
 local body
 body=$(gql 'query{ listAuthKeyLabels(limit: 1000){ items{ id displayName source } total } }' '{}')
 echo "$body" | python3 -c '
import sys, json
 doc = json.load(sys.stdin)
 errs = doc.get("errors") or []
 if errs:
 sys.stderr.write("GraphQL errors: " + json.dumps(errs) + "\n")
 sys.exit(1)
 resp = ((doc.get("data") or {}).get("listAuthKeyLabels") or {})
 items = resp.get("items") or []
 for i in items:
 # Skip yaml-sourced rows — those are exactly what the migration is supposed to shadow.
 # Only treat UI-managed entries (source != "yaml") as "already exists" so the migration is not blocked by its own input.
 if (i.get("source") or "").strip().lower() == "yaml":
 continue
 print((i.get("displayName") or "").strip().lower())
'
}

create_label() {
 local display_name="$1" token="$2" labels_json="$3"
 local vars
 vars=$(python3 -c '
import json, sys
 display_name, token_name, token, labels = sys.argv[1], sys.argv[2], sys.argv[3], json.loads(sys.argv[4])
 print(json.dumps({"input": {
 "displayName": display_name,
 "tokenName": token_name,
 "token": token,
 "labels": labels,
}}))
' "$display_name" "$TOKEN_NAME" "$token" "$labels_json")
 gql 'mutation($input: CreateAuthKeyLabelInput!){
 createAuthKeyLabel(input: $input){ id displayName }
 }' "$vars"
}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
bold "==> Reading YAML auth keys from cluster"
echo " namespace : $NAMESPACE"
echo " secret : $SECRET"
echo " configmap : $CONFIGMAP (key=$CONFIGMAP_KEY)"
echo " target : $GQL_URL"
echo " dry-run : $(
[[ $DRY_RUN -eq 1 ]] && echo yes || echo no
)"
echo

TOKENS_TSV=$(read_secret_tokens)
LABELS_JSON=$(read_configmap_labels)

if [[ -z "$TOKENS_TSV" ]]; then
 yellow "No tokens found in secret. Nothing to migrate."
 exit 0
fi

bold "==> Discovered yaml entries:"
echo "$TOKENS_TSV" | awk -F'\t' '{ print " " $1 }'
echo

# Surface label-only entries (declared in authKeyAdditionalLabels but without a matching token in the auth secret).
# The script can't migrate them — without a token there's nothing to authenticate — but operators should
# know these will be dropped when authKeyAdditionalLabels is cleaned up.
ORPHAN_LABELS=$(LABELS_JSON_ENV="$LABELS_JSON" TOKENS_TSV_ENV="$TOKENS_TSV" python3 -c '
import json, os
 labels = json.loads(os.environ.get("LABELS_JSON_ENV") or "{}") or {}
 tokens = set()
 for line in (os.environ.get("TOKENS_TSV_ENV") or "").splitlines():
 if not line.strip():
 continue
 tokens.add(line.split("\t", 1)[0].strip().lower())
for name in labels:
 if name.strip().lower() not in tokens:
 print(name)
')

if [[ -n "$ORPHAN_LABELS" ]]; then
 bold "==> Orphan label entries (declared in authKeyAdditionalLabels but no matching token in secret):"
 yellow " These entries cannot authenticate ingest traffic and will not be migrated."
 echo "$ORPHAN_LABELS" | sed 's/^/ /'
echo
fi

# Always query existing UI-managed entries — dry-run also needs this so it can accurately predict which entries would be skipped on a real run.
bold "==> Listing existing UI-managed entries"
if ! EXISTING=$(list_existing_display_names 2>&1); then
 red "Failed to query existing labels — is config-mgmt-service reachable on :$PORT?"
 echo "$EXISTING" >&2
 exit 1
fi
if [[ -n "$EXISTING" ]]; then
 echo "$EXISTING" | sed 's/^/ /'
else
 echo " (none)"
fi
echo

CREATED=0
SKIPPED=0
FAILED=0

bold "==> Migrating"
while IFS=$'\t' read -r display_name token; do
 [[ -z "$display_name" ]] && continue
 labels=$(echo "$LABELS_JSON" | jq -c --arg k "$display_name" '.[$k] // []')

if echo "$EXISTING" | grep -Fxq "$display_name"; then
 if [[ $DRY_RUN -eq 1 ]]; then
 yellow " [dry-skip] $display_name — already exists in UI"
 else
 yellow " [skip] $display_name — already exists in UI"
 fi
 SKIPPED=$((SKIPPED + 1))
 continue
 fi

if [[ $DRY_RUN -eq 1 ]]; then
 label_count=$(echo "$labels" | jq 'length')
 green " [dry] $display_name (labels=$label_count, token=${token:0:8}…)"
 continue
 fi

resp=$(create_label "$display_name" "$token" "$labels" || true)
 err=$(echo "$resp" | python3 -c '
import sys, json
 try:
 d = json.load(sys.stdin)
 except Exception as e:
 print(f"unparseable response: {e}")
 sys.exit(0)
 errs = d.get("errors") or []
 if errs:
 print(errs[0].get("message", "unknown error"))
' 2>/dev/null || echo "parse error")

if [[ -n "$err" ]]; then
 red " [fail] $display_name — $err"
 FAILED=$((FAILED + 1))
 else
 green " [create] $display_name"
 CREATED=$((CREATED + 1))
 fi
done <<< "$TOKENS_TSV"

echo
bold "==> Summary"
echo " created : $CREATED"
echo " skipped : $SKIPPED"
echo " failed : $FAILED"

if [[ $DRY_RUN -eq 0 && $FAILED -eq 0 && $CREATED -gt 0 ]]; then
 echo
 bold "Next steps:"
echo " 1. Verify in the UI (Admin > Settings > Auth key labels) that all"
echo " YAML entries now have a matching UI-managed entry that"
echo " 'shadows' the YAML row."
echo " 2. Verify ingestion is still healthy."
echo " 3. Delete the secret and remove authKeyAdditionalLabels from"
echo " custom_values.yaml, then restart config-mgmt-service:"
echo " kubectl delete secret $SECRET -n $NAMESPACE"
echo " kubectl rollout restart deploy/config-mgmt-service -n $NAMESPACE"
fi

[[ $FAILED -eq 0 ]] || exit 1
