`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>
135 lines
4.7 KiB
Ruby
135 lines
4.7 KiB
Ruby
# 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
|