Merge branch 'main' into feature/docker-build-cloud

This commit is contained in:
Donal McBreen
2025-01-17 15:49:28 +00:00
committed by GitHub
54 changed files with 1154 additions and 196 deletions

View File

@@ -28,7 +28,7 @@ jobs:
- "3.1" - "3.1"
- "3.2" - "3.2"
- "3.3" - "3.3"
- "3.4.0-preview2" - "3.4"
gemfile: gemfile:
- Gemfile - Gemfile
- gemfiles/rails_edge.gemfile - gemfiles/rails_edge.gemfile

View File

@@ -2,6 +2,7 @@ module Kamal::Cli
class BootError < StandardError; end class BootError < StandardError; end
class HookError < StandardError; end class HookError < StandardError; end
class LockError < StandardError; end class LockError < StandardError; end
class DependencyError < StandardError; end
end end
# SSHKit uses instance eval, so we need a global const for ergonomics # SSHKit uses instance eval, so we need a global const for ergonomics

View File

@@ -292,7 +292,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
def prepare(name) def prepare(name)
with_accessory(name) do |accessory, hosts| with_accessory(name) do |accessory, hosts|
on(hosts) do on(hosts) do
execute *KAMAL.registry.login execute *KAMAL.registry.login(registry_config: accessory.registry)
execute *KAMAL.docker.create_network execute *KAMAL.docker.create_network
rescue SSHKit::Command::Failed => e rescue SSHKit::Command::Failed => e
raise unless e.message.include?("already exists") raise unless e.message.include?("already exists")

View File

@@ -5,7 +5,7 @@ module Kamal::Cli
class Base < Thor class Base < Thor
include SSHKit::DSL include SSHKit::DSL
def self.exit_on_failure?() false end def self.exit_on_failure?() true end
def self.dynamic_command_class() Kamal::Cli::Alias::Command end def self.dynamic_command_class() Kamal::Cli::Alias::Command end
class_option :verbose, type: :boolean, aliases: "-v", desc: "Detailed logging" class_option :verbose, type: :boolean, aliases: "-v", desc: "Detailed logging"
@@ -30,7 +30,8 @@ module Kamal::Cli
else else
super super
end end
initialize_commander unless KAMAL.configured?
initialize_commander unless config[:invoked_via_subcommand]
end end
private private
@@ -194,5 +195,19 @@ module Kamal::Cli
ENV.clear ENV.clear
ENV.update(current_env) ENV.update(current_env)
end end
def ensure_docker_installed
run_locally do
begin
execute *KAMAL.builder.ensure_docker_installed
rescue SSHKit::Command::Failed => e
error = e.message =~ /command not found/ ?
"Docker is not installed locally" :
"Docker buildx plugin is not installed locally"
raise DependencyError, error
end
end
end
end end
end end

View File

@@ -13,7 +13,7 @@ class Kamal::Cli::Build < Kamal::Cli::Base
def push def push
cli = self cli = self
verify_local_dependencies ensure_docker_installed
run_hook "pre-build" run_hook "pre-build"
uncommitted_changes = Kamal::Git.uncommitted_changes uncommitted_changes = Kamal::Git.uncommitted_changes
@@ -109,20 +109,6 @@ class Kamal::Cli::Build < Kamal::Cli::Base
end end
private private
def verify_local_dependencies
run_locally do
begin
execute *KAMAL.builder.ensure_local_dependencies_installed
rescue SSHKit::Command::Failed => e
build_error = e.message =~ /command not found/ ?
"Docker is not installed locally" :
"Docker buildx plugin is not installed locally"
raise BuildError, build_error
end
end
end
def connect_to_remote_host(remote_host) def connect_to_remote_host(remote_host)
remote_uri = URI.parse(remote_host) remote_uri = URI.parse(remote_host)
if remote_uri.scheme == "ssh" if remote_uri.scheme == "ssh"

View File

@@ -9,15 +9,14 @@ class Kamal::Cli::Main < Kamal::Cli::Base
say "Ensure Docker is installed...", :magenta say "Ensure Docker is installed...", :magenta
invoke "kamal:cli:server:bootstrap", [], invoke_options invoke "kamal:cli:server:bootstrap", [], invoke_options
invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options deploy(boot_accessories: true)
deploy
end end
end end
end end
desc "deploy", "Deploy app to servers" desc "deploy", "Deploy app to servers"
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
def deploy def deploy(boot_accessories: false)
runtime = print_runtime do runtime = print_runtime do
invoke_options = deploy_options invoke_options = deploy_options
@@ -38,6 +37,8 @@ class Kamal::Cli::Main < Kamal::Cli::Base
say "Ensure kamal-proxy is running...", :magenta say "Ensure kamal-proxy is running...", :magenta
invoke "kamal:cli:proxy:boot", [], invoke_options invoke "kamal:cli:proxy:boot", [], invoke_options
invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options if boot_accessories
say "Detect stale containers...", :magenta say "Detect stale containers...", :magenta
invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true) invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)

View File

@@ -23,6 +23,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
desc "boot_config <set|get|reset>", "Manage kamal-proxy boot configuration" desc "boot_config <set|get|reset>", "Manage kamal-proxy boot configuration"
option :publish, type: :boolean, default: true, desc: "Publish the proxy ports on the host" option :publish, type: :boolean, default: true, desc: "Publish the proxy ports on the host"
option :publish_host_ip, type: :string, repeatable: true, default: nil, desc: "Host IP address to bind HTTP/HTTPS traffic to. Defaults to all interfaces"
option :http_port, type: :numeric, default: Kamal::Configuration::PROXY_HTTP_PORT, desc: "HTTP port to publish on the host" option :http_port, type: :numeric, default: Kamal::Configuration::PROXY_HTTP_PORT, desc: "HTTP port to publish on the host"
option :https_port, type: :numeric, default: Kamal::Configuration::PROXY_HTTPS_PORT, desc: "HTTPS port to publish on the host" option :https_port, type: :numeric, default: Kamal::Configuration::PROXY_HTTPS_PORT, desc: "HTTPS port to publish on the host"
option :log_max_size, type: :string, default: Kamal::Configuration::PROXY_LOG_MAX_SIZE, desc: "Max size of proxy logs" option :log_max_size, type: :string, default: Kamal::Configuration::PROXY_LOG_MAX_SIZE, desc: "Max size of proxy logs"
@@ -31,7 +32,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
case subcommand case subcommand
when "set" when "set"
boot_options = [ boot_options = [
*(KAMAL.config.proxy_publish_args(options[:http_port], options[:https_port]) if options[:publish]), *(KAMAL.config.proxy_publish_args(options[:http_port], options[:https_port], options[:publish_host_ip]) if options[:publish]),
*(KAMAL.config.proxy_logging_args(options[:log_max_size])), *(KAMAL.config.proxy_logging_args(options[:log_max_size])),
*options[:docker_options].map { |option| "--#{option}" } *options[:docker_options].map { |option| "--#{option}" }
] ]

View File

@@ -3,6 +3,8 @@ class Kamal::Cli::Registry < Kamal::Cli::Base
option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login" option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login"
option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login" option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login"
def login def login
ensure_docker_installed
run_locally { execute *KAMAL.registry.login } unless options[:skip_local] run_locally { execute *KAMAL.registry.login } unless options[:skip_local]
on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote] on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote]
end end

View File

@@ -76,11 +76,6 @@ class Kamal::Commander
config.accessories&.collect(&:name) || [] config.accessories&.collect(&:name) || []
end end
def accessories_on(host)
config.accessories.select { |accessory| accessory.hosts.include?(host.to_s) }.map(&:name)
end
def app(role: nil, host: nil) def app(role: nil, host: nil)
Kamal::Commands::App.new(config, role: role, host: host) Kamal::Commands::App.new(config, role: role, host: host)
end end
@@ -129,7 +124,6 @@ class Kamal::Commander
config.aliases[name] config.aliases[name]
end end
def with_verbosity(level) def with_verbosity(level)
old_level = self.verbosity old_level = self.verbosity

View File

@@ -4,11 +4,10 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
attr_reader :accessory_config attr_reader :accessory_config
delegate :service_name, :image, :hosts, :port, :files, :directories, :cmd, delegate :service_name, :image, :hosts, :port, :files, :directories, :cmd,
:network_args, :publish_args, :env_args, :volume_args, :label_args, :option_args, :network_args, :publish_args, :env_args, :volume_args, :label_args, :option_args,
:secrets_io, :secrets_path, :env_directory, :proxy, :running_proxy?, :secrets_io, :secrets_path, :env_directory, :proxy, :running_proxy?, :registry,
to: :accessory_config to: :accessory_config
delegate :proxy_container_name, to: :config delegate :proxy_container_name, to: :config
def initialize(config, name:) def initialize(config, name:)
super(config) super(config)
@accessory_config = config.accessory(name) @accessory_config = config.accessory(name)
@@ -42,7 +41,6 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
docker :ps, *service_filter docker :ps, *service_filter
end end
def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil) def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil)
pipe \ pipe \
docker(:logs, service_name, (" --since #{since}" if since), (" --tail #{lines}" if lines), ("--timestamps" if timestamps), "2>&1"), docker(:logs, service_name, (" --since #{since}" if since), (" --tail #{lines}" if lines), ("--timestamps" if timestamps), "2>&1"),
@@ -56,7 +54,6 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
(%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep)
end end
def execute_in_existing_container(*command, interactive: false) def execute_in_existing_container(*command, interactive: false)
docker :exec, docker :exec,
("-it" if interactive), ("-it" if interactive),
@@ -87,7 +84,6 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
super command, host: hosts.first super command, host: hosts.first
end end
def ensure_local_file_present(local_file) def ensure_local_file_present(local_file)
if !local_file.is_a?(StringIO) && !Pathname.new(local_file).exist? if !local_file.is_a?(StringIO) && !Pathname.new(local_file).exist?
raise "Missing file: #{local_file}" raise "Missing file: #{local_file}"

View File

