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
+2
View File
@@ -0,0 +1,2 @@
.bundle/
vendor/
+5
View File
@@ -0,0 +1,5 @@
# frozen_string_literal: true
source 'https://rubygems.org'
gem 'websocket', '~> 1.2'
+14
View File
@@ -0,0 +1,14 @@
GEM
remote: https://rubygems.org/
specs:
websocket (1.2.11)
PLATFORMS
arm64-darwin-25
ruby
DEPENDENCIES
websocket (~> 1.2)
BUNDLED WITH
2.7.2
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', File.dirname(File.realpath(__FILE__))))
require 'portainer_cli'
PortainerCli::CLI.run(ARGV)
+68
View File
@@ -0,0 +1,68 @@
# frozen_string_literal: true
require_relative 'portainer_cli/version'
require_relative 'portainer_cli/config'
require_relative 'portainer_cli/client'
require_relative 'portainer_cli/commands/base'
require_relative 'portainer_cli/commands/configure'
require_relative 'portainer_cli/commands/list'
require_relative 'portainer_cli/commands/create'
require_relative 'portainer_cli/commands/exec'
module PortainerCli
module CLI
def self.require_config!(config)
return if config.valid?
$stderr.puts "Not configured. Run: portainer-cli configure"
exit 1
end
HELP = <<~HELP
portainer-cli #{PortainerCli::VERSION}
Usage:
portainer-cli configure Set Portainer URL and credentials
portainer-cli list endpoints List environments
portainer-cli list stacks List stacks
portainer-cli list containers <endpoint-id> List containers
portainer-cli list volumes <endpoint-id> List volumes
portainer-cli list networks <endpoint-id> List networks
portainer-cli create stack --endpoint ID --name NAME --file FILE
portainer-cli create container --endpoint ID --image IMAGE [--name NAME] [--port h:c] [--env K=V]
portainer-cli exec <endpoint-id> <container> Open interactive shell
--shell SHELL Shell to use (default: /bin/sh)
Config file: ~/.portainer-cli/config.yml
HELP
def self.run(argv)
config = Config.load
cmd = argv.shift&.downcase
case cmd
when 'configure', 'config'
Commands::Configure.new(config).run(argv)
when 'list', 'ls'
require_config!(config)
client = Client.new(config)
Commands::List.new(config, client).run(argv)
when 'create', 'add'
require_config!(config)
client = Client.new(config)
Commands::Create.new(config, client).run(argv)
when 'exec', 'shell', 'console'
require_config!(config)
client = Client.new(config)
Commands::Exec.new(config, client).run(argv)
when 'version', '--version', '-v'
puts "portainer-cli #{PortainerCli::VERSION}"
when 'help', '--help', '-h', nil
puts HELP
else
$stderr.puts "Unknown command: #{cmd}"
$stderr.puts HELP
exit 1
end
end
end
end
+106
View File
@@ -0,0 +1,106 @@
# frozen_string_literal: true
require 'net/http'
require 'json'
require 'uri'
require 'openssl'
module PortainerCli
class Client
class ApiError < StandardError
attr_reader :code
def initialize(msg, code)
super(msg)
@code = code
end
end
def initialize(config)
@config = config
@base = URI.parse(config.url.chomp('/'))
end
def get(path, params = {})
request(:get, path, params: params)
end
def post(path, body = nil)
request(:post, path, body: body)
end
def delete(path)
request(:delete, path)
end
# Returns the raw websocket URL (ws:// or wss://) for exec sessions
def websocket_url(path, params = {})
uri = ws_uri(path)
uri.query = URI.encode_www_form(params) unless params.empty?
uri.to_s
end
def auth_header
@config.auth_header
end
private
def request(method, path, params: {}, body: nil)
uri = http_uri(path)
uri.query = URI.encode_www_form(params) unless params.empty?
http = build_http(uri)
req = build_request(method, uri, body)
resp = http.request(req)
handle_response(resp)
end
def build_http(uri)
http = Net::HTTP.new(uri.host, uri.port)
if uri.scheme == 'https'
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
end
http.open_timeout = 10
http.read_timeout = 30
http
end
def build_request(method, uri, body)
klass = {
get: Net::HTTP::Get,
post: Net::HTTP::Post,
delete: Net::HTTP::Delete
}.fetch(method)
req = klass.new(uri)
req['Content-Type'] = 'application/json'
req['Accept'] = 'application/json'
@config.auth_header.each { |k, v| req[k] = v }
req.body = JSON.generate(body) if body
req
end
def handle_response(resp)
code = resp.code.to_i
raise ApiError.new("HTTP #{code}: #{resp.body}", code) if code >= 400
return nil if resp.body.nil? || resp.body.empty?
JSON.parse(resp.body)
rescue JSON::ParserError
resp.body
end
def http_uri(path)
URI.parse("#{@base}#{path}")
end
def ws_uri(path)
scheme = @base.scheme == 'https' ? 'wss' : 'ws'
URI.parse("#{scheme}://#{@base.host}:#{@base.port}#{path}")
end
end
end
+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
+56
View File
@@ -0,0 +1,56 @@
# frozen_string_literal: true
require 'yaml'
require 'fileutils'
module PortainerCli
class Config
CONFIG_DIR = File.expand_path('~/.portainer-cli').freeze
CONFIG_FILE = File.join(CONFIG_DIR, 'config.yml').freeze
attr_accessor :url, :token, :api_key
def self.load
new.tap(&:read)
end
def read
return unless File.exist?(CONFIG_FILE)
data = YAML.safe_load(File.read(CONFIG_FILE), permitted_classes: [], symbolize_names: false) || {}
@url = data['url']
@token = data['token']
@api_key = data['api_key']
end
def save
FileUtils.mkdir_p(CONFIG_DIR)
data = {}
data['url'] = @url if @url
data['token'] = @token if @token
data['api_key'] = @api_key if @api_key
File.write(CONFIG_FILE, YAML.dump(data))
File.chmod(0o600, CONFIG_FILE)
end
def valid?
!@url.nil? && !@url.empty? && auth_set?
end
def auth_header
if @api_key && !@api_key.empty?
{ 'X-API-Key' => @api_key }
elsif @token && !@token.empty?
{ 'Authorization' => "Bearer #{@token}" }
else
{}
end
end
private
def auth_set?
(@token && !@token.empty?) || (@api_key && !@api_key.empty?)
end
end
end
+5
View File
@@ -0,0 +1,5 @@
# frozen_string_literal: true
module PortainerCli
VERSION = '0.1.0'
end
+112
View File
@@ -0,0 +1,112 @@
# frozen_string_literal: true
require 'socket'
require 'openssl'
require 'base64'
require 'io/console'
require 'websocket'
module PortainerCli
class WebsocketExec
READ_SIZE = 4096
def initialize(url, auth_header)
@url = URI.parse(url)
@auth_header = auth_header
end
def run
socket = open_socket
handshake = perform_handshake(socket)
raise "WebSocket handshake failed: #{handshake.error}" unless handshake.valid?
run_io_loop(socket)
ensure
socket&.close rescue nil
end
private
def open_socket
raw = TCPSocket.new(@url.host, @url.port)
if @url.scheme == 'wss'
ctx = OpenSSL::SSL::SSLContext.new
ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
ssl = OpenSSL::SSL::SSLSocket.new(raw, ctx)
ssl.hostname = @url.host
ssl.sync_close = true
ssl.connect
ssl
else
raw
end
end
def perform_handshake(socket)
handshake = WebSocket::Handshake::Client.new(
url: @url.to_s,
headers: @auth_header
)
socket.write(handshake.to_s)
loop do
byte = socket.read(1)
break if byte.nil?
handshake << byte
break if handshake.finished?
end
handshake
end
def run_io_loop(socket)
incoming = WebSocket::Frame::Incoming::Client.new
# Put terminal in raw mode so all keypresses go directly to the container
$stdin.raw do
loop do
readable, = IO.select([$stdin, socket], nil, nil, nil)
readable.each do |io|
if io == $stdin
data = io.readpartial(READ_SIZE) rescue nil
break if data.nil?
frame = WebSocket::Frame::Outgoing::Client.new(
data: data,
type: :binary,
version: 13
)
socket.write(frame.to_s)
elsif io == socket
data = io.readpartial(READ_SIZE) rescue nil
if data.nil?
# Server closed the connection
return
end
incoming << data
while (frame = incoming.next)
case frame.type
when :binary, :text
$stdout.write(frame.data)
$stdout.flush
when :close
return
when :ping
pong = WebSocket::Frame::Outgoing::Client.new(
data: frame.data,
type: :pong,
version: 13
)
socket.write(pong.to_s)
end
end
end
end
end
end
end
end
end