generalize runtime ownership with doctor and local skill migration

This commit is contained in:
Jason Woltje
2026-02-17 12:10:06 -06:00
parent 967c9c462b
commit db2ec9524f
25 changed files with 3974 additions and 6 deletions

210
rails/portainer/README.md Normal file
View File

@@ -0,0 +1,210 @@
# Portainer CLI Scripts
CLI tools for managing Portainer stacks via the API.
## Setup
### Environment Variables
Set these environment variables before using the scripts:
```bash
export PORTAINER_URL="https://portainer.example.com:9443"
export PORTAINER_API_KEY="your-api-key-here"
```
You can add these to your shell profile (`~/.bashrc`, `~/.zshrc`) or use a `.env` file.
### Creating an API Key
1. Log in to Portainer
2. Click your username in the top right corner > "My account"
3. Scroll to "Access tokens" section
4. Click "Add access token"
5. Enter a descriptive name (e.g., "CLI scripts")
6. Copy the token immediately (you cannot view it again)
### Dependencies
- `curl` - HTTP client
- `jq` - JSON processor
Both are typically pre-installed on most Linux distributions.
## Scripts
### stack-list.sh
List all Portainer stacks.
```bash
# List all stacks in table format
stack-list.sh
# List stacks in JSON format
stack-list.sh -f json
# List only stack names
stack-list.sh -f names
stack-list.sh -q
# Filter by endpoint ID
stack-list.sh -e 1
```
### stack-status.sh
Show status and containers for a stack.
```bash
# Show stack status
stack-status.sh -n mystack
# Show status in JSON format
stack-status.sh -n mystack -f json
# Use stack ID instead of name
stack-status.sh -i 5
```
### stack-redeploy.sh
Redeploy a stack. For git-based stacks, this pulls the latest from the repository.
```bash
# Redeploy a stack by name
stack-redeploy.sh -n mystack
# Redeploy and pull latest images
stack-redeploy.sh -n mystack -p
# Redeploy by stack ID
stack-redeploy.sh -i 5 -p
```
### stack-logs.sh
View logs for stack services/containers.
```bash
# List available services in a stack
stack-logs.sh -n mystack
# View logs for a specific service
stack-logs.sh -n mystack -s webapp
# Show last 200 lines
stack-logs.sh -n mystack -s webapp -t 200
# Follow logs (stream)
stack-logs.sh -n mystack -s webapp -f
# Include timestamps
stack-logs.sh -n mystack -s webapp --timestamps
```
### stack-start.sh
Start an inactive stack.
```bash
stack-start.sh -n mystack
stack-start.sh -i 5
```
### stack-stop.sh
Stop a running stack.
```bash
stack-stop.sh -n mystack
stack-stop.sh -i 5
```
### endpoint-list.sh
List all Portainer endpoints/environments.
```bash
# List in table format
endpoint-list.sh
# List in JSON format
endpoint-list.sh -f json
```
## Common Workflows
### CI/CD Redeploy
After pushing changes to a git-based stack's repository:
```bash
# Redeploy with latest images
stack-redeploy.sh -n myapp -p
# Check status
stack-status.sh -n myapp
# View logs to verify startup
stack-logs.sh -n myapp -s api -t 50
```
### Debugging a Failing Stack
```bash
# Check overall status
stack-status.sh -n myapp
# List all services
stack-logs.sh -n myapp
# View logs for failing service
stack-logs.sh -n myapp -s worker -t 200
# Follow logs in real-time
stack-logs.sh -n myapp -s worker -f
```
### Restart a Stack
```bash
# Stop the stack
stack-stop.sh -n myapp
# Start it again
stack-start.sh -n myapp
# Or just redeploy (pulls latest images)
stack-redeploy.sh -n myapp -p
```
## Error Handling
All scripts:
- Exit with code 0 on success
- Exit with code 1 on error
- Print errors to stderr
- Validate required environment variables before making API calls
## API Reference
These scripts use the Portainer CE API. Key endpoints:
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/stacks` | GET | List all stacks |
| `/api/stacks/{id}` | GET | Get stack details |
| `/api/stacks/{id}/file` | GET | Get stack compose file |
| `/api/stacks/{id}` | PUT | Update/redeploy stack |
| `/api/stacks/{id}/git/redeploy` | PUT | Redeploy git-based stack |
| `/api/stacks/{id}/start` | POST | Start inactive stack |
| `/api/stacks/{id}/stop` | POST | Stop running stack |
| `/api/endpoints` | GET | List all environments |
| `/api/endpoints/{id}/docker/containers/json` | GET | List containers |
| `/api/endpoints/{id}/docker/containers/{id}/logs` | GET | Get container logs |
For full API documentation, see:
- [Portainer API Access](https://docs.portainer.io/api/access)
- [Portainer API Examples](https://docs.portainer.io/api/examples)
- [Portainer API Docs](https://docs.portainer.io/api/docs)

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env bash
#
# endpoint-list.sh - List all Portainer endpoints/environments
#
# Usage: endpoint-list.sh [-f format]
#
# Environment variables:
# PORTAINER_URL - Portainer instance URL (e.g., https://portainer.example.com:9443)
# PORTAINER_API_KEY - API access token
#
# Options:
# -f format Output format: table (default), json
# -h Show this help
set -euo pipefail
# Default values
FORMAT="table"
# Parse arguments
while getopts "f:h" opt; do
case $opt in
f) FORMAT="$OPTARG" ;;
h)
head -16 "$0" | grep "^#" | sed 's/^# \?//'
exit 0
;;
*)
echo "Usage: $0 [-f format]" >&2
exit 1
;;
esac
done
# Validate environment
if [[ -z "${PORTAINER_URL:-}" ]]; then
echo "Error: PORTAINER_URL environment variable not set" >&2
exit 1
fi
if [[ -z "${PORTAINER_API_KEY:-}" ]]; then
echo "Error: PORTAINER_API_KEY environment variable not set" >&2
exit 1
fi
# Remove trailing slash from URL
PORTAINER_URL="${PORTAINER_URL%/}"
# Fetch endpoints
response=$(curl -s -w "\n%{http_code}" \
-H "X-API-Key: ${PORTAINER_API_KEY}" \
"${PORTAINER_URL}/api/endpoints")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: API request failed with status $http_code" >&2
echo "$body" >&2
exit 1
fi
# Output based on format
case "$FORMAT" in
json)
echo "$body" | jq '.'
;;
table)
echo "ID NAME TYPE STATUS URL"
echo "---- ---------------------------- ---------- -------- ---"
echo "$body" | jq -r '.[] | [
.Id,
.Name,
(if .Type == 1 then "docker" elif .Type == 2 then "agent" elif .Type == 3 then "azure" elif .Type == 4 then "edge" elif .Type == 5 then "kubernetes" else "unknown" end),
(if .Status == 1 then "up" elif .Status == 2 then "down" else "unknown" end),
.URL
] | @tsv' | while IFS=$'\t' read -r id name type status url; do
printf "%-4s %-28s %-10s %-8s %s\n" "$id" "$name" "$type" "$status" "$url"
done
;;
*)
echo "Error: Unknown format '$FORMAT'. Use: table, json" >&2
exit 1
;;
esac

100
rails/portainer/stack-list.sh Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
#
# stack-list.sh - List all Portainer stacks
#
# Usage: stack-list.sh [-e endpoint_id] [-f format] [-q]
#
# Environment variables:
# PORTAINER_URL - Portainer instance URL (e.g., https://portainer.example.com:9443)
# PORTAINER_API_KEY - API access token
#
# Options:
# -e endpoint_id Filter by endpoint/environment ID
# -f format Output format: table (default), json, names
# -q Quiet mode - only output stack names (shortcut for -f names)
# -h Show this help
set -euo pipefail
# Default values
ENDPOINT_FILTER=""
FORMAT="table"
QUIET=false
# Parse arguments
while getopts "e:f:qh" opt; do
case $opt in
e) ENDPOINT_FILTER="$OPTARG" ;;
f) FORMAT="$OPTARG" ;;
q) QUIET=true; FORMAT="names" ;;
h)
head -20 "$0" | grep "^#" | sed 's/^# \?//'
exit 0
;;
*)
echo "Usage: $0 [-e endpoint_id] [-f format] [-q]" >&2
exit 1
;;
esac
done
# Validate environment
if [[ -z "${PORTAINER_URL:-}" ]]; then
echo "Error: PORTAINER_URL environment variable not set" >&2
exit 1
fi
if [[ -z "${PORTAINER_API_KEY:-}" ]]; then
echo "Error: PORTAINER_API_KEY environment variable not set" >&2
exit 1
fi
# Remove trailing slash from URL
PORTAINER_URL="${PORTAINER_URL%/}"
# Fetch stacks
response=$(curl -s -w "\n%{http_code}" \
-H "X-API-Key: ${PORTAINER_API_KEY}" \
"${PORTAINER_URL}/api/stacks")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: API request failed with status $http_code" >&2
echo "$body" >&2
exit 1
fi
# Filter by endpoint if specified
if [[ -n "$ENDPOINT_FILTER" ]]; then
body=$(echo "$body" | jq --arg eid "$ENDPOINT_FILTER" '[.[] | select(.EndpointId == ($eid | tonumber))]')
fi
# Output based on format
case "$FORMAT" in
json)
echo "$body" | jq '.'
;;
names)
echo "$body" | jq -r '.[].Name'
;;
table)
echo "ID NAME STATUS TYPE ENDPOINT CREATED"
echo "---- ---------------------------- -------- -------- -------- -------"
echo "$body" | jq -r '.[] | [
.Id,
.Name,
(if .Status == 1 then "active" elif .Status == 2 then "inactive" else "unknown" end),
(if .Type == 1 then "swarm" elif .Type == 2 then "compose" elif .Type == 3 then "k8s" else "unknown" end),
.EndpointId,
(.CreationDate | split("T")[0] // "N/A")
] | @tsv' | while IFS=$'\t' read -r id name status type endpoint created; do
printf "%-4s %-28s %-8s %-8s %-8s %s\n" "$id" "$name" "$status" "$type" "$endpoint" "$created"
done
;;
*)
echo "Error: Unknown format '$FORMAT'. Use: table, json, names" >&2
exit 1
;;
esac

183
rails/portainer/stack-logs.sh Executable file
View File

@@ -0,0 +1,183 @@
#!/usr/bin/env bash
#
# stack-logs.sh - Get logs for a stack service/container
#
# Usage: stack-logs.sh -n <stack-name> [-s service-name] [-t tail] [-f]
#
# Environment variables:
# PORTAINER_URL - Portainer instance URL (e.g., https://portainer.example.com:9443)
# PORTAINER_API_KEY - API access token
#
# Options:
# -n name Stack name (required)
# -s service Service/container name (optional - if omitted, lists available services)
# -t tail Number of lines to show from the end (default: 100)
# -f Follow log output (stream logs)
# --timestamps Show timestamps
# -h Show this help
set -euo pipefail
# Default values
STACK_NAME=""
SERVICE_NAME=""
TAIL_LINES="100"
FOLLOW=false
TIMESTAMPS=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-n) STACK_NAME="$2"; shift 2 ;;
-s) SERVICE_NAME="$2"; shift 2 ;;
-t) TAIL_LINES="$2"; shift 2 ;;
-f) FOLLOW=true; shift ;;
--timestamps) TIMESTAMPS=true; shift ;;
-h|--help)
head -20 "$0" | grep "^#" | sed 's/^# \?//'
exit 0
;;
*)
echo "Unknown option: $1" >&2
echo "Usage: $0 -n <stack-name> [-s service-name] [-t tail] [-f]" >&2
exit 1
;;
esac
done
# Validate environment
if [[ -z "${PORTAINER_URL:-}" ]]; then
echo "Error: PORTAINER_URL environment variable not set" >&2
exit 1
fi
if [[ -z "${PORTAINER_API_KEY:-}" ]]; then
echo "Error: PORTAINER_API_KEY environment variable not set" >&2
exit 1
fi
if [[ -z "$STACK_NAME" ]]; then
echo "Error: -n <stack-name> is required" >&2
exit 1
fi
# Remove trailing slash from URL
PORTAINER_URL="${PORTAINER_URL%/}"
# Function to make API requests
api_request() {
local method="$1"
local endpoint="$2"
curl -s -w "\n%{http_code}" -X "$method" \
-H "X-API-Key: ${PORTAINER_API_KEY}" \
"${PORTAINER_URL}${endpoint}"
}
# Get stack info by name
response=$(api_request GET "/api/stacks")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to list stacks (HTTP $http_code)" >&2
exit 1
fi
stack_info=$(echo "$body" | jq --arg name "$STACK_NAME" '.[] | select(.Name == $name)')
if [[ -z "$stack_info" || "$stack_info" == "null" ]]; then
echo "Error: Stack '$STACK_NAME' not found" >&2
exit 1
fi
ENDPOINT_ID=$(echo "$stack_info" | jq -r '.EndpointId')
# Get containers for this stack
response=$(api_request GET "/api/endpoints/${ENDPOINT_ID}/docker/containers/json?all=true")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to get containers (HTTP $http_code)" >&2
exit 1
fi
# Filter containers belonging to this stack
containers=$(echo "$body" | jq --arg name "$STACK_NAME" '[.[] | select(
(.Labels["com.docker.compose.project"] == $name) or
(.Labels["com.docker.stack.namespace"] == $name)
)]')
container_count=$(echo "$containers" | jq 'length')
if [[ "$container_count" -eq 0 ]]; then
echo "Error: No containers found for stack '$STACK_NAME'" >&2
exit 1
fi
# If no service specified, list available services
if [[ -z "$SERVICE_NAME" ]]; then
echo "Available services in stack '$STACK_NAME':"
echo ""
echo "$containers" | jq -r '.[] |
(.Labels["com.docker.compose.service"] // .Labels["com.docker.swarm.service.name"] // .Names[0]) as $svc |
"\(.Names[0] | ltrimstr("/")) (\($svc // "unknown"))"'
echo ""
echo "Use -s <service-name> to view logs for a specific service."
exit 0
fi
# Find container matching service name
# Match against service label or container name
container=$(echo "$containers" | jq --arg svc "$SERVICE_NAME" 'first(.[] | select(
(.Labels["com.docker.compose.service"] == $svc) or
(.Labels["com.docker.swarm.service.name"] == $svc) or
(.Names[] | contains($svc))
))')
if [[ -z "$container" || "$container" == "null" ]]; then
echo "Error: Service '$SERVICE_NAME' not found in stack '$STACK_NAME'" >&2
echo ""
echo "Available services:"
echo "$containers" | jq -r '.[] |
.Labels["com.docker.compose.service"] // .Labels["com.docker.swarm.service.name"] // .Names[0]'
exit 1
fi
CONTAINER_ID=$(echo "$container" | jq -r '.Id')
CONTAINER_NAME=$(echo "$container" | jq -r '.Names[0]' | sed 's/^\///')
echo "Fetching logs for: $CONTAINER_NAME"
echo "Container ID: ${CONTAINER_ID:0:12}"
echo "---"
# Build query parameters
params="stdout=true&stderr=true&tail=${TAIL_LINES}"
if [[ "$TIMESTAMPS" == "true" ]]; then
params="${params}&timestamps=true"
fi
if [[ "$FOLLOW" == "true" ]]; then
params="${params}&follow=true"
fi
# Get logs
# Note: Docker API returns raw log stream, not JSON
if [[ "$FOLLOW" == "true" ]]; then
# Stream logs
curl -s -N \
-H "X-API-Key: ${PORTAINER_API_KEY}" \
"${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/${CONTAINER_ID}/logs?${params}" | \
# Docker log format has 8-byte header per line, strip it
while IFS= read -r line; do
# Remove docker stream header (first 8 bytes per chunk)
echo "$line" | cut -c9-
done
else
# Get logs (non-streaming)
curl -s \
-H "X-API-Key: ${PORTAINER_API_KEY}" \
"${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/${CONTAINER_ID}/logs?${params}" | \
# Docker log format has 8-byte header per line, attempt to strip it
sed 's/^.\{8\}//' 2>/dev/null || cat
fi

183
rails/portainer/stack-redeploy.sh Executable file
View File

@@ -0,0 +1,183 @@
#!/usr/bin/env bash
#
# stack-redeploy.sh - Redeploy a Portainer stack
#
# For git-based stacks, this pulls the latest from the repository and redeploys.
# For file-based stacks, this redeploys with the current stack file.
#
# Usage: stack-redeploy.sh -n <stack-name> [-p] [-e endpoint_id]
#
# Environment variables:
# PORTAINER_URL - Portainer instance URL (e.g., https://portainer.example.com:9443)
# PORTAINER_API_KEY - API access token
#
# Options:
# -n name Stack name (required)
# -i id Stack ID (alternative to -n)
# -p Pull latest images before redeploying
# -e endpoint_id Endpoint/environment ID (auto-detected from stack if not provided)
# -h Show this help
set -euo pipefail
# Default values
STACK_NAME=""
STACK_ID=""
PULL_IMAGE=false
ENDPOINT_ID=""
# Parse arguments
while getopts "n:i:pe:h" opt; do
case $opt in
n) STACK_NAME="$OPTARG" ;;
i) STACK_ID="$OPTARG" ;;
p) PULL_IMAGE=true ;;
e) ENDPOINT_ID="$OPTARG" ;;
h)
head -22 "$0" | grep "^#" | sed 's/^# \?//'
exit 0
;;
*)
echo "Usage: $0 -n <stack-name> [-p] [-e endpoint_id]" >&2
exit 1
;;
esac
done
# Validate environment
if [[ -z "${PORTAINER_URL:-}" ]]; then
echo "Error: PORTAINER_URL environment variable not set" >&2
exit 1
fi
if [[ -z "${PORTAINER_API_KEY:-}" ]]; then
echo "Error: PORTAINER_API_KEY environment variable not set" >&2
exit 1
fi
if [[ -z "$STACK_NAME" && -z "$STACK_ID" ]]; then
echo "Error: Either -n <stack-name> or -i <stack-id> is required" >&2
exit 1
fi
# Remove trailing slash from URL
PORTAINER_URL="${PORTAINER_URL%/}"
# Function to make API requests
api_request() {
local method="$1"
local endpoint="$2"
local data="${3:-}"
local args=(-s -w "\n%{http_code}" -X "$method" -H "X-API-Key: ${PORTAINER_API_KEY}")
if [[ -n "$data" ]]; then
args+=(-H "Content-Type: application/json" -d "$data")
fi
curl "${args[@]}" "${PORTAINER_URL}${endpoint}"
}
# Get stack info by name or ID
if [[ -n "$STACK_NAME" ]]; then
echo "Looking up stack '$STACK_NAME'..."
response=$(api_request GET "/api/stacks")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to list stacks (HTTP $http_code)" >&2
exit 1
fi
stack_info=$(echo "$body" | jq --arg name "$STACK_NAME" '.[] | select(.Name == $name)')
if [[ -z "$stack_info" || "$stack_info" == "null" ]]; then
echo "Error: Stack '$STACK_NAME' not found" >&2
exit 1
fi
STACK_ID=$(echo "$stack_info" | jq -r '.Id')
ENDPOINT_ID_FROM_STACK=$(echo "$stack_info" | jq -r '.EndpointId')
else
# Get stack info by ID
response=$(api_request GET "/api/stacks/${STACK_ID}")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to get stack (HTTP $http_code)" >&2
exit 1
fi
stack_info="$body"
STACK_NAME=$(echo "$stack_info" | jq -r '.Name')
ENDPOINT_ID_FROM_STACK=$(echo "$stack_info" | jq -r '.EndpointId')
fi
# Use endpoint ID from stack if not provided
if [[ -z "$ENDPOINT_ID" ]]; then
ENDPOINT_ID="$ENDPOINT_ID_FROM_STACK"
fi
# Check if this is a git-based stack
git_config=$(echo "$stack_info" | jq -r '.GitConfig // empty')
if [[ -n "$git_config" && "$git_config" != "null" ]]; then
echo "Stack '$STACK_NAME' (ID: $STACK_ID) is git-based"
echo "Triggering git pull and redeploy..."
# Git-based stack redeploy
# The git redeploy endpoint pulls from the repository and redeploys
request_body=$(jq -n \
--argjson pullImage "$PULL_IMAGE" \
'{
"pullImage": $pullImage,
"prune": false,
"repositoryReferenceName": "",
"repositoryAuthentication": false
}')
response=$(api_request PUT "/api/stacks/${STACK_ID}/git/redeploy?endpointId=${ENDPOINT_ID}" "$request_body")
else
echo "Stack '$STACK_NAME' (ID: $STACK_ID) is file-based"
# Get the current stack file content
echo "Fetching current stack file..."
response=$(api_request GET "/api/stacks/${STACK_ID}/file")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to get stack file (HTTP $http_code)" >&2
exit 1
fi
stack_file_content=$(echo "$body" | jq -r '.StackFileContent')
echo "Redeploying..."
request_body=$(jq -n \
--argjson pullImage "$PULL_IMAGE" \
--arg stackFile "$stack_file_content" \
'{
"pullImage": $pullImage,
"prune": false,
"stackFileContent": $stackFile
}')
response=$(api_request PUT "/api/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}" "$request_body")
fi
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" == "200" ]]; then
echo "Successfully redeployed stack '$STACK_NAME'"
if [[ "$PULL_IMAGE" == "true" ]]; then
echo " - Pulled latest images"
fi
else
echo "Error: Redeploy failed (HTTP $http_code)" >&2
echo "$body" | jq '.' 2>/dev/null || echo "$body" >&2
exit 1
fi

114
rails/portainer/stack-start.sh Executable file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env bash
#
# stack-start.sh - Start an inactive Portainer stack
#
# Usage: stack-start.sh -n <stack-name>
#
# Environment variables:
# PORTAINER_URL - Portainer instance URL (e.g., https://portainer.example.com:9443)
# PORTAINER_API_KEY - API access token
#
# Options:
# -n name Stack name (required)
# -i id Stack ID (alternative to -n)
# -h Show this help
set -euo pipefail
# Default values
STACK_NAME=""
STACK_ID=""
# Parse arguments
while getopts "n:i:h" opt; do
case $opt in
n) STACK_NAME="$OPTARG" ;;
i) STACK_ID="$OPTARG" ;;
h)
head -16 "$0" | grep "^#" | sed 's/^# \?//'
exit 0
;;
*)
echo "Usage: $0 -n <stack-name>" >&2
exit 1
;;
esac
done
# Validate environment
if [[ -z "${PORTAINER_URL:-}" ]]; then
echo "Error: PORTAINER_URL environment variable not set" >&2
exit 1
fi
if [[ -z "${PORTAINER_API_KEY:-}" ]]; then
echo "Error: PORTAINER_API_KEY environment variable not set" >&2
exit 1
fi
if [[ -z "$STACK_NAME" && -z "$STACK_ID" ]]; then
echo "Error: Either -n <stack-name> or -i <stack-id> is required" >&2
exit 1
fi
# Remove trailing slash from URL
PORTAINER_URL="${PORTAINER_URL%/}"
# Function to make API requests
api_request() {
local method="$1"
local endpoint="$2"
curl -s -w "\n%{http_code}" -X "$method" \
-H "X-API-Key: ${PORTAINER_API_KEY}" \
"${PORTAINER_URL}${endpoint}"
}
# Get stack info by name
if [[ -n "$STACK_NAME" ]]; then
response=$(api_request GET "/api/stacks")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to list stacks (HTTP $http_code)" >&2
exit 1
fi
stack_info=$(echo "$body" | jq --arg name "$STACK_NAME" '.[] | select(.Name == $name)')
if [[ -z "$stack_info" || "$stack_info" == "null" ]]; then
echo "Error: Stack '$STACK_NAME' not found" >&2
exit 1
fi
STACK_ID=$(echo "$stack_info" | jq -r '.Id')
ENDPOINT_ID=$(echo "$stack_info" | jq -r '.EndpointId')
else
response=$(api_request GET "/api/stacks/${STACK_ID}")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to get stack (HTTP $http_code)" >&2
exit 1
fi
stack_info="$body"
STACK_NAME=$(echo "$stack_info" | jq -r '.Name')
ENDPOINT_ID=$(echo "$stack_info" | jq -r '.EndpointId')
fi
echo "Starting stack '$STACK_NAME' (ID: $STACK_ID)..."
response=$(api_request POST "/api/stacks/${STACK_ID}/start?endpointId=${ENDPOINT_ID}")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" == "200" ]]; then
echo "Successfully started stack '$STACK_NAME'"
else
echo "Error: Failed to start stack (HTTP $http_code)" >&2
echo "$body" | jq '.' 2>/dev/null || echo "$body" >&2
exit 1
fi

185
rails/portainer/stack-status.sh Executable file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env bash
#
# stack-status.sh - Show stack service status
#
# Usage: stack-status.sh -n <stack-name> [-f format]
#
# Environment variables:
# PORTAINER_URL - Portainer instance URL (e.g., https://portainer.example.com:9443)
# PORTAINER_API_KEY - API access token
#
# Options:
# -n name Stack name (required)
# -i id Stack ID (alternative to -n)
# -f format Output format: table (default), json
# -h Show this help
set -euo pipefail
# Default values
STACK_NAME=""
STACK_ID=""
FORMAT="table"
# Parse arguments
while getopts "n:i:f:h" opt; do
case $opt in
n) STACK_NAME="$OPTARG" ;;
i) STACK_ID="$OPTARG" ;;
f) FORMAT="$OPTARG" ;;
h)
head -18 "$0" | grep "^#" | sed 's/^# \?//'
exit 0
;;
*)
echo "Usage: $0 -n <stack-name> [-f format]" >&2
exit 1
;;
esac
done
# Validate environment
if [[ -z "${PORTAINER_URL:-}" ]]; then
echo "Error: PORTAINER_URL environment variable not set" >&2
exit 1
fi
if [[ -z "${PORTAINER_API_KEY:-}" ]]; then
echo "Error: PORTAINER_API_KEY environment variable not set" >&2
exit 1
fi
if [[ -z "$STACK_NAME" && -z "$STACK_ID" ]]; then
echo "Error: Either -n <stack-name> or -i <stack-id> is required" >&2
exit 1
fi
# Remove trailing slash from URL
PORTAINER_URL="${PORTAINER_URL%/}"
# Function to make API requests
api_request() {
local method="$1"
local endpoint="$2"
curl -s -w "\n%{http_code}" -X "$method" \
-H "X-API-Key: ${PORTAINER_API_KEY}" \
"${PORTAINER_URL}${endpoint}"
}
# Get stack info by name
if [[ -n "$STACK_NAME" ]]; then
response=$(api_request GET "/api/stacks")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to list stacks (HTTP $http_code)" >&2
exit 1
fi
stack_info=$(echo "$body" | jq --arg name "$STACK_NAME" '.[] | select(.Name == $name)')
if [[ -z "$stack_info" || "$stack_info" == "null" ]]; then
echo "Error: Stack '$STACK_NAME' not found" >&2
exit 1
fi
STACK_ID=$(echo "$stack_info" | jq -r '.Id')
ENDPOINT_ID=$(echo "$stack_info" | jq -r '.EndpointId')
STACK_NAME=$(echo "$stack_info" | jq -r '.Name')
else
response=$(api_request GET "/api/stacks/${STACK_ID}")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to get stack (HTTP $http_code)" >&2
exit 1
fi
stack_info="$body"
ENDPOINT_ID=$(echo "$stack_info" | jq -r '.EndpointId')
STACK_NAME=$(echo "$stack_info" | jq -r '.Name')
fi
# Get stack type
STACK_TYPE=$(echo "$stack_info" | jq -r '.Type')
STACK_STATUS=$(echo "$stack_info" | jq -r 'if .Status == 1 then "active" elif .Status == 2 then "inactive" else "unknown" end')
# Get containers for this stack
# Containers are labeled with com.docker.compose.project or com.docker.stack.namespace
response=$(api_request GET "/api/endpoints/${ENDPOINT_ID}/docker/containers/json?all=true")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to get containers (HTTP $http_code)" >&2
exit 1
fi
# Filter containers belonging to this stack
# Check both compose project label and stack namespace label
containers=$(echo "$body" | jq --arg name "$STACK_NAME" '[.[] | select(
(.Labels["com.docker.compose.project"] == $name) or
(.Labels["com.docker.stack.namespace"] == $name)
)]')
container_count=$(echo "$containers" | jq 'length')
# Output based on format
if [[ "$FORMAT" == "json" ]]; then
jq -n \
--arg name "$STACK_NAME" \
--arg id "$STACK_ID" \
--arg status "$STACK_STATUS" \
--arg type "$STACK_TYPE" \
--argjson containers "$containers" \
'{
stack: {
name: $name,
id: ($id | tonumber),
status: $status,
type: (if $type == "1" then "swarm" elif $type == "2" then "compose" else "kubernetes" end)
},
containers: [$containers[] | {
name: .Names[0],
id: .Id[0:12],
image: .Image,
state: .State,
status: .Status,
created: .Created
}]
}'
exit 0
fi
# Table output
echo "Stack: $STACK_NAME (ID: $STACK_ID)"
echo "Status: $STACK_STATUS"
echo "Type: $(if [[ "$STACK_TYPE" == "1" ]]; then echo "swarm"; elif [[ "$STACK_TYPE" == "2" ]]; then echo "compose"; else echo "kubernetes"; fi)"
echo "Containers: $container_count"
echo ""
if [[ "$container_count" -gt 0 ]]; then
echo "CONTAINER ID NAME IMAGE STATE STATUS"
echo "------------ -------------------------------------- ------------------------------ ---------- ------"
echo "$containers" | jq -r '.[] | [
.Id[0:12],
.Names[0],
.Image,
.State,
.Status
] | @tsv' | while IFS=$'\t' read -r id name image state status; do
# Clean up container name (remove leading /)
name="${name#/}"
# Truncate long values
name="${name:0:38}"
image="${image:0:30}"
printf "%-12s %-38s %-30s %-10s %s\n" "$id" "$name" "$image" "$state" "$status"
done
else
echo "No containers found for this stack."
echo ""
echo "Note: If the stack was recently created or is inactive, containers may not exist yet."
fi

114
rails/portainer/stack-stop.sh Executable file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env bash
#
# stack-stop.sh - Stop a running Portainer stack
#
# Usage: stack-stop.sh -n <stack-name>
#
# Environment variables:
# PORTAINER_URL - Portainer instance URL (e.g., https://portainer.example.com:9443)
# PORTAINER_API_KEY - API access token
#
# Options:
# -n name Stack name (required)
# -i id Stack ID (alternative to -n)
# -h Show this help
set -euo pipefail
# Default values
STACK_NAME=""
STACK_ID=""
# Parse arguments
while getopts "n:i:h" opt; do
case $opt in
n) STACK_NAME="$OPTARG" ;;
i) STACK_ID="$OPTARG" ;;
h)
head -16 "$0" | grep "^#" | sed 's/^# \?//'
exit 0
;;
*)
echo "Usage: $0 -n <stack-name>" >&2
exit 1
;;
esac
done
# Validate environment
if [[ -z "${PORTAINER_URL:-}" ]]; then
echo "Error: PORTAINER_URL environment variable not set" >&2
exit 1
fi
if [[ -z "${PORTAINER_API_KEY:-}" ]]; then
echo "Error: PORTAINER_API_KEY environment variable not set" >&2
exit 1
fi
if [[ -z "$STACK_NAME" && -z "$STACK_ID" ]]; then
echo "Error: Either -n <stack-name> or -i <stack-id> is required" >&2
exit 1
fi
# Remove trailing slash from URL
PORTAINER_URL="${PORTAINER_URL%/}"
# Function to make API requests
api_request() {
local method="$1"
local endpoint="$2"
curl -s -w "\n%{http_code}" -X "$method" \
-H "X-API-Key: ${PORTAINER_API_KEY}" \
"${PORTAINER_URL}${endpoint}"
}
# Get stack info by name
if [[ -n "$STACK_NAME" ]]; then
response=$(api_request GET "/api/stacks")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to list stacks (HTTP $http_code)" >&2
exit 1
fi
stack_info=$(echo "$body" | jq --arg name "$STACK_NAME" '.[] | select(.Name == $name)')
if [[ -z "$stack_info" || "$stack_info" == "null" ]]; then
echo "Error: Stack '$STACK_NAME' not found" >&2
exit 1
fi
STACK_ID=$(echo "$stack_info" | jq -r '.Id')
ENDPOINT_ID=$(echo "$stack_info" | jq -r '.EndpointId')
else
response=$(api_request GET "/api/stacks/${STACK_ID}")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to get stack (HTTP $http_code)" >&2
exit 1
fi
stack_info="$body"
STACK_NAME=$(echo "$stack_info" | jq -r '.Name')
ENDPOINT_ID=$(echo "$stack_info" | jq -r '.EndpointId')
fi
echo "Stopping stack '$STACK_NAME' (ID: $STACK_ID)..."
response=$(api_request POST "/api/stacks/${STACK_ID}/stop?endpointId=${ENDPOINT_ID}")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" == "200" ]]; then
echo "Successfully stopped stack '$STACK_NAME'"
else
echo "Error: Failed to stop stack (HTTP $http_code)" >&2
echo "$body" | jq '.' 2>/dev/null || echo "$body" >&2
exit 1
fi