Files
portainer-cli/lib/portainer_cli/commands/console.rb
T
grzlus a1015366a9 Smarter container matching using stack-service-replica pattern
Prefer exact "<stack>-<service>-<N>" compose naming over generic substring
match, so "librarian" in stack "librarian" resolves to "librarian-librarian-1"
unambiguously. Substring fallback is also scoped to the stack's own containers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 13:05:21 +01:00

144 lines
5.0 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)
@config = config
@client = client
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 }
end.parse!(args)
stack_name = args.shift or error("Usage: portainer-cli console <stack> <container> [--shell /bin/bash]")
container_name = args.shift or error("Usage: portainer-cli console <stack> <container> [--shell /bin/bash]")
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])
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_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
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)
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)
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
end
end
end
end