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,2 @@
|
||||
.bundle/
|
||||
vendor/
|
||||
@@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
source 'https://rubygems.org'
|
||||
|
||||
gem 'websocket', '~> 1.2'
|
||||
@@ -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
|
||||
Executable
+7
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PortainerCli
|
||||
VERSION = '0.1.0'
|
||||
end
|
||||
@@ -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