Files
portainer-cli/lib/portainer_cli/commands/console.rb
T
grzlusandClaude Opus 5.5 2d45ce3af8 Add cp command to copy files out of containers, binary-safe
`portainer-cli cp <stack> <container>:<path> <dest>` goes through Docker's
archive API, so unlike `console --run "cat …"` there's no TTY in between and
binary files (database dumps) arrive intact. Works like `docker cp` for files
and directories; a dest of "-" writes a single file to stdout.

Stack and container lookup moves from Console into a shared Resolver module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:57:40 +02:00

94 lines
3.2 KiB
Ruby

# frozen_string_literal: true
require_relative 'base'
require_relative 'resolver'
require_relative '../websocket_exec'
require 'optparse'
module PortainerCli
module Commands
# portainer-cli console <stack-name> <container-name> [--shell /bin/bash]
#
# Resolves the endpoint automatically from the stack, so the user never
# needs to know or specify an endpoint ID.
class Console
include Base
include Resolver
def initialize(config, client, debug: false)
@config = config
@client = client
@debug = debug
end
def run(args)
opts = { shell: '/bin/sh' }
OptionParser.new do |o|
o.on('--shell SHELL', 'Shell to use (default: /bin/sh)') { |v| opts[:shell] = v }
o.on('--run CMD', 'Run command instead of a bare shell') { |v| opts[:run] = v }
o.on('--quiet', 'Suppress status output') { opts[:quiet] = true }
o.on('--endpoint ID', 'Endpoint ID to disambiguate stacks') { |v| opts[:endpoint] = v }
end.parse!(args)
stack_name = args.shift or error("Usage: portainer-cli console <stack> [container] [--endpoint ID] [--run CMD] [--shell /bin/bash]")
container_name = args.shift || stack_name
stack = resolve_stack(stack_name, opts[:endpoint])
endpoint_id = stack['EndpointId']
status("Stack: #{stack['Name']} Endpoint: #{endpoint_id}", opts[:quiet])
container_id = resolve_container(endpoint_id, container_name, stack['Name'], opts[:quiet])
status("Connecting to #{container_name} (#{container_id[0, 12]})...", opts[:quiet])
exec_id = create_exec(endpoint_id, container_id, opts[:shell], opts[:run])
ws_url = exec_websocket_url(exec_id, endpoint_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, ssl_verify: @config.ssl_verify, debug: @debug).run
status("\r\nSession ended.", opts[:quiet])
rescue PortainerCli::Client::ApiError => e
error(e.message)
rescue Interrupt
$stderr.puts "\r\nInterrupted."
end
private
def create_exec(endpoint_id, container_id, shell, run_cmd = nil)
cmd = run_cmd ? [shell, '-c', run_cmd] : [shell]
result = @client.post(
"/api/endpoints/#{endpoint_id}/docker/containers/#{container_id}/exec",
{
'AttachStdin' => true,
'AttachStdout' => true,
'AttachStderr' => true,
'Tty' => true,
'Cmd' => cmd
}
)
result['Id']
end
def exec_websocket_url(exec_id, endpoint_id)
token = @config.token || @config.api_key
@client.websocket_url('/api/websocket/exec', token: token, id: exec_id, endpointId: endpoint_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
end
end
end
end