Compare commits
10 Commits
855877f6a6
...
ed5eca8924
| Author | SHA1 | Date | |
|---|---|---|---|
| ed5eca8924 | |||
| f263088d15 | |||
| 56f73f72e0 | |||
| 8e45e826ff | |||
| 382ba15f15 | |||
| 948910fc87 | |||
| 7235f687b1 | |||
| 9fe6ef339a | |||
| c59deca51e | |||
| a1015366a9 |
@@ -0,0 +1,26 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# 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.
|
||||||
@@ -30,15 +30,17 @@ 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)
|
||||||
--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 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: ~/.portainer-cli/config.yml
|
Config file: ~/.config/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
|
||||||
|
|
||||||
@@ -56,11 +58,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).run(argv)
|
Commands::Console.new(config, client, debug: debug).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).run(argv)
|
Commands::Exec.new(config, client, debug: debug).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
|
||||||
|
|||||||
@@ -60,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 = OpenSSL::SSL::VERIFY_PEER
|
http.verify_mode = @config.ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
||||||
end
|
end
|
||||||
http.open_timeout = 10
|
http.open_timeout = 10
|
||||||
http.read_timeout = 30
|
http.read_timeout = 30
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ 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}")
|
||||||
|
|||||||
@@ -13,38 +13,41 @@ module PortainerCli
|
|||||||
class Console
|
class Console
|
||||||
include Base
|
include Base
|
||||||
|
|
||||||
def initialize(config, client)
|
def initialize(config, client, debug: false)
|
||||||
@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 }
|
||||||
end.parse!(args)
|
end.parse!(args)
|
||||||
|
|
||||||
stack_name = args.shift or error("Usage: portainer-cli console <stack> <container> [--shell /bin/bash]")
|
stack_name = args.shift or error("Usage: portainer-cli console <stack> [container] [--run CMD] [--shell /bin/bash]")
|
||||||
container_name = args.shift or error("Usage: portainer-cli console <stack> <container> [--shell /bin/bash]")
|
container_name = args.shift || stack_name
|
||||||
|
|
||||||
stack = resolve_stack(stack_name)
|
stack = resolve_stack(stack_name)
|
||||||
endpoint_id = stack['EndpointId']
|
endpoint_id = stack['EndpointId']
|
||||||
|
|
||||||
$stderr.puts dim("Stack: #{stack['Name']} Endpoint: #{endpoint_id}")
|
status("Stack: #{stack['Name']} Endpoint: #{endpoint_id}", opts[:quiet])
|
||||||
|
|
||||||
container_id = resolve_container(endpoint_id, container_name)
|
container_id = resolve_container(endpoint_id, container_name, stack['Name'], opts[:quiet])
|
||||||
|
|
||||||
$stderr.puts dim("Connecting to #{container_name} (#{container_id[0, 12]})...")
|
status("Connecting to #{container_name} (#{container_id[0, 12]})...", opts[:quiet])
|
||||||
|
|
||||||
exec_id = create_exec(endpoint_id, container_id, opts[:shell])
|
exec_id = create_exec(endpoint_id, container_id, opts[:shell], opts[:run])
|
||||||
ws_url = exec_websocket_url(exec_id)
|
ws_url = exec_websocket_url(exec_id, endpoint_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).run
|
WebsocketExec.new(ws_url, @config.auth_header, ssl_verify: @config.ssl_verify, debug: @debug).run
|
||||||
|
|
||||||
$stderr.puts dim("\r\nSession ended.")
|
status("\r\nSession ended.", opts[:quiet])
|
||||||
rescue PortainerCli::Client::ApiError => e
|
rescue PortainerCli::Client::ApiError => e
|
||||||
error(e.message)
|
error(e.message)
|
||||||
rescue Interrupt
|
rescue Interrupt
|
||||||
@@ -53,6 +56,10 @@ module PortainerCli
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def status(msg, quiet)
|
||||||
|
$stderr.puts dim(msg) unless quiet
|
||||||
|
end
|
||||||
|
|
||||||
def resolve_stack(name_or_id)
|
def resolve_stack(name_or_id)
|
||||||
stacks = @client.get('/api/stacks')
|
stacks = @client.get('/api/stacks')
|
||||||
match = stacks.find do |s|
|
match = stacks.find do |s|
|
||||||
@@ -65,27 +72,39 @@ module PortainerCli
|
|||||||
match
|
match
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolve_container(endpoint_id, name_or_id)
|
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)
|
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
|
# 1. Exact match: full name or ID prefix
|
||||||
exact = containers.find do |c|
|
exact = containers.find do |c|
|
||||||
c['Id'].start_with?(name_or_id) ||
|
c['Id'].start_with?(name_or_id) ||
|
||||||
Array(c['Names']).any? { |n| n.delete_prefix('/') == name_or_id }
|
names_for.(c).any? { |n| n == name_or_id }
|
||||||
end
|
end
|
||||||
return exact['Id'] if exact
|
return exact['Id'] if exact
|
||||||
|
|
||||||
# 2. Fuzzy: container name contains the search term as a word segment
|
# 2. Compose-style match: "<stack>-<service>-<replica>"
|
||||||
# Prefers "<stack>-<name>-<n>" pattern, falls back to any substring match
|
# This is the most precise fuzzy match when we know the stack name.
|
||||||
names_for = ->(c) { Array(c['Names']).map { |n| n.delete_prefix('/') } }
|
if stack_name
|
||||||
|
composed = containers.select do |c|
|
||||||
fuzzy = 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/) }
|
||||||
names_for.(c).any? { |n| n.include?(name_or_id) }
|
|
||||||
end
|
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
|
if fuzzy.size == 1
|
||||||
matched_name = names_for.(fuzzy.first).first
|
matched_name = names_for.(fuzzy.first).first
|
||||||
$stderr.puts dim("Matched '#{name_or_id}' → #{matched_name}")
|
status("Matched '#{name_or_id}' → #{matched_name}", quiet)
|
||||||
return fuzzy.first['Id']
|
return fuzzy.first['Id']
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -94,11 +113,24 @@ module PortainerCli
|
|||||||
error("Ambiguous container '#{name_or_id}'. Did you mean one of:\n#{list}")
|
error("Ambiguous container '#{name_or_id}'. Did you mean one of:\n#{list}")
|
||||||
end
|
end
|
||||||
|
|
||||||
available = containers.map { |c| " #{c['Id'][0, 12]} #{names_for.(c).first}" }.join("\n")
|
# 4. Try 'app' as a last resort when the implicit default didn't match
|
||||||
error("Container '#{name_or_id}' not found on endpoint #{endpoint_id}. Available:\n#{available}")
|
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
|
end
|
||||||
|
|
||||||
def create_exec(endpoint_id, container_id, shell)
|
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]
|
||||||
result = @client.post(
|
result = @client.post(
|
||||||
"/api/endpoints/#{endpoint_id}/docker/containers/#{container_id}/exec",
|
"/api/endpoints/#{endpoint_id}/docker/containers/#{container_id}/exec",
|
||||||
{
|
{
|
||||||
@@ -106,15 +138,15 @@ module PortainerCli
|
|||||||
'AttachStdout' => true,
|
'AttachStdout' => true,
|
||||||
'AttachStderr' => true,
|
'AttachStderr' => true,
|
||||||
'Tty' => true,
|
'Tty' => true,
|
||||||
'Cmd' => [shell]
|
'Cmd' => cmd
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
result['Id']
|
result['Id']
|
||||||
end
|
end
|
||||||
|
|
||||||
def exec_websocket_url(exec_id)
|
def exec_websocket_url(exec_id, endpoint_id)
|
||||||
token = @config.token || @config.api_key
|
token = @config.token || @config.api_key
|
||||||
@client.websocket_url('/api/websocket/exec', token: token, id: exec_id)
|
@client.websocket_url('/api/websocket/exec', token: token, id: exec_id, endpointId: endpoint_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_resize(endpoint_id, exec_id)
|
def send_resize(endpoint_id, exec_id)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
require_relative 'base'
|
require_relative 'base'
|
||||||
require 'optparse'
|
require 'optparse'
|
||||||
|
require 'shellwords'
|
||||||
|
|
||||||
module PortainerCli
|
module PortainerCli
|
||||||
module Commands
|
module Commands
|
||||||
@@ -55,6 +56,7 @@ 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
|
||||||
@@ -90,7 +92,11 @@ module PortainerCli
|
|||||||
port_bindings = {}
|
port_bindings = {}
|
||||||
exposed_ports = {}
|
exposed_ports = {}
|
||||||
opts[:ports].each do |mapping|
|
opts[:ports].each do |mapping|
|
||||||
host_port, cont_port = mapping.split(':', 2)
|
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?
|
||||||
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 }]
|
||||||
@@ -102,7 +108,7 @@ module PortainerCli
|
|||||||
'ExposedPorts' => exposed_ports,
|
'ExposedPorts' => exposed_ports,
|
||||||
'HostConfig' => { 'PortBindings' => port_bindings }
|
'HostConfig' => { 'PortBindings' => port_bindings }
|
||||||
}
|
}
|
||||||
body['Cmd'] = opts[:cmd].split(' ') if opts[:cmd]
|
body['Cmd'] = Shellwords.split(opts[:cmd]) 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)
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ module PortainerCli
|
|||||||
class Exec
|
class Exec
|
||||||
include Base
|
include Base
|
||||||
|
|
||||||
def initialize(config, client)
|
def initialize(config, client, debug: false)
|
||||||
@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...]
|
||||||
@@ -31,12 +32,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)
|
ws_url = exec_websocket_url(exec_id, endpoint_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).run
|
WebsocketExec.new(ws_url, @config.auth_header, ssl_verify: @config.ssl_verify, debug: @debug).run
|
||||||
|
|
||||||
$stderr.puts dim("\r\nSession ended.")
|
$stderr.puts dim("\r\nSession ended.")
|
||||||
rescue PortainerCli::Client::ApiError => e
|
rescue PortainerCli::Client::ApiError => e
|
||||||
@@ -77,10 +78,9 @@ module PortainerCli
|
|||||||
result['Id']
|
result['Id']
|
||||||
end
|
end
|
||||||
|
|
||||||
def exec_websocket_url(exec_id)
|
def exec_websocket_url(exec_id, endpoint_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)
|
@client.websocket_url('/api/websocket/exec', token: token, id: exec_id, endpointId: endpoint_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_resize(endpoint_id, exec_id)
|
def send_resize(endpoint_id, exec_id)
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ require 'fileutils'
|
|||||||
|
|
||||||
module PortainerCli
|
module PortainerCli
|
||||||
class Config
|
class Config
|
||||||
CONFIG_DIR = File.expand_path('~/.portainer-cli').freeze
|
CONFIG_DIR = File.expand_path('~/.config/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
|
attr_accessor :url, :token, :api_key, :ssl_verify
|
||||||
|
|
||||||
def self.load
|
def self.load
|
||||||
new.tap(&:read)
|
new.tap(&:read)
|
||||||
@@ -21,6 +21,7 @@ module PortainerCli
|
|||||||
@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
|
||||||
@@ -29,6 +30,7 @@ module PortainerCli
|
|||||||
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
|
||||||
|
|||||||
@@ -10,17 +10,20 @@ module PortainerCli
|
|||||||
class WebsocketExec
|
class WebsocketExec
|
||||||
READ_SIZE = 4096
|
READ_SIZE = 4096
|
||||||
|
|
||||||
def initialize(url, auth_header)
|
def initialize(url, auth_header, ssl_verify: true, debug: false)
|
||||||
@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
|
||||||
|
|
||||||
handshake = perform_handshake(socket)
|
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
|
||||||
@@ -33,15 +36,23 @@ module PortainerCli
|
|||||||
|
|
||||||
if @url.scheme == 'wss'
|
if @url.scheme == 'wss'
|
||||||
ctx = OpenSSL::SSL::SSLContext.new
|
ctx = OpenSSL::SSL::SSLContext.new
|
||||||
ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
ctx.verify_mode = @ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
||||||
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)
|
||||||
@@ -49,18 +60,37 @@ 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