Add portainer skill for stack management via API

- 11 scripts for stack lifecycle (start/stop/restart, update, prune)
- Detailed documentation with usage examples and workflow
- Updated README.md files with portainer skill info
This commit is contained in:
Luke
2026-02-12 05:25:44 +00:00
parent 47314f50c9
commit f435487eb0
15 changed files with 1257 additions and 0 deletions

View File

@@ -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 "<endpoint-name>"
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"

View File

@@ -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 "<stack-name>"
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'

View File

@@ -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 "<stack-name>"
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 // []'

View File

@@ -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 "<stack-name>"
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"

View File

@@ -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 "<stack-name>"
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
}'

View File

@@ -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)"'

View File

@@ -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 "<stack-name>"
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 <none>)
DANGLING="$(printf '%s' "$images_body" | jq -r '.[] | select(.RepoTags == null or (.RepoTags | length == 0) or (.RepoTags[0] | startswith("<none>"))) | .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"

View File

@@ -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 "<stack-name>"
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"

View File

@@ -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 "<stack-name>"
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"

View File

@@ -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 "<stack-name>"
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"

View File

@@ -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 "<stack-name>" "<compose-file>" [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 <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=<stack-name>
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