From 2d45ce3af817156c42f9f0440259e4388c1955d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20=C5=81uszczek?= Date: Wed, 21 Oct 2026 18:57:40 +0200 Subject: [PATCH] Add cp command to copy files out of containers, binary-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `portainer-cli cp : ` goes through Docker's archive API, so unlike `console --run "cat …"` there's no TTY in between and binary files (database dumps) arrive intact. Works like `docker cp` for files and directories; a dest of "-" writes a single file to stdout. Stack and container lookup moves from Console into a shared Resolver module. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 ++- lib/portainer_cli.rb | 8 ++ lib/portainer_cli/client.rb | 16 +++ lib/portainer_cli/commands/console.rb | 101 +------------------ lib/portainer_cli/commands/cp.rb | 134 +++++++++++++++++++++++++ lib/portainer_cli/commands/resolver.rb | 110 ++++++++++++++++++++ 6 files changed, 282 insertions(+), 100 deletions(-) create mode 100644 lib/portainer_cli/commands/cp.rb create mode 100644 lib/portainer_cli/commands/resolver.rb diff --git a/README.md b/README.md index 5f08d73..d873afa 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A Ruby command-line tool for interacting with [Portainer](https://www.portainer. - 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 @@ -98,6 +99,16 @@ Suppresses all status output — only the container's own stdout/stderr is print 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 @@ -124,7 +135,7 @@ portainer-cli exec --shell /bin/bash ## Container name resolution -When using `console`, names are resolved in this order: +When using `console` or `cp`, names are resolved in this order: 1. **Exact match** — full container name or ID prefix 2. **Compose pattern** — `--` (e.g. `librarian` → `librarian-librarian-1`) diff --git a/lib/portainer_cli.rb b/lib/portainer_cli.rb index ef4765b..be2595f 100644 --- a/lib/portainer_cli.rb +++ b/lib/portainer_cli.rb @@ -9,6 +9,7 @@ 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 @@ -34,6 +35,9 @@ module PortainerCli --endpoint ID Disambiguate when stack name exists on multiple endpoints --shell SHELL Shell to use (default: /bin/sh) --run CMD Run command instead of a bare shell + portainer-cli cp : Copy a file/directory out of a container (binary-safe) + --endpoint ID Disambiguate when stack name exists on multiple endpoints + --quiet Suppress status output; of - writes to stdout portainer-cli exec Open interactive shell (manual endpoint) --shell SHELL Shell to use (default: /bin/sh) @@ -60,6 +64,10 @@ module PortainerCli 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) when 'exec', 'shell' require_config!(config) client = Client.new(config) diff --git a/lib/portainer_cli/client.rb b/lib/portainer_cli/client.rb index 192b4a6..316cca1 100644 --- a/lib/portainer_cli/client.rb +++ b/lib/portainer_cli/client.rb @@ -32,6 +32,22 @@ 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) diff --git a/lib/portainer_cli/commands/console.rb b/lib/portainer_cli/commands/console.rb index 0e34243..5204f76 100644 --- a/lib/portainer_cli/commands/console.rb +++ b/lib/portainer_cli/commands/console.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative 'base' +require_relative 'resolver' require_relative '../websocket_exec' require 'optparse' @@ -12,6 +13,7 @@ module PortainerCli # needs to know or specify an endpoint ID. class Console include Base + include Resolver def initialize(config, client, debug: false) @config = config @@ -57,105 +59,6 @@ module PortainerCli 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: "--" - # 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 - def create_exec(endpoint_id, container_id, shell, run_cmd = nil) cmd = run_cmd ? [shell, '-c', run_cmd] : [shell] result = @client.post( diff --git a/lib/portainer_cli/commands/cp.rb b/lib/portainer_cli/commands/cp.rb new file mode 100644 index 0000000..16189a5 --- /dev/null +++ b/lib/portainer_cli/commands/cp.rb @@ -0,0 +1,134 @@ +# 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 : [--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. + # of "-" writes a single file to stdout. + class Cp + include Base + include Resolver + + USAGE = 'Usage: portainer-cli cp : [--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 diff --git a/lib/portainer_cli/commands/resolver.rb b/lib/portainer_cli/commands/resolver.rb new file mode 100644 index 0000000..04b285a --- /dev/null +++ b/lib/portainer_cli/commands/resolver.rb @@ -0,0 +1,110 @@ +# 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: "--" + # 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