@@ -4,10 +4,10 @@ module Kamal::Commands::App::Assets
combine \ combine \
make_directory(role.asset_extracted_directory), make_directory(role.asset_extracted_directory),
[ *docker(:stop, "-t 1", asset_container, "2> /dev/null"), "|| true" ], [ *docker(:container, :rm, asset_container, "2> /dev/null"), "|| true" ],
docker(:run, "--name", asset_container, "--detach", "--rm", "--entrypoint", "sleep", config.absolute_image, "1000000"), docker(:container, :create, "--name", asset_container, config.absolute_image),
docker(:cp, "-L", "#{asset_container}:#{role.asset_path}/.", role.asset_extracted_directory), docker(:container, :cp, "-L", "#{asset_container}:#{role.asset_path}/.", role.asset_extracted_directory),
docker(:stop, "-t 1", asset_container), docker(:container, :rm, asset_container),
by: "&&" by: "&&"
end end

View File

@@ -34,6 +34,12 @@ module Kamal::Commands
[ :rm, path ] [ :rm, path ]
end end
def ensure_docker_installed
combine \
ensure_local_docker_installed,
ensure_local_buildx_installed
end
private private
def combine(*commands, by: "&&") def combine(*commands, by: "&&")
commands commands
@@ -104,5 +110,13 @@ module Kamal::Commands
" -i #{key}" " -i #{key}"
end end
end end
def ensure_local_docker_installed
docker "--version"
end
def ensure_local_buildx_installed
docker :buildx, "version"
end
end end
end end

View File

@@ -39,24 +39,4 @@ class Kamal::Commands::Builder < Kamal::Commands::Base
def cloud def cloud
@cloud ||= Kamal::Commands::Builder::Cloud.new(config) @cloud ||= Kamal::Commands::Builder::Cloud.new(config)
end end
def ensure_local_dependencies_installed
if name.native?
ensure_local_docker_installed
else
combine \
ensure_local_docker_installed,
ensure_local_buildx_installed
end
end
private
def ensure_local_docker_installed
docker "--version"
end
def ensure_local_buildx_installed
docker :buildx, "version"
end
end end

View File

@@ -1,14 +1,16 @@
class Kamal::Commands::Registry < Kamal::Commands::Base class Kamal::Commands::Registry < Kamal::Commands::Base
delegate :registry, to: :config def login(registry_config: nil)
registry_config ||= config.registry
def login
docker :login, docker :login,
registry.server, registry_config.server,
"-u", sensitive(Kamal::Utils.escape_shell_value(registry.username)), "-u", sensitive(Kamal::Utils.escape_shell_value(registry_config.username)),
"-p", sensitive(Kamal::Utils.escape_shell_value(registry.password)) "-p", sensitive(Kamal::Utils.escape_shell_value(registry_config.password))
end end
def logout def logout(registry_config: nil)
docker :logout, registry.server registry_config ||= config.registry
docker :logout, registry_config.server
end end
end end

View File

