Add cp command to copy files out of containers, binary-safe

`portainer-cli cp <stack> <container>:<path> <dest>` 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 <noreply@anthropic.com>
This commit is contained in:
grzlusandClaude Opus 5.5 committed 2026-10-21 18:57:40 +02:00
1 parent e002cdf170
commit 2d45ce3af8
6 files changed
+282 -100

No files matched your search

+16
View File
@@ -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)
+2 -99
View File
@@ -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: "<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
def create_exec(endpoint_id, container_id, shell, run_cmd = nil)
cmd = run_cmd ? [shell, '-c', run_cmd] : [shell]
result = @client.post(
+134
View File
@@ -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 <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
+110
View File
@@ -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: "<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
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.