#!/usr/bin/env bash # Portainer management script # Usage: # ./portainer.sh list # ./portainer.sh redeploy # ./portainer.sh deploy # ./portainer.sh get-env # ./portainer.sh set-env KEY=VALUE [KEY=VALUE ...] set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CREDENTIALS_FILE="$SCRIPT_DIR/.credentials" if [[ ! -f "$CREDENTIALS_FILE" ]]; then echo "Error: credentials file not found at $CREDENTIALS_FILE" >&2 exit 1 fi # shellcheck source=.credentials source "$CREDENTIALS_FILE" API="$PORTAINER_URL/api" ENDPOINT_ID="$PORTAINER_ENDPOINT_ID" AUTH_HEADER="X-API-Key: $PORTAINER_API_TOKEN" # -------------------------------------------------------------------------- # Helpers # -------------------------------------------------------------------------- api_get() { curl -sk -H "$AUTH_HEADER" "$API/$1" } api_put() { curl -sk -X PUT -H "$AUTH_HEADER" -H "Content-Type: application/json" \ -d "$2" "$API/$1" } api_post() { curl -sk -X POST -H "$AUTH_HEADER" -H "Content-Type: application/json" \ -d "$2" "$API/$1" } get_stack_by_name() { local name="$1" api_get "stacks?filters=%7B%22EndpointID%22%3A${ENDPOINT_ID}%7D" \ | python3 -c " import json, sys stacks = json.load(sys.stdin) matches = [s for s in stacks if s['Name'] == '$name'] if not matches: sys.exit(1) s = matches[0] print(s['Id']) " } get_stack_json_by_name() { local name="$1" api_get "stacks?filters=%7B%22EndpointID%22%3A${ENDPOINT_ID}%7D" \ | python3 -c " import json, sys stacks = json.load(sys.stdin) matches = [s for s in stacks if s['Name'] == '$name'] if not matches: sys.exit(1) print(json.dumps(matches[0])) " } # -------------------------------------------------------------------------- # Commands # -------------------------------------------------------------------------- cmd_list() { echo "Fetching stacks..." api_get "stacks?filters=%7B%22EndpointID%22%3A${ENDPOINT_ID}%7D" \ | python3 -c " import json, sys stacks = json.load(sys.stdin) stacks.sort(key=lambda s: s['Id']) status_map = {1: 'active', 2: 'inactive'} print(f'{'ID':<6} {'STATUS':<10} NAME') print('-' * 40) for s in stacks: status = status_map.get(s['Status'], str(s['Status'])) print(f\"{s['Id']:<6} {status:<10} {s['Name']}\") " } cmd_redeploy() { local name="${1:-}" if [[ -z "$name" ]]; then echo "Usage: $0 redeploy " >&2 exit 1 fi echo "Looking up stack '$name'..." local stack_json if ! stack_json=$(get_stack_json_by_name "$name"); then echo "Error: stack '$name' not found" >&2 exit 1 fi local stack_id stack_id=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['Id'])" "$stack_json") echo "Found stack ID: $stack_id" # Preserve existing env vars — git/redeploy clears them if env is omitted local payload payload=$(python3 -c " import json, sys stack = json.loads(sys.argv[1]) env = stack.get('Env') or [] print(json.dumps({'pullImage': True, 'prune': False, 'env': env})) " "$stack_json") echo "Redeploying..." local response response=$(api_put "stacks/${stack_id}/git/redeploy?endpointId=${ENDPOINT_ID}" "$payload") python3 -c " import json, sys d = json.loads(sys.argv[1]) if 'message' in d and 'Id' not in d: print('Error:', d['message']) sys.exit(1) hash = d.get('GitConfig', {}).get('ConfigHash', 'unknown')[:10] print(f'Done. ConfigHash: {hash}') " "$response" } cmd_deploy() { local name="${1:-}" local compose_path="${2:-}" if [[ -z "$name" || -z "$compose_path" ]]; then echo "Usage: $0 deploy " >&2 echo " Example: $0 deploy myapp myapp/docker-compose.yml" >&2 exit 1 fi echo "Creating stack '$name' from $compose_path..." local payload payload=$(python3 -c " import json print(json.dumps({ 'name': '$name', 'repositoryURL': '$GITEA_REPO_URL', 'repositoryReferenceName': 'refs/heads/main', 'composeFile': '$compose_path', 'repositoryAuthentication': True, 'repositoryUsername': '$GITEA_USER', 'repositoryPassword': '$GITEA_TOKEN', })) ") local response response=$(api_post "stacks/create/standalone/repository?endpointId=${ENDPOINT_ID}" "$payload") python3 -c " import json, sys d = json.load(sys.stdin) if 'message' in d and 'Id' not in d: print('Error:', d['message']) sys.exit(1) print(f\"Done. Stack ID: {d['Id']}, Name: {d['Name']}\") " <<< "$response" } cmd_get_env() { local name="${1:-}" if [[ -z "$name" ]]; then echo "Usage: $0 get-env " >&2 exit 1 fi echo "Looking up stack '$name'..." local stack_json if ! stack_json=$(get_stack_json_by_name "$name"); then echo "Error: stack '$name' not found" >&2 exit 1 fi python3 -c " import json, sys s = json.loads(sys.argv[1]) env = s.get('Env') or [] if not env: print('(no env vars set)') else: for e in sorted(env, key=lambda x: x['name']): print(f\"{e['name']}={e['value']}\") " "$stack_json" } cmd_set_env() { local name="${1:-}" shift || true if [[ -z "$name" || $# -eq 0 ]]; then echo "Usage: $0 set-env KEY=VALUE [KEY=VALUE ...]" >&2 echo " Example: $0 set-env authelia SECRET_KEY=abc123 OTHER_KEY=xyz" >&2 exit 1 fi echo "Looking up stack '$name'..." local stack_json if ! stack_json=$(get_stack_json_by_name "$name"); then echo "Error: stack '$name' not found" >&2 exit 1 fi local stack_id stack_id=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['Id'])" "$stack_json") echo "Found stack ID: $stack_id" local kvs=("$@") local payload payload=$(python3 -c " import json, sys stack = json.loads(sys.argv[1]) new_kvs = sys.argv[2:] # Merge env vars: preserve existing, override/add new env_dict = {e['name']: e['value'] for e in (stack.get('Env') or [])} for kv in new_kvs: eq = kv.index('=') env_dict[kv[:eq]] = kv[eq+1:] env_list = [{'name': k, 'value': v} for k, v in env_dict.items()] git = stack.get('GitConfig') or {} if git: # Use git/redeploy endpoint (pullImage:false = redeploy with existing images only) p = {'pullImage': False, 'prune': False, 'env': env_list} else: print('Error: string-based stacks are not supported; update env vars via Portainer UI.', file=sys.stderr) sys.exit(2) print(json.dumps(p)) " "$stack_json" "${kvs[@]}") echo "Updating env vars (redeploys with existing images)..." local endpoint local stack_json_check stack_json_check=$(python3 -c " import json, sys stack = json.loads(sys.argv[1]) git = stack.get('GitConfig') or {} print('git' if git else 'string') " "$stack_json") local response if [[ "$stack_json_check" == "git" ]]; then response=$(api_put "stacks/${stack_id}/git/redeploy?endpointId=${ENDPOINT_ID}" "$payload") else echo "Error: string-based stacks are not supported; update env vars via Portainer UI." >&2 exit 2 fi python3 -c " import json, sys try: d = json.loads(sys.argv[1]) except Exception: print('Failed to parse response:', sys.argv[1][:300]) sys.exit(1) if 'message' in d and 'Id' not in d: print('Error:', d['message']) if 'details' in d: print('Details:', d['details']) sys.exit(1) updated = len(d.get('Env') or []) print(f'Done. Stack has {updated} env var(s) set.') " "$response" } # -------------------------------------------------------------------------- # Dispatch # -------------------------------------------------------------------------- command="${1:-}" case "$command" in list) cmd_list ;; redeploy) cmd_redeploy "${2:-}" ;; deploy) cmd_deploy "${2:-}" "${3:-}" ;; get-env) cmd_get_env "${2:-}" ;; set-env) cmd_set_env "${2:-}" "${@:3}" ;; *) echo "Usage: $0 [args]" echo "" echo "Commands:" echo " list List all stacks" echo " redeploy Pull latest git commit and redeploy" echo " deploy Create new git-linked stack" echo " get-env Show env vars for a stack" echo " set-env KEY=VAL [...] Set env vars (redeploys without new image pull)" exit 1 ;; esac