Initial implementation of portainer-cli
Ruby CLI tool for interacting with the Portainer API. Supports listing endpoints/stacks/containers/volumes/networks, creating stacks and containers, and opening an interactive exec session in a container via WebSocket. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'base'
|
||||
require_relative '../websocket_exec'
|
||||
require 'optparse'
|
||||
|
||||
module PortainerCli
|
||||
module Commands
|
||||
class Exec
|
||||
include Base
|
||||
|
||||
def initialize(config, client)
|
||||
@config = config
|
||||
@client = client
|
||||
end
|
||||
|
||||
# portainer-cli exec <endpoint-id> <container-name-or-id> [-- cmd args...]
|
||||
def run(args)
|
||||
require_config!(@config)
|
||||
|
||||
opts = { shell: '/bin/sh' }
|
||||
OptionParser.new do |o|
|
||||
o.on('--shell SHELL', 'Shell to use (default: /bin/sh)') { |v| opts[:shell] = v }
|
||||
end.parse!(args)
|
||||
|
||||
endpoint_id = args.shift or error("Usage: portainer-cli exec <endpoint-id> <container> [--shell /bin/bash]")
|
||||
container_name = args.shift or error("Usage: portainer-cli exec <endpoint-id> <container> [--shell /bin/bash]")
|
||||
|
||||
container_id = resolve_container(endpoint_id, container_name)
|
||||
|
||||
$stderr.puts dim("Connecting to #{container_name} (#{container_id[0, 12]})...")
|
||||
|
||||
exec_id = create_exec(endpoint_id, container_id, opts[:shell])
|
||||
ws_url = exec_websocket_url(exec_id)
|
||||
|
||||
send_resize(endpoint_id, exec_id) if $stdout.tty?
|
||||
trap('SIGWINCH') { send_resize(endpoint_id, exec_id) } if $stdout.tty?
|
||||
|
||||
WebsocketExec.new(ws_url, @config.auth_header).run
|
||||
|
||||
$stderr.puts dim("\r\nSession ended.")
|
||||
rescue PortainerCli::Client::ApiError => e
|
||||
error(e.message)
|
||||
rescue Interrupt
|
||||
$stderr.puts "\r\nInterrupted."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_container(endpoint_id, name_or_id)
|
||||
containers = @client.get("/api/endpoints/#{endpoint_id}/docker/containers/json", all: 1)
|
||||
|
||||
match = containers.find do |c|
|
||||
c['Id'].start_with?(name_or_id) ||
|
||||
Array(c['Names']).any? { |n| n.delete_prefix('/') == name_or_id }
|
||||
end
|
||||
|
||||
unless match
|
||||
error("Container '#{name_or_id}' not found on endpoint #{endpoint_id}.\n" \
|
||||
"Run 'portainer-cli list containers #{endpoint_id}' to see available containers.")
|
||||
end
|
||||
|
||||
match['Id']
|
||||
end
|
||||
|
||||
def create_exec(endpoint_id, container_id, shell)
|
||||
result = @client.post(
|
||||
"/api/endpoints/#{endpoint_id}/docker/containers/#{container_id}/exec",
|
||||
{
|
||||
'AttachStdin' => true,
|
||||
'AttachStdout' => true,
|
||||
'AttachStderr' => true,
|
||||
'Tty' => true,
|
||||
'Cmd' => [shell]
|
||||
}
|
||||
)
|
||||
result['Id']
|
||||
end
|
||||
|
||||
def exec_websocket_url(exec_id)
|
||||
# Portainer uses JWT for websocket auth (not API key), so we need to include token in query
|
||||
token = @config.token || @config.api_key
|
||||
@client.websocket_url('/api/websocket/exec', token: token, id: exec_id)
|
||||
end
|
||||
|
||||
def send_resize(endpoint_id, exec_id)
|
||||
rows, cols = $stdout.winsize
|
||||
@client.post(
|
||||
"/api/endpoints/#{endpoint_id}/docker/exec/#{exec_id}/resize",
|
||||
{ 'h' => rows, 'w' => cols }
|
||||
)
|
||||
rescue StandardError
|
||||
# Non-fatal — resize failures should not kill the session
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user