@@ -59,7 +59,7 @@ class Kamal::Configuration
# Eager load config to validate it, these are first as they have dependencies later on # Eager load config to validate it, these are first as they have dependencies later on
@servers = Servers.new(config: self) @servers = Servers.new(config: self)
@registry = Registry.new(config: self) @registry = Registry.new(config: @raw_config, secrets: secrets)
@accessories = @raw_config.accessories&.keys&.collect { |name| Accessory.new(name, config: self) } || [] @accessories = @raw_config.accessories&.keys&.collect { |name| Accessory.new(name, config: self) } || []
@aliases = @raw_config.aliases&.keys&.to_h { |name| [ name, Alias.new(name, config: self) ] } || {} @aliases = @raw_config.aliases&.keys&.to_h { |name| [ name, Alias.new(name, config: self) ] } || {}
@@ -82,7 +82,6 @@ class Kamal::Configuration
ensure_unique_hosts_for_ssl_roles ensure_unique_hosts_for_ssl_roles
end end
def version=(version) def version=(version)
@declared_version = version @declared_version = version
end end
@@ -106,7 +105,6 @@ class Kamal::Configuration
raw_config.minimum_version raw_config.minimum_version
end end
def roles def roles
servers.roles servers.roles
end end
@@ -119,7 +117,6 @@ class Kamal::Configuration
accessories.detect { |a| a.name == name.to_s } accessories.detect { |a| a.name == name.to_s }
end end
def all_hosts def all_hosts
(roles + accessories).flat_map(&:hosts).uniq (roles + accessories).flat_map(&:hosts).uniq
end end
@@ -180,7 +177,6 @@ class Kamal::Configuration
raw_config.retain_containers || 5 raw_config.retain_containers || 5
end end
def volume_args def volume_args
if raw_config.volumes.present? if raw_config.volumes.present?
argumentize "--volume", raw_config.volumes argumentize "--volume", raw_config.volumes
@@ -193,7 +189,6 @@ class Kamal::Configuration
logging.args logging.args
end end
def readiness_delay def readiness_delay
raw_config.readiness_delay || 7 raw_config.readiness_delay || 7
end end
@@ -206,7 +201,6 @@ class Kamal::Configuration
raw_config.drain_timeout || 30 raw_config.drain_timeout || 30
end end
def run_directory def run_directory
".kamal" ".kamal"
end end
@@ -227,7 +221,6 @@ class Kamal::Configuration
File.join app_directory, "assets" File.join app_directory, "assets"
end end
def hooks_path def hooks_path
raw_config.hooks_path || ".kamal/hooks" raw_config.hooks_path || ".kamal/hooks"
end end
@@ -236,7 +229,6 @@ class Kamal::Configuration
raw_config.asset_path raw_config.asset_path
end end
def env_tags def env_tags
@env_tags ||= if (tags = raw_config.env["tags"]) @env_tags ||= if (tags = raw_config.env["tags"])
tags.collect { |name, config| Env::Tag.new(name, config: config, secrets: secrets) } tags.collect { |name, config| Env::Tag.new(name, config: config, secrets: secrets) }
@@ -249,8 +241,16 @@ class Kamal::Configuration
env_tags.detect { |t| t.name == name.to_s } env_tags.detect { |t| t.name == name.to_s }
end end
def proxy_publish_args(http_port, https_port) def proxy_publish_args(http_port, https_port, bind_ips = nil)
argumentize "--publish", [ "#{http_port}:#{PROXY_HTTP_PORT}", "#{https_port}:#{PROXY_HTTPS_PORT}" ] ensure_valid_bind_ips(bind_ips)
(bind_ips || [ nil ]).map do |bind_ip|
bind_ip = format_bind_ip(bind_ip)
publish_http = [ bind_ip, http_port, PROXY_HTTP_PORT ].compact.join(":")
publish_https = [ bind_ip, https_port, PROXY_HTTPS_PORT ].compact.join(":")
argumentize "--publish", [ publish_http, publish_https ]
end.join(" ")
end end
def proxy_logging_args(max_size) def proxy_logging_args(max_size)
@@ -277,7 +277,6 @@ class Kamal::Configuration
File.join proxy_directory, "options" File.join proxy_directory, "options"
end end
def to_h def to_h
{ {
roles: role_names, roles: role_names,
@@ -344,6 +343,15 @@ class Kamal::Configuration
true true
end end
def ensure_valid_bind_ips(bind_ips)
bind_ips.present? && bind_ips.each do |ip|
next if ip =~ Resolv::IPv4::Regex || ip =~ Resolv::IPv6::Regex
raise ArgumentError, "Invalid publish IP address: #{ip}"
end
true
end
def ensure_retain_containers_valid def ensure_retain_containers_valid
raise Kamal::ConfigurationError, "Must retain at least 1 container" if retain_containers < 1 raise Kamal::ConfigurationError, "Must retain at least 1 container" if retain_containers < 1
@@ -375,6 +383,15 @@ class Kamal::Configuration
true true
end end
def format_bind_ip(ip)
# Ensure IPv6 address inside square brackets - e.g. [::1]
if ip =~ Resolv::IPv6::Regex && ip !~ /\[.*\]/
"[#{ip}]"
else
ip
end
end
def role_names def role_names
raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
end end

View File

@@ -5,7 +5,7 @@ class Kamal::Configuration::Accessory
delegate :argumentize, :optionize, to: Kamal::Utils delegate :argumentize, :optionize, to: Kamal::Utils
attr_reader :name, :accessory_config, :env, :proxy attr_reader :name, :env, :proxy, :registry
def initialize(name, config:) def initialize(name, config:)
@name, @config, @accessory_config = name.inquiry, config, config.raw_config["accessories"][name] @name, @config, @accessory_config = name.inquiry, config, config.raw_config["accessories"][name]
@@ -16,12 +16,9 @@ class Kamal::Configuration::Accessory
context: "accessories/#{name}", context: "accessories/#{name}",
with: Kamal::Configuration::Validator::Accessory with: Kamal::Configuration::Validator::Accessory
@env = Kamal::Configuration::Env.new \ @env = initialize_env
config: accessory_config.fetch("env", {}), @proxy = initialize_proxy if running_proxy?
secrets: config.secrets, @registry = initialize_registry if accessory_config["registry"].present?
context: "accessories/#{name}/env"
initialize_proxy if running_proxy?
end end
def service_name def service_name
@@ -29,7 +26,7 @@ class Kamal::Configuration::Accessory
end end
def image def image
accessory_config["image"] [ registry&.server, accessory_config["image"] ].compact.join("/")
end end
def hosts def hosts
@@ -109,18 +106,32 @@ class Kamal::Configuration::Accessory
end end
def running_proxy? def running_proxy?
@accessory_config["proxy"].present? accessory_config["proxy"].present?
end
def initialize_proxy
@proxy = Kamal::Configuration::Proxy.new \
config: config,
proxy_config: accessory_config["proxy"],
context: "accessories/#{name}/proxy"
end end
private private
attr_accessor :config attr_reader :config, :accessory_config
def initialize_env
Kamal::Configuration::Env.new \
config: accessory_config.fetch("env", {}),
secrets: config.secrets,
context: "accessories/#{name}/env"
end
def initialize_proxy
Kamal::Configuration::Proxy.new \
config: config,
proxy_config: accessory_config["proxy"],
context: "accessories/#{name}/proxy"
end
def initialize_registry
Kamal::Configuration::Registry.new \
config: accessory_config,
secrets: config.secrets,
context: "accessories/#{name}/registry"
end
def default_labels def default_labels
{ "service" => service_name } { "service" => service_name }

View File

@@ -23,9 +23,27 @@ accessories:
# Image # Image
# #
# The Docker image to use, prefix it with a registry if not using Docker Hub: # The Docker image to use.
# Prefix it with its server when using root level registry different from Docker Hub.
# Define registry directly or via anchors when it differs from root level registry.
image: mysql:8.0 image: mysql:8.0
# Registry
#
# By default accessories use Docker Hub registry.
# You can specify different registry per accessory with this option.
# Don't prefix image with this registry server.
# Use anchors if you need to set the same specific registry for several accessories.
#
# ```yml
# registry:
# <<: *specific-registry
# ```
#
# See kamal docs registry for more information:
registry:
...
# Accessory hosts # Accessory hosts
# #
# Specify one of `host`, `hosts`, or `roles`: # Specify one of `host`, `hosts`, or `roles`:

View File

@@ -1,12 +1,10 @@
class Kamal::Configuration::Registry class Kamal::Configuration::Registry
include Kamal::Configuration::Validation include Kamal::Configuration::Validation
attr_reader :registry_config, :secrets def initialize(config:, secrets:, context: "registry")
@registry_config = config["registry"] || {}
def initialize(config:) @secrets = secrets
@registry_config = config.raw_config.registry || {} validate! registry_config, context: context, with: Kamal::Configuration::Validator::Registry
@secrets = config.secrets
validate! registry_config, with: Kamal::Configuration::Validator::Registry
end end
def server def server
@@ -22,6 +20,8 @@ class Kamal::Configuration::Registry
end end
private private
attr_reader :registry_config, :secrets
def lookup(key) def lookup(key)
if registry_config[key].is_a?(Array) if registry_config[key].is_a?(Array)
secrets[registry_config[key].first] secrets[registry_config[key].first]

View File

@@ -10,7 +10,7 @@ class Kamal::Configuration::Role
def initialize(name, config:) def initialize(name, config:)
@name, @config = name.inquiry, config @name, @config = name.inquiry, config
validate! \ validate! \
specializations, role_config,
example: validation_yml["servers"]["workers"], example: validation_yml["servers"]["workers"],
context: "servers/#{name}", context: "servers/#{name}",
with: Kamal::Configuration::Validator::Role with: Kamal::Configuration::Validator::Role
@@ -204,11 +204,11 @@ class Kamal::Configuration::Role
end end
def specializations def specializations
if config.raw_config.servers.is_a?(Array) || config.raw_config.servers[name].is_a?(Array) @specializations ||= role_config.is_a?(Array) ? {} : role_config
{} end
else
config.raw_config.servers[name] def role_config
end @role_config ||= config.raw_config.servers.is_a?(Array) ? {} : config.raw_config.servers[name]
end end
def custom_labels def custom_labels

View File

@@ -3,7 +3,7 @@ class Kamal::Configuration::Validator::Role < Kamal::Configuration::Validator
validate_type! config, Array, Hash validate_type! config, Array, Hash
if config.is_a?(Array) if config.is_a?(Array)
validate_servers! "servers", config validate_servers!(config)
else else
super super
end end

View File

@@ -3,6 +3,8 @@ module Kamal::Secrets::Adapters
def self.lookup(name) def self.lookup(name)
name = "one_password" if name.downcase == "1password" name = "one_password" if name.downcase == "1password"
name = "last_pass" if name.downcase == "lastpass" name = "last_pass" if name.downcase == "lastpass"
name = "gcp_secret_manager" if name.downcase == "gcp"
name = "bitwarden_secrets_manager" if name.downcase == "bitwarden-sm"
adapter_class(name) adapter_class(name)
end end

View File

@@ -4,9 +4,9 @@ class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Ba
nil nil
end end
def fetch_secrets(secrets, account:, session:) def fetch_secrets(secrets, from:, account:, session:)
{}.tap do |results| {}.tap do |results|
get_from_secrets_manager(secrets, account: account).each do |secret| get_from_secrets_manager(prefixed_secrets(secrets, from: from), account: account).each do |secret|
secret_name = secret["Name"] secret_name = secret["Name"]
secret_string = JSON.parse(secret["SecretString"]) secret_string = JSON.parse(secret["SecretString"])

View File

@@ -7,8 +7,7 @@ class Kamal::Secrets::Adapters::Base
check_dependencies! check_dependencies!
session = login(account) session = login(account)
full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") } fetch_secrets(secrets, from: from, account: account, session: session)
fetch_secrets(full_secrets, account: account, session: session)
end end
def requires_account? def requires_account?
@@ -27,4 +26,8 @@ class Kamal::Secrets::Adapters::Base
def check_dependencies! def check_dependencies!
raise NotImplementedError raise NotImplementedError
end end
def prefixed_secrets(secrets, from:)
secrets.map { |secret| [ from, secret ].compact.join("/") }
end
end end

View File

@@ -21,9 +21,9 @@ class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base
session session
end end
def fetch_secrets(secrets, account:, session:) def fetch_secrets(secrets, from:, account:, session:)
{}.tap do |results| {}.tap do |results|
items_fields(secrets).each do |item, fields| items_fields(prefixed_secrets(secrets, from: from)).each do |item, fields|
item_json = run_command("get item #{item.shellescape}", session: session, raw: true) item_json = run_command("get item #{item.shellescape}", session: session, raw: true)
raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success? raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success?
item_json = JSON.parse(item_json) item_json = JSON.parse(item_json)

View File

@@ -0,0 +1,72 @@
class Kamal::Secrets::Adapters::BitwardenSecretsManager < Kamal::Secrets::Adapters::Base
def requires_account?
false
end
private
LIST_ALL_SELECTOR = "all"
LIST_ALL_FROM_PROJECT_SUFFIX = "/all"
LIST_COMMAND = "secret list -o env"
GET_COMMAND = "secret get -o env"
def fetch_secrets(secrets, from:, account:, session:)
raise RuntimeError, "You must specify what to retrieve from Bitwarden Secrets Manager" if secrets.length == 0
secrets = prefixed_secrets(secrets, from: from)
command, project = extract_command_and_project(secrets)
{}.tap do |results|
if command.nil?
secrets.each do |secret_uuid|
secret = run_command("#{GET_COMMAND} #{secret_uuid.shellescape}")
raise RuntimeError, "Could not read #{secret_uuid} from Bitwarden Secrets Manager" unless $?.success?
key, value = parse_secret(secret)
results[key] = value
end
else
secrets = run_command(command)
raise RuntimeError, "Could not read secrets from Bitwarden Secrets Manager" unless $?.success?
secrets.split("\n").each do |secret|
key, value = parse_secret(secret)
results[key] = value
end
end
end
end
def extract_command_and_project(secrets)
if secrets.length == 1
if secrets[0] == LIST_ALL_SELECTOR
[ LIST_COMMAND, nil ]
elsif secrets[0].end_with?(LIST_ALL_FROM_PROJECT_SUFFIX)
project = secrets[0].split(LIST_ALL_FROM_PROJECT_SUFFIX).first
[ "#{LIST_COMMAND} #{project.shellescape}", project ]
end
end
end
def parse_secret(secret)
key, value = secret.split("=", 2)
value = value.gsub(/^"|"$/, "")
[ key, value ]
end
def run_command(command, session: nil)
full_command = [ "bws", command ].join(" ")
`#{full_command}`
end
def login(account)
run_command("run 'echo OK'")
raise RuntimeError, "Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?" unless $?.success?
end
def check_dependencies!
raise RuntimeError, "Bitwarden Secrets Manager CLI is not installed" unless cli_installed?
end
def cli_installed?
`bws --version 2> /dev/null`
$?.success?
end
end

View File

@@ -16,8 +16,21 @@ class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base
$?.success? $?.success?
end end
def fetch_secrets(secrets, **) def fetch_secrets(secrets, from:, **)
project_and_config_flags = "" secrets = prefixed_secrets(secrets, from: from)
flags = secrets_get_flags(secrets)
secret_names = secrets.collect { |s| s.split("/").last }
items = `doppler secrets get #{secret_names.map(&:shellescape).join(" ")} --json #{flags}`
raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success?
items = JSON.parse(items)
items.transform_values { |value| value["computed"] }
end
def secrets_get_flags(secrets)
unless service_token_set? unless service_token_set?
project, config, _ = secrets.first.split("/") project, config, _ = secrets.first.split("/")
@@ -27,15 +40,6 @@ class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base
project_and_config_flags = "-p #{project.shellescape} -c #{config.shellescape}" project_and_config_flags = "-p #{project.shellescape} -c #{config.shellescape}"
end end
secret_names = secrets.collect { |s| s.split("/").last }
items = `doppler secrets get #{secret_names.map(&:shellescape).join(" ")} --json #{project_and_config_flags}`
raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success?
items = JSON.parse(items)
items.transform_values { |value| value["computed"] }
end end
def service_token_set? def service_token_set?

View File

@@ -0,0 +1,71 @@
##
# Enpass is different from most password managers, in a way that it's offline and doesn't need an account.
#
# Usage
#
# Fetch all password from FooBar item
# `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar`
#
# Fetch only DB_PASSWORD from FooBar item
# `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar/DB_PASSWORD`
class Kamal::Secrets::Adapters::Enpass < Kamal::Secrets::Adapters::Base
def requires_account?
false
end
private
def fetch_secrets(secrets, from:, account:, session:)
secrets_titles = fetch_secret_titles(secrets)
result = `enpass-cli -json -vault #{from.shellescape} show #{secrets_titles.map(&:shellescape).join(" ")}`.strip
parse_result_and_take_secrets(result, secrets)
end
def check_dependencies!
raise RuntimeError, "Enpass CLI is not installed" unless cli_installed?
end
def cli_installed?
`enpass-cli version 2> /dev/null`
$?.success?
end
def login(account)
nil
end
def fetch_secret_titles(secrets)
secrets.reduce(Set.new) do |secret_titles, secret|
# Sometimes secrets contain a '/', when the intent is to fetch a single password for an item. Example: FooBar/DB_PASSWORD
# Another case is, when the intent is to fetch all passwords for an item. Example: FooBar (and FooBar may have multiple different passwords)
key, separator, value = secret.rpartition("/")
if key.empty?
secret_titles << value
else
secret_titles << key
end
end.to_a
end
def parse_result_and_take_secrets(unparsed_result, secrets)
result = JSON.parse(unparsed_result)
result.reduce({}) do |secrets_with_passwords, item|
title = item["title"]
label = item["label"]
password = item["password"]
if title && password.present?
key = [ title, label ].compact.reject(&:empty?).join("/")
if secrets.include?(title) || secrets.include?(key)
raise RuntimeError, "#{key} is present more than once" if secrets_with_passwords[key]
secrets_with_passwords[key] = password
end
end
secrets_with_passwords
end
end
end

View File

@@ -0,0 +1,112 @@
class Kamal::Secrets::Adapters::GcpSecretManager < Kamal::Secrets::Adapters::Base
private
def login(account)
# Since only the account option is passed from the cli, we'll use it for both account and service account
# impersonation.
#
# Syntax:
# ACCOUNT: USER | USER "|" DELEGATION_CHAIN
# USER: DEFAULT_USER | EMAIL
# DELEGATION_CHAIN: EMAIL | EMAIL "," DELEGATION_CHAIN
# EMAIL: <The email address of the user or service account, like "my-user@example.com" >
# DEFAULT_USER: "default"
#
# Some valid examples:
# - "my-user@example.com" sets the user
# - "my-user@example.com|my-service-user@example.com" will use my-user and enable service account impersonation as my-service-user
# - "default" will use the default user and no impersonation
# - "default|my-service-user@example.com" will use the default user, and enable service account impersonation as my-service-user
# - "default|my-service-user@example.com,another-service-user@example.com" same as above, but with an impersonation delegation chain
unless logged_in?
`gcloud auth login`
raise RuntimeError, "could not login to gcloud" unless logged_in?
end
nil
end
def fetch_secrets(secrets, from:, account:, session:)
user, service_account = parse_account(account)
{}.tap do |results|
secrets_with_metadata(prefixed_secrets(secrets, from: from)).each do |secret, (project, secret_name, secret_version)|
item_name = "#{project}/#{secret_name}"
results[item_name] = fetch_secret(project, secret_name, secret_version, user, service_account)
raise RuntimeError, "Could not read #{item_name} from Google Secret Manager" unless $?.success?
end
end
end
def fetch_secret(project, secret_name, secret_version, user, service_account)
secret = run_command(
"secrets versions access #{secret_version.shellescape} --secret=#{secret_name.shellescape}",
project: project,
user: user,
service_account: service_account
)
Base64.decode64(secret.dig("payload", "data"))
end
# The secret needs to at least contain a secret name, but project name, and secret version can also be specified.
#
# The string "default" can be used to refer to the default project configured for gcloud.
#
# The version can be either the string "latest", or a version number.
#
# The following formats are valid:
#
# - The following are all equivalent, and sets project: default, secret name: my-secret, version: latest
# - "my-secret"
# - "default/my-secret"
# - "default/my-secret/latest"
# - "my-secret/latest" in combination with --from=default
# - "my-secret/123" (only in combination with --from=some-project) -> project: some-project, secret name: my-secret, version: 123
# - "some-project/my-secret/123" -> project: some-project, secret name: my-secret, version: 123
def secrets_with_metadata(secrets)
{}.tap do |items|
secrets.each do |secret|
parts = secret.split("/")
parts.unshift("default") if parts.length == 1
project = parts.shift
secret_name = parts.shift
secret_version = parts.shift || "latest"
items[secret] = [ project, secret_name, secret_version ]
end
end
end
def run_command(command, project: "default", user: "default", service_account: nil)
full_command = [ "gcloud", command ]
full_command << "--project=#{project.shellescape}" unless project == "default"
full_command << "--account=#{user.shellescape}" unless user == "default"
full_command << "--impersonate-service-account=#{service_account.shellescape}" if service_account
full_command << "--format=json"
full_command = full_command.join(" ")
result = `#{full_command}`.strip
JSON.parse(result)
end
def check_dependencies!
raise RuntimeError, "gcloud CLI is not installed" unless cli_installed?
end
def cli_installed?
`gcloud --version 2> /dev/null`
$?.success?
end
def logged_in?
JSON.parse(`gcloud auth list --format=json`).any?
end
def parse_account(account)
account.split("|", 2)
end
def is_user?(candidate)
candidate.include?("@")
end
end

View File

@@ -11,7 +11,8 @@ class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base
`lpass status --color never`.strip == "Logged in as #{account}." `lpass status --color never`.strip == "Logged in as #{account}."
end end
def fetch_secrets(secrets, account:, session:) def fetch_secrets(secrets, from:, account:, session:)
secrets = prefixed_secrets(secrets, from: from)
items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json` items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json`
raise RuntimeError, "Could not read #{secrets} from LastPass" unless $?.success? raise RuntimeError, "Could not read #{secrets} from LastPass" unless $?.success?

View File

@@ -15,9 +15,9 @@ class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base
$?.success? $?.success?
end end
def fetch_secrets(secrets, account:, session:) def fetch_secrets(secrets, from:, account:, session:)
{}.tap do |results| {}.tap do |results|
vaults_items_fields(secrets).map do |vault, items| vaults_items_fields(prefixed_secrets(secrets, from: from)).map do |vault, items|
items.each do |item, fields| items.each do |item, fields|
fields_json = JSON.parse(op_item_get(vault, item, fields, account: account, session: session)) fields_json = JSON.parse(op_item_get(vault, item, fields, account: account, session: session))
fields_json = [ fields_json ] if fields.one? fields_json = [ fields_json ] if fields.one?

View File

@@ -4,8 +4,8 @@ class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base
true true
end end
def fetch_secrets(secrets, account:, session:) def fetch_secrets(secrets, from:, account:, session:)
secrets.to_h { |secret| [ secret, secret.reverse ] } prefixed_secrets(secrets, from: from).to_h { |secret| [ secret, secret.reverse ] }
end end
def check_dependencies! def check_dependencies!

View File

@@ -1,5 +0,0 @@
class Kamal::Secrets::Adapters::TestOptionalAccount < Kamal::Secrets::Adapters::Test
def requires_account?
false
end
end

View File

@@ -14,8 +14,8 @@ class CliAccessoryTest < CliTestCase
Kamal::Cli::Accessory.any_instance.expects(:upload).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:upload).with("mysql")
run_command("boot", "mysql").tap do |output| run_command("boot", "mysql").tap do |output|
assert_match /docker login.*on 1.1.1.3/, output assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3", output
assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", output assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output
end end
end end
@@ -24,17 +24,21 @@ class CliAccessoryTest < CliTestCase
Kamal::Cli::Accessory.any_instance.expects(:upload).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:upload).with("mysql")
Kamal::Cli::Accessory.any_instance.expects(:directories).with("redis") Kamal::Cli::Accessory.any_instance.expects(:directories).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis") Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:directories).with("busybox")
Kamal::Cli::Accessory.any_instance.expects(:upload).with("busybox")
run_command("boot", "all").tap do |output| run_command("boot", "all").tap do |output|
assert_match /docker login.*on 1.1.1.3/, output assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3", output
assert_match /docker login.*on 1.1.1.1/, output assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.1", output
assert_match /docker login.*on 1.1.1.2/, output assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.2", output
assert_match "docker login other.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3", output
assert_match /docker network create kamal.*on 1.1.1.1/, output assert_match /docker network create kamal.*on 1.1.1.1/, output
assert_match /docker network create kamal.*on 1.1.1.2/, output assert_match /docker network create kamal.*on 1.1.1.2/, output
assert_match /docker network create kamal.*on 1.1.1.3/, output assert_match /docker network create kamal.*on 1.1.1.3/, output
assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", output assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output
assert_match "docker run --name custom-box --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-box\" other.registry/busybox:latest on 1.1.1.3", output
end end
end end
@@ -60,13 +64,16 @@ class CliAccessoryTest < CliTestCase
end end
test "reboot all" do test "reboot all" do
Kamal::Commands::Registry.any_instance.expects(:login).times(3) Kamal::Commands::Registry.any_instance.expects(:login).times(4)
Kamal::Cli::Accessory.any_instance.expects(:stop).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:stop).with("mysql")
Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("mysql")
Kamal::Cli::Accessory.any_instance.expects(:boot).with("mysql", prepare: false) Kamal::Cli::Accessory.any_instance.expects(:boot).with("mysql", prepare: false)
Kamal::Cli::Accessory.any_instance.expects(:stop).with("redis") Kamal::Cli::Accessory.any_instance.expects(:stop).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("redis") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:boot).with("redis", prepare: false) Kamal::Cli::Accessory.any_instance.expects(:boot).with("redis", prepare: false)
Kamal::Cli::Accessory.any_instance.expects(:stop).with("busybox")
Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("busybox")
Kamal::Cli::Accessory.any_instance.expects(:boot).with("busybox", prepare: false)
run_command("reboot", "all") run_command("reboot", "all")
end end
@@ -94,7 +101,7 @@ class CliAccessoryTest < CliTestCase
end end
test "details with non-existent accessory" do test "details with non-existent accessory" do
assert_equal "No accessory by the name of 'hello' (options: mysql and redis)", stderred { run_command("details", "hello") } assert_equal "No accessory by the name of 'hello' (options: mysql, redis, and busybox)", stderred { run_command("details", "hello") }
end end
test "details with all" do test "details with all" do
@@ -180,6 +187,10 @@ class CliAccessoryTest < CliTestCase
Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("redis") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:remove_image).with("redis") Kamal::Cli::Accessory.any_instance.expects(:remove_image).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:remove_service_directory).with("redis") Kamal::Cli::Accessory.any_instance.expects(:remove_service_directory).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:stop).with("busybox")
Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("busybox")
Kamal::Cli::Accessory.any_instance.expects(:remove_image).with("busybox")
Kamal::Cli::Accessory.any_instance.expects(:remove_service_directory).with("busybox")
run_command("remove", "all", "-y") run_command("remove", "all", "-y")
end end
@@ -189,7 +200,7 @@ class CliAccessoryTest < CliTestCase
end end
test "remove_image" do test "remove_image" do
assert_match "docker image rm --force mysql", run_command("remove_image", "mysql") assert_match "docker image rm --force private.registry/mysql:5.7", run_command("remove_image", "mysql")
end end
test "remove_service_directory" do test "remove_service_directory" do
@@ -201,8 +212,8 @@ class CliAccessoryTest < CliTestCase
Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis") Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis")
run_command("boot", "redis", "--hosts", "1.1.1.1").tap do |output| run_command("boot", "redis", "--hosts", "1.1.1.1").tap do |output|
assert_match /docker login.*on 1.1.1.1/, output assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.1", output
assert_no_match /docker login.*on 1.1.1.2/, output assert_no_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.2", output
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output
assert_no_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output assert_no_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output
end end
@@ -213,8 +224,8 @@ class CliAccessoryTest < CliTestCase
Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis") Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis")
run_command("boot", "redis", "--hosts", "1.1.1.1,1.1.1.3").tap do |output| run_command("boot", "redis", "--hosts", "1.1.1.1,1.1.1.3").tap do |output|
assert_match /docker login.*on 1.1.1.1/, output assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.1", output
assert_no_match /docker login.*on 1.1.1.3/, output assert_no_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3", output
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output
assert_no_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.3", output assert_no_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.3", output
end end
@@ -225,7 +236,7 @@ class CliAccessoryTest < CliTestCase
assert_match "Upgrading all accessories on 1.1.1.3,1.1.1.1,1.1.1.2...", output assert_match "Upgrading all accessories on 1.1.1.3,1.1.1.1,1.1.1.2...", output
assert_match "docker network create kamal on 1.1.1.3", output assert_match "docker network create kamal on 1.1.1.3", output
assert_match "docker container stop app-mysql on 1.1.1.3", output assert_match "docker container stop app-mysql on 1.1.1.3", output
assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST="%" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", output assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST="%" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output
assert_match "Upgraded all accessories on 1.1.1.3,1.1.1.1,1.1.1.2...", output assert_match "Upgraded all accessories on 1.1.1.3,1.1.1.1,1.1.1.2...", output
end end
end end
@@ -235,14 +246,13 @@ class CliAccessoryTest < CliTestCase
assert_match "Upgrading all accessories on 1.1.1.3...", output assert_match "Upgrading all accessories on 1.1.1.3...", output
assert_match "docker network create kamal on 1.1.1.3", output assert_match "docker network create kamal on 1.1.1.3", output
assert_match "docker container stop app-mysql on 1.1.1.3", output assert_match "docker container stop app-mysql on 1.1.1.3", output
assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST="%" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", output assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST="%" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output
assert_match "Upgraded all accessories on 1.1.1.3", output assert_match "Upgraded all accessories on 1.1.1.3", output
end end
end end
private private
def run_command(*command) def run_command(*command)
stdouted { Kamal::Cli::Accessory.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } stdouted { Kamal::Cli::Accessory.start([ *command, "-c", "test/fixtures/deploy_with_accessories_with_different_registries.yml" ]) }
end end
end end

