# 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: "--" # 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