# 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