View File

@@ -73,7 +73,7 @@ class CliAppTest < CliTestCase
run_command("boot", config: :with_assets).tap do |output| run_command("boot", config: :with_assets).tap do |output|
assert_match "docker tag dhh/app:latest dhh/app:latest", output assert_match "docker tag dhh/app:latest dhh/app:latest", output
assert_match "/usr/bin/env mkdir -p .kamal/apps/app/assets/volumes/web-latest ; cp -rnT .kamal/apps/app/assets/extracted/web-latest .kamal/apps/app/assets/volumes/web-latest ; cp -rnT .kamal/apps/app/assets/extracted/web-latest .kamal/apps/app/assets/volumes/web-123 || true ; cp -rnT .kamal/apps/app/assets/extracted/web-123 .kamal/apps/app/assets/volumes/web-latest || true", output assert_match "/usr/bin/env mkdir -p .kamal/apps/app/assets/volumes/web-latest ; cp -rnT .kamal/apps/app/assets/extracted/web-latest .kamal/apps/app/assets/volumes/web-latest ; cp -rnT .kamal/apps/app/assets/extracted/web-latest .kamal/apps/app/assets/volumes/web-123 || true ; cp -rnT .kamal/apps/app/assets/extracted/web-123 .kamal/apps/app/assets/volumes/web-latest || true", output
assert_match "/usr/bin/env mkdir -p .kamal/apps/app/assets/extracted/web-latest && docker stop -t 1 app-web-assets 2> /dev/null || true && docker run --name app-web-assets --detach --rm --entrypoint sleep dhh/app:latest 1000000 && docker cp -L app-web-assets:/public/assets/. .kamal/apps/app/assets/extracted/web-latest && docker stop -t 1 app-web-assets", output assert_match "/usr/bin/env mkdir -p .kamal/apps/app/assets/extracted/web-latest && docker container rm app-web-assets 2> /dev/null || true && docker container create --name app-web-assets dhh/app:latest && docker container cp -L app-web-assets:/public/assets/. .kamal/apps/app/assets/extracted/web-latest && docker container rm app-web-assets", output
assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output
assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
assert_match "/usr/bin/env find .kamal/apps/app/assets/extracted -maxdepth 1 -name 'web-*' ! -name web-latest -exec rm -rf \"{}\" + ; find .kamal/apps/app/assets/volumes -maxdepth 1 -name 'web-*' ! -name web-latest -exec rm -rf \"{}\" +", output assert_match "/usr/bin/env find .kamal/apps/app/assets/extracted -maxdepth 1 -name 'web-*' ! -name web-latest -exec rm -rf \"{}\" + ; find .kamal/apps/app/assets/volumes -maxdepth 1 -name 'web-*' ! -name web-latest -exec rm -rf \"{}\" +", output
@@ -382,8 +382,10 @@ class CliAppTest < CliTestCase
test "version through main" do test "version through main" do
stdouted { Kamal::Cli::Main.start([ "app", "version", "-c", "test/fixtures/deploy_with_accessories.yml", "--hosts", "1.1.1.1" ]) }.tap do |output| with_argv([ "app", "version", "-c", "test/fixtures/deploy_with_accessories.yml", "--hosts", "1.1.1.1" ]) do
assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output stdouted { Kamal::Cli::Main.start }.tap do |output|
assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output
end
end end
end end

