Smarter container matching using stack-service-replica pattern

Prefer exact "<stack>-<service>-<N>" compose naming over generic substring
match, so "librarian" in stack "librarian" resolves to "librarian-librarian-1"
unambiguously. Substring fallback is also scoped to the stack's own containers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 13:05:21 +01:00
parent 855877f6a6
commit a1015366a9
+23 -11
View File
@@ -32,7 +32,7 @@ module PortainerCli
$stderr.puts dim("Stack: #{stack['Name']} Endpoint: #{endpoint_id}")
container_id = resolve_container(endpoint_id, container_name)
container_id = resolve_container(endpoint_id, container_name, stack['Name'])
$stderr.puts dim("Connecting to #{container_name} (#{container_id[0, 12]})...")
@@ -65,23 +65,35 @@ module PortainerCli
match
end
def resolve_container(endpoint_id, name_or_id)
def resolve_container(endpoint_id, name_or_id, stack_name = nil)
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) ||
Array(c['Names']).any? { |n| n.delete_prefix('/') == name_or_id }
names_for.(c).any? { |n| n == name_or_id }
end
return exact['Id'] if exact
# 2. Fuzzy: container name contains the search term as a word segment
# Prefers "<stack>-<name>-<n>" pattern, falls back to any substring match
names_for = ->(c) { Array(c['Names']).map { |n| n.delete_prefix('/') } }
fuzzy = containers.select do |c|
names_for.(c).any? { |n| n.include?(name_or_id) }
# 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
$stderr.puts dim("Matched '#{name_or_id}' → #{matched_name}")
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
@@ -94,8 +106,8 @@ module PortainerCli
error("Ambiguous container '#{name_or_id}'. Did you mean one of:\n#{list}")
end
available = containers.map { |c| " #{c['Id'][0, 12]} #{names_for.(c).first}" }.join("\n")
error("Container '#{name_or_id}' not found on endpoint #{endpoint_id}. Available:\n#{available}")
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)