# frozen_string_literal: true require_relative 'base' require 'optparse' require 'shellwords' module PortainerCli module Commands class Create include Base def initialize(config, client) @config = config @client = client end def run(args) require_config!(@config) sub = args.shift&.downcase case sub when 'stack' create_stack(args) when 'container' create_container(args) else puts "Usage: portainer-cli create [options]" exit 1 end end private # portainer-cli create stack --name mystack --endpoint 1 --file docker-compose.yml # portainer-cli create stack --name mystack --endpoint 1 --compose "version: '3'..." def create_stack(args) opts = { env: [] } OptionParser.new do |o| o.on('--name NAME', 'Stack name') { |v| opts[:name] = v } o.on('--endpoint ID', 'Endpoint ID') { |v| opts[:endpoint] = v } o.on('--file FILE', 'docker-compose.yml path') { |v| opts[:file] = v } o.on('--compose COMPOSE', 'Inline compose string') { |v| opts[:compose] = v } o.on('--env KEY=VAL', 'Environment variable') { |v| opts[:env] << v } end.parse!(args) error("--name is required") unless opts[:name] error("--endpoint is required") unless opts[:endpoint] compose_content = if opts[:file] error("File not found: #{opts[:file]}") unless File.exist?(opts[:file]) File.read(opts[:file]) elsif opts[:compose] opts[:compose] else error("Either --file or --compose is required") end env_vars = opts[:env].map do |pair| error("Invalid --env '#{pair}': expected KEY=VALUE") unless pair.include?('=') k, v = pair.split('=', 2) { 'name' => k, 'value' => v.to_s } end body = { 'name' => opts[:name], 'stackFileContent' => compose_content, 'env' => env_vars } result = @client.post( "/api/stacks/create/standalone/string?endpointId=#{opts[:endpoint]}", body ) puts green("Stack created: #{result['Name']} (ID: #{result['Id']})") end # portainer-cli create container --endpoint 1 --name web --image nginx:alpine --port 8080:80 --env FOO=bar def create_container(args) opts = { ports: [], env: [] } OptionParser.new do |o| o.on('--endpoint ID', 'Endpoint ID') { |v| opts[:endpoint] = v } o.on('--name NAME', 'Container name') { |v| opts[:name] = v } o.on('--image IMAGE', 'Docker image') { |v| opts[:image] = v } o.on('--port HOST:CONT', 'Port mapping') { |v| opts[:ports] << v } o.on('--env KEY=VAL', 'Environment variable') { |v| opts[:env] << v } o.on('--cmd CMD', 'Command to run') { |v| opts[:cmd] = v } end.parse!(args) error("--endpoint is required") unless opts[:endpoint] error("--image is required") unless opts[:image] port_bindings = {} exposed_ports = {} opts[:ports].each do |mapping| parts = mapping.split(':', 2) error("Invalid --port '#{mapping}': expected HOST:CONTAINER (e.g. 8080:80)") unless parts.size == 2 host_port, cont_port = parts error("Invalid --port '#{mapping}': missing host port") if host_port.empty? error("Invalid --port '#{mapping}': missing container port") if cont_port.empty? cont_key = "#{cont_port}/tcp" exposed_ports[cont_key] = {} port_bindings[cont_key] = [{ 'HostPort' => host_port }] end body = { 'Image' => opts[:image], 'Env' => opts[:env], 'ExposedPorts' => exposed_ports, 'HostConfig' => { 'PortBindings' => port_bindings } } body['Cmd'] = Shellwords.split(opts[:cmd]) if opts[:cmd] query = opts[:name] ? "?name=#{URI.encode_www_form_component(opts[:name])}" : '' result = @client.post("/api/endpoints/#{opts[:endpoint]}/docker/containers/create#{query}", body) container_id = result['Id'] @client.post("/api/endpoints/#{opts[:endpoint]}/docker/containers/#{container_id}/start") puts green("Container started: #{opts[:name] || container_id[0, 12]}") end end end end