Compare commits
2
Commits
ed5eca8924
...
2d45ce3af8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d45ce3af8 | ||
|
|
e002cdf170 |
No files matched your search
@@ -14,6 +14,7 @@ A Ruby command-line tool for interacting with [Portainer](https://www.portainer.
|
|||||||
- Supports both API key and JWT token authentication
|
- Supports both API key and JWT token authentication
|
||||||
- Configurable SSL verification (useful for self-signed certificates)
|
- Configurable SSL verification (useful for self-signed certificates)
|
||||||
- `--run CMD` flag for running a specific command (e.g. `rails c`)
|
- `--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
|
- `--quiet` flag for scripting and AI agent use cases
|
||||||
|
|
||||||
## Requirements
|
## 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'"
|
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
|
### Create resources
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -124,7 +135,7 @@ portainer-cli exec <endpoint-id> <container-name-or-id> --shell /bin/bash
|
|||||||
|
|
||||||
## Container name resolution
|
## 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
|
1. **Exact match** — full container name or ID prefix
|
||||||
2. **Compose pattern** — `<stack>-<service>-<N>` (e.g. `librarian` → `librarian-librarian-1`)
|
2. **Compose pattern** — `<stack>-<service>-<N>` (e.g. `librarian` → `librarian-librarian-1`)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ require_relative 'portainer_cli/commands/list'
|
|||||||
require_relative 'portainer_cli/commands/create'
|
require_relative 'portainer_cli/commands/create'
|
||||||
require_relative 'portainer_cli/commands/exec'
|
require_relative 'portainer_cli/commands/exec'
|
||||||
require_relative 'portainer_cli/commands/console'
|
require_relative 'portainer_cli/commands/console'
|
||||||
|
require_relative 'portainer_cli/commands/cp'
|
||||||
|
|
||||||
module PortainerCli
|
module PortainerCli
|
||||||
module CLI
|
module CLI
|
||||||
@@ -31,8 +32,12 @@ module PortainerCli
|
|||||||
portainer-cli create stack --endpoint ID --name NAME --file FILE
|
portainer-cli create stack --endpoint ID --name NAME --file FILE
|
||||||
portainer-cli create container --endpoint ID --image IMAGE [--name NAME] [--port h:c] [--env K=V]
|
portainer-cli create container --endpoint ID --image IMAGE [--name NAME] [--port h:c] [--env K=V]
|
||||||
portainer-cli console <stack> [container] Open interactive shell (resolves endpoint automatically)
|
portainer-cli console <stack> [container] Open interactive shell (resolves endpoint automatically)
|
||||||
|
--endpoint ID Disambiguate when stack name exists on multiple endpoints
|
||||||
--shell SHELL Shell to use (default: /bin/sh)
|
--shell SHELL Shell to use (default: /bin/sh)
|
||||||
--run CMD Run command instead of a bare shell
|
--run CMD Run command instead of a bare shell
|
||||||
|
portainer-cli cp <stack> <container>:<path> <dest> Copy a file/directory out of a container (binary-safe)
|
||||||
|
--endpoint ID Disambiguate when stack name exists on multiple endpoints
|
||||||
|
--quiet Suppress status output; <dest> of - writes to stdout
|
||||||
portainer-cli exec <endpoint-id> <container> Open interactive shell (manual endpoint)
|
portainer-cli exec <endpoint-id> <container> Open interactive shell (manual endpoint)
|
||||||
--shell SHELL Shell to use (default: /bin/sh)
|
--shell SHELL Shell to use (default: /bin/sh)
|
||||||
|
|
||||||
@@ -59,6 +64,10 @@ module PortainerCli
|
|||||||
require_config!(config)
|
require_config!(config)
|
||||||
client = Client.new(config)
|
client = Client.new(config)
|
||||||
Commands::Console.new(config, client, debug: debug).run(argv)
|
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'
|
when 'exec', 'shell'
|
||||||
require_config!(config)
|
require_config!(config)
|
||||||
client = Client.new(config)
|
client = Client.new(config)
|
||||||
|
|||||||
@@ -32,6 +32,22 @@ module PortainerCli
|
|||||||
request(:delete, path)
|
request(:delete, path)
|
||||||
end
|
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
|
# Returns the raw websocket URL (ws:// or wss://) for exec sessions
|
||||||
def websocket_url(path, params = {})
|
def websocket_url(path, params = {})
|
||||||
uri = ws_uri(path)
|
uri = ws_uri(path)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require_relative 'base'
|
require_relative 'base'
|
||||||
|
require_relative 'resolver'
|
||||||
require_relative '../websocket_exec'
|
require_relative '../websocket_exec'
|
||||||
require 'optparse'
|
require 'optparse'
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ module PortainerCli
|
|||||||
# needs to know or specify an endpoint ID.
|
# needs to know or specify an endpoint ID.
|
||||||
class Console
|
class Console
|
||||||
include Base
|
include Base
|
||||||
|
include Resolver
|
||||||
|
|
||||||
def initialize(config, client, debug: false)
|
def initialize(config, client, debug: false)
|
||||||
@config = config
|
@config = config
|
||||||
@@ -22,15 +24,16 @@ module PortainerCli
|
|||||||
def run(args)
|
def run(args)
|
||||||
opts = { shell: '/bin/sh' }
|
opts = { shell: '/bin/sh' }
|
||||||
OptionParser.new do |o|
|
OptionParser.new do |o|
|
||||||
o.on('--shell SHELL', 'Shell to use (default: /bin/sh)') { |v| opts[:shell] = v }
|
o.on('--shell SHELL', 'Shell to use (default: /bin/sh)') { |v| opts[:shell] = v }
|
||||||
o.on('--run CMD', 'Run command instead of a bare shell') { |v| opts[:run] = v }
|
o.on('--run CMD', 'Run command instead of a bare shell') { |v| opts[:run] = v }
|
||||||
o.on('--quiet', 'Suppress status output') { opts[:quiet] = true }
|
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)
|
end.parse!(args)
|
||||||
|
|
||||||
stack_name = args.shift or error("Usage: portainer-cli console <stack> [container] [--run CMD] [--shell /bin/bash]")
|
stack_name = args.shift or error("Usage: portainer-cli console <stack> [container] [--endpoint ID] [--run CMD] [--shell /bin/bash]")
|
||||||
container_name = args.shift || stack_name
|
container_name = args.shift || stack_name
|
||||||
|
|
||||||
stack = resolve_stack(stack_name)
|
stack = resolve_stack(stack_name, opts[:endpoint])
|
||||||
endpoint_id = stack['EndpointId']
|
endpoint_id = stack['EndpointId']
|
||||||
|
|
||||||
status("Stack: #{stack['Name']} Endpoint: #{endpoint_id}", opts[:quiet])
|
status("Stack: #{stack['Name']} Endpoint: #{endpoint_id}", opts[:quiet])
|
||||||
@@ -56,79 +59,6 @@ module PortainerCli
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def status(msg, quiet)
|
|
||||||
$stderr.puts dim(msg) unless quiet
|
|
||||||
end
|
|
||||||
|
|
||||||
def resolve_stack(name_or_id)
|
|
||||||
stacks = @client.get('/api/stacks')
|
|
||||||
match = stacks.find do |s|
|
|
||||||
s['Name'] == name_or_id || s['Id'].to_s == name_or_id
|
|
||||||
end
|
|
||||||
unless match
|
|
||||||
available = stacks.map { |s| " #{s['Id']} #{s['Name']}" }.join("\n")
|
|
||||||
error("Stack '#{name_or_id}' not found. Available stacks:\n#{available}")
|
|
||||||
end
|
|
||||||
match
|
|
||||||
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)
|
def create_exec(endpoint_id, container_id, shell, run_cmd = nil)
|
||||||
cmd = run_cmd ? [shell, '-c', run_cmd] : [shell]
|
cmd = run_cmd ? [shell, '-c', run_cmd] : [shell]
|
||||||
result = @client.post(
|
result = @client.post(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||