View File

@@ -155,7 +155,7 @@ class CliBuildTest < CliTestCase
.raises(SSHKit::Command::Failed.new("no buildx")) .raises(SSHKit::Command::Failed.new("no buildx"))
Kamal::Commands::Builder.any_instance.stubs(:native_and_local?).returns(false) Kamal::Commands::Builder.any_instance.stubs(:native_and_local?).returns(false)
assert_raises(Kamal::Cli::Build::BuildError) { run_command("push") } assert_raises(Kamal::Cli::DependencyError) { run_command("push") }
end end
test "push pre-build hook failure" do test "push pre-build hook failure" do
@@ -286,17 +286,4 @@ class CliBuildTest < CliTestCase
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args[0..1] == [ :docker, :buildx ] } .with { |*args| args[0..1] == [ :docker, :buildx ] }
end end
def with_build_directory
build_directory = File.join Dir.tmpdir, "kamal-clones", "app-#{pwd_sha}", "kamal"
FileUtils.mkdir_p build_directory
FileUtils.touch File.join build_directory, "Dockerfile"
yield build_directory + "/"
ensure
FileUtils.rm_rf build_directory
end
def pwd_sha
Digest::SHA256.hexdigest(Dir.pwd)[0..12]
end
end end

View File

@@ -51,4 +51,17 @@ class CliTestCase < ActiveSupport::TestCase
ensure ensure
ARGV.replace(old_argv) ARGV.replace(old_argv)
end end
def with_build_directory
build_directory = File.join Dir.tmpdir, "kamal-clones", "app-#{pwd_sha}", "kamal"
FileUtils.mkdir_p build_directory
FileUtils.touch File.join build_directory, "Dockerfile"
yield build_directory + "/"
ensure
FileUtils.rm_rf build_directory
end
def pwd_sha
Digest::SHA256.hexdigest(Dir.pwd)[0..12]
end
end end

View File

@@ -8,8 +8,7 @@ class CliMainTest < CliTestCase
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options) Kamal::Cli::Main.any_instance.expects(:deploy).with(boot_accessories: true)
Kamal::Cli::Main.any_instance.expects(:deploy)
run_command("setup").tap do |output| run_command("setup").tap do |output|
assert_match /Ensure Docker is installed.../, output assert_match /Ensure Docker is installed.../, output
@@ -460,6 +459,7 @@ class CliMainTest < CliTestCase
test "run an alias for a console" do test "run an alias for a console" do
run_command("console", config_file: "deploy_with_aliases").tap do |output| run_command("console", config_file: "deploy_with_aliases").tap do |output|
assert_no_match "App Host: 1.1.1.4", output
assert_match "docker exec app-console-999 bin/console on 1.1.1.5", output assert_match "docker exec app-console-999 bin/console on 1.1.1.5", output
assert_match "App Host: 1.1.1.5", output assert_match "App Host: 1.1.1.5", output
end end
@@ -486,6 +486,33 @@ class CliMainTest < CliTestCase
end end
end end
test "switch config file with an alias" do
with_config_files do
with_argv([ "other_config" ]) do
stdouted { Kamal::Cli::Main.start }.tap do |output|
assert_match ":service_with_version: app2-999", output
end
end
end
end
test "switch destination with an alias" do
with_config_files do
with_argv([ "other_destination_config" ]) do
stdouted { Kamal::Cli::Main.start }.tap do |output|
assert_match ":service_with_version: app3-999", output
end
end
end
end
test "run on primary via alias" do
run_command("primary_details", config_file: "deploy_with_aliases").tap do |output|
assert_match "App Host: 1.1.1.1", output
assert_no_match "App Host: 1.1.1.2", output
end
end
test "upgrade" do test "upgrade" do
invoke_options = { "config_file" => "test/fixtures/deploy_with_accessories.yml", "skip_hooks" => false, "confirmed" => true, "rolling" => false } invoke_options = { "config_file" => "test/fixtures/deploy_with_accessories.yml", "skip_hooks" => false, "confirmed" => true, "rolling" => false }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:upgrade", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:upgrade", [], invoke_options)
@@ -530,6 +557,20 @@ class CliMainTest < CliTestCase
end end
end end
def with_config_files
Dir.mktmpdir do |tmpdir|
config_dir = File.join(tmpdir, "config")
FileUtils.mkdir_p(config_dir)
FileUtils.cp "test/fixtures/deploy.yml", config_dir
FileUtils.cp "test/fixtures/deploy2.yml", config_dir
FileUtils.cp "test/fixtures/deploy.elsewhere.yml", config_dir
Dir.chdir(tmpdir) do
yield
end
end
end
def assert_file(file, content) def assert_file(file, content)
assert_match content, File.read(file) assert_match content, File.read(file)
end end

