8e45e826ff
--run CMD wraps the command in the shell: /bin/sh -c CMD, keeping TTY mode so interactive commands like 'rails c' or 'bash' work correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
159 lines
5.8 KiB
Ruby
159 lines
5.8 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_relative 'base'
|
|
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
|
|
|
|
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 }
|
|
end.parse!(args)
|
|
|
|
stack_name = args.shift or error("Usage: portainer-cli console <stack> [container] [--run CMD] [--shell /bin/bash]")
|
|
container_name = args.shift || stack_name
|
|
|
|
stack = resolve_stack(stack_name)
|
|
endpoint_id = stack['EndpointId']
|
|
|
|
$stderr.puts dim("Stack: #{stack['Name']} Endpoint: #{endpoint_id}")
|
|
|
|
container_id = resolve_container(endpoint_id, container_name, stack['Name'])
|
|
|
|
$stderr.puts dim("Connecting to #{container_name} (#{container_id[0, 12]})...")
|
|
|
|
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
|
|
|
|
$stderr.puts dim("\r\nSession ended.")
|
|
rescue PortainerCli::Client::ApiError => e
|
|
error(e.message)
|
|
rescue Interrupt
|
|
$stderr.puts "\r\nInterrupted."
|
|
end
|
|
|
|
private
|
|
|
|
def resolve_stack(name_or_id)
|
|
stacks = @client.get('/api/stacks')
|
|
match = stacks.find do |s|
|
|
s['Name'] == name_or_id || s['Id'].to_s == name_or_id
|
|
end
|
|
unless match
|
|
available = stacks.map { |s| " #{s['Id']} #{s['Name']}" }.join("\n")
|
|
error("Stack '#{name_or_id}' not found. Available stacks:\n#{available}")
|
|
end
|
|
match
|
|
end
|
|
|
|
def resolve_container(endpoint_id, name_or_id, stack_name = nil)
|
|
containers = @client.get("/api/endpoints/#{endpoint_id}/docker/containers/json", all: 1)
|
|
names_for = ->(c) { Array(c['Names']).map { |n| n.delete_prefix('/') } }
|
|
|
|
# 1. Exact match: full name or ID prefix
|
|
exact = containers.find do |c|
|
|
c['Id'].start_with?(name_or_id) ||
|
|
names_for.(c).any? { |n| n == name_or_id }
|
|
end
|
|
return exact['Id'] if exact
|
|
|
|
# 2. Compose-style match: "<stack>-<service>-<replica>"
|
|
# This is the most precise fuzzy match when we know the stack name.
|
|
if stack_name
|
|
composed = containers.select do |c|
|
|
names_for.(c).any? { |n| n == "#{stack_name}-#{name_or_id}-1" || n.match?(/\A#{Regexp.escape(stack_name)}-#{Regexp.escape(name_or_id)}-\d+\z/) }
|
|
end
|
|
if composed.size == 1
|
|
matched_name = names_for.(composed.first).first
|
|
$stderr.puts dim("Matched '#{name_or_id}' → #{matched_name}")
|
|
return composed.first['Id']
|
|
end
|
|
end
|
|
|
|
# 3. Substring fallback (only within this stack's containers)
|
|
stack_prefix = stack_name ? "#{stack_name}-" : nil
|
|
pool = stack_prefix ? containers.select { |c| names_for.(c).any? { |n| n.start_with?(stack_prefix) } } : containers
|
|
|
|
fuzzy = pool.select { |c| names_for.(c).any? { |n| n.include?(name_or_id) } }
|
|
|
|
if fuzzy.size == 1
|
|
matched_name = names_for.(fuzzy.first).first
|
|
$stderr.puts dim("Matched '#{name_or_id}' → #{matched_name}")
|
|
return fuzzy.first['Id']
|
|
end
|
|
|
|
if fuzzy.size > 1
|
|
list = fuzzy.map { |c| " #{c['Id'][0, 12]} #{names_for.(c).first}" }.join("\n")
|
|
error("Ambiguous container '#{name_or_id}'. Did you mean one of:\n#{list}")
|
|
end
|
|
|
|
# 4. Try 'app' as a last resort when the implicit default didn't match
|
|
if name_or_id != 'app' && stack_name
|
|
app_candidates = containers.select do |c|
|
|
names_for.(c).any? { |n| n.match?(/\A#{Regexp.escape(stack_name)}-app-\d+\z/) }
|
|
end
|
|
if app_candidates.size == 1
|
|
matched_name = names_for.(app_candidates.first).first
|
|
$stderr.puts dim("Matched '#{name_or_id}' → #{matched_name} (fallback to 'app')")
|
|
return app_candidates.first['Id']
|
|
end
|
|
end
|
|
|
|
available = pool.map { |c| " #{c['Id'][0, 12]} #{names_for.(c).first}" }.join("\n")
|
|
error("Container '#{name_or_id}' not found. Available:\n#{available}")
|
|
end
|
|
|
|
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
|