Compare commits

..
0 Commits
12 changed files with 88 additions and 551 deletions

No files matched your search

-26
View File
@@ -1,26 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2026 Grzegorz Luszczek
Everyone is permitted to copy and distribute verbatim copies of this license
document, but changing it is not allowed.
PREAMBLE
The GNU General Public License is a free, copyleft license for software and
other kinds of works.
When we speak of free software, we are referring to freedom, not price. Our
General Public Licenses are designed to make sure that you have the freedom
to distribute copies of free software (and charge for them if you wish), that
you receive source code or can get it if you want it, that you can change the
software or use pieces of it in new free programs, and that you know you can
do these things.
To protect your rights, we need to prevent others from denying you these
rights or asking you to surrender the rights. Therefore, you have certain
responsibilities if you distribute copies of the software, or if you modify
it: responsibilities to respect the freedom of others.
For the full license text, see: https://www.gnu.org/licenses/gpl-3.0.txt
-161
View File
@@ -1,161 +0,0 @@
# portainer-cli
A Ruby command-line tool for interacting with [Portainer](https://www.portainer.io/) via its REST API. List environments, stacks, containers, volumes and networks — and open fully interactive shells inside running containers directly from your terminal.
> **This tool was fully generated by AI** (Claude, by Anthropic) through an iterative conversation-driven development session.
## Features
- List endpoints, stacks, containers, volumes, and networks
- Create stacks (from file or inline compose) and containers
- **Interactive exec into containers** via WebSocket — works like `docker exec -it` but through Portainer
- Smart container name resolution: fuzzy matching using Docker Compose naming conventions (`<stack>-<service>-<N>`)
- Automatic endpoint resolution — no need to look up endpoint IDs manually
- Supports both API key and JWT token authentication
- Configurable SSL verification (useful for self-signed certificates)
- `--run CMD` flag for running a specific command (e.g. `rails c`)
- `cp` to copy files and directories out of containers, binary-safe
- `--quiet` flag for scripting and AI agent use cases
## Requirements
- Ruby 3.x
- [`websocket`](https://rubygems.org/gems/websocket) gem
- A running Portainer instance (v2.x or later)
## Installation
```sh
git clone https://github.com/grzlus/portainer-cli
cd portainer-cli
bundle install
chmod +x bin/portainer-cli
# Link into your PATH (adjust the target to wherever suits you)
ln -sf "$PWD/bin/portainer-cli" ~/bin/portainer-cli
```
## Configuration
```sh
portainer-cli configure
```
You will be prompted for:
- **Portainer URL** — e.g. `https://portainer.example.com`
- **Authentication** — API key (recommended, no expiry) or JWT token
- **SSL verification** — answer `n` if your instance uses a self-signed certificate
Configuration is saved to `~/.config/portainer-cli/config.yml` (mode `0600`).
### Manual config
```yaml
# ~/.config/portainer-cli/config.yml
url: https://portainer.example.com
api_key: ptr_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
ssl_verify: true
```
## Usage
### List resources
```sh
portainer-cli list endpoints
portainer-cli list stacks
portainer-cli list containers <endpoint-id>
portainer-cli list volumes <endpoint-id>
portainer-cli list networks <endpoint-id>
```
### Open an interactive shell
```sh
# Resolves endpoint automatically from the stack name.
# Container name defaults to the stack name, then falls back to 'app'.
portainer-cli console <stack>
portainer-cli console <stack> <container>
# Examples
portainer-cli console myapp
portainer-cli console myapp worker
portainer-cli console myapp --shell /bin/bash
```
### Run a specific command
```sh
portainer-cli console myapp --run "rails c"
portainer-cli console myapp --run "bundle exec rake db:migrate"
```
### Quiet mode (scripting / AI agents)
Suppresses all status output — only the container's own stdout/stderr is printed.
```sh
portainer-cli console myapp --quiet --run "rails runner 'puts User.count'"
```
### Copy files out of a container
Like `docker cp`, through Docker's archive API. There's no TTY in between, so binary files (database dumps, images) arrive intact — which `console --run "cat …"` can't promise.
```sh
portainer-cli cp myapp backup:/backups/latest.dump ./latest.dump
portainer-cli cp myapp app:/app/log ./ # directory → ./log
portainer-cli cp myapp app:/etc/hostname - --quiet # single file to stdout
```
### Create resources
```sh
# Stack from a compose file
portainer-cli create stack --endpoint 1 --name myapp --file docker-compose.yml
# Stack from inline compose
portainer-cli create stack --endpoint 1 --name myapp --compose "version: '3'
services:
web:
image: nginx"
# Container
portainer-cli create container --endpoint 1 --image nginx:alpine --name web \
--port 8080:80 --env APP_ENV=production
```
### Low-level exec (manual endpoint ID)
```sh
portainer-cli exec <endpoint-id> <container-name-or-id>
portainer-cli exec <endpoint-id> <container-name-or-id> --shell /bin/bash
```
## Container name resolution
When using `console` or `cp`, names are resolved in this order:
1. **Exact match** — full container name or ID prefix
2. **Compose pattern** — `<stack>-<service>-<N>` (e.g. `librarian` → `librarian-librarian-1`)
3. **Substring match** — scoped to the stack's own containers
4. **`app` fallback** — tries `<stack>-app-1` as a last resort
If multiple containers match ambiguously, a list is printed and the command exits.
## Debugging
```sh
portainer-cli --debug console myapp
```
Prints the WebSocket URL, auth headers, and the full HTTP upgrade handshake request/response — useful for diagnosing connection issues behind reverse proxies.
## License
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later version.
See [LICENSE](LICENSE) for the full text.
+4 -15
View File
@@ -9,7 +9,6 @@ require_relative 'portainer_cli/commands/list'
require_relative 'portainer_cli/commands/create' require_relative 'portainer_cli/commands/create'
require_relative 'portainer_cli/commands/exec' require_relative 'portainer_cli/commands/exec'
require_relative 'portainer_cli/commands/console' require_relative 'portainer_cli/commands/console'
require_relative 'portainer_cli/commands/cp'
module PortainerCli module PortainerCli
module CLI module CLI
@@ -31,21 +30,15 @@ module PortainerCli
portainer-cli list networks <endpoint-id> List networks portainer-cli list networks <endpoint-id> List networks
portainer-cli create stack --endpoint ID --name NAME --file FILE 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 create container --endpoint ID --image IMAGE [--name NAME] [--port h:c] [--env K=V]
portainer-cli console <stack> [container] Open interactive shell (resolves endpoint automatically) portainer-cli console <stack> <container> Open interactive shell (resolves endpoint automatically)
--endpoint ID Disambiguate when stack name exists on multiple endpoints
--shell SHELL Shell to use (default: /bin/sh) --shell SHELL Shell to use (default: /bin/sh)
--run CMD Run command instead of a bare shell
portainer-cli cp <stack> <container>:<path> <dest> Copy a file/directory out of a container (binary-safe)
--endpoint ID Disambiguate when stack name exists on multiple endpoints
--quiet Suppress status output; <dest> of - writes to stdout
portainer-cli exec <endpoint-id> <container> Open interactive shell (manual endpoint) portainer-cli exec <endpoint-id> <container> Open interactive shell (manual endpoint)
--shell SHELL Shell to use (default: /bin/sh) --shell SHELL Shell to use (default: /bin/sh)
Config file: ~/.config/portainer-cli/config.yml Config file: ~/.portainer-cli/config.yml
HELP HELP
def self.run(argv) def self.run(argv)
debug = argv.delete('--debug')
config = Config.load config = Config.load
cmd = argv.shift&.downcase cmd = argv.shift&.downcase
@@ -63,15 +56,11 @@ module PortainerCli
when 'console' when 'console'
require_config!(config) require_config!(config)
client = Client.new(config) client = Client.new(config)
Commands::Console.new(config, client, debug: debug).run(argv) Commands::Console.new(config, client).run(argv)
when 'cp'
require_config!(config)
client = Client.new(config)
Commands::Cp.new(config, client).run(argv)
when 'exec', 'shell' when 'exec', 'shell'
require_config!(config) require_config!(config)
client = Client.new(config) client = Client.new(config)
Commands::Exec.new(config, client, debug: debug).run(argv) Commands::Exec.new(config, client).run(argv)
when 'version', '--version', '-v' when 'version', '--version', '-v'
puts "portainer-cli #{PortainerCli::VERSION}" puts "portainer-cli #{PortainerCli::VERSION}"
when 'help', '--help', '-h', nil when 'help', '--help', '-h', nil
+1 -17
View File
@@ -32,22 +32,6 @@ module PortainerCli
request(:delete, path) request(:delete, path)
end end
# GET a raw (non-JSON) body, yielding it in chunks as it arrives
def stream(path, params = {}, &block)
uri = http_uri(path)
uri.query = URI.encode_www_form(params) unless params.empty?
req = Net::HTTP::Get.new(uri)
@config.auth_header.each { |k, v| req[k] = v }
build_http(uri).request(req) do |resp|
code = resp.code.to_i
raise ApiError.new("HTTP #{code}: #{resp.body}", code) if code >= 400
resp.read_body(&block)
end
end
# Returns the raw websocket URL (ws:// or wss://) for exec sessions # Returns the raw websocket URL (ws:// or wss://) for exec sessions
def websocket_url(path, params = {}) def websocket_url(path, params = {})
uri = ws_uri(path) uri = ws_uri(path)
@@ -76,7 +60,7 @@ module PortainerCli
http = Net::HTTP.new(uri.host, uri.port) http = Net::HTTP.new(uri.host, uri.port)
if uri.scheme == 'https' if uri.scheme == 'https'
http.use_ssl = true http.use_ssl = true
http.verify_mode = @config.ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE http.verify_mode = OpenSSL::SSL::VERIFY_PEER
end end
http.open_timeout = 10 http.open_timeout = 10
http.read_timeout = 30 http.read_timeout = 30
-5
View File
@@ -32,11 +32,6 @@ module PortainerCli
@config.api_key = prompt_secret("API key", @config.api_key) @config.api_key = prompt_secret("API key", @config.api_key)
end end
puts
current_verify = @config.ssl_verify.nil? ? true : @config.ssl_verify
verify_choice = prompt("Verify SSL certificates? [y/n]", current_verify ? "y" : "n")
@config.ssl_verify = (verify_choice.downcase != "n")
@config.save @config.save
puts puts
puts green("Configuration saved to #{Config::CONFIG_FILE}") puts green("Configuration saved to #{Config::CONFIG_FILE}")
+61 -23
View File
@@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative 'base' require_relative 'base'
require_relative 'resolver'
require_relative '../websocket_exec' require_relative '../websocket_exec'
require 'optparse' require 'optparse'
@@ -13,44 +12,39 @@ module PortainerCli
# needs to know or specify an endpoint ID. # needs to know or specify an endpoint ID.
class Console class Console
include Base include Base
include Resolver
def initialize(config, client, debug: false) def initialize(config, client)
@config = config @config = config
@client = client @client = client
@debug = debug
end end
def run(args) def run(args)
opts = { shell: '/bin/sh' } opts = { shell: '/bin/sh' }
OptionParser.new do |o| OptionParser.new do |o|
o.on('--shell SHELL', 'Shell to use (default: /bin/sh)') { |v| opts[:shell] = v } 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) end.parse!(args)
stack_name = args.shift or error("Usage: portainer-cli console <stack> [container] [--endpoint ID] [--run CMD] [--shell /bin/bash]") stack_name = args.shift or error("Usage: portainer-cli console <stack> <container> [--shell /bin/bash]")
container_name = args.shift || stack_name container_name = args.shift or error("Usage: portainer-cli console <stack> <container> [--shell /bin/bash]")
stack = resolve_stack(stack_name, opts[:endpoint]) stack = resolve_stack(stack_name)
endpoint_id = stack['EndpointId'] endpoint_id = stack['EndpointId']
status("Stack: #{stack['Name']} Endpoint: #{endpoint_id}", opts[:quiet]) $stderr.puts dim("Stack: #{stack['Name']} Endpoint: #{endpoint_id}")
container_id = resolve_container(endpoint_id, container_name, stack['Name'], opts[:quiet]) container_id = resolve_container(endpoint_id, container_name)
status("Connecting to #{container_name} (#{container_id[0, 12]})...", opts[:quiet]) $stderr.puts dim("Connecting to #{container_name} (#{container_id[0, 12]})...")
exec_id = create_exec(endpoint_id, container_id, opts[:shell], opts[:run]) exec_id = create_exec(endpoint_id, container_id, opts[:shell])
ws_url = exec_websocket_url(exec_id, endpoint_id) ws_url = exec_websocket_url(exec_id)
send_resize(endpoint_id, exec_id) if $stdout.tty? send_resize(endpoint_id, exec_id) if $stdout.tty?
trap('SIGWINCH') { 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 WebsocketExec.new(ws_url, @config.auth_header).run
status("\r\nSession ended.", opts[:quiet]) $stderr.puts dim("\r\nSession ended.")
rescue PortainerCli::Client::ApiError => e rescue PortainerCli::Client::ApiError => e
error(e.message) error(e.message)
rescue Interrupt rescue Interrupt
@@ -59,8 +53,52 @@ module PortainerCli
private private
def create_exec(endpoint_id, container_id, shell, run_cmd = nil) def resolve_stack(name_or_id)
cmd = run_cmd ? [shell, '-c', run_cmd] : [shell] 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)
# 1. Exact match: full name or ID prefix
exact = containers.find do |c|
c['Id'].start_with?(name_or_id) ||
Array(c['Names']).any? { |n| n.delete_prefix('/') == name_or_id }
end
return exact['Id'] if exact
# 2. Fuzzy: container name contains the search term as a word segment
# Prefers "<stack>-<name>-<n>" pattern, falls back to any substring match
names_for = ->(c) { Array(c['Names']).map { |n| n.delete_prefix('/') } }
fuzzy = containers.select do |c|
names_for.(c).any? { |n| n.include?(name_or_id) }
end
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 = containers.map { |c| " #{c['Id'][0, 12]} #{names_for.(c).first}" }.join("\n")
error("Container '#{name_or_id}' not found on endpoint #{endpoint_id}. Available:\n#{available}")
end
def create_exec(endpoint_id, container_id, shell)
result = @client.post( result = @client.post(
"/api/endpoints/#{endpoint_id}/docker/containers/#{container_id}/exec", "/api/endpoints/#{endpoint_id}/docker/containers/#{container_id}/exec",
{ {
@@ -68,15 +106,15 @@ module PortainerCli
'AttachStdout' => true, 'AttachStdout' => true,
'AttachStderr' => true, 'AttachStderr' => true,
'Tty' => true, 'Tty' => true,
'Cmd' => cmd 'Cmd' => [shell]
} }
) )
result['Id'] result['Id']
end end
def exec_websocket_url(exec_id, endpoint_id) def exec_websocket_url(exec_id)
token = @config.token || @config.api_key token = @config.token || @config.api_key
@client.websocket_url('/api/websocket/exec', token: token, id: exec_id, endpointId: endpoint_id) @client.websocket_url('/api/websocket/exec', token: token, id: exec_id)
end end
def send_resize(endpoint_id, exec_id) def send_resize(endpoint_id, exec_id)
-134
View File
@@ -1,134 +0,0 @@
# frozen_string_literal: true
require_relative 'base'
require_relative 'resolver'
require 'optparse'
require 'tempfile'
require 'fileutils'
require 'rubygems/package'
module PortainerCli
module Commands
# portainer-cli cp <stack> <container>:<path> <dest> [--endpoint ID] [--quiet]
#
# Copies a file or directory out of a container, like `docker cp`. It goes
# through Docker's archive API (a tar stream), so unlike `console --run`
# there's no TTY in the way and binary files arrive intact.
# <dest> of "-" writes a single file to stdout.
class Cp
include Base
include Resolver
USAGE = 'Usage: portainer-cli cp <stack> <container>:<path> <dest> [--endpoint ID] [--quiet]'
def initialize(config, client)
@config = config
@client = client
end
def run(args)
opts = {}
OptionParser.new do |o|
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)
source = args.shift or error(USAGE)
dest = args.shift or error(USAGE)
container_name, path = source.split(':', 2)
error(USAGE) if path.nil? || path.empty? || container_name.empty?
stack = resolve_stack(stack_name, opts[:endpoint])
endpoint_id = stack['EndpointId']
container_id = resolve_container(endpoint_id, container_name, stack['Name'], opts[:quiet])
Tempfile.create(['portainer-cp', '.tar'], binmode: true) do |tar|
download(endpoint_id, container_id, path, tar, opts[:quiet])
tar.rewind
extract(tar, dest, opts[:quiet])
end
rescue PortainerCli::Client::ApiError => e
error(e.code == 404 ? "#{path}: no such file or directory in #{container_name}" : e.message)
rescue Interrupt
$stderr.puts "\nInterrupted."
exit 130
end
private
def download(endpoint_id, container_id, path, io, quiet)
bytes = 0
@client.stream("/api/endpoints/#{endpoint_id}/docker/containers/#{container_id}/archive", path: path) do |chunk|
io.write(chunk)
bytes += chunk.bytesize
$stderr.print "\r#{dim(format('%.1f MB', bytes / 1_048_576.0))}" unless quiet || !$stderr.tty?
end
$stderr.print "\r" unless quiet || !$stderr.tty?
io.flush
end
def extract(tar, dest, quiet)
entries = read_entries(tar)
error('Archive is empty') if entries.empty?
if dest == '-'
error('Only a single file can be written to stdout') unless entries.size == 1 && entries.first[:file]
tar.rewind
each_file(tar) { |_, entry| IO.copy_stream(entry, $stdout) }
return
end
root = entries.first[:name].split('/').first
# Same rules as `docker cp`: into an existing directory keep the source's
# name, otherwise the source is renamed to dest.
base = File.directory?(dest) ? File.join(dest, root) : dest
tar.rewind
each_entry(tar) do |entry|
name = entry.full_name.delete_suffix('/')
rest = name.delete_prefix(root)
target = rest.empty? ? base : File.join(base, rest)
if entry.directory?
FileUtils.mkdir_p(target)
elsif entry.file?
FileUtils.mkdir_p(File.dirname(target))
write_atomically(target, entry)
status("#{name} → #{target}", quiet)
else
status("Skipping #{name} (not a regular file)", quiet)
end
end
end
def read_entries(tar)
each_entry(tar).map do |entry|
name = entry.full_name.delete_suffix('/')
if name.start_with?('/') || name.split('/').include?('..')
error("Refusing unsafe path in archive: #{name}")
end
{ name: name, file: entry.file? }
end
end
# Skips PAX extended headers, which TarReader hands out as entries of their own
def each_entry(tar)
return enum_for(:each_entry, tar) unless block_given?
Gem::Package::TarReader.new(tar).each { |entry| yield entry unless %w[x g].include?(entry.header.typeflag) }
end
def each_file(tar)
each_entry(tar) { |entry| yield entry.full_name, entry if entry.file? }
end
def write_atomically(target, entry)
part = "#{target}.part"
File.open(part, 'wb') { |f| IO.copy_stream(entry, f) }
File.rename(part, target)
ensure
FileUtils.rm_f(part) if part && File.exist?(part)
end
end
end
end
+2 -8
View File
@@ -2,7 +2,6 @@
require_relative 'base' require_relative 'base'
require 'optparse' require 'optparse'
require 'shellwords'
module PortainerCli module PortainerCli
module Commands module Commands
@@ -56,7 +55,6 @@ module PortainerCli
end end
env_vars = opts[:env].map do |pair| env_vars = opts[:env].map do |pair|
error("Invalid --env '#{pair}': expected KEY=VALUE") unless pair.include?('=')
k, v = pair.split('=', 2) k, v = pair.split('=', 2)
{ 'name' => k, 'value' => v.to_s } { 'name' => k, 'value' => v.to_s }
end end
@@ -92,11 +90,7 @@ module PortainerCli
port_bindings = {} port_bindings = {}
exposed_ports = {} exposed_ports = {}
opts[:ports].each do |mapping| opts[:ports].each do |mapping|
parts = mapping.split(':', 2) host_port, cont_port = mapping.split(':', 2)
error("Invalid --port '#{mapping}': expected HOST:CONTAINER (e.g. 8080:80)") unless parts.size == 2
host_port, cont_port = parts
error("Invalid --port '#{mapping}': missing host port") if host_port.empty?
error("Invalid --port '#{mapping}': missing container port") if cont_port.empty?
cont_key = "#{cont_port}/tcp" cont_key = "#{cont_port}/tcp"
exposed_ports[cont_key] = {} exposed_ports[cont_key] = {}
port_bindings[cont_key] = [{ 'HostPort' => host_port }] port_bindings[cont_key] = [{ 'HostPort' => host_port }]
@@ -108,7 +102,7 @@ module PortainerCli
'ExposedPorts' => exposed_ports, 'ExposedPorts' => exposed_ports,
'HostConfig' => { 'PortBindings' => port_bindings } 'HostConfig' => { 'PortBindings' => port_bindings }
} }
body['Cmd'] = Shellwords.split(opts[:cmd]) if opts[:cmd] body['Cmd'] = opts[:cmd].split(' ') if opts[:cmd]
query = opts[:name] ? "?name=#{URI.encode_www_form_component(opts[:name])}" : '' query = opts[:name] ? "?name=#{URI.encode_www_form_component(opts[:name])}" : ''
result = @client.post("/api/endpoints/#{opts[:endpoint]}/docker/containers/create#{query}", body) result = @client.post("/api/endpoints/#{opts[:endpoint]}/docker/containers/create#{query}", body)
+6 -6
View File
@@ -9,10 +9,9 @@ module PortainerCli
class Exec class Exec
include Base include Base
def initialize(config, client, debug: false) def initialize(config, client)
@config = config @config = config
@client = client @client = client
@debug = debug
end end
# portainer-cli exec <endpoint-id> <container-name-or-id> [-- cmd args...] # portainer-cli exec <endpoint-id> <container-name-or-id> [-- cmd args...]
@@ -32,12 +31,12 @@ module PortainerCli
$stderr.puts dim("Connecting to #{container_name} (#{container_id[0, 12]})...") $stderr.puts dim("Connecting to #{container_name} (#{container_id[0, 12]})...")
exec_id = create_exec(endpoint_id, container_id, opts[:shell]) exec_id = create_exec(endpoint_id, container_id, opts[:shell])
ws_url = exec_websocket_url(exec_id, endpoint_id) ws_url = exec_websocket_url(exec_id)
send_resize(endpoint_id, exec_id) if $stdout.tty? send_resize(endpoint_id, exec_id) if $stdout.tty?
trap('SIGWINCH') { 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 WebsocketExec.new(ws_url, @config.auth_header).run
$stderr.puts dim("\r\nSession ended.") $stderr.puts dim("\r\nSession ended.")
rescue PortainerCli::Client::ApiError => e rescue PortainerCli::Client::ApiError => e
@@ -78,9 +77,10 @@ module PortainerCli
result['Id'] result['Id']
end end
def exec_websocket_url(exec_id, endpoint_id) def exec_websocket_url(exec_id)
# Portainer uses JWT for websocket auth (not API key), so we need to include token in query
token = @config.token || @config.api_key token = @config.token || @config.api_key
@client.websocket_url('/api/websocket/exec', token: token, id: exec_id, endpointId: endpoint_id) @client.websocket_url('/api/websocket/exec', token: token, id: exec_id)
end end
def send_resize(endpoint_id, exec_id) def send_resize(endpoint_id, exec_id)
-110
View File
@@ -1,110 +0,0 @@
# frozen_string_literal: true
module PortainerCli
module Commands
# Finds a stack's endpoint and one of its containers by (fuzzy) name.
# Needs @client and the Base helpers.
module Resolver
private
def status(msg, quiet)
$stderr.puts dim(msg) unless quiet
end
def resolve_stack(name_or_id, endpoint_id = nil)
stacks = @client.get('/api/stacks')
matches = stacks.select do |s|
s['Name'] == name_or_id || s['Id'].to_s == name_or_id
end
if matches.empty?
available = stacks.map { |s| " #{s['Id']} #{s['Name']} (endpoint: #{s['EndpointId']})" }.join("\n")
error("Stack '#{name_or_id}' not found. Available stacks:\n#{available}")
end
if matches.size > 1
if endpoint_id
matches = matches.select { |s| s['EndpointId'].to_s == endpoint_id.to_s }
if matches.empty?
error("Stack '#{name_or_id}' not found on endpoint #{endpoint_id}. " \
"It exists on endpoint(s): #{matches.map { |s| s['EndpointId'] }.join(', ')}")
end
else
endpoint_names = endpoint_name_map
list = matches.map do |s|
ep_name = endpoint_names[s['EndpointId']] || s['EndpointId'].to_s
" #{s['Id']} #{s['Name']} (endpoint #{s['EndpointId']}: #{ep_name})"
end.join("\n")
error("Ambiguous stack '#{name_or_id}' exists on multiple endpoints. Use --endpoint ID:\n#{list}")
end
end
matches.first
end
def endpoint_name_map
@client.get('/api/endpoints').each_with_object({}) { |e, h| h[e['Id']] = e['Name'] }
rescue StandardError
{}
end
def resolve_container(endpoint_id, name_or_id, stack_name = nil, quiet = false)
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
status("Matched '#{name_or_id}' → #{matched_name}", quiet)
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
status("Matched '#{name_or_id}' → #{matched_name}", quiet)
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
status("Matched '#{name_or_id}' → #{matched_name} (fallback to 'app')", quiet)
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
end
end
end
+8 -10
View File
@@ -5,10 +5,10 @@ require 'fileutils'
module PortainerCli module PortainerCli
class Config class Config
CONFIG_DIR = File.expand_path('~/.config/portainer-cli').freeze CONFIG_DIR = File.expand_path('~/.portainer-cli').freeze
CONFIG_FILE = File.join(CONFIG_DIR, 'config.yml').freeze CONFIG_FILE = File.join(CONFIG_DIR, 'config.yml').freeze
attr_accessor :url, :token, :api_key, :ssl_verify attr_accessor :url, :token, :api_key
def self.load def self.load
new.tap(&:read) new.tap(&:read)
@@ -18,19 +18,17 @@ module PortainerCli
return unless File.exist?(CONFIG_FILE) return unless File.exist?(CONFIG_FILE)
data = YAML.safe_load(File.read(CONFIG_FILE), permitted_classes: [], symbolize_names: false) || {} data = YAML.safe_load(File.read(CONFIG_FILE), permitted_classes: [], symbolize_names: false) || {}
@url = data['url'] @url = data['url']
@token = data['token'] @token = data['token']
@api_key = data['api_key'] @api_key = data['api_key']
@ssl_verify = data.fetch('ssl_verify', true)
end end
def save def save
FileUtils.mkdir_p(CONFIG_DIR) FileUtils.mkdir_p(CONFIG_DIR)
data = {} data = {}
data['url'] = @url if @url data['url'] = @url if @url
data['token'] = @token if @token data['token'] = @token if @token
data['api_key'] = @api_key if @api_key data['api_key'] = @api_key if @api_key
data['ssl_verify'] = @ssl_verify unless @ssl_verify.nil?
File.write(CONFIG_FILE, YAML.dump(data)) File.write(CONFIG_FILE, YAML.dump(data))
File.chmod(0o600, CONFIG_FILE) File.chmod(0o600, CONFIG_FILE)
end end
+6 -36
View File
@@ -10,20 +10,17 @@ module PortainerCli
class WebsocketExec class WebsocketExec
READ_SIZE = 4096 READ_SIZE = 4096
def initialize(url, auth_header, ssl_verify: true, debug: false) def initialize(url, auth_header)
@url = URI.parse(url) @url = URI.parse(url)
@auth_header = auth_header @auth_header = auth_header
@ssl_verify = ssl_verify
@debug = debug
end end
def run def run
debug "WS URL: #{@url}"
debug "Auth headers: #{@auth_header.map { |k, v| "#{k}: #{v[0, 8]}..." }.join(', ')}"
socket = open_socket socket = open_socket
perform_handshake(socket) handshake = perform_handshake(socket)
raise "WebSocket handshake failed: #{handshake.error}" unless handshake.valid?
run_io_loop(socket) run_io_loop(socket)
ensure ensure
socket&.close rescue nil socket&.close rescue nil
@@ -36,23 +33,15 @@ module PortainerCli
if @url.scheme == 'wss' if @url.scheme == 'wss'
ctx = OpenSSL::SSL::SSLContext.new ctx = OpenSSL::SSL::SSLContext.new
ctx.verify_mode = @ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
ssl = OpenSSL::SSL::SSLSocket.new(raw, ctx) ssl = OpenSSL::SSL::SSLSocket.new(raw, ctx)
ssl.hostname = @url.host ssl.hostname = @url.host
ssl.sync_close = true ssl.sync_close = true
begin ssl.connect
ssl.connect
rescue OpenSSL::SSL::SSLError => e
raw.close rescue nil
hint = @ssl_verify ? " (try: portainer-cli configure → answer 'n' to SSL verification)" : ""
raise "SSL connection failed: #{e.message}#{hint}"
end
ssl ssl
else else
raw raw
end end
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
raise "Cannot connect to #{@url.host}:#{@url.port} — #{e.message}"
end end
def perform_handshake(socket) def perform_handshake(socket)
@@ -60,37 +49,18 @@ module PortainerCli
url: @url.to_s, url: @url.to_s,
headers: @auth_header headers: @auth_header
) )
debug "Handshake request:\n#{handshake.to_s.chomp}"
socket.write(handshake.to_s) socket.write(handshake.to_s)
header_bytes = +''
loop do loop do
byte = socket.read(1) byte = socket.read(1)
break if byte.nil? break if byte.nil?
header_bytes << byte
handshake << byte handshake << byte
break if handshake.finished? break if handshake.finished?
end end
debug "Handshake response headers:\n#{header_bytes.chomp}"
unless handshake.valid?
# Try to read the response body for error details
body = ''
if (len = header_bytes.match(/Content-Length:\s*(\d+)/i)&.[](1)&.to_i) && len > 0
body = socket.read(len).to_s
end
raise "WebSocket handshake failed: #{handshake.error}\n" \
"Server response:\n#{header_bytes.chomp}\n#{body}"
end
handshake handshake
end end
def debug(msg)
return unless @debug
$stderr.puts "\e[2m[debug] #{msg}\e[0m"
end
def run_io_loop(socket) def run_io_loop(socket)
incoming = WebSocket::Frame::Incoming::Client.new incoming = WebSocket::Frame::Incoming::Client.new
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.