View File

@@ -281,6 +281,32 @@ class CliProxyTest < CliTestCase
end end
end end
test "boot_config set bind IP" do
run_command("boot_config", "set", "--publish-host-ip", "127.0.0.1").tap do |output|
%w[ 1.1.1.1 1.1.1.2 ].each do |host|
assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output
assert_match "Uploading \"--publish 127.0.0.1:80:80 --publish 127.0.0.1:443:443 --log-opt max-size=10m\" to .kamal/proxy/options on #{host}", output
end
end
end
test "boot_config set multiple bind IPs" do
run_command("boot_config", "set", "--publish-host-ip", "127.0.0.1", "--publish-host-ip", "::1").tap do |output|
%w[ 1.1.1.1 1.1.1.2 ].each do |host|
assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output
assert_match "Uploading \"--publish 127.0.0.1:80:80 --publish 127.0.0.1:443:443 --publish [::1]:80:80 --publish [::1]:443:443 --log-opt max-size=10m\" to .kamal/proxy/options on #{host}", output
end
end
end
test "boot_config set invalid bind IPs" do
exception = assert_raises do
run_command("boot_config", "set", "--publish-host-ip", "1.2.3.invalidIP", "--publish-host-ip", "::1")
end
assert_includes exception.message, "Invalid publish IP address: 1.2.3.invalidIP"
end
test "boot_config set docker options" do test "boot_config set docker options" do
run_command("boot_config", "set", "--docker_options", "label=foo=bar", "add_host=thishost:thathost").tap do |output| run_command("boot_config", "set", "--docker_options", "label=foo=bar", "add_host=thishost:thathost").tap do |output|
%w[ 1.1.1.1 1.1.1.2 ].each do |host| %w[ 1.1.1.1 1.1.1.2 ].each do |host|

View File

@@ -43,6 +43,16 @@ class CliRegistryTest < CliTestCase
end end
end end
test "login with no docker" do
stub_setup
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, "--version", "&&", :docker, :buildx, "version")
.raises(SSHKit::Command::Failed.new("command not found"))
assert_raises(Kamal::Cli::DependencyError) { run_command("login") }
end
private private
def run_command(*command) def run_command(*command)
stdouted { Kamal::Cli::Registry.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } stdouted { Kamal::Cli::Registry.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) }

View File

@@ -13,12 +13,6 @@ class CliSecretsTest < CliTestCase
run_command("fetch", "foo", "bar", "baz", "--adapter", "test") run_command("fetch", "foo", "bar", "baz", "--adapter", "test")
end end
test "fetch without required --account" do
assert_equal \
"\\{\\\"foo\\\":\\\"oof\\\",\\\"bar\\\":\\\"rab\\\",\\\"baz\\\":\\\"zab\\\"\\}",
run_command("fetch", "foo", "bar", "baz", "--adapter", "test_optional_account")
end
test "extract" do test "extract" do
assert_equal "oof", run_command("extract", "foo", "{\"foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}") assert_equal "oof", run_command("extract", "foo", "{\"foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}")
end end

View File

