#!/usr/bin/env bash # # stack-logs.sh - Get logs for a stack service/container # # Usage: stack-logs.sh -n [-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 [-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 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 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}×tamps=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