ed5eca8924
- 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>
123 lines
4.5 KiB
Ruby
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
|