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
+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