Files
portainer-cli/lib/portainer_cli/commands/create.rb
T
grzlus ed5eca8924 Fix 5 pre-release bugs
- Help text showed wrong config path (~/.portainer-cli vs ~/.config/portainer-cli)
- --port without colon (e.g. --port 8080) crashed with nil/tcp key; now errors clearly
- --cmd used naive split(' ') breaking quoted args; switched to Shellwords.split
- --env without '=' (e.g. --env FOO) silently passed empty value; now errors clearly
- SSL/connection errors surfaced as raw exception; now caught with actionable message

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 15:35:28 +01:00

123 lines
4.5 KiB
Ruby

# 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 <stack|container> [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