commit d3cb4fdebd2dac01a4786cb57a6f165a57d4257d Author: Grzegorz Łuszczek Date: Sun Mar 22 12:58:33 2026 +0100 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4541a7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.bundle/ +vendor/ diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..77daf36 --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gem 'websocket', '~> 1.2' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..3a5ad36 --- /dev/null +++ b/Gemfile.lock @@ -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 diff --git a/bin/portainer-cli b/bin/portainer-cli new file mode 100755 index 0000000..7d04cbd --- /dev/null +++ b/bin/portainer-cli @@ -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) diff --git a/lib/portainer_cli.rb b/lib/portainer_cli.rb new file mode 100644 index 0000000..c332c0e --- /dev/null +++ b/lib/portainer_cli.rb @@ -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 List containers + portainer-cli list volumes List volumes + portainer-cli list networks 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 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 diff --git a/lib/portainer_cli/client.rb b/lib/portainer_cli/client.rb new file mode 100644 index 0000000..18a5817 --- /dev/null +++ b/lib/portainer_cli/client.rb @@ -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 diff --git a/lib/portainer_cli/commands/base.rb b/lib/portainer_cli/commands/base.rb new file mode 100644 index 0000000..dec856b --- /dev/null +++ b/lib/portainer_cli/commands/base.rb @@ -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 diff --git a/lib/portainer_cli/commands/configure.rb b/lib/portainer_cli/commands/configure.rb new file mode 100644 index 0000000..6cc3422 --- /dev/null +++ b/lib/portainer_cli/commands/configure.rb @@ -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 diff --git a/lib/portainer_cli/commands/create.rb b/lib/portainer_cli/commands/create.rb new file mode 100644 index 0000000..e50ea68 --- /dev/null +++ b/lib/portainer_cli/commands/create.rb @@ -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 [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 diff --git a/lib/portainer_cli/commands/exec.rb b/lib/portainer_cli/commands/exec.rb new file mode 100644 index 0000000..a2a0958 --- /dev/null +++ b/lib/portainer_cli/commands/exec.rb @@ -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 [-- 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 [--shell /bin/bash]") + container_name = args.shift or error("Usage: portainer-cli exec [--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 diff --git a/lib/portainer_cli/commands/list.rb b/lib/portainer_cli/commands/list.rb new file mode 100644 index 0000000..8c19842 --- /dev/null +++ b/lib/portainer_cli/commands/list.rb @@ -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 diff --git a/lib/portainer_cli/config.rb b/lib/portainer_cli/config.rb new file mode 100644 index 0000000..a1c63ec --- /dev/null +++ b/lib/portainer_cli/config.rb @@ -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 diff --git a/lib/portainer_cli/version.rb b/lib/portainer_cli/version.rb new file mode 100644 index 0000000..a4790e7 --- /dev/null +++ b/lib/portainer_cli/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module PortainerCli + VERSION = '0.1.0' +end diff --git a/lib/portainer_cli/websocket_exec.rb b/lib/portainer_cli/websocket_exec.rb new file mode 100644 index 0000000..7840179 --- /dev/null +++ b/lib/portainer_cli/websocket_exec.rb @@ -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