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,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
|
||||
Reference in New Issue
Block a user