diff --git a/README.md b/README.md index 936a291..e208ce6 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This repository is intended to be a simple skill source: install the repo (or a | Skill | What it does | Path | |---|---|---| | `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` | +| `portainer` | Manage Portainer stacks via API (list, start/stop/restart, update, prune images). | `skills/portainer` | | `web-automation` | Automate browsing/scraping with Playwright + Camoufox (auth flows, extraction, bot-protected sites). | `skills/web-automation` | ## Install ideas diff --git a/docs/README.md b/docs/README.md index 4c7fb56..221c304 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,4 +5,5 @@ This folder contains detailed docs for each skill in this repository. ## Skills - [`gitea-api`](gitea-api.md) — REST-based Gitea automation (no `tea` CLI required) +- [`portainer`](portainer.md) — Portainer stack management (list, lifecycle, updates, image pruning) - [`web-automation`](web-automation.md) — Playwright + Camoufox browser automation and scraping diff --git a/docs/portainer.md b/docs/portainer.md new file mode 100644 index 0000000..c765a35 --- /dev/null +++ b/docs/portainer.md @@ -0,0 +1,184 @@ +# Portainer Skill + +Interact with Portainer stacks via API key authentication. Manage stacks, resolve identifiers, update deployments, and clean up old images. + +## Overview + +This skill provides a comprehensive set of commands for managing Portainer Docker stacks through the API. All stack commands accept stack names and automatically resolve IDs internally. + +## Prerequisites + +### Auth Configuration + +Create a config file at: +``` +~/.clawdbot/credentials/portainer/config.json +``` + +With the following content: +```json +{ + "base_url": "https://your-portainer-instance.com", + "api_key": "YOUR_PORTAINER_API_KEY" +} +``` + +To generate an API key: +1. Log into Portainer +2. Go to User Settings → Access tokens +3. Create a new token with appropriate permissions + +## Commands + +### Stack Identification + +#### Get Stack ID +```bash +bash scripts/get-stack-id.sh "" +``` +Resolves a stack name to its numeric ID. Prints only the ID on success. + +#### Get Endpoint ID +```bash +bash scripts/get-endpoint-id.sh "" +``` +Resolves an endpoint (environment) name to its ID. + +### Stack Inventory + +#### List All Stacks +```bash +bash scripts/list-stacks.sh +``` +Lists all stacks with their ID, Name, and Status. + +#### Get Stack Status +```bash +bash scripts/get-stack-status.sh "" +``` +Returns JSON with stack details: Id, Name, Status, Type, EndpointId, CreationDate, UpdatedDate. + +### Stack Lifecycle + +#### Stop Stack +```bash +bash scripts/stop-stack.sh "" +``` + +#### Start Stack +```bash +bash scripts/start-stack.sh "" +``` + +#### Restart Stack +```bash +bash scripts/restart-stack.sh "" +``` + +### Stack Configuration + +#### Get Environment Variables +```bash +bash scripts/get-stack-env.sh "" +``` +Returns JSON array of `{name, value}` objects. + +#### Get Compose File +```bash +bash scripts/get-stack-compose.sh "" +``` +Returns the raw docker-compose.yml content. + +### Stack Updates + +#### Update Stack +```bash +bash scripts/update-stack.sh "" "" [options] +``` + +Options: +- `--pull` — Force pull images and redeploy (like `docker compose down/pull/up`) +- `--env-file ` — Path to a file with env vars (format: NAME=value per line) + +Notes: +- Without `--env-file`, existing environment variables are preserved +- The `--pull` flag may return HTTP 504 for large images, but the operation completes in the background + +#### Prune Stack Images +```bash +bash scripts/prune-stack-images.sh "" +``` +Removes dangling images on the endpoint. Run this after `update-stack --pull` completes. + +## Typical Workflow + +### Updating a Stack with a New Image Version + +```bash +# 1. Get the current compose file +bash scripts/get-stack-compose.sh "my-stack" > /tmp/my-stack-compose.yml + +# 2. Update the stack (pull new images) +bash scripts/update-stack.sh "my-stack" "/tmp/my-stack-compose.yml" --pull + +# 3. Wait for update to complete (even if you see a 504 timeout) + +# 4. Clean up old images +bash scripts/prune-stack-images.sh "my-stack" +``` + +### Modifying a Stack's Compose File + +```bash +# 1. Get the current compose file +bash scripts/get-stack-compose.sh "my-stack" > /tmp/my-stack-compose.yml + +# 2. Edit the compose file +nano /tmp/my-stack-compose.yml + +# 3. Update the stack with the modified file +bash scripts/update-stack.sh "my-stack" "/tmp/my-stack-compose.yml" + +# 4. Optionally prune old images if you changed image tags +bash scripts/prune-stack-images.sh "my-stack" +``` + +## Error Handling + +All scripts: +- Exit with code 0 on success +- Exit with non-zero code on failure +- Print error messages to stderr +- Print results to stdout + +Common errors: +- **Missing config file**: Create the auth configuration file +- **Invalid API key**: Generate a new API key in Portainer +- **Stack not found**: Check the stack name with `list-stacks.sh` +- **504 Gateway Timeout**: The operation is still running in the background; wait and then run the prune command + +## API Reference + +This skill uses the following Portainer API endpoints: + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/api/stacks` | GET | List all stacks | +| `/api/stacks/{id}` | GET | Get stack details | +| `/api/stacks/{id}` | PUT | Update stack | +| `/api/stacks/{id}/file` | GET | Get compose file | +| `/api/stacks/{id}/start` | POST | Start stack | +| `/api/stacks/{id}/stop` | POST | Stop stack | +| `/api/stacks/{id}/restart` | POST | Restart stack | +| `/api/endpoints` | GET | List endpoints | +| `/api/endpoints/{id}/docker/containers/json` | GET | List containers | +| `/api/endpoints/{id}/docker/images/json` | GET | List images | +| `/api/endpoints/{id}/docker/images/{id}` | DELETE | Remove image | + +## Notes + +- All `*-stack.sh` commands resolve the stack ID internally from the name +- Endpoint ID is fetched automatically from stack info for lifecycle and update operations +- The `--pull` flag triggers Portainer to pull new images and recreate containers +- Large image pulls may cause HTTP 504 timeouts, but operations complete server-side +- Use `prune-stack-images.sh` after updates to clean up old dangling images diff --git a/skills/portainer/SKILL.md b/skills/portainer/SKILL.md new file mode 100644 index 0000000..c5734ac --- /dev/null +++ b/skills/portainer/SKILL.md @@ -0,0 +1,111 @@ +--- +name: portainer +description: Interact with Portainer stacks via API key authentication. Use for any Portainer stack operations including: listing stacks, resolving stack/endpoint IDs, getting stack status, starting/stopping/restarting stacks, retrieving env vars and compose files, and updating stacks with new compose content. All stack commands accept names and resolve IDs automatically. +--- + +# Portainer Skill + +Manage Portainer stacks via API. All stack commands accept names and resolve IDs automatically. + +## Required auth config + +`/home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json` + +```json +{ + "base_url": "https://portainer.example.com", + "api_key": "YOUR_PORTAINER_API_KEY" +} +``` + +## Commands + +### Resolve stack ID + +```bash +bash scripts/get-stack-id.sh "" +``` +Prints only the stack ID. Exits non-zero if not found. + +### Resolve endpoint ID + +```bash +bash scripts/get-endpoint-id.sh "" +``` +Prints only the endpoint (environment) ID. + +### List all stacks + +```bash +bash scripts/list-stacks.sh +``` +Outputs: `ID Name Status` (tab-aligned). + +### Get stack status + +```bash +bash scripts/get-stack-status.sh "" +``` +Returns JSON with: Id, Name, Status, Type, EndpointId, CreationDate, UpdatedDate. + +### Restart stack + +```bash +bash scripts/restart-stack.sh "" +``` + +### Stop stack + +```bash +bash scripts/stop-stack.sh "" +``` + +### Start stack + +```bash +bash scripts/start-stack.sh "" +``` + +### Get stack env vars + +```bash +bash scripts/get-stack-env.sh "" +``` +Returns JSON array of `{name, value}` objects. + +### Get stack compose file + +```bash +bash scripts/get-stack-compose.sh "" +``` +Returns the raw docker-compose.yml content. + +### Update stack + +```bash +bash scripts/update-stack.sh "" "" [--env-file ""] [--prune-old] +``` +Updates a stack with a new compose file. Preserves existing env vars unless `--env-file` is provided. + +Options: +- `--pull` — Force pull images and redeploy (like `docker compose down/pull/up`). Note: may return 504 timeout for large images, but operation completes in background. + +### Prune stack images + +```bash +bash scripts/prune-stack-images.sh "" +``` +Removes dangling images on the endpoint. Run this after `update-stack --pull` completes to clean up old image versions. + +**Typical workflow:** +```bash +bash scripts/update-stack.sh "stack-name" "compose.yml" --pull +# wait for update to complete (even if 504 timeout) +bash scripts/prune-stack-images.sh "stack-name" +``` + +## Notes + +- All `*-stack.sh` commands resolve the stack ID internally from the name. +- Endpoint ID is fetched automatically from stack info for lifecycle and update operations. +- `update-stack.sh` is the primary command for deploying new versions — it will trigger Portainer to pull new images if the compose file references updated image tags. diff --git a/skills/portainer/scripts/get-endpoint-id.sh b/skills/portainer/scripts/get-endpoint-id.sh new file mode 100755 index 0000000..2c3c3c9 --- /dev/null +++ b/skills/portainer/scripts/get-endpoint-id.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG_PATH="/home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json" + +err() { + echo "Error: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || err "Required command not found: $1" +} + +usage() { + cat >&2 <<'EOF' +Usage: get-endpoint-id.sh "" + +Looks up a Portainer endpoint (environment) by exact name and prints its ID. +Requires config at: + /home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json +EOF + exit 2 +} + +[[ $# -eq 1 ]] || usage +ENDPOINT_NAME="$1" + +require_cmd curl +require_cmd jq + +[[ -f "$CONFIG_PATH" ]] || err "Missing config file: $CONFIG_PATH" + +BASE_URL="$(jq -r '.base_url // empty' "$CONFIG_PATH")" +API_KEY="$(jq -r '.api_key // empty' "$CONFIG_PATH")" + +[[ -n "$BASE_URL" ]] || err "config.base_url is missing" +[[ -n "$API_KEY" ]] || err "config.api_key is missing" + +BASE_URL="${BASE_URL%/}" + +response="$(curl -sS -w $'\n%{http_code}' \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/endpoints")" + +http_code="$(printf '%s' "$response" | tail -n1)" +body="$(printf '%s' "$response" | sed '$d')" + +if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$body" + err "Portainer API request failed (HTTP $http_code): $msg" +fi + +endpoint_id="$(printf '%s' "$body" | jq -r --arg name "$ENDPOINT_NAME" '.[] | select(.Name == $name) | .Id' | head -n1)" + +[[ -n "$endpoint_id" ]] || err "No endpoint found with name: $ENDPOINT_NAME" + +printf '%s\n' "$endpoint_id" diff --git a/skills/portainer/scripts/get-stack-compose.sh b/skills/portainer/scripts/get-stack-compose.sh new file mode 100755 index 0000000..ff31e9c --- /dev/null +++ b/skills/portainer/scripts/get-stack-compose.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_PATH="/home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json" + +err() { + echo "Error: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || err "Required command not found: $1" +} + +usage() { + cat >&2 <<'EOF' +Usage: get-stack-compose.sh "" + +Gets the docker-compose.yml content for a Portainer stack by name. +Requires config at: + /home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json +EOF + exit 2 +} + +[[ $# -eq 1 ]] || usage +STACK_NAME="$1" + +require_cmd curl +require_cmd jq + +[[ -f "$CONFIG_PATH" ]] || err "Missing config file: $CONFIG_PATH" + +BASE_URL="$(jq -r '.base_url // empty' "$CONFIG_PATH")" +API_KEY="$(jq -r '.api_key // empty' "$CONFIG_PATH")" + +[[ -n "$BASE_URL" ]] || err "config.base_url is missing" +[[ -n "$API_KEY" ]] || err "config.api_key is missing" + +BASE_URL="${BASE_URL%/}" + +# Resolve stack ID from name +STACK_ID="$(bash "$SCRIPT_DIR/get-stack-id.sh" "$STACK_NAME")" +[[ -n "$STACK_ID" ]] || err "Failed to resolve stack ID for: $STACK_NAME" + +# Get stack file content +response="$(curl -sS -w $'\n%{http_code}' \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/stacks/$STACK_ID/file")" + +http_code="$(printf '%s' "$response" | tail -n1)" +body="$(printf '%s' "$response" | sed '$d')" + +if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$body" + err "Failed to fetch stack file (HTTP $http_code): $msg" +fi + +# Extract StackFileContent +printf '%s' "$body" | jq -r '.StackFileContent // empty' diff --git a/skills/portainer/scripts/get-stack-env.sh b/skills/portainer/scripts/get-stack-env.sh new file mode 100755 index 0000000..2220be2 --- /dev/null +++ b/skills/portainer/scripts/get-stack-env.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_PATH="/home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json" + +err() { + echo "Error: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || err "Required command not found: $1" +} + +usage() { + cat >&2 <<'EOF' +Usage: get-stack-env.sh "" + +Gets environment variables for a Portainer stack by name (resolves ID automatically). +Outputs JSON array of {name, value} objects. +Requires config at: + /home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json +EOF + exit 2 +} + +[[ $# -eq 1 ]] || usage +STACK_NAME="$1" + +require_cmd curl +require_cmd jq + +[[ -f "$CONFIG_PATH" ]] || err "Missing config file: $CONFIG_PATH" + +BASE_URL="$(jq -r '.base_url // empty' "$CONFIG_PATH")" +API_KEY="$(jq -r '.api_key // empty' "$CONFIG_PATH")" + +[[ -n "$BASE_URL" ]] || err "config.base_url is missing" +[[ -n "$API_KEY" ]] || err "config.api_key is missing" + +BASE_URL="${BASE_URL%/}" + +# Resolve stack ID from name +STACK_ID="$(bash "$SCRIPT_DIR/get-stack-id.sh" "$STACK_NAME")" +[[ -n "$STACK_ID" ]] || err "Failed to resolve stack ID for: $STACK_NAME" + +# Get stack details (includes Env) +response="$(curl -sS -w $'\n%{http_code}' \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/stacks/$STACK_ID")" + +http_code="$(printf '%s' "$response" | tail -n1)" +body="$(printf '%s' "$response" | sed '$d')" + +if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$body" + err "Failed to fetch stack info (HTTP $http_code): $msg" +fi + +# Extract and output env array +printf '%s' "$body" | jq '.Env // []' diff --git a/skills/portainer/scripts/get-stack-id.sh b/skills/portainer/scripts/get-stack-id.sh new file mode 100755 index 0000000..a84e126 --- /dev/null +++ b/skills/portainer/scripts/get-stack-id.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG_PATH="/home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json" + +err() { + echo "Error: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || err "Required command not found: $1" +} + +usage() { + cat >&2 <<'EOF' +Usage: get-stack-id.sh "" + +Looks up a Portainer stack by exact name and prints its ID. +Requires config at: + /home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json +EOF + exit 2 +} + +[[ $# -eq 1 ]] || usage +STACK_NAME="$1" + +require_cmd curl +require_cmd jq + +[[ -f "$CONFIG_PATH" ]] || err "Missing config file: $CONFIG_PATH" + +BASE_URL="$(jq -r '.base_url // empty' "$CONFIG_PATH")" +API_KEY="$(jq -r '.api_key // empty' "$CONFIG_PATH")" + +[[ -n "$BASE_URL" ]] || err "config.base_url is missing" +[[ -n "$API_KEY" ]] || err "config.api_key is missing" + +BASE_URL="${BASE_URL%/}" + +response="$(curl -sS -w $'\n%{http_code}' \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/stacks")" + +http_code="$(printf '%s' "$response" | tail -n1)" +body="$(printf '%s' "$response" | sed '$d')" + +if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$body" + err "Portainer API request failed (HTTP $http_code): $msg" +fi + +stack_id="$(printf '%s' "$body" | jq -r --arg name "$STACK_NAME" '.[] | select(.Name == $name) | .Id' | head -n1)" + +[[ -n "$stack_id" ]] || err "No stack found with name: $STACK_NAME" + +printf '%s\n' "$stack_id" diff --git a/skills/portainer/scripts/get-stack-status.sh b/skills/portainer/scripts/get-stack-status.sh new file mode 100755 index 0000000..424b1a2 --- /dev/null +++ b/skills/portainer/scripts/get-stack-status.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_PATH="/home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json" + +err() { + echo "Error: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || err "Required command not found: $1" +} + +usage() { + cat >&2 <<'EOF' +Usage: get-stack-status.sh "" + +Gets detailed status for a Portainer stack by name (resolves ID automatically). +Requires config at: + /home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json +EOF + exit 2 +} + +[[ $# -eq 1 ]] || usage +STACK_NAME="$1" + +require_cmd curl +require_cmd jq + +[[ -f "$CONFIG_PATH" ]] || err "Missing config file: $CONFIG_PATH" + +BASE_URL="$(jq -r '.base_url // empty' "$CONFIG_PATH")" +API_KEY="$(jq -r '.api_key // empty' "$CONFIG_PATH")" + +[[ -n "$BASE_URL" ]] || err "config.base_url is missing" +[[ -n "$API_KEY" ]] || err "config.api_key is missing" + +BASE_URL="${BASE_URL%/}" + +# Resolve stack ID from name +STACK_ID="$(bash "$SCRIPT_DIR/get-stack-id.sh" "$STACK_NAME")" +[[ -n "$STACK_ID" ]] || err "Failed to resolve stack ID for: $STACK_NAME" + +# Get stack details +response="$(curl -sS -w $'\n%{http_code}' \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/stacks/$STACK_ID")" + +http_code="$(printf '%s' "$response" | tail -n1)" +body="$(printf '%s' "$response" | sed '$d')" + +if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$body" + err "Failed to fetch stack status (HTTP $http_code): $msg" +fi + +# Output key fields +printf '%s' "$body" | jq '{ + Id, + Name, + Status, + Type, + EndpointId, + SwarmId, + CreationDate, + UpdatedDate +}' diff --git a/skills/portainer/scripts/list-stacks.sh b/skills/portainer/scripts/list-stacks.sh new file mode 100755 index 0000000..364b8d8 --- /dev/null +++ b/skills/portainer/scripts/list-stacks.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG_PATH="/home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json" + +err() { + echo "Error: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || err "Required command not found: $1" +} + +require_cmd curl +require_cmd jq + +[[ -f "$CONFIG_PATH" ]] || err "Missing config file: $CONFIG_PATH" + +BASE_URL="$(jq -r '.base_url // empty' "$CONFIG_PATH")" +API_KEY="$(jq -r '.api_key // empty' "$CONFIG_PATH")" + +[[ -n "$BASE_URL" ]] || err "config.base_url is missing" +[[ -n "$API_KEY" ]] || err "config.api_key is missing" + +BASE_URL="${BASE_URL%/}" + +response="$(curl -sS -w $'\n%{http_code}' \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/stacks")" + +http_code="$(printf '%s' "$response" | tail -n1)" +body="$(printf '%s' "$response" | sed '$d')" + +if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$body" + err "Portainer API request failed (HTTP $http_code): $msg" +fi + +printf '%s' "$body" | jq -r '.[] | "\(.Id)\t\(.Name)\t\(.Status)"' diff --git a/skills/portainer/scripts/prune-stack-images.sh b/skills/portainer/scripts/prune-stack-images.sh new file mode 100755 index 0000000..92742ac --- /dev/null +++ b/skills/portainer/scripts/prune-stack-images.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_PATH="/home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json" + +err() { + echo "Error: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || err "Required command not found: $1" +} + +usage() { + cat >&2 <<'EOF' +Usage: prune-stack-images.sh "" + +Removes unused images that were previously associated with a stack. +Run this after an update-stack --pull completes to clean up old image versions. + +Requires config at: + /home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json +EOF + exit 2 +} + +[[ $# -eq 1 ]] || usage +STACK_NAME="$1" + +require_cmd curl +require_cmd jq + +[[ -f "$CONFIG_PATH" ]] || err "Missing config file: $CONFIG_PATH" + +BASE_URL="$(jq -r '.base_url // empty' "$CONFIG_PATH")" +API_KEY="$(jq -r '.api_key // empty' "$CONFIG_PATH")" + +[[ -n "$BASE_URL" ]] || err "config.base_url is missing" +[[ -n "$API_KEY" ]] || err "config.api_key is missing" + +BASE_URL="${BASE_URL%/}" + +# Resolve stack ID from name +STACK_ID="$(bash "$SCRIPT_DIR/get-stack-id.sh" "$STACK_NAME")" +[[ -n "$STACK_ID" ]] || err "Failed to resolve stack ID for: $STACK_NAME" + +# Get stack info for EndpointId +STACK_INFO="$(curl -sS -w $'\n%{http_code}' \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/stacks/$STACK_ID")" + +http_code="$(printf '%s' "$STACK_INFO" | tail -n1)" +body="$(printf '%s' "$STACK_INFO" | sed '$d')" + +if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$body" + err "Failed to fetch stack info (HTTP $http_code): $msg" +fi + +ENDPOINT_ID="$(printf '%s' "$body" | jq -r '.EndpointId')" +[[ -n "$ENDPOINT_ID" && "$ENDPOINT_ID" != "null" ]] || err "Could not determine endpoint ID for stack" + +# Get images currently in use by this stack's containers +FILTERS="$(jq -n --arg project "$STACK_NAME" '{"label": ["com.docker.compose.project=\($project)"]}')" + +CONTAINERS_RESPONSE="$(curl -sS -w $'\n%{http_code}' \ + -H "X-API-Key: $API_KEY" \ + -G \ + --data-urlencode "all=1" \ + --data-urlencode "filters=$FILTERS" \ + "$BASE_URL/api/endpoints/$ENDPOINT_ID/docker/containers/json")" + +containers_http_code="$(printf '%s' "$CONTAINERS_RESPONSE" | tail -n1)" +containers_body="$(printf '%s' "$CONTAINERS_RESPONSE" | sed '$d')" + +if [[ "$containers_http_code" -lt 200 || "$containers_http_code" -ge 300 ]]; then + err "Failed to fetch containers (HTTP $containers_http_code)" +fi + +# Extract image names/repoTags used by current containers +CURRENT_IMAGES="$(printf '%s' "$containers_body" | jq -r '.[].Image' | sort -u)" + +if [[ -z "$CURRENT_IMAGES" ]]; then + echo "No containers found for stack '$STACK_NAME'" + exit 0 +fi + +echo "Current images in use by stack '$STACK_NAME':" +echo "$CURRENT_IMAGES" | while read -r img; do echo " - $img"; done + +# Get all images on the endpoint +IMAGES_RESPONSE="$(curl -sS -w $'\n%{http_code}' \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/endpoints/$ENDPOINT_ID/docker/images/json?all=false")" + +images_http_code="$(printf '%s' "$IMAGES_RESPONSE" | tail -n1)" +images_body="$(printf '%s' "$IMAGES_RESPONSE" | sed '$d')" + +if [[ "$images_http_code" -lt 200 || "$images_http_code" -ge 300 ]]; then + err "Failed to fetch images (HTTP $images_http_code)" +fi + +# Find dangling images (no RepoTags or only ) +DANGLING="$(printf '%s' "$images_body" | jq -r '.[] | select(.RepoTags == null or (.RepoTags | length == 0) or (.RepoTags[0] | startswith(""))) | .Id')" + +if [[ -z "$DANGLING" ]]; then + echo "No dangling images found" + exit 0 +fi + +echo "" +echo "Dangling images found:" +echo "$DANGLING" | while read -r img; do echo " - $img"; done + +# Remove dangling images +echo "" +echo "Removing dangling images..." + +for IMAGE_ID in $DANGLING; do + DELETE_RESPONSE="$(curl -sS -w $'\n%{http_code}' -X DELETE \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/endpoints/$ENDPOINT_ID/docker/images/$IMAGE_ID?force=false")" + + delete_http_code="$(printf '%s' "$DELETE_RESPONSE" | tail -n1)" + delete_body="$(printf '%s' "$DELETE_RESPONSE" | sed '$d')" + + if [[ "$delete_http_code" -ge 200 && "$delete_http_code" -lt 300 ]]; then + echo "Removed: $IMAGE_ID" + else + msg="$(printf '%s' "$delete_body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$delete_body" + echo "Warning: Could not remove $IMAGE_ID: $msg" + fi +done + +echo "" +echo "Pruning complete" diff --git a/skills/portainer/scripts/restart-stack.sh b/skills/portainer/scripts/restart-stack.sh new file mode 100755 index 0000000..341bbc6 --- /dev/null +++ b/skills/portainer/scripts/restart-stack.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_PATH="/home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json" + +err() { + echo "Error: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || err "Required command not found: $1" +} + +usage() { + cat >&2 <<'EOF' +Usage: restart-stack.sh "" + +Restarts a Portainer stack by name (resolves ID automatically). +Requires config at: + /home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json +EOF + exit 2 +} + +[[ $# -eq 1 ]] || usage +STACK_NAME="$1" + +require_cmd curl +require_cmd jq + +[[ -f "$CONFIG_PATH" ]] || err "Missing config file: $CONFIG_PATH" + +BASE_URL="$(jq -r '.base_url // empty' "$CONFIG_PATH")" +API_KEY="$(jq -r '.api_key // empty' "$CONFIG_PATH")" + +[[ -n "$BASE_URL" ]] || err "config.base_url is missing" +[[ -n "$API_KEY" ]] || err "config.api_key is missing" + +BASE_URL="${BASE_URL%/}" + +# Resolve stack ID from name +STACK_ID="$(bash "$SCRIPT_DIR/get-stack-id.sh" "$STACK_NAME")" +[[ -n "$STACK_ID" ]] || err "Failed to resolve stack ID for: $STACK_NAME" + +# Get endpoint ID for the stack (required for restart API) +STACK_INFO="$(curl -sS -w $'\n%{http_code}' \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/stacks/$STACK_ID")" + +http_code="$(printf '%s' "$STACK_INFO" | tail -n1)" +body="$(printf '%s' "$STACK_INFO" | sed '$d')" + +if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$body" + err "Failed to fetch stack info (HTTP $http_code): $msg" +fi + +ENDPOINT_ID="$(printf '%s' "$body" | jq -r '.EndpointId')" +[[ -n "$ENDPOINT_ID" && "$ENDPOINT_ID" != "null" ]] || err "Could not determine endpoint ID for stack" + +# Restart the stack +response="$(curl -sS -w $'\n%{http_code}' -X POST \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/stacks/$STACK_ID/restart?endpointId=$ENDPOINT_ID")" + +http_code="$(printf '%s' "$response" | tail -n1)" +body="$(printf '%s' "$response" | sed '$d')" + +if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$body" + err "Failed to restart stack (HTTP $http_code): $msg" +fi + +echo "Stack '$STACK_NAME' (ID: $STACK_ID) restarted successfully" diff --git a/skills/portainer/scripts/start-stack.sh b/skills/portainer/scripts/start-stack.sh new file mode 100755 index 0000000..fa99a5a --- /dev/null +++ b/skills/portainer/scripts/start-stack.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_PATH="/home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json" + +err() { + echo "Error: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || err "Required command not found: $1" +} + +usage() { + cat >&2 <<'EOF' +Usage: start-stack.sh "" + +Starts a Portainer stack by name (resolves ID automatically). +Requires config at: + /home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json +EOF + exit 2 +} + +[[ $# -eq 1 ]] || usage +STACK_NAME="$1" + +require_cmd curl +require_cmd jq + +[[ -f "$CONFIG_PATH" ]] || err "Missing config file: $CONFIG_PATH" + +BASE_URL="$(jq -r '.base_url // empty' "$CONFIG_PATH")" +API_KEY="$(jq -r '.api_key // empty' "$CONFIG_PATH")" + +[[ -n "$BASE_URL" ]] || err "config.base_url is missing" +[[ -n "$API_KEY" ]] || err "config.api_key is missing" + +BASE_URL="${BASE_URL%/}" + +# Resolve stack ID from name +STACK_ID="$(bash "$SCRIPT_DIR/get-stack-id.sh" "$STACK_NAME")" +[[ -n "$STACK_ID" ]] || err "Failed to resolve stack ID for: $STACK_NAME" + +# Get endpoint ID for the stack +STACK_INFO="$(curl -sS -w $'\n%{http_code}' \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/stacks/$STACK_ID")" + +http_code="$(printf '%s' "$STACK_INFO" | tail -n1)" +body="$(printf '%s' "$STACK_INFO" | sed '$d')" + +if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$body" + err "Failed to fetch stack info (HTTP $http_code): $msg" +fi + +ENDPOINT_ID="$(printf '%s' "$body" | jq -r '.EndpointId')" +[[ -n "$ENDPOINT_ID" && "$ENDPOINT_ID" != "null" ]] || err "Could not determine endpoint ID for stack" + +# Start the stack +response="$(curl -sS -w $'\n%{http_code}' -X POST \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/stacks/$STACK_ID/start?endpointId=$ENDPOINT_ID")" + +http_code="$(printf '%s' "$response" | tail -n1)" +body="$(printf '%s' "$response" | sed '$d')" + +if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$body" + err "Failed to start stack (HTTP $http_code): $msg" +fi + +echo "Stack '$STACK_NAME' (ID: $STACK_ID) started successfully" diff --git a/skills/portainer/scripts/stop-stack.sh b/skills/portainer/scripts/stop-stack.sh new file mode 100755 index 0000000..21f5903 --- /dev/null +++ b/skills/portainer/scripts/stop-stack.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_PATH="/home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json" + +err() { + echo "Error: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || err "Required command not found: $1" +} + +usage() { + cat >&2 <<'EOF' +Usage: stop-stack.sh "" + +Stops a Portainer stack by name (resolves ID automatically). +Requires config at: + /home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json +EOF + exit 2 +} + +[[ $# -eq 1 ]] || usage +STACK_NAME="$1" + +require_cmd curl +require_cmd jq + +[[ -f "$CONFIG_PATH" ]] || err "Missing config file: $CONFIG_PATH" + +BASE_URL="$(jq -r '.base_url // empty' "$CONFIG_PATH")" +API_KEY="$(jq -r '.api_key // empty' "$CONFIG_PATH")" + +[[ -n "$BASE_URL" ]] || err "config.base_url is missing" +[[ -n "$API_KEY" ]] || err "config.api_key is missing" + +BASE_URL="${BASE_URL%/}" + +# Resolve stack ID from name +STACK_ID="$(bash "$SCRIPT_DIR/get-stack-id.sh" "$STACK_NAME")" +[[ -n "$STACK_ID" ]] || err "Failed to resolve stack ID for: $STACK_NAME" + +# Get endpoint ID for the stack +STACK_INFO="$(curl -sS -w $'\n%{http_code}' \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/stacks/$STACK_ID")" + +http_code="$(printf '%s' "$STACK_INFO" | tail -n1)" +body="$(printf '%s' "$STACK_INFO" | sed '$d')" + +if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$body" + err "Failed to fetch stack info (HTTP $http_code): $msg" +fi + +ENDPOINT_ID="$(printf '%s' "$body" | jq -r '.EndpointId')" +[[ -n "$ENDPOINT_ID" && "$ENDPOINT_ID" != "null" ]] || err "Could not determine endpoint ID for stack" + +# Stop the stack +response="$(curl -sS -w $'\n%{http_code}' -X POST \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/stacks/$STACK_ID/stop?endpointId=$ENDPOINT_ID")" + +http_code="$(printf '%s' "$response" | tail -n1)" +body="$(printf '%s' "$response" | sed '$d')" + +if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$body" + err "Failed to stop stack (HTTP $http_code): $msg" +fi + +echo "Stack '$STACK_NAME' (ID: $STACK_ID) stopped successfully" diff --git a/skills/portainer/scripts/update-stack.sh b/skills/portainer/scripts/update-stack.sh new file mode 100755 index 0000000..2f92c9b --- /dev/null +++ b/skills/portainer/scripts/update-stack.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_PATH="/home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json" + +err() { + echo "Error: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || err "Required command not found: $1" +} + +usage() { + cat >&2 <<'EOF' +Usage: update-stack.sh "" "" [options] + +Updates a Portainer stack by name with a new docker-compose file. +Preserves existing env vars unless --env-file is provided. + +Arguments: + stack-name Name of the stack to update + compose-file Path to the new docker-compose.yml file + +Options: + --env-file Path to a file with env vars (format: NAME=value per line) + If not provided, existing env vars are preserved. + --pull Force pull images and redeploy (like docker compose down/pull/up). + --prune-old After update, remove images that were used by this stack + but are no longer in use (old versions left dangling). + +Requires config at: + /home/node/.openclaw/workspace/.clawdbot/credentials/portainer/config.json +EOF + exit 2 +} + +# Parse arguments +[[ $# -ge 2 ]] || usage +STACK_NAME="$1" +COMPOSE_FILE="$2" +shift 2 + +ENV_FILE="" +PRUNE_OLD=false +PULL_IMAGE=false +while [[ $# -gt 0 ]]; do + case "$1" in + --env-file) + [[ $# -ge 2 ]] || err "--env-file requires a value" + ENV_FILE="$2" + shift 2 + ;; + --pull) + PULL_IMAGE=true + shift + ;; + --prune-old) + PRUNE_OLD=true + shift + ;; + *) + err "Unknown option: $1" + ;; + esac +done + +require_cmd curl +require_cmd jq + +[[ -f "$CONFIG_PATH" ]] || err "Missing config file: $CONFIG_PATH" +[[ -f "$COMPOSE_FILE" ]] || err "Compose file not found: $COMPOSE_FILE" + +BASE_URL="$(jq -r '.base_url // empty' "$CONFIG_PATH")" +API_KEY="$(jq -r '.api_key // empty' "$CONFIG_PATH")" + +[[ -n "$BASE_URL" ]] || err "config.base_url is missing" +[[ -n "$API_KEY" ]] || err "config.api_key is missing" + +BASE_URL="${BASE_URL%/}" + +# Resolve stack ID from name +STACK_ID="$(bash "$SCRIPT_DIR/get-stack-id.sh" "$STACK_NAME")" +[[ -n "$STACK_ID" ]] || err "Failed to resolve stack ID for: $STACK_NAME" + +# Get current stack info for EndpointId and existing env +STACK_INFO="$(curl -sS -w $'\n%{http_code}' \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/stacks/$STACK_ID")" + +http_code="$(printf '%s' "$STACK_INFO" | tail -n1)" +body="$(printf '%s' "$STACK_INFO" | sed '$d')" + +if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$body" + err "Failed to fetch stack info (HTTP $http_code): $msg" +fi + +ENDPOINT_ID="$(printf '%s' "$body" | jq -r '.EndpointId')" +[[ -n "$ENDPOINT_ID" && "$ENDPOINT_ID" != "null" ]] || err "Could not determine endpoint ID for stack" + +# Capture old image IDs before update (if --prune-old) +OLD_IMAGE_IDS="" +if [[ "$PRUNE_OLD" == true ]]; then + # Get containers for this stack using the compose project label + # The label format is: com.docker.compose.project= + FILTERS="$(jq -n --arg project "$STACK_NAME" '{"label": ["com.docker.compose.project=\($project)"]}')" + + CONTAINERS_RESPONSE="$(curl -sS -w $'\n%{http_code}' \ + -H "X-API-Key: $API_KEY" \ + -G \ + --data-urlencode "all=1" \ + --data-urlencode "filters=$FILTERS" \ + "$BASE_URL/api/endpoints/$ENDPOINT_ID/docker/containers/json")" + + containers_http_code="$(printf '%s' "$CONTAINERS_RESPONSE" | tail -n1)" + containers_body="$(printf '%s' "$CONTAINERS_RESPONSE" | sed '$d')" + + if [[ "$containers_http_code" -ge 200 && "$containers_http_code" -lt 300 ]]; then + # Extract image IDs from containers + OLD_IMAGE_IDS="$(printf '%s' "$containers_body" | jq -r '.[].ImageID // empty' | sort -u | tr '\n' ' ' | sed 's/ $//')" + if [[ -n "$OLD_IMAGE_IDS" ]]; then + echo "Captured old image IDs for pruning: $OLD_IMAGE_IDS" + else + echo "No existing container images found to track for pruning" + PRUNE_OLD=false + fi + else + echo "Warning: Could not fetch containers for prune tracking, skipping prune" + PRUNE_OLD=false + fi +fi + +# Determine env vars to use +if [[ -n "$ENV_FILE" ]]; then + # Read env file and convert to Portainer format + [[ -f "$ENV_FILE" ]] || err "Env file not found: $ENV_FILE" + ENV_JSON="$(grep -v '^#' "$ENV_FILE" | grep -v '^$' | while IFS='=' read -r name value; do + [[ -n "$name" ]] && printf '{"name":"%s","value":"%s"},' "$name" "$value" + done | sed 's/,$//')" + ENV_PAYLOAD="[$ENV_JSON]" +else + # Preserve existing env vars + ENV_PAYLOAD="$(printf '%s' "$body" | jq '.Env // []')" +fi + +# Read new compose file content +COMPOSE_CONTENT="$(cat "$COMPOSE_FILE")" + +# Build JSON payload (include pullImage if --pull flag is set) +if [[ "$PULL_IMAGE" == true ]]; then + PAYLOAD="$(jq -n \ + --argjson env "$ENV_PAYLOAD" \ + --arg compose "$COMPOSE_CONTENT" \ + '{Env: $env, StackFileContent: $compose, pullImage: true}')" +else + PAYLOAD="$(jq -n \ + --argjson env "$ENV_PAYLOAD" \ + --arg compose "$COMPOSE_CONTENT" \ + '{Env: $env, StackFileContent: $compose}')" +fi + +# Build URL +UPDATE_URL="$BASE_URL/api/stacks/$STACK_ID?endpointId=$ENDPOINT_ID" + +# Update the stack +response="$(curl -sS -w $'\n%{http_code}' -X PUT \ + -H "X-API-Key: $API_KEY" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "$UPDATE_URL")" + +http_code="$(printf '%s' "$response" | tail -n1)" +body="$(printf '%s' "$response" | sed '$d')" + +if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + msg="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$body" + err "Failed to update stack (HTTP $http_code): $msg" +fi + +echo "Stack '$STACK_NAME' (ID: $STACK_ID) updated successfully" + +# Prune old images if requested and we captured image IDs +if [[ "$PRUNE_OLD" == true && -n "$OLD_IMAGE_IDS" ]]; then + echo "Pruning old images no longer in use..." + + for IMAGE_ID in $OLD_IMAGE_IDS; do + # Check if image is still in use by any container + IMAGE_FILTERS="$(jq -n --arg imageId "$IMAGE_ID" '{"ancestor": [$imageId]}')" + + USAGE_RESPONSE="$(curl -sS -w $'\n%{http_code}' \ + -H "X-API-Key: $API_KEY" \ + -G \ + --data-urlencode "all=1" \ + --data-urlencode "filters=$IMAGE_FILTERS" \ + "$BASE_URL/api/endpoints/$ENDPOINT_ID/docker/containers/json")" + + usage_http_code="$(printf '%s' "$USAGE_RESPONSE" | tail -n1)" + usage_body="$(printf '%s' "$USAGE_RESPONSE" | sed '$d')" + + if [[ "$usage_http_code" -ge 200 && "$usage_http_code" -lt 300 ]]; then + container_count="$(printf '%s' "$usage_body" | jq 'length')" + + if [[ "$container_count" -eq 0 ]]; then + # Image not in use, safe to remove + DELETE_RESPONSE="$(curl -sS -w $'\n%{http_code}' -X DELETE \ + -H "X-API-Key: $API_KEY" \ + "$BASE_URL/api/endpoints/$ENDPOINT_ID/docker/images/$IMAGE_ID?force=false")" + + delete_http_code="$(printf '%s' "$DELETE_RESPONSE" | tail -n1)" + delete_body="$(printf '%s' "$DELETE_RESPONSE" | sed '$d')" + + if [[ "$delete_http_code" -ge 200 && "$delete_http_code" -lt 300 ]]; then + echo "Removed unused image: $IMAGE_ID" + else + msg="$(printf '%s' "$delete_body" | jq -r '.message // empty' 2>/dev/null || true)" + [[ -n "$msg" ]] || msg="$delete_body" + echo "Warning: Could not remove image $IMAGE_ID: $msg" + fi + else + echo "Image $IMAGE_ID still in use by $container_count container(s), skipping" + fi + fi + done + + echo "Pruning complete" +fi