diff --git a/TOOLS.md b/TOOLS.md index 2cb9f43..249c19c 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -72,6 +72,27 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a ~/.config/mosaic/tools/woodpecker/pipeline-trigger.sh -b ``` +### DNS — Cloudflare + +Multi-instance support: `-a ` selects a named instance (e.g. `personal`, `work`). Omit `-a` to use the default from `cloudflare.default` in credentials.json. + +```bash +# List zones (domains) +~/.config/mosaic/tools/cloudflare/zone-list.sh [-a instance] + +# List DNS records (zone by name or ID) +~/.config/mosaic/tools/cloudflare/record-list.sh -z [-a instance] [-t type] [-n name] + +# Create DNS record +~/.config/mosaic/tools/cloudflare/record-create.sh -z -t -n -c [-a instance] [-p] [-l ttl] [-P priority] + +# Update DNS record +~/.config/mosaic/tools/cloudflare/record-update.sh -z -r -t -n -c [-a instance] [-p] [-l ttl] + +# Delete DNS record +~/.config/mosaic/tools/cloudflare/record-delete.sh -z -r [-a instance] +``` + ### IT Service — GLPI ```bash @@ -100,7 +121,7 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a # Source in any script to load service credentials source ~/.config/mosaic/tools/_lib/credentials.sh load_credentials -# Supported: portainer, coolify, authentik, glpi, github, gitea-mosaicstack, gitea-usc, woodpecker +# Supported: portainer, coolify, authentik, glpi, github, gitea-mosaicstack, gitea-usc, woodpecker, cloudflare ``` ## Git Providers diff --git a/guides/INFRASTRUCTURE.md b/guides/INFRASTRUCTURE.md index 9367770..9cdfbef 100644 --- a/guides/INFRASTRUCTURE.md +++ b/guides/INFRASTRUCTURE.md @@ -153,6 +153,75 @@ The human is escalation-only for missing access, hard policy conflicts, or irrev - Magic variables (`SERVICE_FQDN_*`) require list-style env syntax, not dict-style - Rate limit: 200 requests per interval +### Cloudflare DNS Operations + +Use the Cloudflare tools for any DNS configuration: pointing domains at services, adding TXT verification records, managing MX records, etc. + +**Multi-instance support**: Credentials support named instances (e.g. `personal`, `work`). A `default` key in credentials.json determines which instance is used when `-a` is omitted. Pass `-a ` to target a specific account. + +```bash +# List all zones (domains) in the account +~/.config/mosaic/tools/cloudflare/zone-list.sh [-a instance] + +# List DNS records for a zone (accepts zone name or ID) +~/.config/mosaic/tools/cloudflare/record-list.sh -z [-t type] [-n name] + +# Create a DNS record +~/.config/mosaic/tools/cloudflare/record-create.sh -z -t -n -c [-p] [-l ttl] [-P priority] + +# Update a DNS record (requires record ID from record-list) +~/.config/mosaic/tools/cloudflare/record-update.sh -z -r -t -n -c [-p] + +# Delete a DNS record +~/.config/mosaic/tools/cloudflare/record-delete.sh -z -r +``` + +**Flag reference:** + +| Flag | Purpose | +|------|---------| +| `-z` | Zone name (e.g. `mosaicstack.dev`) or 32-char zone ID | +| `-a` | Named Cloudflare instance (omit for default) | +| `-t` | Record type: `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `SRV`, etc. | +| `-n` | Record name: short (`app`) or FQDN (`app.example.com`) | +| `-c` | Record content/value (IP, hostname, TXT string, etc.) | +| `-r` | Record ID (from `record-list.sh` output) | +| `-p` | Enable Cloudflare proxy (orange cloud) — omit for DNS-only (grey cloud) | +| `-l` | TTL in seconds (default: `1` = auto) | +| `-P` | Priority for MX/SRV records | +| `-f` | Output format: `table` (default) or `json` | + +**Common workflows:** + +```bash +# Point a new subdomain at a server (proxied through Cloudflare) +~/.config/mosaic/tools/cloudflare/record-create.sh \ + -z example.com -t A -n myapp -c 203.0.113.10 -p + +# Add a TXT record for domain verification (never proxied) +~/.config/mosaic/tools/cloudflare/record-create.sh \ + -z example.com -t TXT -n _verify -c "verification=abc123" + +# Check what records exist before making changes +~/.config/mosaic/tools/cloudflare/record-list.sh -z example.com -t CNAME + +# Update an existing record (get record ID from record-list first) +~/.config/mosaic/tools/cloudflare/record-update.sh \ + -z example.com -r -t A -n myapp -c 10.0.0.5 -p +``` + +**DNS + Deployment integration**: When deploying a new service via Coolify or Portainer that needs a public domain, the typical sequence is: + +1. Create the DNS record pointing at the host IP (with `-p` for Cloudflare proxy if desired) +2. Deploy the service via Coolify/Portainer +3. Verify the domain resolves and the service is reachable + +**Proxy (`-p`) guidance:** + +- Use proxy (orange cloud) for web services — provides CDN, DDoS protection, and hides origin IP +- Skip proxy (grey cloud) for non-HTTP services (mail, SSH), wildcard records, or when the service handles its own TLS termination and needs direct client IP visibility +- Proxy is NOT compatible with non-standard ports outside Cloudflare's supported range + ### Stack Health Check Verify all infrastructure services are reachable: diff --git a/tools/_lib/credentials.sh b/tools/_lib/credentials.sh index 3eadcc2..11aa113 100755 --- a/tools/_lib/credentials.sh +++ b/tools/_lib/credentials.sh @@ -10,7 +10,7 @@ # # Supported services: # portainer, coolify, authentik, glpi, github, -# gitea-mosaicstack, gitea-usc, woodpecker +# gitea-mosaicstack, gitea-usc, woodpecker, cloudflare # # After loading, service-specific env vars are exported. # Run `load_credentials --help` for details. @@ -49,6 +49,8 @@ Services and exported variables: gitea-mosaicstack → GITEA_URL, GITEA_TOKEN gitea-usc → GITEA_URL, GITEA_TOKEN woodpecker → WOODPECKER_URL, WOODPECKER_TOKEN + cloudflare → CLOUDFLARE_API_TOKEN (uses default instance) + cloudflare- → CLOUDFLARE_API_TOKEN (specific instance, e.g. cloudflare-personal) EOF return 0 fi @@ -110,9 +112,25 @@ EOF [[ -n "$WOODPECKER_URL" ]] || { echo "Error: woodpecker.url not found" >&2; return 1; } [[ -n "$WOODPECKER_TOKEN" ]] || { echo "Error: woodpecker.token not found" >&2; return 1; } ;; + cloudflare-*) + local cf_instance="${service#cloudflare-}" + export CLOUDFLARE_API_TOKEN="${CLOUDFLARE_API_TOKEN:-$(_mosaic_read_cred ".cloudflare.${cf_instance}.api_token")}" + export CLOUDFLARE_INSTANCE="$cf_instance" + [[ -n "$CLOUDFLARE_API_TOKEN" ]] || { echo "Error: cloudflare.${cf_instance}.api_token not found" >&2; return 1; } + ;; + cloudflare) + # Resolve default instance, then load it + local cf_default + cf_default="${CLOUDFLARE_INSTANCE:-$(_mosaic_read_cred '.cloudflare.default')}" + if [[ -z "$cf_default" ]]; then + echo "Error: cloudflare.default not set and no CLOUDFLARE_INSTANCE env var" >&2 + return 1 + fi + load_credentials "cloudflare-${cf_default}" + ;; *) echo "Error: Unknown service '$service'" >&2 - echo "Supported: portainer, coolify, authentik, glpi, github, gitea-mosaicstack, gitea-usc, woodpecker" >&2 + echo "Supported: portainer, coolify, authentik, glpi, github, gitea-mosaicstack, gitea-usc, woodpecker, cloudflare, cloudflare-" >&2 return 1 ;; esac diff --git a/tools/cloudflare/_lib.sh b/tools/cloudflare/_lib.sh new file mode 100755 index 0000000..495472a --- /dev/null +++ b/tools/cloudflare/_lib.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# _lib.sh — Shared helpers for Cloudflare tool scripts +# +# Usage: source "$(dirname "$0")/_lib.sh" +# +# Provides: +# CF_API — Base API URL +# cf_auth — Authorization header value +# cf_load_instance — Load credentials for a specific or default instance +# cf_resolve_zone — Resolves a zone name to its ID (passes IDs through) + +CF_API="https://api.cloudflare.com/client/v4" + +cf_auth() { + echo "Bearer $CLOUDFLARE_API_TOKEN" +} + +# Load credentials for a Cloudflare instance. +# If instance is empty, loads the default. +cf_load_instance() { + local instance="$1" + if [[ -n "$instance" ]]; then + load_credentials "cloudflare-${instance}" + else + load_credentials cloudflare + fi +} + +# Resolve a zone name (e.g. "mosaicstack.dev") to its zone ID. +# If the input is already a 32-char hex ID, passes it through. +cf_resolve_zone() { + local input="$1" + + # If it looks like a zone ID (32 hex chars), pass through + if [[ "$input" =~ ^[0-9a-f]{32}$ ]]; then + echo "$input" + return 0 + fi + + # Resolve by name + local response + response=$(curl -s -w "\n%{http_code}" \ + -H "Authorization: $(cf_auth)" \ + -H "Content-Type: application/json" \ + "${CF_API}/zones?name=${input}&status=active") + + local http_code + http_code=$(echo "$response" | tail -n1) + local body + body=$(echo "$response" | sed '$d') + + if [[ "$http_code" != "200" ]]; then + echo "Error: Failed to resolve zone '$input' (HTTP $http_code)" >&2 + return 1 + fi + + local zone_id + zone_id=$(echo "$body" | jq -r '.result[0].id // empty') + + if [[ -z "$zone_id" ]]; then + echo "Error: Zone '$input' not found" >&2 + return 1 + fi + + echo "$zone_id" +} diff --git a/tools/cloudflare/record-create.sh b/tools/cloudflare/record-create.sh new file mode 100755 index 0000000..baab88b --- /dev/null +++ b/tools/cloudflare/record-create.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# record-create.sh — Create a DNS record in a Cloudflare zone +# +# Usage: record-create.sh -z -t -n -c [-a instance] [-l ttl] [-p] [-P priority] +# +# Options: +# -z zone Zone name or ID (required) +# -t type Record type: A, AAAA, CNAME, MX, TXT, etc. (required) +# -n name Record name, e.g. "app" or "app.example.com" (required) +# -c content Record value/content (required) +# -a instance Cloudflare instance name (default: uses credentials default) +# -l ttl TTL in seconds (default: 1 = auto) +# -p Enable Cloudflare proxy (orange cloud) +# -P priority MX/SRV priority (default: 10) +# -h Show this help +set -euo pipefail + +MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" +source "$MOSAIC_HOME/tools/_lib/credentials.sh" +source "$(dirname "$0")/_lib.sh" + +ZONE="" +INSTANCE="" +TYPE="" +NAME="" +CONTENT="" +TTL=1 +PROXIED=false +PRIORITY="" + +while getopts "z:a:t:n:c:l:pP:h" opt; do + case $opt in + z) ZONE="$OPTARG" ;; + a) INSTANCE="$OPTARG" ;; + t) TYPE="$OPTARG" ;; + n) NAME="$OPTARG" ;; + c) CONTENT="$OPTARG" ;; + l) TTL="$OPTARG" ;; + p) PROXIED=true ;; + P) PRIORITY="$OPTARG" ;; + h) head -18 "$0" | grep "^#" | sed 's/^# \?//'; exit 0 ;; + *) echo "Usage: $0 -z -t -n -c [-a instance] [-l ttl] [-p] [-P priority]" >&2; exit 1 ;; + esac +done + +if [[ -z "$ZONE" || -z "$TYPE" || -z "$NAME" || -z "$CONTENT" ]]; then + echo "Error: -z, -t, -n, and -c are all required" >&2 + exit 1 +fi + +cf_load_instance "$INSTANCE" +ZONE_ID=$(cf_resolve_zone "$ZONE") || exit 1 + +# Build JSON payload +payload=$(jq -n \ + --arg type "$TYPE" \ + --arg name "$NAME" \ + --arg content "$CONTENT" \ + --argjson ttl "$TTL" \ + --argjson proxied "$PROXIED" \ + '{type: $type, name: $name, content: $content, ttl: $ttl, proxied: $proxied}') + +# Add priority for MX/SRV records +if [[ -n "$PRIORITY" ]]; then + payload=$(echo "$payload" | jq --argjson priority "$PRIORITY" '. + {priority: $priority}') +fi + +response=$(curl -s -w "\n%{http_code}" \ + -X POST \ + -H "Authorization: $(cf_auth)" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "${CF_API}/zones/${ZONE_ID}/dns_records") + +http_code=$(echo "$response" | tail -n1) +body=$(echo "$response" | sed '$d') + +if [[ "$http_code" != "200" ]]; then + echo "Error: Failed to create record (HTTP $http_code)" >&2 + echo "$body" | jq -r '.errors[]?.message // empty' 2>/dev/null >&2 + exit 1 +fi + +record_id=$(echo "$body" | jq -r '.result.id') +echo "Created $TYPE record: $NAME → $CONTENT (ID: $record_id)" diff --git a/tools/cloudflare/record-delete.sh b/tools/cloudflare/record-delete.sh new file mode 100755 index 0000000..7783d2b --- /dev/null +++ b/tools/cloudflare/record-delete.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# record-delete.sh — Delete a DNS record from a Cloudflare zone +# +# Usage: record-delete.sh -z -r [-a instance] +# +# Options: +# -z zone Zone name or ID (required) +# -r record-id DNS record ID (required) +# -a instance Cloudflare instance name (default: uses credentials default) +# -h Show this help +set -euo pipefail + +MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" +source "$MOSAIC_HOME/tools/_lib/credentials.sh" +source "$(dirname "$0")/_lib.sh" + +ZONE="" +INSTANCE="" +RECORD_ID="" + +while getopts "z:a:r:h" opt; do + case $opt in + z) ZONE="$OPTARG" ;; + a) INSTANCE="$OPTARG" ;; + r) RECORD_ID="$OPTARG" ;; + h) head -11 "$0" | grep "^#" | sed 's/^# \?//'; exit 0 ;; + *) echo "Usage: $0 -z -r [-a instance]" >&2; exit 1 ;; + esac +done + +if [[ -z "$ZONE" || -z "$RECORD_ID" ]]; then + echo "Error: -z and -r are both required" >&2 + exit 1 +fi + +cf_load_instance "$INSTANCE" +ZONE_ID=$(cf_resolve_zone "$ZONE") || exit 1 + +response=$(curl -s -w "\n%{http_code}" \ + -X DELETE \ + -H "Authorization: $(cf_auth)" \ + -H "Content-Type: application/json" \ + "${CF_API}/zones/${ZONE_ID}/dns_records/${RECORD_ID}") + +http_code=$(echo "$response" | tail -n1) +body=$(echo "$response" | sed '$d') + +if [[ "$http_code" != "200" ]]; then + echo "Error: Failed to delete record (HTTP $http_code)" >&2 + echo "$body" | jq -r '.errors[]?.message // empty' 2>/dev/null >&2 + exit 1 +fi + +echo "Deleted DNS record $RECORD_ID from zone $ZONE" diff --git a/tools/cloudflare/record-list.sh b/tools/cloudflare/record-list.sh new file mode 100755 index 0000000..8d000f8 --- /dev/null +++ b/tools/cloudflare/record-list.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# record-list.sh — List DNS records for a Cloudflare zone +# +# Usage: record-list.sh -z [-a instance] [-t type] [-n name] [-f format] +# +# Options: +# -z zone Zone name or ID (required) +# -a instance Cloudflare instance name (default: uses credentials default) +# -t type Filter by record type (A, AAAA, CNAME, MX, TXT, etc.) +# -n name Filter by record name +# -f format Output format: table (default), json +# -h Show this help +set -euo pipefail + +MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" +source "$MOSAIC_HOME/tools/_lib/credentials.sh" +source "$(dirname "$0")/_lib.sh" + +ZONE="" +INSTANCE="" +TYPE="" +NAME="" +FORMAT="table" + +while getopts "z:a:t:n:f:h" opt; do + case $opt in + z) ZONE="$OPTARG" ;; + a) INSTANCE="$OPTARG" ;; + t) TYPE="$OPTARG" ;; + n) NAME="$OPTARG" ;; + f) FORMAT="$OPTARG" ;; + h) head -14 "$0" | grep "^#" | sed 's/^# \?//'; exit 0 ;; + *) echo "Usage: $0 -z [-a instance] [-t type] [-n name] [-f format]" >&2; exit 1 ;; + esac +done + +if [[ -z "$ZONE" ]]; then + echo "Error: -z zone is required" >&2 + exit 1 +fi + +cf_load_instance "$INSTANCE" +ZONE_ID=$(cf_resolve_zone "$ZONE") || exit 1 + +# Build query params +params="per_page=100" +[[ -n "$TYPE" ]] && params="${params}&type=${TYPE}" +[[ -n "$NAME" ]] && params="${params}&name=${NAME}" + +response=$(curl -s -w "\n%{http_code}" \ + -H "Authorization: $(cf_auth)" \ + -H "Content-Type: application/json" \ + "${CF_API}/zones/${ZONE_ID}/dns_records?${params}") + +http_code=$(echo "$response" | tail -n1) +body=$(echo "$response" | sed '$d') + +if [[ "$http_code" != "200" ]]; then + echo "Error: Failed to list records (HTTP $http_code)" >&2 + echo "$body" | jq -r '.errors[]?.message // empty' 2>/dev/null >&2 + exit 1 +fi + +if [[ "$FORMAT" == "json" ]]; then + echo "$body" | jq '.result' + exit 0 +fi + +echo "RECORD ID TYPE NAME CONTENT PROXIED TTL" +echo "-------------------------------- ----- -------------------------------------- ------------------------------- ------- -----" +echo "$body" | jq -r '.result[] | [ + .id, + .type, + .name, + .content, + (if .proxied then "yes" else "no" end), + (if .ttl == 1 then "auto" else (.ttl | tostring) end) +] | @tsv' | while IFS=$'\t' read -r id type name content proxied ttl; do + printf "%-32s %-5s %-38s %-31s %-7s %s\n" "$id" "$type" "${name:0:38}" "${content:0:31}" "$proxied" "$ttl" +done diff --git a/tools/cloudflare/record-update.sh b/tools/cloudflare/record-update.sh new file mode 100755 index 0000000..e6de6d7 --- /dev/null +++ b/tools/cloudflare/record-update.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# record-update.sh — Update a DNS record in a Cloudflare zone +# +# Usage: record-update.sh -z -r -t -n -c [-a instance] [-l ttl] [-p] [-P priority] +# +# Options: +# -z zone Zone name or ID (required) +# -r record-id DNS record ID (required) +# -t type Record type: A, AAAA, CNAME, MX, TXT, etc. (required) +# -n name Record name (required) +# -c content Record value/content (required) +# -a instance Cloudflare instance name (default: uses credentials default) +# -l ttl TTL in seconds (default: 1 = auto) +# -p Enable Cloudflare proxy (orange cloud) +# -P priority MX/SRV priority +# -h Show this help +set -euo pipefail + +MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" +source "$MOSAIC_HOME/tools/_lib/credentials.sh" +source "$(dirname "$0")/_lib.sh" + +ZONE="" +INSTANCE="" +RECORD_ID="" +TYPE="" +NAME="" +CONTENT="" +TTL=1 +PROXIED=false +PRIORITY="" + +while getopts "z:a:r:t:n:c:l:pP:h" opt; do + case $opt in + z) ZONE="$OPTARG" ;; + a) INSTANCE="$OPTARG" ;; + r) RECORD_ID="$OPTARG" ;; + t) TYPE="$OPTARG" ;; + n) NAME="$OPTARG" ;; + c) CONTENT="$OPTARG" ;; + l) TTL="$OPTARG" ;; + p) PROXIED=true ;; + P) PRIORITY="$OPTARG" ;; + h) head -18 "$0" | grep "^#" | sed 's/^# \?//'; exit 0 ;; + *) echo "Usage: $0 -z -r -t -n -c [-a instance]" >&2; exit 1 ;; + esac +done + +if [[ -z "$ZONE" || -z "$RECORD_ID" || -z "$TYPE" || -z "$NAME" || -z "$CONTENT" ]]; then + echo "Error: -z, -r, -t, -n, and -c are all required" >&2 + exit 1 +fi + +cf_load_instance "$INSTANCE" +ZONE_ID=$(cf_resolve_zone "$ZONE") || exit 1 + +payload=$(jq -n \ + --arg type "$TYPE" \ + --arg name "$NAME" \ + --arg content "$CONTENT" \ + --argjson ttl "$TTL" \ + --argjson proxied "$PROXIED" \ + '{type: $type, name: $name, content: $content, ttl: $ttl, proxied: $proxied}') + +if [[ -n "$PRIORITY" ]]; then + payload=$(echo "$payload" | jq --argjson priority "$PRIORITY" '. + {priority: $priority}') +fi + +response=$(curl -s -w "\n%{http_code}" \ + -X PUT \ + -H "Authorization: $(cf_auth)" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "${CF_API}/zones/${ZONE_ID}/dns_records/${RECORD_ID}") + +http_code=$(echo "$response" | tail -n1) +body=$(echo "$response" | sed '$d') + +if [[ "$http_code" != "200" ]]; then + echo "Error: Failed to update record (HTTP $http_code)" >&2 + echo "$body" | jq -r '.errors[]?.message // empty' 2>/dev/null >&2 + exit 1 +fi + +echo "Updated $TYPE record: $NAME → $CONTENT (ID: $RECORD_ID)" diff --git a/tools/cloudflare/zone-list.sh b/tools/cloudflare/zone-list.sh new file mode 100755 index 0000000..3a9e383 --- /dev/null +++ b/tools/cloudflare/zone-list.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# zone-list.sh — List Cloudflare zones (domains) +# +# Usage: zone-list.sh [-a instance] [-f format] +# +# Options: +# -a instance Cloudflare instance name (default: uses credentials default) +# -f format Output format: table (default), json +# -h Show this help +set -euo pipefail + +MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" +source "$MOSAIC_HOME/tools/_lib/credentials.sh" +source "$(dirname "$0")/_lib.sh" + +INSTANCE="" +FORMAT="table" + +while getopts "a:f:h" opt; do + case $opt in + a) INSTANCE="$OPTARG" ;; + f) FORMAT="$OPTARG" ;; + h) head -10 "$0" | grep "^#" | sed 's/^# \?//'; exit 0 ;; + *) echo "Usage: $0 [-a instance] [-f format]" >&2; exit 1 ;; + esac +done + +cf_load_instance "$INSTANCE" + +response=$(curl -s -w "\n%{http_code}" \ + -H "Authorization: $(cf_auth)" \ + -H "Content-Type: application/json" \ + "${CF_API}/zones?per_page=50") + +http_code=$(echo "$response" | tail -n1) +body=$(echo "$response" | sed '$d') + +if [[ "$http_code" != "200" ]]; then + echo "Error: Failed to list zones (HTTP $http_code)" >&2 + echo "$body" | jq -r '.errors[]?.message // empty' 2>/dev/null >&2 + exit 1 +fi + +if [[ "$FORMAT" == "json" ]]; then + echo "$body" | jq '.result' + exit 0 +fi + +echo "ZONE ID NAME STATUS PLAN" +echo "-------------------------------- ---------------------------- -------- ----------" +echo "$body" | jq -r '.result[] | [ + .id, + .name, + .status, + .plan.name +] | @tsv' | while IFS=$'\t' read -r id name status plan; do + printf "%-32s %-28s %-8s %s\n" "$id" "$name" "$status" "$plan" +done