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:
@@ -15,6 +15,7 @@ This repository is intended to be a simple skill source: install the repo (or a
|
|||||||
| Skill | What it does | Path |
|
| Skill | What it does | Path |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` |
|
| `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` |
|
| `web-automation` | Automate browsing/scraping with Playwright + Camoufox (auth flows, extraction, bot-protected sites). | `skills/web-automation` |
|
||||||
|
|
||||||
## Install ideas
|
## Install ideas
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ This folder contains detailed docs for each skill in this repository.
|
|||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
- [`gitea-api`](gitea-api.md) — REST-based Gitea automation (no `tea` CLI required)
|
- [`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
|
- [`web-automation`](web-automation.md) — Playwright + Camoufox browser automation and scraping
|
||||||
|
|||||||
184
docs/portainer.md
Normal file
184
docs/portainer.md
Normal file
@@ -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 "<stack-name>"
|
||||||
|
```
|
||||||
|
Resolves a stack name to its numeric ID. Prints only the ID on success.
|
||||||
|
|
||||||
|
#### Get Endpoint ID
|
||||||
|
```bash
|
||||||
|
bash scripts/get-endpoint-id.sh "<endpoint-name>"
|
||||||
|
```
|
||||||
|
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 "<stack-name>"
|
||||||
|
```
|
||||||
|
Returns JSON with stack details: Id, Name, Status, Type, EndpointId, CreationDate, UpdatedDate.
|
||||||
|
|
||||||
|
### Stack Lifecycle
|
||||||
|
|
||||||
|
#### Stop Stack
|
||||||
|
```bash
|
||||||
|
bash scripts/stop-stack.sh "<stack-name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Start Stack
|
||||||
|
```bash
|
||||||
|
bash scripts/start-stack.sh "<stack-name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Restart Stack
|
||||||
|
```bash
|
||||||
|
bash scripts/restart-stack.sh "<stack-name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stack Configuration
|
||||||
|
|
||||||
|
#### Get Environment Variables
|
||||||
|
```bash
|
||||||
|
bash scripts/get-stack-env.sh "<stack-name>"
|
||||||
|
```
|
||||||
|
Returns JSON array of `{name, value}` objects.
|
||||||
|
|
||||||
|
#### Get Compose File
|
||||||
|
```bash
|
||||||
|
bash scripts/get-stack-compose.sh "<stack-name>"
|
||||||
|
```
|
||||||
|
Returns the raw docker-compose.yml content.
|
||||||
|
|
||||||
|
### Stack Updates
|
||||||
|
|
||||||
|
#### Update Stack
|
||||||
|
```bash
|
||||||
|
bash scripts/update-stack.sh "<stack-name>" "<compose-file>" [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--pull` — Force pull images and redeploy (like `docker compose down/pull/up`)
|
||||||
|
- `--env-file <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 "<stack-name>"
|
||||||
|
```
|
||||||
|
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
|
||||||
111
skills/portainer/SKILL.md
Normal file
111
skills/portainer/SKILL.md
Normal file
@@ -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 "<stack-name>"
|
||||||
|
```
|
||||||
|
Prints only the stack ID. Exits non-zero if not found.
|
||||||
|
|
||||||
|
### Resolve endpoint ID
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/get-endpoint-id.sh "<endpoint-name>"
|
||||||
|
```
|
||||||
|
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 "<stack-name>"
|
||||||
|
```
|
||||||
|
Returns JSON with: Id, Name, Status, Type, EndpointId, CreationDate, UpdatedDate.
|
||||||
|
|
||||||
|
### Restart stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/restart-stack.sh "<stack-name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/stop-stack.sh "<stack-name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/start-stack.sh "<stack-name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get stack env vars
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/get-stack-env.sh "<stack-name>"
|
||||||
|
```
|
||||||
|
Returns JSON array of `{name, value}` objects.
|
||||||
|
|
||||||
|
### Get stack compose file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/get-stack-compose.sh "<stack-name>"
|
||||||
|
```
|
||||||
|
Returns the raw docker-compose.yml content.
|
||||||
|
|
||||||
|
### Update stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/update-stack.sh "<stack-name>" "<compose-file>" [--env-file "<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 "<stack-name>"
|
||||||
|
```
|
||||||
|
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.
|
||||||
59
skills/portainer/scripts/get-endpoint-id.sh
Executable file
59
skills/portainer/scripts/get-endpoint-id.sh
Executable 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"
|
||||||
62
skills/portainer/scripts/get-stack-compose.sh
Executable file
62
skills/portainer/scripts/get-stack-compose.sh
Executable 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'
|
||||||
63
skills/portainer/scripts/get-stack-env.sh
Executable file
63
skills/portainer/scripts/get-stack-env.sh
Executable 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 // []'
|
||||||
59
skills/portainer/scripts/get-stack-id.sh
Executable file
59
skills/portainer/scripts/get-stack-id.sh
Executable 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"
|
||||||
71
skills/portainer/scripts/get-stack-status.sh
Executable file
71
skills/portainer/scripts/get-stack-status.sh
Executable 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
|
||||||
|
}'
|
||||||
41
skills/portainer/scripts/list-stacks.sh
Executable file
41
skills/portainer/scripts/list-stacks.sh
Executable 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)"'
|
||||||
140
skills/portainer/scripts/prune-stack-images.sh
Executable file
140
skills/portainer/scripts/prune-stack-images.sh
Executable 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"
|
||||||
78
skills/portainer/scripts/restart-stack.sh
Executable file
78
skills/portainer/scripts/restart-stack.sh
Executable 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"
|
||||||
78
skills/portainer/scripts/start-stack.sh
Executable file
78
skills/portainer/scripts/start-stack.sh
Executable 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"
|
||||||
78
skills/portainer/scripts/stop-stack.sh
Executable file
78
skills/portainer/scripts/stop-stack.sh
Executable 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"
|
||||||
231
skills/portainer/scripts/update-stack.sh
Executable file
231
skills/portainer/scripts/update-stack.sh
Executable 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
|
||||||
Reference in New Issue
Block a user