Merge branch 'main' into feature/docker-build-cloud
This commit is contained in:
@@ -2,6 +2,7 @@ module Kamal::Cli
|
||||
class BootError < StandardError; end
|
||||
class HookError < StandardError; end
|
||||
class LockError < StandardError; end
|
||||
class DependencyError < StandardError; end
|
||||
end
|
||||
|
||||
# SSHKit uses instance eval, so we need a global const for ergonomics
|
||||
|
||||
@@ -292,7 +292,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
||||
def prepare(name)
|
||||
with_accessory(name) do |accessory, hosts|
|
||||
on(hosts) do
|
||||
execute *KAMAL.registry.login
|
||||
execute *KAMAL.registry.login(registry_config: accessory.registry)
|
||||
execute *KAMAL.docker.create_network
|
||||
rescue SSHKit::Command::Failed => e
|
||||
raise unless e.message.include?("already exists")
|
||||
|
||||
@@ -5,7 +5,7 @@ module Kamal::Cli
|
||||
class Base < Thor
|
||||
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
|
||||
|
||||
class_option :verbose, type: :boolean, aliases: "-v", desc: "Detailed logging"
|
||||
@@ -30,7 +30,8 @@ module Kamal::Cli
|
||||
else
|
||||
super
|
||||
end
|
||||
initialize_commander unless KAMAL.configured?
|
||||
|
||||
initialize_commander unless config[:invoked_via_subcommand]
|
||||
end
|
||||
|
||||
private
|
||||
@@ -194,5 +195,19 @@ module Kamal::Cli
|
||||
ENV.clear
|
||||
ENV.update(current_env)
|
||||
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
|
||||
|
||||
@@ -13,7 +13,7 @@ class Kamal::Cli::Build < Kamal::Cli::Base
|
||||
def push
|
||||
cli = self
|
||||
|
||||
verify_local_dependencies
|
||||
ensure_docker_installed
|
||||
run_hook "pre-build"
|
||||
|
||||
uncommitted_changes = Kamal::Git.uncommitted_changes
|
||||
@@ -109,20 +109,6 @@ class Kamal::Cli::Build < Kamal::Cli::Base
|
||||
end
|
||||
|
||||
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)
|
||||
remote_uri = URI.parse(remote_host)
|
||||
if remote_uri.scheme == "ssh"
|
||||
|
||||
@@ -9,15 +9,14 @@ class Kamal::Cli::Main < Kamal::Cli::Base
|
||||
say "Ensure Docker is installed...", :magenta
|
||||
invoke "kamal:cli:server:bootstrap", [], invoke_options
|
||||
|
||||
invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options
|
||||
deploy
|
||||
deploy(boot_accessories: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
desc "deploy", "Deploy app to servers"
|
||||
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
|
||||
invoke_options = deploy_options
|
||||
|
||||
@@ -38,6 +37,8 @@ class Kamal::Cli::Main < Kamal::Cli::Base
|
||||
say "Ensure kamal-proxy is running...", :magenta
|
||||
invoke "kamal:cli:proxy:boot", [], invoke_options
|
||||
|
||||
invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options if boot_accessories
|
||||
|
||||
say "Detect stale containers...", :magenta
|
||||
invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
|
||||
|
||||
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_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 :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"
|
||||
@@ -31,7 +32,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
|
||||
case subcommand
|
||||
when "set"
|
||||
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])),
|
||||
*options[:docker_options].map { |option| "--#{option}" }
|
||||
]
|
||||
|
||||
@@ -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_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login"
|
||||
def login
|
||||
ensure_docker_installed
|
||||
|
||||
run_locally { execute *KAMAL.registry.login } unless options[:skip_local]
|
||||
on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote]
|
||||
end
|
||||
|
||||
@@ -76,11 +76,6 @@ class Kamal::Commander
|
||||
config.accessories&.collect(&:name) || []
|
||||
end
|
||||
|
||||
def accessories_on(host)
|
||||
config.accessories.select { |accessory| accessory.hosts.include?(host.to_s) }.map(&:name)
|
||||
end
|
||||
|
||||
|
||||
def app(role: nil, host: nil)
|
||||
Kamal::Commands::App.new(config, role: role, host: host)
|
||||
end
|
||||
@@ -129,7 +124,6 @@ class Kamal::Commander
|
||||
config.aliases[name]
|
||||
end
|
||||
|
||||
|
||||
def with_verbosity(level)
|
||||
old_level = self.verbosity
|
||||
|
||||
|
||||
@@ -4,11 +4,10 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
|
||||
attr_reader :accessory_config
|
||||
delegate :service_name, :image, :hosts, :port, :files, :directories, :cmd,
|
||||
: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
|
||||
delegate :proxy_container_name, to: :config
|
||||
|
||||
|
||||
def initialize(config, name:)
|
||||
super(config)
|
||||
@accessory_config = config.accessory(name)
|
||||
@@ -42,7 +41,6 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
|
||||
docker :ps, *service_filter
|
||||
end
|
||||
|
||||
|
||||
def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil)
|
||||
pipe \
|
||||
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)
|
||||
end
|
||||
|
||||
|
||||
def execute_in_existing_container(*command, interactive: false)
|
||||
docker :exec,
|
||||
("-it" if interactive),
|
||||
@@ -87,7 +84,6 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
|
||||
super command, host: hosts.first
|
||||
end
|
||||
|
||||
|
||||
def ensure_local_file_present(local_file)
|
||||
if !local_file.is_a?(StringIO) && !Pathname.new(local_file).exist?
|
||||
raise "Missing file: #{local_file}"
|
||||
|
||||
@@ -4,10 +4,10 @@ module Kamal::Commands::App::Assets
|
||||
|
||||
combine \
|
||||
make_directory(role.asset_extracted_directory),
|
||||
[ *docker(:stop, "-t 1", asset_container, "2> /dev/null"), "|| true" ],
|
||||
docker(:run, "--name", asset_container, "--detach", "--rm", "--entrypoint", "sleep", config.absolute_image, "1000000"),
|
||||
docker(:cp, "-L", "#{asset_container}:#{role.asset_path}/.", role.asset_extracted_directory),
|
||||
docker(:stop, "-t 1", asset_container),
|
||||
[ *docker(:container, :rm, asset_container, "2> /dev/null"), "|| true" ],
|
||||
docker(:container, :create, "--name", asset_container, config.absolute_image),
|
||||
docker(:container, :cp, "-L", "#{asset_container}:#{role.asset_path}/.", role.asset_extracted_directory),
|
||||
docker(:container, :rm, asset_container),
|
||||
by: "&&"
|
||||
end
|
||||
|
||||
|
||||
@@ -34,6 +34,12 @@ module Kamal::Commands
|
||||
[ :rm, path ]
|
||||
end
|
||||
|
||||
def ensure_docker_installed
|
||||
combine \
|
||||
ensure_local_docker_installed,
|
||||
ensure_local_buildx_installed
|
||||
end
|
||||
|
||||
private
|
||||
def combine(*commands, by: "&&")
|
||||
commands
|
||||
@@ -104,5 +110,13 @@ module Kamal::Commands
|
||||
" -i #{key}"
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_local_docker_installed
|
||||
docker "--version"
|
||||
end
|
||||
|
||||
def ensure_local_buildx_installed
|
||||
docker :buildx, "version"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,24 +39,4 @@ class Kamal::Commands::Builder < Kamal::Commands::Base
|
||||
def cloud
|
||||
@cloud ||= Kamal::Commands::Builder::Cloud.new(config)
|
||||
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
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
class Kamal::Commands::Registry < Kamal::Commands::Base
|
||||
delegate :registry, to: :config
|
||||
def login(registry_config: nil)
|
||||
registry_config ||= config.registry
|
||||
|
||||
def login
|
||||
docker :login,
|
||||
registry.server,
|
||||
"-u", sensitive(Kamal::Utils.escape_shell_value(registry.username)),
|
||||
"-p", sensitive(Kamal::Utils.escape_shell_value(registry.password))
|
||||
registry_config.server,
|
||||
"-u", sensitive(Kamal::Utils.escape_shell_value(registry_config.username)),
|
||||
"-p", sensitive(Kamal::Utils.escape_shell_value(registry_config.password))
|
||||
end
|
||||
|
||||
def logout
|
||||
docker :logout, registry.server
|
||||
def logout(registry_config: nil)
|
||||
registry_config ||= config.registry
|
||||
|
||||
docker :logout, registry_config.server
|
||||
end
|
||||
end
|
||||
|
||||
@@ -59,7 +59,7 @@ class Kamal::Configuration
|
||||
|
||||
# Eager load config to validate it, these are first as they have dependencies later on
|
||||
@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) } || []
|
||||
@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
|
||||
end
|
||||
|
||||
|
||||
def version=(version)
|
||||
@declared_version = version
|
||||
end
|
||||
@@ -106,7 +105,6 @@ class Kamal::Configuration
|
||||
raw_config.minimum_version
|
||||
end
|
||||
|
||||
|
||||
def roles
|
||||
servers.roles
|
||||
end
|
||||
@@ -119,7 +117,6 @@ class Kamal::Configuration
|
||||
accessories.detect { |a| a.name == name.to_s }
|
||||
end
|
||||
|
||||
|
||||
def all_hosts
|
||||
(roles + accessories).flat_map(&:hosts).uniq
|
||||
end
|
||||
@@ -180,7 +177,6 @@ class Kamal::Configuration
|
||||
raw_config.retain_containers || 5
|
||||
end
|
||||
|
||||
|
||||
def volume_args
|
||||
if raw_config.volumes.present?
|
||||
argumentize "--volume", raw_config.volumes
|
||||
@@ -193,7 +189,6 @@ class Kamal::Configuration
|
||||
logging.args
|
||||
end
|
||||
|
||||
|
||||
def readiness_delay
|
||||
raw_config.readiness_delay || 7
|
||||
end
|
||||
@@ -206,7 +201,6 @@ class Kamal::Configuration
|
||||
raw_config.drain_timeout || 30
|
||||
end
|
||||
|
||||
|
||||
def run_directory
|
||||
".kamal"
|
||||
end
|
||||
@@ -227,7 +221,6 @@ class Kamal::Configuration
|
||||
File.join app_directory, "assets"
|
||||
end
|
||||
|
||||
|
||||
def hooks_path
|
||||
raw_config.hooks_path || ".kamal/hooks"
|
||||
end
|
||||
@@ -236,7 +229,6 @@ class Kamal::Configuration
|
||||
raw_config.asset_path
|
||||
end
|
||||
|
||||
|
||||
def env_tags
|
||||
@env_tags ||= if (tags = raw_config.env["tags"])
|
||||
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 }
|
||||
end
|
||||
|
||||
def proxy_publish_args(http_port, https_port)
|
||||
argumentize "--publish", [ "#{http_port}:#{PROXY_HTTP_PORT}", "#{https_port}:#{PROXY_HTTPS_PORT}" ]
|
||||
def proxy_publish_args(http_port, https_port, bind_ips = nil)
|
||||
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
|
||||
|
||||
def proxy_logging_args(max_size)
|
||||
@@ -277,7 +277,6 @@ class Kamal::Configuration
|
||||
File.join proxy_directory, "options"
|
||||
end
|
||||
|
||||
|
||||
def to_h
|
||||
{
|
||||
roles: role_names,
|
||||
@@ -344,6 +343,15 @@ class Kamal::Configuration
|
||||
true
|
||||
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
|
||||
raise Kamal::ConfigurationError, "Must retain at least 1 container" if retain_containers < 1
|
||||
|
||||
@@ -375,6 +383,15 @@ class Kamal::Configuration
|
||||
true
|
||||
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
|
||||
raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
|
||||
end
|
||||
|
||||
@@ -5,7 +5,7 @@ class Kamal::Configuration::Accessory
|
||||
|
||||
delegate :argumentize, :optionize, to: Kamal::Utils
|
||||
|
||||
attr_reader :name, :accessory_config, :env, :proxy
|
||||
attr_reader :name, :env, :proxy, :registry
|
||||
|
||||
def initialize(name, config:)
|
||||
@name, @config, @accessory_config = name.inquiry, config, config.raw_config["accessories"][name]
|
||||
@@ -16,12 +16,9 @@ class Kamal::Configuration::Accessory
|
||||
context: "accessories/#{name}",
|
||||
with: Kamal::Configuration::Validator::Accessory
|
||||
|
||||
@env = Kamal::Configuration::Env.new \
|
||||
config: accessory_config.fetch("env", {}),
|
||||
secrets: config.secrets,
|
||||
context: "accessories/#{name}/env"
|
||||
|
||||
initialize_proxy if running_proxy?
|
||||
@env = initialize_env
|
||||
@proxy = initialize_proxy if running_proxy?
|
||||
@registry = initialize_registry if accessory_config["registry"].present?
|
||||
end
|
||||
|
||||
def service_name
|
||||
@@ -29,7 +26,7 @@ class Kamal::Configuration::Accessory
|
||||
end
|
||||
|
||||
def image
|
||||
accessory_config["image"]
|
||||
[ registry&.server, accessory_config["image"] ].compact.join("/")
|
||||
end
|
||||
|
||||
def hosts
|
||||
@@ -109,18 +106,32 @@ class Kamal::Configuration::Accessory
|
||||
end
|
||||
|
||||
def running_proxy?
|
||||
@accessory_config["proxy"].present?
|
||||
end
|
||||
|
||||
def initialize_proxy
|
||||
@proxy = Kamal::Configuration::Proxy.new \
|
||||
config: config,
|
||||
proxy_config: accessory_config["proxy"],
|
||||
context: "accessories/#{name}/proxy"
|
||||
accessory_config["proxy"].present?
|
||||
end
|
||||
|
||||
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
|
||||
{ "service" => service_name }
|
||||
|
||||
@@ -23,9 +23,27 @@ accessories:
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
#
|
||||
# Specify one of `host`, `hosts`, or `roles`:
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
class Kamal::Configuration::Registry
|
||||
include Kamal::Configuration::Validation
|
||||
|
||||
attr_reader :registry_config, :secrets
|
||||
|
||||
def initialize(config:)
|
||||
@registry_config = config.raw_config.registry || {}
|
||||
@secrets = config.secrets
|
||||
validate! registry_config, with: Kamal::Configuration::Validator::Registry
|
||||
def initialize(config:, secrets:, context: "registry")
|
||||
@registry_config = config["registry"] || {}
|
||||
@secrets = secrets
|
||||
validate! registry_config, context: context, with: Kamal::Configuration::Validator::Registry
|
||||
end
|
||||
|
||||
def server
|
||||
@@ -22,6 +20,8 @@ class Kamal::Configuration::Registry
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :registry_config, :secrets
|
||||
|
||||
def lookup(key)
|
||||
if registry_config[key].is_a?(Array)
|
||||
secrets[registry_config[key].first]
|
||||
|
||||
@@ -10,7 +10,7 @@ class Kamal::Configuration::Role
|
||||
def initialize(name, config:)
|
||||
@name, @config = name.inquiry, config
|
||||
validate! \
|
||||
specializations,
|
||||
role_config,
|
||||
example: validation_yml["servers"]["workers"],
|
||||
context: "servers/#{name}",
|
||||
with: Kamal::Configuration::Validator::Role
|
||||
@@ -204,11 +204,11 @@ class Kamal::Configuration::Role
|
||||
end
|
||||
|
||||
def specializations
|
||||
if config.raw_config.servers.is_a?(Array) || config.raw_config.servers[name].is_a?(Array)
|
||||
{}
|
||||
else
|
||||
config.raw_config.servers[name]
|
||||
end
|
||||
@specializations ||= role_config.is_a?(Array) ? {} : role_config
|
||||
end
|
||||
|
||||
def role_config
|
||||
@role_config ||= config.raw_config.servers.is_a?(Array) ? {} : config.raw_config.servers[name]
|
||||
end
|
||||
|
||||
def custom_labels
|
||||
|
||||
@@ -3,7 +3,7 @@ class Kamal::Configuration::Validator::Role < Kamal::Configuration::Validator
|
||||
validate_type! config, Array, Hash
|
||||
|
||||
if config.is_a?(Array)
|
||||
validate_servers! "servers", config
|
||||
validate_servers!(config)
|
||||
else
|
||||
super
|
||||
end
|
||||
|
||||
@@ -3,6 +3,8 @@ module Kamal::Secrets::Adapters
|
||||
def self.lookup(name)
|
||||
name = "one_password" if name.downcase == "1password"
|
||||
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)
|
||||
end
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Ba
|
||||
nil
|
||||
end
|
||||
|
||||
def fetch_secrets(secrets, account:, session:)
|
||||
def fetch_secrets(secrets, from:, account:, session:)
|
||||
{}.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_string = JSON.parse(secret["SecretString"])
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ class Kamal::Secrets::Adapters::Base
|
||||
check_dependencies!
|
||||
|
||||
session = login(account)
|
||||
full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") }
|
||||
fetch_secrets(full_secrets, account: account, session: session)
|
||||
fetch_secrets(secrets, from: from, account: account, session: session)
|
||||
end
|
||||
|
||||
def requires_account?
|
||||
@@ -27,4 +26,8 @@ class Kamal::Secrets::Adapters::Base
|
||||
def check_dependencies!
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def prefixed_secrets(secrets, from:)
|
||||
secrets.map { |secret| [ from, secret ].compact.join("/") }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,9 +21,9 @@ class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base
|
||||
session
|
||||
end
|
||||
|
||||
def fetch_secrets(secrets, account:, session:)
|
||||
def fetch_secrets(secrets, from:, account:, session:)
|
||||
{}.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)
|
||||
raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success?
|
||||
item_json = JSON.parse(item_json)
|
||||
|
||||
72
lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb
Normal file
72
lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb
Normal 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
|
||||
@@ -16,8 +16,21 @@ class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base
|
||||
$?.success?
|
||||
end
|
||||
|
||||
def fetch_secrets(secrets, **)
|
||||
project_and_config_flags = ""
|
||||
def fetch_secrets(secrets, from:, **)
|
||||
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?
|
||||
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}"
|
||||
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
|
||||
|
||||
def service_token_set?
|
||||
|
||||
71
lib/kamal/secrets/adapters/enpass.rb
Normal file
71
lib/kamal/secrets/adapters/enpass.rb
Normal 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
|
||||
112
lib/kamal/secrets/adapters/gcp_secret_manager.rb
Normal file
112
lib/kamal/secrets/adapters/gcp_secret_manager.rb
Normal 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
|
||||
@@ -11,7 +11,8 @@ class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base
|
||||
`lpass status --color never`.strip == "Logged in as #{account}."
|
||||
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`
|
||||
raise RuntimeError, "Could not read #{secrets} from LastPass" unless $?.success?
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base
|
||||
$?.success?
|
||||
end
|
||||
|
||||
def fetch_secrets(secrets, account:, session:)
|
||||
def fetch_secrets(secrets, from:, account:, session:)
|
||||
{}.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|
|
||||
fields_json = JSON.parse(op_item_get(vault, item, fields, account: account, session: session))
|
||||
fields_json = [ fields_json ] if fields.one?
|
||||
|
||||
@@ -4,8 +4,8 @@ class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base
|
||||
true
|
||||
end
|
||||
|
||||
def fetch_secrets(secrets, account:, session:)
|
||||
secrets.to_h { |secret| [ secret, secret.reverse ] }
|
||||
def fetch_secrets(secrets, from:, account:, session:)
|
||||
prefixed_secrets(secrets, from: from).to_h { |secret| [ secret, secret.reverse ] }
|
||||
end
|
||||
|
||||
def check_dependencies!
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
class Kamal::Secrets::Adapters::TestOptionalAccount < Kamal::Secrets::Adapters::Test
|
||||
def requires_account?
|
||||
false
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user