Compare commits
0
Commits
master
..
855877f6a6
No files matched your search
@@ -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
|
||||
@@ -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
@@ -9,7 +9,6 @@ require_relative 'portainer_cli/commands/list'
|
||||
require_relative 'portainer_cli/commands/create'
|
||||
require_relative 'portainer_cli/commands/exec'
|
||||
require_relative 'portainer_cli/commands/console'
|
||||
require_relative 'portainer_cli/commands/cp'
|
||||
|
||||
module PortainerCli
|
||||
module CLI
|
||||
@@ -31,21 +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)
|
||||
--endpoint ID Disambiguate when stack name exists on multiple endpoints
|
||||
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 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)
|
||||
--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
|
||||
|
||||
@@ -63,15 +56,11 @@ module PortainerCli
|
||||
when 'console'
|
||||
require_config!(config)
|
||||
client = Client.new(config)
|
||||
Commands::Console.new(config, client, debug: debug).run(argv)
|
||||
when 'cp'
|
||||
require_config!(config)
|
||||
client = Client.new(config)
|
||||
Commands::Cp.new(config, client).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
|
||||
|
||||
@@ -32,22 +32,6 @@ module PortainerCli
|
||||
request(:delete, path)
|
||||
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
|
||||
def websocket_url(path, params = {})
|
||||
uri = ws_uri(path)
|
||||
@@ -76,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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'base'
|
||||
require_relative 'resolver'
|
||||
require_relative '../websocket_exec'
|
||||
require 'optparse'
|
||||
|
||||
@@ -13,44 +12,39 @@ module PortainerCli
|
||||
# needs to know or specify an endpoint ID.
|
||||
class Console
|
||||
include Base
|
||||
include Resolver
|
||||
|
||||
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 }
|
||||
o.on('--endpoint ID', 'Endpoint ID to disambiguate stacks') { |v| opts[:endpoint] = v }
|
||||
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 <stack> [container] [--endpoint ID] [--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, opts[:endpoint])
|
||||
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
|
||||
@@ -59,8 +53,52 @@ module PortainerCli
|
||||
|
||||
private
|
||||
|
||||
def create_exec(endpoint_id, container_id, shell, run_cmd = nil)
|
||||
cmd = run_cmd ? [shell, '-c', run_cmd] : [shell]
|
||||
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)
|
||||
|
||||
# 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(
|
||||
"/api/endpoints/#{endpoint_id}/docker/containers/#{container_id}/exec",
|
||||
{
|
||||
@@ -68,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)
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -18,19 +18,17 @@ module PortainerCli
|
||||
return unless File.exist?(CONFIG_FILE)
|
||||
|
||||
data = YAML.safe_load(File.read(CONFIG_FILE), permitted_classes: [], symbolize_names: false) || {}
|
||||
@url = data['url']
|
||||
@token = data['token']
|
||||
@api_key = data['api_key']
|
||||
@ssl_verify = data.fetch('ssl_verify', true)
|
||||
@url = data['url']
|
||||
@token = data['token']
|
||||
@api_key = data['api_key']
|
||||
end
|
||||
|
||||
def save
|
||||
FileUtils.mkdir_p(CONFIG_DIR)
|
||||
data = {}
|
||||
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?
|
||||
data['url'] = @url if @url
|
||||
data['token'] = @token if @token
|
||||
data['api_key'] = @api_key if @api_key
|
||||
File.write(CONFIG_FILE, YAML.dump(data))
|
||||
File.chmod(0o600, CONFIG_FILE)
|
||||
end
|
||||
|
||||
@@ -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.connect
|
||||
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
|
||||
|
||||
|
||||