Compare commits

..
0 Commits
10 changed files with 45 additions and 298 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
-150
View File
@@ -1,150 +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`)
- `--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'"
```
### 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`, 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 -6
View File
@@ -30,17 +30,15 @@ module PortainerCli
portainer-cli list networks <endpoint-id> 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 console <stack> [container] Open interactive shell (resolves endpoint automatically)
portainer-cli console <stack> <container> Open interactive shell (resolves endpoint automatically)
--shell SHELL Shell to use (default: /bin/sh)
--run CMD Run command instead of a bare shell
portainer-cli exec <endpoint-id> <container> Open interactive shell (manual endpoint)
--shell SHELL Shell to use (default: /bin/sh)
Config file: ~/.config/portainer-cli/config.yml
Config file: ~/.portainer-cli/config.yml
HELP
def self.run(argv)
debug = argv.delete('--debug')
config = Config.load
cmd = argv.shift&.downcase
@@ -58,11 +56,11 @@ module PortainerCli
when 'console'
require_config!(config)
client = Client.new(config)
Commands::Console.new(config, client, debug: debug).run(argv)
Commands::Console.new(config, client).run(argv)
when 'exec', 'shell'
require_config!(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'
puts "portainer-cli #{PortainerCli::VERSION}"
when 'help', '--help', '-h', nil
+1 -1
View File
@@ -60,7 +60,7 @@ module PortainerCli
http = Net::HTTP.new(uri.host, uri.port)
if uri.scheme == 'https'
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
http.open_timeout = 10
http.read_timeout = 30
-5
View File
@@ -32,11 +32,6 @@ module PortainerCli
@config.api_key = prompt_secret("API key", @config.api_key)
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
puts
puts green("Configuration saved to #{Config::CONFIG_FILE}")
+25 -57
View File
@@ -13,41 +13,38 @@ module PortainerCli
class Console
include Base
def initialize(config, client, debug: false)
def initialize(config, client)
@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 }
o.on('--quiet', 'Suppress status output') { opts[:quiet] = true }
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_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']
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])
ws_url = exec_websocket_url(exec_id, endpoint_id)
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, 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
error(e.message)
rescue Interrupt
@@ -56,10 +53,6 @@ module PortainerCli
private
def status(msg, quiet)
$stderr.puts dim(msg) unless quiet
end
def resolve_stack(name_or_id)
stacks = @client.get('/api/stacks')
match = stacks.find do |s|
@@ -72,39 +65,27 @@ module PortainerCli
match
end
def resolve_container(endpoint_id, name_or_id, stack_name = nil, quiet = false)
def resolve_container(endpoint_id, name_or_id)
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 }
Array(c['Names']).any? { |n| n.delete_prefix('/') == 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
# 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('/') } }
# 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) } }
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
status("Matched '#{name_or_id}' → #{matched_name}", quiet)
$stderr.puts dim("Matched '#{name_or_id}' → #{matched_name}")
return fuzzy.first['Id']
end
@@ -113,24 +94,11 @@ module PortainerCli
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
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
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]
def create_exec(endpoint_id, container_id, shell)
result = @client.post(
"/api/endpoints/#{endpoint_id}/docker/containers/#{container_id}/exec",
{
@@ -138,15 +106,15 @@ module PortainerCli
'AttachStdout' => true,
'AttachStderr' => true,
'Tty' => true,
'Cmd' => cmd
'Cmd' => [shell]
}
)
result['Id']
end
def exec_websocket_url(exec_id, endpoint_id)
def exec_websocket_url(exec_id)
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
def send_resize(endpoint_id, exec_id)
+2 -8
View File
@@ -2,7 +2,6 @@
require_relative 'base'
require 'optparse'
require 'shellwords'
module PortainerCli
module Commands
@@ -56,7 +55,6 @@ module PortainerCli
end
env_vars = opts[:env].map do |pair|
error("Invalid --env '#{pair}': expected KEY=VALUE") unless pair.include?('=')
k, v = pair.split('=', 2)
{ 'name' => k, 'value' => v.to_s }
end
@@ -92,11 +90,7 @@ module PortainerCli
port_bindings = {}
exposed_ports = {}
opts[:ports].each do |mapping|
parts = 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?
host_port, cont_port = mapping.split(':', 2)
cont_key = "#{cont_port}/tcp"
exposed_ports[cont_key] = {}
port_bindings[cont_key] = [{ 'HostPort' => host_port }]
@@ -108,7 +102,7 @@ module PortainerCli
'ExposedPorts' => exposed_ports,
'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])}" : ''
result = @client.post("/api/endpoints/#{opts[:endpoint]}/docker/containers/create#{query}", body)
+6 -6
View File
@@ -9,10 +9,9 @@ module PortainerCli
class Exec
include Base
def initialize(config, client, debug: false)
def initialize(config, client)
@config = config
@client = client
@debug = debug
end
# 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]})...")
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?
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.")
rescue PortainerCli::Client::ApiError => e
@@ -78,9 +77,10 @@ module PortainerCli
result['Id']
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
@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
def send_resize(endpoint_id, exec_id)
+2 -4
View File
@@ -5,10 +5,10 @@ require 'fileutils'
module PortainerCli
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
attr_accessor :url, :token, :api_key, :ssl_verify
attr_accessor :url, :token, :api_key
def self.load
new.tap(&:read)
@@ -21,7 +21,6 @@ module PortainerCli
@url = data['url']
@token = data['token']
@api_key = data['api_key']
@ssl_verify = data.fetch('ssl_verify', true)
end
def save
@@ -30,7 +29,6 @@ module PortainerCli
data['url'] = @url if @url
data['token'] = @token if @token
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.chmod(0o600, CONFIG_FILE)
end
+5 -35
View File
@@ -10,20 +10,17 @@ module PortainerCli
class WebsocketExec
READ_SIZE = 4096
def initialize(url, auth_header, ssl_verify: true, debug: false)
def initialize(url, auth_header)
@url = URI.parse(url)
@auth_header = auth_header
@ssl_verify = ssl_verify
@debug = debug
end
def run
debug "WS URL: #{@url}"
debug "Auth headers: #{@auth_header.map { |k, v| "#{k}: #{v[0, 8]}..." }.join(', ')}"
socket = open_socket
perform_handshake(socket)
handshake = perform_handshake(socket)
raise "WebSocket handshake failed: #{handshake.error}" unless handshake.valid?
run_io_loop(socket)
ensure
socket&.close rescue nil
@@ -36,23 +33,15 @@ module PortainerCli
if @url.scheme == 'wss'
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.hostname = @url.host
ssl.sync_close = true
begin
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
else
raw
end
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
raise "Cannot connect to #{@url.host}:#{@url.port} — #{e.message}"
end
def perform_handshake(socket)
@@ -60,37 +49,18 @@ module PortainerCli
url: @url.to_s,
headers: @auth_header
)
debug "Handshake request:\n#{handshake.to_s.chomp}"
socket.write(handshake.to_s)
header_bytes = +''
loop do
byte = socket.read(1)
break if byte.nil?
header_bytes << byte
handshake << byte
break if handshake.finished?
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
end
def debug(msg)
return unless @debug
$stderr.puts "\e[2m[debug] #{msg}\e[0m"
end
def run_io_loop(socket)
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.