@@ -5,7 +5,9 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
setup_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123") setup_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123")
@config = { @config = {
service: "app", image: "dhh/app", registry: { "server" => "private.registry", "username" => "dhh", "password" => "secret" }, service: "app",
image: "dhh/app",
registry: { "server" => "private.registry", "username" => "dhh", "password" => "secret" },
servers: [ "1.1.1.1" ], servers: [ "1.1.1.1" ],
builder: { "arch" => "amd64" }, builder: { "arch" => "amd64" },
accessories: { accessories: {
@@ -39,6 +41,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
"busybox" => { "busybox" => {
"service" => "custom-busybox", "service" => "custom-busybox",
"image" => "busybox:latest", "image" => "busybox:latest",
"registry" => { "server" => "other.registry", "username" => "user", "password" => "pw" },
"host" => "1.1.1.7", "host" => "1.1.1.7",
"proxy" => { "proxy" => {
"host" => "busybox.example.com" "host" => "busybox.example.com"
@@ -62,7 +65,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
new_command(:redis).run.join(" ") new_command(:redis).run.join(" ")
assert_equal \ assert_equal \
"docker run --name custom-busybox --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" busybox:latest", "docker run --name custom-busybox --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" other.registry/busybox:latest",
new_command(:busybox).run.join(" ") new_command(:busybox).run.join(" ")
end end
@@ -70,7 +73,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } }
assert_equal \ assert_equal \
"docker run --name custom-busybox --detach --restart unless-stopped --network kamal --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" busybox:latest", "docker run --name custom-busybox --detach --restart unless-stopped --network kamal --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" other.registry/busybox:latest",
new_command(:busybox).run.join(" ") new_command(:busybox).run.join(" ")
end end
@@ -100,7 +103,6 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
new_command(:mysql).info.join(" ") new_command(:mysql).info.join(" ")
end end
test "execute in new container" do test "execute in new container" do
assert_equal \ assert_equal \
"docker run --rm --network kamal --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env private.registry/mysql:8.0 mysql -u root", "docker run --rm --network kamal --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env private.registry/mysql:8.0 mysql -u root",
@@ -127,8 +129,6 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
end end
end end
test "logs" do test "logs" do
assert_equal \ assert_equal \
"docker logs app-mysql --timestamps 2>&1", "docker logs app-mysql --timestamps 2>&1",

View File

@@ -469,10 +469,10 @@ class CommandsAppTest < ActiveSupport::TestCase
test "extract assets" do test "extract assets" do
assert_equal [ assert_equal [
:mkdir, "-p", ".kamal/apps/app/assets/extracted/web-999", "&&", :mkdir, "-p", ".kamal/apps/app/assets/extracted/web-999", "&&",
:docker, :stop, "-t 1", "app-web-assets", "2> /dev/null", "|| true", "&&", :docker, :container, :rm, "app-web-assets", "2> /dev/null", "|| true", "&&",
:docker, :run, "--name", "app-web-assets", "--detach", "--rm", "--entrypoint", "sleep", "dhh/app:999", "1000000", "&&", :docker, :container, :create, "--name", "app-web-assets", "dhh/app:999", "&&",
:docker, :cp, "-L", "app-web-assets:/public/assets/.", ".kamal/apps/app/assets/extracted/web-999", "&&", :docker, :container, :cp, "-L", "app-web-assets:/public/assets/.", ".kamal/apps/app/assets/extracted/web-999", "&&",
:docker, :stop, "-t 1", "app-web-assets" :docker, :container, :rm, "app-web-assets"
], new_command(asset_path: "/public/assets").extract_assets ], new_command(asset_path: "/public/assets").extract_assets
end end

View File

@@ -2,14 +2,27 @@ require "test_helper"
class CommandsRegistryTest < ActiveSupport::TestCase class CommandsRegistryTest < ActiveSupport::TestCase
setup do setup do
@config = { service: "app", @config = {
service: "app",
image: "dhh/app", image: "dhh/app",
registry: { "username" => "dhh", registry: {
"username" => "dhh",
"password" => "secret", "password" => "secret",
"server" => "hub.docker.com" "server" => "hub.docker.com"
}, },
builder: { "arch" => "amd64" }, builder: { "arch" => "amd64" },
servers: [ "1.1.1.1" ] servers: [ "1.1.1.1" ],
accessories: {
"db" => {
"image" => "mysql:8.0",
"hosts" => [ "1.1.1.1" ],
"registry" => {
"username" => "user",
"password" => "pw",
"server" => "other.hub.docker.com"
}
}
}
} }
end end
@@ -19,13 +32,24 @@ class CommandsRegistryTest < ActiveSupport::TestCase
registry.login.join(" ") registry.login.join(" ")
end end
test "given registry login" do
assert_equal \
"docker login other.hub.docker.com -u \"user\" -p \"pw\"",
registry.login(registry_config: accessory_registry_config).join(" ")
end
test "registry login with ENV password" do test "registry login with ENV password" do
with_test_secrets("secrets" => "KAMAL_REGISTRY_PASSWORD=more-secret") do with_test_secrets("secrets" => "KAMAL_REGISTRY_PASSWORD=more-secret\nKAMAL_MYSQL_REGISTRY_PASSWORD=secret-pw") do
@config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ] @config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ]
@config[:accessories]["db"]["registry"]["password"] = [ "KAMAL_MYSQL_REGISTRY_PASSWORD" ]
assert_equal \ assert_equal \
"docker login hub.docker.com -u \"dhh\" -p \"more-secret\"", "docker login hub.docker.com -u \"dhh\" -p \"more-secret\"",
registry.login.join(" ") registry.login.join(" ")
assert_equal \
"docker login other.hub.docker.com -u \"user\" -p \"secret-pw\"",
registry.login(registry_config: accessory_registry_config).join(" ")
end end
end end
@@ -55,8 +79,22 @@ class CommandsRegistryTest < ActiveSupport::TestCase
registry.logout.join(" ") registry.logout.join(" ")
end end
test "given registry logout" do
assert_equal \
"docker logout other.hub.docker.com",
registry.logout(registry_config: accessory_registry_config).join(" ")
end
private private
def registry def registry
Kamal::Commands::Registry.new Kamal::Configuration.new(@config) Kamal::Commands::Registry.new main_config
end
def main_config
Kamal::Configuration.new(@config)
end
def accessory_registry_config
main_config.accessory("db").registry
end end
end end

View File

@@ -3,7 +3,9 @@ require "test_helper"
class ConfigurationAccessoryTest < ActiveSupport::TestCase class ConfigurationAccessoryTest < ActiveSupport::TestCase
setup do setup do
@deploy = { @deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, service: "app",
image: "dhh/app",
registry: { "username" => "dhh", "password" => "secret" },
servers: { servers: {
"web" => [ "1.1.1.1", "1.1.1.2" ], "web" => [ "1.1.1.1", "1.1.1.2" ],
"workers" => [ "1.1.1.3", "1.1.1.4" ] "workers" => [ "1.1.1.3", "1.1.1.4" ]
@@ -12,7 +14,7 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
env: { "REDIS_URL" => "redis://x/y" }, env: { "REDIS_URL" => "redis://x/y" },
accessories: { accessories: {
"mysql" => { "mysql" => {
"image" => "mysql:8.0", "image" => "public.registry/mysql:8.0",
"host" => "1.1.1.5", "host" => "1.1.1.5",
"port" => "3306", "port" => "3306",
"env" => { "env" => {
@@ -52,6 +54,7 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
"monitoring" => { "monitoring" => {
"service" => "custom-monitoring", "service" => "custom-monitoring",
"image" => "monitoring:latest", "image" => "monitoring:latest",
"registry" => { "server" => "other.registry", "username" => "user", "password" => "pw" },
"roles" => [ "web" ], "roles" => [ "web" ],
"port" => "4321:4321", "port" => "4321:4321",
"labels" => { "labels" => {
@@ -80,6 +83,21 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
assert_equal "custom-monitoring", @config.accessory(:monitoring).service_name assert_equal "custom-monitoring", @config.accessory(:monitoring).service_name
end end
test "image" do
assert_equal "public.registry/mysql:8.0", @config.accessory(:mysql).image
assert_equal "redis:latest", @config.accessory(:redis).image
assert_equal "other.registry/monitoring:latest", @config.accessory(:monitoring).image
end
test "registry" do
assert_nil @config.accessory(:mysql).registry
assert_nil @config.accessory(:redis).registry
monitoring_registry = @config.accessory(:monitoring).registry
assert_equal "other.registry", monitoring_registry.server
assert_equal "user", monitoring_registry.username
assert_equal "pw", monitoring_registry.password
end
test "port" do test "port" do
assert_equal "3306:3306", @config.accessory(:mysql).port assert_equal "3306:3306", @config.accessory(:mysql).port
assert_equal "6379:6379", @config.accessory(:redis).port assert_equal "6379:6379", @config.accessory(:redis).port

12
test/fixtures/deploy.elsewhere.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
service: app3
image: dhh/app3
servers:
- "1.1.1.3"
- "1.1.1.4"
registry:
username: user
password: pw
builder:
arch: amd64
aliases:
other_config: config -c config/deploy2.yml

13
test/fixtures/deploy.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
service: app
image: dhh/app
servers:
- "1.1.1.1"
- "1.1.1.2"
registry:
username: user
password: pw
builder:
arch: amd64
aliases:
other_config: config -c config/deploy2.yml
other_destination_config: config -d elsewhere

12
test/fixtures/deploy2.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
service: app2
image: dhh/app2
servers:
- "1.1.1.1"
- "1.1.1.2"
registry:
username: user2
password: pw2
builder:
arch: amd64
aliases:
other_config: config -c config/deploy2.yml

View File

@@ -0,0 +1,47 @@
service: app
image: dhh/app
servers:
web:
- "1.1.1.1"
- "1.1.1.2"
workers:
- "1.1.1.3"
- "1.1.1.4"
registry:
server: private.registry
username: user
password: pw
builder:
arch: amd64
accessories:
mysql:
image: private.registry/mysql:5.7
host: 1.1.1.3
port: 3306
env:
clear:
MYSQL_ROOT_HOST: '%'
secret:
- MYSQL_ROOT_PASSWORD
files:
- test/fixtures/files/my.cnf:/etc/mysql/my.cnf
directories:
- data:/var/lib/mysql
redis:
image: redis:latest
roles:
- web
port: 6379
directories:
- data:/data
busybox:
service: custom-box
image: busybox:latest
host: 1.1.1.3
registry:
server: other.registry
username: other_user
password: other_pw
readiness_delay: 0

View File

@@ -21,3 +21,6 @@ aliases:
console: app exec --reuse -p -r console "bin/console" console: app exec --reuse -p -r console "bin/console"
exec: app exec --reuse -p -r console exec: app exec --reuse -p -r console
rails: app exec --reuse -p -r console rails rails: app exec --reuse -p -r console rails
primary_details: details -p
deploy_secondary: deploy -d secondary

View File

@@ -90,9 +90,9 @@ class MainTest < IntegrationTest
test "setup and remove" do test "setup and remove" do
@app = "app_with_roles" @app = "app_with_roles"
kamal :proxy, :set_config, kamal :proxy, :boot_config, "set",
"--publish=false", "--publish=false",
"--options=label=traefik.http.services.kamal_proxy.loadbalancer.server.scheme=http", "--docker-options=label=traefik.http.services.kamal_proxy.loadbalancer.server.scheme=http",
"label=traefik.http.routers.kamal_proxy.rule=PathPrefix\\\(\\\`/\\\`\\\)", "label=traefik.http.routers.kamal_proxy.rule=PathPrefix\\\(\\\`/\\\`\\\)",
"label=traefik.http.routers.kamal_proxy.priority=2" "label=traefik.http.routers.kamal_proxy.priority=2"

View File

@@ -0,0 +1,119 @@
require "test_helper"
class BitwardenSecretsManagerAdapterTest < SecretAdapterTestCase
test "fetch with no parameters" do
stub_ticks.with("bws --version 2> /dev/null")
stub_login
error = assert_raises RuntimeError do
(shellunescape(run_command("fetch")))
end
assert_equal("You must specify what to retrieve from Bitwarden Secrets Manager", error.message)
end
test "fetch all" do
stub_ticks.with("bws --version 2> /dev/null")
stub_login
stub_ticks
.with("bws secret list -o env")
.returns("KAMAL_REGISTRY_PASSWORD=\"some_password\"\nMY_OTHER_SECRET=\"my=weird\"secret\"")
expected = '{"KAMAL_REGISTRY_PASSWORD":"some_password","MY_OTHER_SECRET":"my\=weird\"secret"}'
actual = shellunescape(run_command("fetch", "all"))
assert_equal expected, actual
end
test "fetch all with from" do
stub_ticks.with("bws --version 2> /dev/null")
stub_login
stub_ticks
.with("bws secret list -o env 82aeb5bd-6958-4a89-8197-eacab758acce")
.returns("KAMAL_REGISTRY_PASSWORD=\"some_password\"\nMY_OTHER_SECRET=\"my=weird\"secret\"")
expected = '{"KAMAL_REGISTRY_PASSWORD":"some_password","MY_OTHER_SECRET":"my\=weird\"secret"}'
actual = shellunescape(run_command("fetch", "all", "--from", "82aeb5bd-6958-4a89-8197-eacab758acce"))
assert_equal expected, actual
end
test "fetch item" do
stub_ticks.with("bws --version 2> /dev/null")
stub_login
stub_ticks
.with("bws secret get -o env 82aeb5bd-6958-4a89-8197-eacab758acce")
.returns("KAMAL_REGISTRY_PASSWORD=\"some_password\"")
expected = '{"KAMAL_REGISTRY_PASSWORD":"some_password"}'
actual = shellunescape(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce"))
assert_equal expected, actual
end
test "fetch with multiple items" do
stub_ticks.with("bws --version 2> /dev/null")
stub_login
stub_ticks
.with("bws secret get -o env 82aeb5bd-6958-4a89-8197-eacab758acce")
.returns("KAMAL_REGISTRY_PASSWORD=\"some_password\"")
stub_ticks
.with("bws secret get -o env 6f8cdf27-de2b-4c77-a35d-07df8050e332")
.returns("MY_OTHER_SECRET=\"my=weird\"secret\"")
expected = '{"KAMAL_REGISTRY_PASSWORD":"some_password","MY_OTHER_SECRET":"my\=weird\"secret"}'
actual = shellunescape(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce", "6f8cdf27-de2b-4c77-a35d-07df8050e332"))
assert_equal expected, actual
end
test "fetch all empty" do
stub_ticks.with("bws --version 2> /dev/null")
stub_login
stub_ticks_with("bws secret list -o env", succeed: false).returns("Error:\n0: Received error message from server")
error = assert_raises RuntimeError do
(shellunescape(run_command("fetch", "all")))
end
assert_equal("Could not read secrets from Bitwarden Secrets Manager", error.message)
end
test "fetch nonexistent item" do
stub_ticks.with("bws --version 2> /dev/null")
stub_login
stub_ticks_with("bws secret get -o env 82aeb5bd-6958-4a89-8197-eacab758acce", succeed: false)
.returns("ERROR (RuntimeError): Could not read 82aeb5bd-6958-4a89-8197-eacab758acce from Bitwarden Secrets Manager")
error = assert_raises RuntimeError do
(shellunescape(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce")))
end
assert_equal("Could not read 82aeb5bd-6958-4a89-8197-eacab758acce from Bitwarden Secrets Manager", error.message)
end
test "fetch with no access token" do
stub_ticks.with("bws --version 2> /dev/null")
stub_ticks_with("bws run 'echo OK'", succeed: false)
error = assert_raises RuntimeError do
(shellunescape(run_command("fetch", "all")))
end
assert_equal("Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?", error.message)
end
test "fetch without CLI installed" do
stub_ticks_with("bws --version 2> /dev/null", succeed: false)
error = assert_raises RuntimeError do
shellunescape(run_command("fetch"))
end
assert_equal "Bitwarden Secrets Manager CLI is not installed", error.message
end
private
def stub_login
stub_ticks.with("bws run 'echo OK'").returns("OK")
end
def run_command(*command)
stdouted do
Kamal::Cli::Secrets.start \
[ *command,
"--adapter", "bitwarden-sm" ]
end
end
end

View File

@@ -0,0 +1,81 @@
require "test_helper"
class EnpassAdapterTest < SecretAdapterTestCase
test "fetch without CLI installed" do
stub_ticks_with("enpass-cli version 2> /dev/null", succeed: false)
error = assert_raises RuntimeError do
JSON.parse(shellunescape(run_command("fetch", "mynote")))
end
assert_equal "Enpass CLI is not installed", error.message
end
test "fetch one item" do
stub_ticks_with("enpass-cli version 2> /dev/null")
stub_ticks
.with("enpass-cli -json -vault vault-path show FooBar")
.returns(<<~JSON)
[{"category":"computer","label":"SECRET_1","login":"","password":"my-password-1","title":"FooBar","type":"password"}]
JSON
json = JSON.parse(shellunescape(run_command("fetch", "FooBar/SECRET_1")))
expected_json = { "FooBar/SECRET_1" => "my-password-1" }
assert_equal expected_json, json
end
test "fetch multiple items" do
stub_ticks_with("enpass-cli version 2> /dev/null")
stub_ticks
.with("enpass-cli -json -vault vault-path show FooBar")
.returns(<<~JSON)
[
{"category":"computer","label":"SECRET_1","login":"","password":"my-password-1","title":"FooBar","type":"password"},
{"category":"computer","label":"SECRET_2","login":"","password":"my-password-2","title":"FooBar","type":"password"},
{"category":"computer","label":"SECRET_3","login":"","password":"my-password-1","title":"Hello","type":"password"}
]
JSON
json = JSON.parse(shellunescape(run_command("fetch", "FooBar/SECRET_1", "FooBar/SECRET_2")))
expected_json = { "FooBar/SECRET_1" => "my-password-1", "FooBar/SECRET_2" => "my-password-2" }
assert_equal expected_json, json
end
test "fetch all with from" do
stub_ticks_with("enpass-cli version 2> /dev/null")
stub_ticks
.with("enpass-cli -json -vault vault-path show FooBar")
.returns(<<~JSON)
[
{"category":"computer","label":"SECRET_1","login":"","password":"my-password-1","title":"FooBar","type":"password"},
{"category":"computer","label":"SECRET_2","login":"","password":"my-password-2","title":"FooBar","type":"password"},
{"category":"computer","label":"SECRET_3","login":"","password":"my-password-1","title":"Hello","type":"password"},
{"category":"computer","label":"","login":"","password":"my-password-3","title":"FooBar","type":"password"}
]
JSON
json = JSON.parse(shellunescape(run_command("fetch", "FooBar")))
expected_json = { "FooBar/SECRET_1" => "my-password-1", "FooBar/SECRET_2" => "my-password-2", "FooBar" => "my-password-3" }
assert_equal expected_json, json
end
private
def run_command(*command)
stdouted do
Kamal::Cli::Secrets.start \
[ *command,
"-c", "test/fixtures/deploy_with_accessories.yml",
"--adapter", "enpass",
"--from", "vault-path" ]
end
end
end

View File

@@ -0,0 +1,220 @@
require "test_helper"
class GcpSecretManagerAdapterTest < SecretAdapterTestCase
test "fetch" do
stub_gcloud_version
stub_authenticated
stub_mypassword
json = JSON.parse(shellunescape(run_command("fetch", "mypassword")))
expected_json = { "default/mypassword"=>"secret123" }
assert_equal expected_json, json
end
test "fetch unauthenticated" do
stub_ticks.with("gcloud --version 2> /dev/null")
stub_mypassword
stub_unauthenticated
error = assert_raises RuntimeError do
JSON.parse(shellunescape(run_command("fetch", "mypassword")))
end
assert_match(/could not login to gcloud/, error.message)
end
test "fetch with from" do
stub_gcloud_version
stub_authenticated
stub_items(0, project: "other-project")
stub_items(1, project: "other-project")
stub_items(2, project: "other-project")
json = JSON.parse(shellunescape(run_command("fetch", "--from", "other-project", "item1", "item2", "item3")))
expected_json = {
"other-project/item1"=>"secret1", "other-project/item2"=>"secret2", "other-project/item3"=>"secret3"
}
assert_equal expected_json, json
end
test "fetch with multiple projects" do
stub_gcloud_version
stub_authenticated
stub_items(0, project: "some-project")
stub_items(1, project: "project-confidence")
stub_items(2, project: "manhattan-project")
json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1", "project-confidence/item2", "manhattan-project/item3")))
expected_json = {
"some-project/item1"=>"secret1", "project-confidence/item2"=>"secret2", "manhattan-project/item3"=>"secret3"
}
assert_equal expected_json, json
end
test "fetch with specific version" do
stub_gcloud_version
stub_authenticated
stub_items(0, project: "some-project", version: "123")
json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123")))
expected_json = {
"some-project/item1"=>"secret1"
}
assert_equal expected_json, json
end
test "fetch with non-default account" do
stub_gcloud_version
stub_authenticated
stub_items(0, project: "some-project", version: "123", account: "email@example.com")
json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "email@example.com")))
expected_json = {
"some-project/item1"=>"secret1"
}
assert_equal expected_json, json
end
test "fetch with service account impersonation" do
stub_gcloud_version
stub_authenticated
stub_items(0, project: "some-project", version: "123", impersonate_service_account: "service-user@example.com")
json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "default|service-user@example.com")))
expected_json = {
"some-project/item1"=>"secret1"
}
assert_equal expected_json, json
end
test "fetch with delegation chain and specific user" do
stub_gcloud_version
stub_authenticated
stub_items(0, project: "some-project", version: "123", account: "user@example.com", impersonate_service_account: "service-user@example.com,service-user2@example.com")
json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "user@example.com|service-user@example.com,service-user2@example.com")))
expected_json = {
"some-project/item1"=>"secret1"
}
assert_equal expected_json, json
end
test "fetch with non-default account and service account impersonation" do
stub_gcloud_version
stub_authenticated
stub_items(0, project: "some-project", version: "123", account: "email@example.com", impersonate_service_account: "service-user@example.com")
json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "email@example.com|service-user@example.com")))
expected_json = {
"some-project/item1"=>"secret1"
}
assert_equal expected_json, json
end
test "fetch without CLI installed" do
stub_gcloud_version(succeed: false)
error = assert_raises RuntimeError do
JSON.parse(shellunescape(run_command("fetch", "item1")))
end
assert_equal "gcloud CLI is not installed", error.message
end
private
def run_command(*command, account: "default")
stdouted do
Kamal::Cli::Secrets.start \
[ *command,
"-c", "test/fixtures/deploy_with_accessories.yml",
"--adapter", "gcp_secret_manager",
"--account", account ]
end
end
def stub_gcloud_version(succeed: true)
stub_ticks_with("gcloud --version 2> /dev/null", succeed: succeed)
end
def stub_authenticated
stub_ticks
.with("gcloud auth list --format=json")
.returns(<<~JSON)
[
{
"account": "email@example.com",
"status": "ACTIVE"
}
]
JSON
end
def stub_unauthenticated
stub_ticks
.with("gcloud auth list --format=json")
.returns("[]")
stub_ticks
.with("gcloud auth login")
.returns(<<~JSON)
{
"expired": false,
"valid": true
}
JSON
end
def stub_mypassword
stub_ticks
.with("gcloud secrets versions access latest --secret=mypassword --format=json")
.returns(<<~JSON)
{
"name": "projects/000000000/secrets/mypassword/versions/1",
"payload": {
"data": "c2VjcmV0MTIz",
"dataCrc32c": "2522602764"
}
}
JSON
end
def stub_items(n, project: nil, account: nil, version: "latest", impersonate_service_account: nil)
payloads = [
{ data: "c2VjcmV0MQ==", checksum: 1846998209 },
{ data: "c2VjcmV0Mg==", checksum: 2101741365 },
{ data: "c2VjcmV0Mw==", checksum: 2402124854 }
]
stub_ticks
.with("gcloud secrets versions access #{version} " \
"--secret=item#{n + 1}" \
"#{" --project=#{project}" if project}" \
"#{" --account=#{account}" if account}" \
"#{" --impersonate-service-account=#{impersonate_service_account}" if impersonate_service_account} " \
"--format=json")
.returns(<<~JSON)
{
"name": "projects/000000001/secrets/item1/versions/1",
"payload": {
"data": "#{payloads[n][:data]}",
"dataCrc32c": "#{payloads[n][:checksum]}"
}
}
JSON
end
end

View File

@@ -20,6 +20,20 @@ class SecretsTest < ActiveSupport::TestCase
end end
end end
test "env references" do
with_test_secrets("secrets" => "SECRET1=$SECRET1") do
ENV["SECRET1"] = "ABC"
assert_equal "ABC", Kamal::Secrets.new["SECRET1"]
end
end
test "secrets file value overrides env" do
with_test_secrets("secrets" => "SECRET1=DEF") do
ENV["SECRET1"] = "ABC"
assert_equal "DEF", Kamal::Secrets.new["SECRET1"]
end
end
test "destinations" do test "destinations" do
with_test_secrets("secrets.dest" => "SECRET=DEF", "secrets" => "SECRET=ABC", "secrets-common" => "SECRET=GHI\nSECRET2=JKL") do with_test_secrets("secrets.dest" => "SECRET=DEF", "secrets" => "SECRET=ABC", "secrets-common" => "SECRET=GHI\nSECRET2=JKL") do
assert_equal "ABC", Kamal::Secrets.new["SECRET"] assert_equal "ABC", Kamal::Secrets.new["SECRET"]