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:
2026-03-22 12:58:33 +01:00
commit d3cb4fdebd
14 changed files with 815 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
# frozen_string_literal: true
module PortainerCli
module Commands
module Base
RESET = "\e[0m"
BOLD = "\e[1m"
CYAN = "\e[36m"
GREEN = "\e[32m"
YELLOW = "\e[33m"
RED = "\e[31m"
DIM = "\e[2m"
def colorize?
$stdout.tty?
end
def bold(str) = colorize? ? "#{BOLD}#{str}#{RESET}" : str
def cyan(str) = colorize? ? "#{CYAN}#{str}#{RESET}" : str
def green(str) = colorize? ? "#{GREEN}#{str}#{RESET}" : str
def yellow(str) = colorize? ? "#{YELLOW}#{str}#{RESET}" : str
def red(str) = colorize? ? "#{RED}#{str}#{RESET}" : str
def dim(str) = colorize? ? "#{DIM}#{str}#{RESET}" : str
def print_table(headers, rows)
widths = headers.each_with_index.map do |h, i|
[h.length, rows.map { |r| r[i].to_s.length }.max || 0].max
end
sep = widths.map { |w| '-' * (w + 2) }.join('+')
header_row = headers.each_with_index.map { |h, i| " #{bold(h.ljust(widths[i]))} " }.join('|')
puts sep
puts header_row
puts sep
rows.each do |row|
puts row.each_with_index.map { |cell, i| " #{cell.to_s.ljust(widths[i])} " }.join('|')
end
puts sep
end
def error(msg)
$stderr.puts red("Error: #{msg}")
exit 1
end
def require_config!(config)
error("Not configured. Run: portainer-cli configure") unless config.valid?
end
end
end
end
+65
View File
@@ -0,0 +1,65 @@
# frozen_string_literal: true
require_relative 'base'
module PortainerCli
module Commands
class Configure
include Base
def initialize(config)
@config = config
end
def run(_args)
puts bold("Portainer CLI configuration")
puts "Config will be saved to: #{cyan(Config::CONFIG_FILE)}"
puts
@config.url = prompt("Portainer URL", @config.url || "https://portainer.example.com")
puts
puts "Authentication — choose one:"
puts " #{cyan('1')} API Key (recommended, no expiry)"
puts " #{cyan('2')} JWT token (from login)"
choice = prompt("Choice [1/2]", "1")
if choice == "2"
@config.api_key = nil
@config.token = prompt_secret("JWT token", @config.token)
else
@config.token = nil
@config.api_key = prompt_secret("API key", @config.api_key)
end
@config.save
puts
puts green("Configuration saved to #{Config::CONFIG_FILE}")
end
private
def prompt(label, default = nil)
default_hint = default ? " [#{dim(default)}]" : ""
print "#{label}#{default_hint}: "
input = $stdin.gets&.chomp
(input.nil? || input.empty?) ? default.to_s : input
end
def prompt_secret(label, current = nil)
hint = current ? " [#{dim('keep current')}]" : ""
print "#{label}#{hint}: "
# Hide input if on a TTY
value = if $stdin.tty?
require 'io/console'
v = $stdin.noecho(&:gets)&.chomp
puts
v
else
$stdin.gets&.chomp
end
(value.nil? || value.empty?) ? current.to_s : value
end
end
end
end
+116
View File
@@ -0,0 +1,116 @@
# frozen_string_literal: true
require_relative 'base'
require 'optparse'
module PortainerCli
module Commands
class Create
include Base
def initialize(config, client)
@config = config
@client = client
end
def run(args)
require_config!(@config)
sub = args.shift&.downcase
case sub
when 'stack'
create_stack(args)
when 'container'
create_container(args)
else
puts "Usage: portainer-cli create <stack|container> [options]"
exit 1
end
end
private
# portainer-cli create stack --name mystack --endpoint 1 --file docker-compose.yml
# portainer-cli create stack --name mystack --endpoint 1 --compose "version: '3'..."
def create_stack(args)
opts = { env: [] }
OptionParser.new do |o|
o.on('--name NAME', 'Stack name') { |v| opts[:name] = v }
o.on('--endpoint ID', 'Endpoint ID') { |v| opts[:endpoint] = v }
o.on('--file FILE', 'docker-compose.yml path') { |v| opts[:file] = v }
o.on('--compose COMPOSE', 'Inline compose string') { |v| opts[:compose] = v }
o.on('--env KEY=VAL', 'Environment variable') { |v| opts[:env] << v }
end.parse!(args)
error("--name is required") unless opts[:name]
error("--endpoint is required") unless opts[:endpoint]
compose_content = if opts[:file]
error("File not found: #{opts[:file]}") unless File.exist?(opts[:file])
File.read(opts[:file])
elsif opts[:compose]
opts[:compose]
else
error("Either --file or --compose is required")
end
env_vars = opts[:env].map do |pair|
k, v = pair.split('=', 2)
{ 'name' => k, 'value' => v.to_s }
end
body = {
'name' => opts[:name],
'stackFileContent' => compose_content,
'env' => env_vars
}
result = @client.post(
"/api/stacks/create/standalone/string?endpointId=#{opts[:endpoint]}",
body
)
puts green("Stack created: #{result['Name']} (ID: #{result['Id']})")
end
# portainer-cli create container --endpoint 1 --name web --image nginx:alpine --port 8080:80 --env FOO=bar
def create_container(args)
opts = { ports: [], env: [] }
OptionParser.new do |o|
o.on('--endpoint ID', 'Endpoint ID') { |v| opts[:endpoint] = v }
o.on('--name NAME', 'Container name') { |v| opts[:name] = v }
o.on('--image IMAGE', 'Docker image') { |v| opts[:image] = v }
o.on('--port HOST:CONT', 'Port mapping') { |v| opts[:ports] << v }
o.on('--env KEY=VAL', 'Environment variable') { |v| opts[:env] << v }
o.on('--cmd CMD', 'Command to run') { |v| opts[:cmd] = v }
end.parse!(args)
error("--endpoint is required") unless opts[:endpoint]
error("--image is required") unless opts[:image]
port_bindings = {}
exposed_ports = {}
opts[:ports].each do |mapping|
host_port, cont_port = mapping.split(':', 2)
cont_key = "#{cont_port}/tcp"
exposed_ports[cont_key] = {}
port_bindings[cont_key] = [{ 'HostPort' => host_port }]
end
body = {
'Image' => opts[:image],
'Env' => opts[:env],
'ExposedPorts' => exposed_ports,
'HostConfig' => { 'PortBindings' => port_bindings }
}
body['Cmd'] = opts[:cmd].split(' ') if opts[:cmd]
query = opts[:name] ? "?name=#{URI.encode_www_form_component(opts[:name])}" : ''
result = @client.post("/api/endpoints/#{opts[:endpoint]}/docker/containers/create#{query}", body)
container_id = result['Id']
@client.post("/api/endpoints/#{opts[:endpoint]}/docker/containers/#{container_id}/start")
puts green("Container started: #{opts[:name] || container_id[0, 12]}")
end
end
end
end
+97
View File
@@ -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
+110
View File
@@ -0,0 +1,110 @@
# frozen_string_literal: true
require_relative 'base'
module PortainerCli
module Commands
class List
include Base
SUBCOMMANDS = %w[endpoints stacks containers volumes networks].freeze
def initialize(config, client)
@config = config
@client = client
end
def run(args)
require_config!(@config)
sub = args.shift&.downcase
case sub
when 'endpoints', 'envs', 'env'
list_endpoints
when 'stacks', 'stack'
list_stacks
when 'containers', 'container', 'ps'
endpoint_id = resolve_endpoint_id(args)
list_containers(endpoint_id)
when 'volumes', 'volume'
endpoint_id = resolve_endpoint_id(args)
list_volumes(endpoint_id)
when 'networks', 'network'
endpoint_id = resolve_endpoint_id(args)
list_networks(endpoint_id)
else
puts "Usage: portainer-cli list <#{SUBCOMMANDS.join('|')}> [endpoint-id]"
exit 1
end
end
private
def list_endpoints
data = @client.get('/api/endpoints')
rows = data.map do |e|
[e['Id'], e['Name'], e['URL'] || e['PublicURL'] || '-', endpoint_status(e['Status'])]
end
print_table(%w[ID Name URL Status], rows)
end
def list_stacks
data = @client.get('/api/stacks')
rows = data.map do |s|
[s['Id'], s['Name'], s['EndpointId'], stack_status(s['Status'])]
end
print_table(%w[ID Name Endpoint Status], rows)
end
def list_containers(endpoint_id)
data = @client.get("/api/endpoints/#{endpoint_id}/docker/containers/json", all: 1)
rows = data.map do |c|
name = Array(c['Names']).first&.delete_prefix('/') || '-'
image = c['Image']
status = c['Status']
id = c['Id'][0, 12]
[id, name, image, status]
end
print_table(%w[ID Name Image Status], rows)
end
def list_volumes(endpoint_id)
data = @client.get("/api/endpoints/#{endpoint_id}/docker/volumes")
vols = data.is_a?(Hash) ? (data['Volumes'] || []) : data
rows = vols.map { |v| [v['Name'], v['Driver'], v['Mountpoint']] }
print_table(%w[Name Driver Mountpoint], rows)
end
def list_networks(endpoint_id)
data = @client.get("/api/endpoints/#{endpoint_id}/docker/networks")
rows = data.map { |n| [n['Id'][0, 12], n['Name'], n['Driver'], n['Scope']] }
print_table(%w[ID Name Driver Scope], rows)
end
def resolve_endpoint_id(args)
id = args.shift
unless id
puts "Endpoint ID required. Run 'portainer-cli list endpoints' to see IDs."
exit 1
end
id
end
def endpoint_status(code)
case code
when 1 then green('active')
when 2 then red('inactive')
else dim(code.to_s)
end
end
def stack_status(code)
case code
when 1 then green('active')
when 2 then yellow('inactive')
else dim(code.to_s)
end
end
end
end
end