# frozen_string_literal: true require_relative 'base' require_relative '../websocket_exec' require 'optparse' module PortainerCli module Commands # portainer-cli console [--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 } end.parse!(args) stack_name = args.shift or error("Usage: portainer-cli console [--shell /bin/bash]") container_name = args.shift or error("Usage: portainer-cli console [--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, 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: "--" # 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, 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