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:
6 files changed
+282
-100
No files matched your search
@@ -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(
|
||||
|
||||