incus-contrib/bin/manage-dashboards

859 lines
26 KiB
Bash
Executable File

#!/usr/bin/env bash
# manage-dashboards - Manage Grafana dashboards for the Incus observability stack
#
# Push, export, validate, and inspect Grafana dashboards via the Grafana HTTP
# API. Dashboards are organized into folders and use datasource UID variables
# for portability across deployments.
#
# Usage: manage-dashboards [OPTIONS]
# Run 'manage-dashboards --help' for full usage information.
set -euo pipefail
readonly VERSION="0.1.0"
readonly SCRIPT_NAME="manage-dashboards"
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly DASHBOARDS_DIR="${SCRIPT_DIR}/data/observability-dashboards"
readonly MANIFEST_FILE="${DASHBOARDS_DIR}/dashboard.yaml"
# ---------------------------------------------------------------------------
# Defaults (overridden by CLI flags)
# ---------------------------------------------------------------------------
CLUSTER_REMOTE="oc-node-01"
MONITORING_CONTAINER="monitoring"
GRAFANA_URL="http://localhost:3000"
GRAFANA_USER="admin"
GRAFANA_PASSWORD="admin"
# Runtime flags
DRY_RUN=false
VERBOSE=false
QUIET=false
ACTION=""
FILTER_CATEGORY=""
FILTER_FILE=""
FILTER_UID=""
# ---------------------------------------------------------------------------
# Color and formatting
# ---------------------------------------------------------------------------
setup_colors() {
if [[ -t 1 ]] && [[ "${NO_COLOR:-}" != "1" ]] && [[ "${TERM:-}" != "dumb" ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
RESET='\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' DIM='' RESET=''
fi
}
info() { [[ "$QUIET" == true ]] && return; echo -e "${BLUE}[info]${RESET} $*"; }
success() { [[ "$QUIET" == true ]] && return; echo -e "${GREEN}[ok]${RESET} $*"; }
warn() { echo -e "${YELLOW}[warn]${RESET} $*" >&2; }
error() { echo -e "${RED}[error]${RESET} $*" >&2; }
step() { [[ "$QUIET" == true ]] && return; echo -e "${CYAN}[step]${RESET} ${BOLD}$*${RESET}"; }
detail() { [[ "$VERBOSE" != true ]] && return; echo -e "${DIM} $*${RESET}"; }
dry_run_guard() {
if [[ "$DRY_RUN" == true ]]; then
info "(dry-run) $*"
return 1
fi
return 0
}
# ---------------------------------------------------------------------------
# Help
# ---------------------------------------------------------------------------
usage() {
cat <<EOF
${BOLD}${SCRIPT_NAME}${RESET} v${VERSION} - Manage Grafana dashboards for the Incus observability stack
${BOLD}USAGE${RESET}
${SCRIPT_NAME} [OPTIONS]
${BOLD}ACTIONS${RESET}
--install Push dashboards to Grafana via API, organized into folders
--export Export dashboards from Grafana to clean JSON files
--list Show available dashboards with install status
--validate Check JSON quality and portability
--status Compare installed vs local versions
${BOLD}FILTERS${RESET}
--category NAME Only process dashboards in this category/folder
--file PATH Only process this specific dashboard file
--uid UID Only process the dashboard with this UID (for --export)
${BOLD}OPTIONS${RESET}
-r, --remote NAME Incus remote (default: ${CLUSTER_REMOTE})
-n, --dry-run Preview actions without executing
-v, --verbose Show detailed output
-q, --quiet Suppress informational output
-h, --help Show this help message
${BOLD}DATASOURCE PORTABILITY${RESET}
Dashboard JSON files use \${DS_PROMETHEUS} and \${DS_LOKI} variables
instead of hardcoded datasource UIDs. The --install action resolves
these to the actual UIDs from the target Grafana instance. The --export
action normalizes UIDs back to variables.
${BOLD}DASHBOARD MANIFEST${RESET}
${MANIFEST_FILE}
YAML file listing each dashboard with file path, UID, title, folder,
category, and version. Used by --install and --list.
${BOLD}EXAMPLES${RESET}
# Validate all dashboard JSON files
${SCRIPT_NAME} --validate
# Install all dashboards into Grafana
${SCRIPT_NAME} --install
# Install only dashboards in the "core" category
${SCRIPT_NAME} --install --category core
# Export a single dashboard by UID
${SCRIPT_NAME} --export --uid incus-cluster-overview
# Compare installed versions with local files
${SCRIPT_NAME} --status
# Preview what would be installed
${SCRIPT_NAME} --install --dry-run
EOF
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
grafana_api() {
local method="$1" path="$2"
shift 2
incus exec "${CLUSTER_REMOTE}:${MONITORING_CONTAINER}" -- \
curl -s -u "${GRAFANA_USER}:${GRAFANA_PASSWORD}" \
-X "$method" "${GRAFANA_URL}${path}" "$@"
}
# Resolve datasource UID variables to actual UIDs from Grafana
resolve_datasource_uids() {
local ds_json
ds_json=$(grafana_api GET "/api/datasources")
PROMETHEUS_UID=$(echo "$ds_json" | python3 -c "
import sys, json
ds = json.load(sys.stdin)
for d in ds:
if d.get('type') == 'prometheus':
print(d['uid'])
break
" 2>/dev/null || echo "")
LOKI_UID=$(echo "$ds_json" | python3 -c "
import sys, json
ds = json.load(sys.stdin)
for d in ds:
if d.get('type') == 'loki':
print(d['uid'])
break
" 2>/dev/null || echo "")
if [[ -z "$PROMETHEUS_UID" ]]; then
error "Could not find Prometheus datasource in Grafana"
return 1
fi
detail "Prometheus datasource UID: ${PROMETHEUS_UID}"
detail "Loki datasource UID: ${LOKI_UID:-not found}"
}
# Get or create a Grafana folder, returns folder ID
ensure_folder() {
local folder_title="$1"
# Check if folder exists
local folder_id
folder_id=$(grafana_api GET "/api/folders" | python3 -c "
import sys, json
folders = json.load(sys.stdin)
for f in folders:
if f.get('title') == '${folder_title}':
print(f['id'])
break
" 2>/dev/null || echo "")
if [[ -n "$folder_id" ]]; then
detail "Folder '${folder_title}' exists (ID: ${folder_id})" >&2
echo "$folder_id"
return 0
fi
# Create folder
local folder_uid
folder_uid=$(echo "$folder_title" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
local result
result=$(grafana_api POST "/api/folders" \
-H "Content-Type: application/json" \
-d "{\"title\": \"${folder_title}\", \"uid\": \"${folder_uid}\"}")
folder_id=$(echo "$result" | python3 -c "
import sys, json
print(json.load(sys.stdin).get('id', ''))
" 2>/dev/null || echo "")
if [[ -n "$folder_id" ]]; then
detail "Created folder '${folder_title}' (ID: ${folder_id})" >&2
echo "$folder_id"
else
error "Failed to create folder '${folder_title}': ${result}"
return 1
fi
}
# Read manifest and output entries as tab-separated lines
read_manifest() {
if [[ ! -f "$MANIFEST_FILE" ]]; then
error "Dashboard manifest not found: ${MANIFEST_FILE}"
error "Expected a YAML file listing dashboard metadata."
return 1
fi
python3 -c "
import sys, re
entries = []
current = {}
with open('${MANIFEST_FILE}') as f:
for line in f:
stripped = line.rstrip()
# New entry starts with ' - file:'
m = re.match(r'^ - file:\s*(.*)', stripped)
if m:
if current:
entries.append(current)
current = {'file': m.group(1).strip()}
continue
# Key-value within an entry
m = re.match(r'^ (\w+):\s*(.*)', stripped)
if m and current:
key, val = m.group(1), m.group(2).strip()
# Strip YAML >- multiline indicators and quotes
if val in ('>-', '>', '|'):
val = ''
val = val.strip('\"').strip(\"'\")
if key in current and not val:
continue
current[key] = val
continue
# Continuation line (multiline description)
if current and stripped.startswith(' ') and stripped.strip():
if 'description' in current:
sep = ' ' if current['description'] else ''
current['description'] = current['description'] + sep + stripped.strip()
if current:
entries.append(current)
for d in entries:
parts = [
d.get('file', ''),
d.get('uid', ''),
d.get('title', ''),
d.get('folder', 'General'),
d.get('category', ''),
d.get('version', '1'),
d.get('description', ''),
]
print('\t'.join(parts))
"
}
# Substitute datasource variables in JSON content
inject_datasource_uids() {
local json_content="$1"
echo "$json_content" \
| sed "s/\\\${DS_PROMETHEUS}/${PROMETHEUS_UID}/g" \
| sed "s/\\\${DS_LOKI}/${LOKI_UID}/g"
}
# Normalize datasource UIDs back to variables for portability
normalize_datasource_uids() {
local json_content="$1"
local prom_uid="$2"
local loki_uid="$3"
local result="$json_content"
if [[ -n "$prom_uid" ]]; then
result=$(echo "$result" | sed "s/${prom_uid}/\\\${DS_PROMETHEUS}/g")
fi
if [[ -n "$loki_uid" ]]; then
result=$(echo "$result" | sed "s/${loki_uid}/\\\${DS_LOKI}/g")
fi
echo "$result"
}
# ---------------------------------------------------------------------------
# Install action
# ---------------------------------------------------------------------------
action_install() {
step "Installing dashboards to Grafana"
echo
if ! dry_run_guard "Resolve datasource UIDs and push dashboards"; then
info "(dry-run) Would install dashboards from ${DASHBOARDS_DIR}"
local count=0
while IFS=$'\t' read -r file uid title folder category version description <&3; do
if [[ -n "$FILTER_CATEGORY" && "$category" != "$FILTER_CATEGORY" ]]; then
continue
fi
if [[ -n "$FILTER_FILE" && "$file" != "$FILTER_FILE" ]]; then
continue
fi
info "(dry-run) Would install: ${title} -> folder '${folder}'"
count=$((count + 1))
done 3< <(read_manifest)
info "(dry-run) ${count} dashboard(s) would be installed"
return 0
fi
resolve_datasource_uids
local installed=0
local failed=0
while IFS=$'\t' read -r file uid title folder category version description <&3; do
if [[ -n "$FILTER_CATEGORY" && "$category" != "$FILTER_CATEGORY" ]]; then
continue
fi
if [[ -n "$FILTER_FILE" && "$file" != "$FILTER_FILE" ]]; then
continue
fi
local full_path="${DASHBOARDS_DIR}/${file}"
if [[ ! -f "$full_path" ]]; then
warn "Dashboard file not found: ${full_path}"
failed=$((failed + 1))
continue
fi
# Get or create the target folder
local folder_id
folder_id=$(ensure_folder "$folder") || { failed=$((failed + 1)); continue; }
# Build Grafana API payload: inject datasource UIDs and wrap in envelope
local api_payload
api_payload=$(python3 - "$full_path" "$PROMETHEUS_UID" "${LOKI_UID:-}" "$folder_id" << 'PYEOF'
import json, sys
with open(sys.argv[1]) as f:
dashboard = json.load(f)
# Inject actual datasource UIDs
json_str = json.dumps(dashboard)
json_str = json_str.replace('${DS_PROMETHEUS}', sys.argv[2])
if sys.argv[3]:
json_str = json_str.replace('${DS_LOKI}', sys.argv[3])
dashboard = json.loads(json_str)
# Remove Grafana-internal fields
for key in ('id', 'version', 'iteration'):
dashboard.pop(key, None)
payload = {
'dashboard': dashboard,
'folderId': int(sys.argv[4]),
'overwrite': True,
'message': 'Installed by manage-dashboards'
}
print(json.dumps(payload))
PYEOF
)
if [[ -z "$api_payload" ]]; then
error "Failed to process dashboard JSON: ${file}"
failed=$((failed + 1))
continue
fi
# Push via API - write payload to temp file to avoid command-line size limits
local tmpfile
tmpfile=$(mktemp /tmp/dashboard-payload.XXXXXX)
echo "$api_payload" > "$tmpfile"
incus file push "$tmpfile" "${CLUSTER_REMOTE}:${MONITORING_CONTAINER}/tmp/dashboard-payload.json" >/dev/null 2>&1
local result
result=$(grafana_api POST "/api/dashboards/db" \
-H "Content-Type: application/json" \
-d @/tmp/dashboard-payload.json 2>/dev/null)
rm -f "$tmpfile"
local status_str
status_str=$(echo "$result" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
print(d.get('status', d.get('message', 'unknown')))
except:
print('error')
" 2>/dev/null || echo "error")
if [[ "$status_str" == "success" ]]; then
success " ${title} -> ${folder}"
installed=$((installed + 1))
else
error " ${title}: ${status_str}"
detail " Response: ${result}"
failed=$((failed + 1))
fi
# Clean up temp file in container
incus exec "${CLUSTER_REMOTE}:${MONITORING_CONTAINER}" -- rm -f /tmp/dashboard-payload.json 2>/dev/null || true
done 3< <(read_manifest)
echo
if [[ $failed -eq 0 ]]; then
success "Installed ${installed} dashboard(s)"
else
warn "Installed ${installed}, failed ${failed}"
fi
}
# ---------------------------------------------------------------------------
# Export action
# ---------------------------------------------------------------------------
action_export() {
step "Exporting dashboards from Grafana"
echo
resolve_datasource_uids
local exported=0
if [[ -n "$FILTER_UID" ]]; then
# Export a single dashboard by UID
export_single_dashboard "$FILTER_UID"
return $?
fi
# Export all dashboards listed in the manifest
while IFS=$'\t' read -r file uid title folder category version description <&3; do
if [[ -n "$FILTER_CATEGORY" && "$category" != "$FILTER_CATEGORY" ]]; then
continue
fi
if export_single_dashboard "$uid" "${DASHBOARDS_DIR}/${file}"; then
exported=$((exported + 1))
fi
done 3< <(read_manifest)
echo
success "Exported ${exported} dashboard(s)"
}
export_single_dashboard() {
local uid="$1"
local output_file="${2:-}"
# Fetch from Grafana API
local raw_response
raw_response=$(grafana_api GET "/api/dashboards/uid/${uid}" 2>/dev/null)
local has_dashboard
has_dashboard=$(echo "$raw_response" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
if 'dashboard' in d:
print('yes')
else:
print('no')
except:
print('no')
" 2>/dev/null || echo "no")
if [[ "$has_dashboard" != "yes" ]]; then
warn "Dashboard '${uid}' not found in Grafana"
return 1
fi
# If no output file specified, find it in the manifest
if [[ -z "$output_file" ]]; then
output_file=$(read_manifest | while IFS=$'\t' read -r file muid title folder category version description; do
if [[ "$muid" == "$uid" ]]; then
echo "${DASHBOARDS_DIR}/${file}"
break
fi
done)
fi
if [[ -z "$output_file" ]]; then
output_file="${DASHBOARDS_DIR}/core/${uid}.json"
warn "Dashboard '${uid}' not in manifest, exporting to ${output_file}"
fi
if ! dry_run_guard "Export ${uid} to ${output_file}"; then
return 0
fi
# Clean and normalize the dashboard JSON
python3 << PYEOF > "$output_file"
import json, sys
raw = json.loads('''$(echo "$raw_response" | sed "s/'/\\\\'/g")''')
dashboard = raw['dashboard']
# Remove Grafana-internal fields
for key in ['id', 'version', 'iteration']:
dashboard.pop(key, None)
# Normalize datasource UIDs to variables
json_str = json.dumps(dashboard, indent=2)
prom_uid = "${PROMETHEUS_UID}"
loki_uid = "${LOKI_UID:-}"
if prom_uid:
json_str = json_str.replace(prom_uid, r'\${DS_PROMETHEUS}')
if loki_uid:
json_str = json_str.replace(loki_uid, r'\${DS_LOKI}')
# Re-parse to ensure valid JSON
dashboard = json.loads(json_str.replace(r'\${DS_PROMETHEUS}', '\${DS_PROMETHEUS}').replace(r'\${DS_LOKI}', '\${DS_LOKI}'))
# Write with consistent formatting
json_str = json.dumps(dashboard, indent=2)
# Restore the variable syntax that json.dumps may have escaped
json_str = json_str.replace(r'\${DS_PROMETHEUS}', '\${DS_PROMETHEUS}')
json_str = json_str.replace(r'\${DS_LOKI}', '\${DS_LOKI}')
print(json_str)
PYEOF
if [[ $? -eq 0 ]]; then
success " Exported: ${uid} -> $(basename "$output_file")"
return 0
else
error " Failed to export: ${uid}"
return 1
fi
}
# ---------------------------------------------------------------------------
# List action
# ---------------------------------------------------------------------------
action_list() {
step "Dashboard Inventory"
echo
# Try to get installed dashboards from Grafana
local installed_json=""
installed_json=$(grafana_api GET "/api/search?type=dash-db" 2>/dev/null || echo "[]")
printf " ${BOLD}%-35s %-30s %-20s %s${RESET}\n" "UID" "Title" "Folder" "Status"
printf " %-35s %-30s %-20s %s\n" "---" "-----" "------" "------"
while IFS=$'\t' read -r file uid title folder category version description <&3; do
if [[ -n "$FILTER_CATEGORY" && "$category" != "$FILTER_CATEGORY" ]]; then
continue
fi
local full_path="${DASHBOARDS_DIR}/${file}"
local local_status="missing"
if [[ -f "$full_path" ]]; then
local_status="local"
fi
# Check if installed in Grafana
local grafana_status
grafana_status=$(echo "$installed_json" | python3 -c "
import sys, json
try:
dashboards = json.load(sys.stdin)
for d in dashboards:
if d.get('uid') == '${uid}':
print('installed')
break
else:
print('not installed')
except:
print('unknown')
" 2>/dev/null || echo "unknown")
local status_display
if [[ "$local_status" == "missing" ]]; then
status_display="${RED}file missing${RESET}"
elif [[ "$grafana_status" == "installed" ]]; then
status_display="${GREEN}installed${RESET}"
elif [[ "$grafana_status" == "not installed" ]]; then
status_display="${YELLOW}not installed${RESET}"
else
status_display="${DIM}${local_status}${RESET}"
fi
printf " %-35s %-30s %-20s " "$uid" "$title" "$folder"
echo -e "$status_display"
done 3< <(read_manifest)
}
# ---------------------------------------------------------------------------
# Validate action
# ---------------------------------------------------------------------------
action_validate() {
step "Validating dashboard JSON files"
echo
local total=0
local passed=0
local warnings=0
local errors=0
while IFS=$'\t' read -r file uid title folder category version description <&3; do
if [[ -n "$FILTER_CATEGORY" && "$category" != "$FILTER_CATEGORY" ]]; then
continue
fi
local full_path="${DASHBOARDS_DIR}/${file}"
total=$((total + 1))
if [[ ! -f "$full_path" ]]; then
error " ${file}: FILE NOT FOUND"
errors=$((errors + 1))
continue
fi
local issues=0
local warns=0
local basename_file
basename_file=$(basename "$full_path")
# Check 1: Valid JSON
if ! python3 -c "import json; json.load(open('${full_path}'))" 2>/dev/null; then
error " ${basename_file}: INVALID JSON"
errors=$((errors + 1))
continue
fi
# Run all checks via python
local check_result
check_result=$(python3 << PYEOF
import json
with open('${full_path}') as f:
d = json.load(f)
issues = []
warns = []
# Check 2: UID present and matches manifest
json_uid = d.get('uid', '')
if not json_uid:
issues.append('missing uid field')
elif json_uid != '${uid}':
issues.append(f'uid mismatch: JSON has "{json_uid}", manifest has "${uid}"')
# Check 3: Title present
if not d.get('title'):
issues.append('missing title field')
# Check 4: Description present
if not d.get('description'):
warns.append('missing description field')
# Check 5: No hardcoded datasource UIDs (should use variables)
json_str = json.dumps(d)
# Known Grafana internal UIDs that are OK
safe_uids = ['-- Grafana --']
# Check for UIDs that look like Grafana-generated ones
import re
# Match patterns like "uid": "PBFA97CFB590B2093" (hex-like, not a variable)
uid_pattern = re.compile(r'"uid":\s*"([A-Za-z0-9]{16,})"')
for match in uid_pattern.finditer(json_str):
found_uid = match.group(1)
if found_uid not in safe_uids and not found_uid.startswith('\${'):
issues.append(f'hardcoded datasource UID: {found_uid}')
break
# Check 6: Datasource variable usage
if '\${DS_PROMETHEUS}' not in json_str and '\${DS_LOKI}' not in json_str:
# Check if it has any datasource references at all
if '"type": "prometheus"' in json_str or '"type": "loki"' in json_str:
warns.append('no datasource variables found (uses hardcoded UIDs?)')
# Check 7: Has panels
panels = d.get('panels', [])
if not panels:
warns.append('no panels defined')
# Check 8: Check for id field (should not be present in stored files)
if 'id' in d and d['id'] is not None:
warns.append('contains "id" field (should be null or absent for portability)')
for i in issues:
print(f'ERROR:{i}')
for w in warns:
print(f'WARN:{w}')
if not issues:
print('OK')
PYEOF
)
local has_error=false
while IFS= read -r line; do
case "$line" in
ERROR:*)
error " ${basename_file}: ${line#ERROR:}"
issues=$((issues + 1))
has_error=true
;;
WARN:*)
warn " ${basename_file}: ${line#WARN:}"
warns=$((warns + 1))
;;
OK)
;;
esac
done <<< "$check_result"
if [[ "$has_error" == true ]]; then
errors=$((errors + 1))
else
if [[ $warns -gt 0 ]]; then
warnings=$((warnings + warns))
fi
passed=$((passed + 1))
success " ${basename_file}: valid"
fi
done 3< <(read_manifest)
echo
info "${total} dashboard(s) checked: ${passed} passed, ${errors} errors, ${warnings} warnings"
if [[ $errors -gt 0 ]]; then
return 1
fi
}
# ---------------------------------------------------------------------------
# Status action
# ---------------------------------------------------------------------------
action_status() {
step "Dashboard Status (local vs installed)"
echo
local installed_json
installed_json=$(grafana_api GET "/api/search?type=dash-db" 2>/dev/null || echo "[]")
printf " ${BOLD}%-30s %-15s %-15s %s${RESET}\n" "Dashboard" "Local" "Grafana" "Match"
printf " %-30s %-15s %-15s %s\n" "---------" "-----" "-------" "-----"
while IFS=$'\t' read -r file uid title folder category version description <&3; do
local full_path="${DASHBOARDS_DIR}/${file}"
local local_ver="missing"
if [[ -f "$full_path" ]]; then
local_ver="v${version}"
fi
local grafana_ver
grafana_ver=$(echo "$installed_json" | python3 -c "
import sys, json
try:
for d in json.load(sys.stdin):
if d.get('uid') == '${uid}':
print('installed')
break
else:
print('absent')
except:
print('unknown')
" 2>/dev/null || echo "unknown")
local match_str
if [[ "$local_ver" == "missing" ]]; then
match_str="${RED}file missing${RESET}"
elif [[ "$grafana_ver" == "absent" ]]; then
match_str="${YELLOW}not installed${RESET}"
elif [[ "$grafana_ver" == "installed" ]]; then
match_str="${GREEN}synced${RESET}"
else
match_str="${DIM}unknown${RESET}"
fi
printf " %-30s %-15s %-15s " "$title" "$local_ver" "$grafana_ver"
echo -e "$match_str"
done 3< <(read_manifest)
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--install) ACTION="install";;
--export) ACTION="export";;
--list) ACTION="list";;
--validate) ACTION="validate";;
--status) ACTION="status";;
--category) FILTER_CATEGORY="$2"; shift;;
--file) FILTER_FILE="$2"; shift;;
--uid) FILTER_UID="$2"; shift;;
-r|--remote) CLUSTER_REMOTE="$2"; shift;;
-n|--dry-run) DRY_RUN=true;;
-v|--verbose) VERBOSE=true;;
-q|--quiet) QUIET=true;;
-h|--help) usage; exit 0;;
*)
error "Unknown option: $1"
echo "Run '${SCRIPT_NAME} --help' for usage."
exit 1
;;
esac
shift
done
if [[ -z "$ACTION" ]]; then
error "No action specified"
echo "Run '${SCRIPT_NAME} --help' for usage."
exit 1
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
setup_colors
parse_args "$@"
echo
case "$ACTION" in
install) action_install;;
export) action_export;;
list) action_list;;
validate) action_validate;;
status) action_status;;
esac
}
main "$@"