From d8e00a4f1a9d4273c3e8e4b7ec3d4437db8dacf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20=C5=81uszczek?= Date: Sun, 22 Mar 2026 13:01:41 +0100 Subject: [PATCH] Add console command with automatic endpoint resolution portainer-cli console looks up the stack by name to find its endpoint ID automatically, so the user never needs to specify it. Co-Authored-By: Claude Sonnet 4.6 --- lib/portainer_cli.rb | 11 ++- lib/portainer_cli/commands/console.rb | 113 ++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 lib/portainer_cli/commands/console.rb diff --git a/lib/portainer_cli.rb b/lib/portainer_cli.rb index c332c0e..5290e4d 100644 --- a/lib/portainer_cli.rb +++ b/lib/portainer_cli.rb @@ -8,6 +8,7 @@ require_relative 'portainer_cli/commands/configure' require_relative 'portainer_cli/commands/list' require_relative 'portainer_cli/commands/create' require_relative 'portainer_cli/commands/exec' +require_relative 'portainer_cli/commands/console' module PortainerCli module CLI @@ -29,7 +30,9 @@ module PortainerCli portainer-cli list networks 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 Open interactive shell + portainer-cli console Open interactive shell (resolves endpoint automatically) + --shell SHELL Shell to use (default: /bin/sh) + portainer-cli exec Open interactive shell (manual endpoint) --shell SHELL Shell to use (default: /bin/sh) Config file: ~/.portainer-cli/config.yml @@ -50,7 +53,11 @@ module PortainerCli require_config!(config) client = Client.new(config) Commands::Create.new(config, client).run(argv) - when 'exec', 'shell', 'console' + when 'console' + require_config!(config) + client = Client.new(config) + Commands::Console.new(config, client).run(argv) + when 'exec', 'shell' require_config!(config) client = Client.new(config) Commands::Exec.new(config, client).run(argv) diff --git a/lib/portainer_cli/commands/console.rb b/lib/portainer_cli/commands/console.rb new file mode 100644 index 0000000..68d2f4d --- /dev/null +++ b/lib/portainer_cli/commands/console.rb @@ -0,0 +1,113 @@ +# 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) + @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 [--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) + + $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) + 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 + available = containers.map do |c| + " #{c['Id'][0, 12]} #{Array(c['Names']).first&.delete_prefix('/')}" + end.join("\n") + error("Container '#{name_or_id}' not found on endpoint #{endpoint_id}. Available:\n#{available}") + 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) + 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