Catch up with main
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
|
||||
|
||||
@@ -162,7 +162,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
||||
option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)"
|
||||
option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server"
|
||||
option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)"
|
||||
option :grep_options, aliases: "-o", desc: "Additional options supplied to grep"
|
||||
option :grep_options, desc: "Additional options supplied to grep"
|
||||
option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)"
|
||||
option :skip_timestamps, type: :boolean, aliases: "-T", desc: "Skip appending timestamps to logging output"
|
||||
def logs(name)
|
||||
@@ -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")
|
||||
|
||||
@@ -94,9 +94,15 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
||||
option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
|
||||
option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
|
||||
option :env, aliases: "-e", type: :hash, desc: "Set environment variables for the command"
|
||||
option :detach, type: :boolean, default: false, desc: "Execute command in a detached container"
|
||||
def exec(*cmd)
|
||||
if (incompatible_options = [ :interactive, :reuse ].select { |key| options[:detach] && options[key] }.presence)
|
||||
raise ArgumentError, "Detach is not compatible with #{incompatible_options.join(" or ")}"
|
||||
end
|
||||
|
||||
cmd = Kamal::Utils.join_commands(cmd)
|
||||
env = options[:env]
|
||||
detach = options[:detach]
|
||||
case
|
||||
when options[:interactive] && options[:reuse]
|
||||
say "Get current version of running container...", :magenta unless options[:version]
|
||||
@@ -138,7 +144,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
||||
|
||||
roles.each do |role|
|
||||
execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
|
||||
puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_new_container(cmd, env: env))
|
||||
puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_new_container(cmd, env: env, detach: detach))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -186,15 +192,17 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
||||
option :since, aliases: "-s", desc: "Show lines since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)"
|
||||
option :lines, type: :numeric, aliases: "-n", desc: "Number of lines to show from each server"
|
||||
option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)"
|
||||
option :grep_options, aliases: "-o", desc: "Additional options supplied to grep"
|
||||
option :grep_options, desc: "Additional options supplied to grep"
|
||||
option :follow, aliases: "-f", desc: "Follow log on primary server (or specific host set by --hosts)"
|
||||
option :skip_timestamps, type: :boolean, aliases: "-T", desc: "Skip appending timestamps to logging output"
|
||||
option :container_id, desc: "Docker container ID to fetch logs"
|
||||
def logs
|
||||
# FIXME: Catch when app containers aren't running
|
||||
|
||||
grep = options[:grep]
|
||||
grep_options = options[:grep_options]
|
||||
since = options[:since]
|
||||
container_id = options[:container_id]
|
||||
timestamps = !options[:skip_timestamps]
|
||||
|
||||
if options[:follow]
|
||||
@@ -207,8 +215,8 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
||||
role = KAMAL.roles_on(KAMAL.primary_host).first
|
||||
|
||||
app = KAMAL.app(role: role, host: host)
|
||||
info app.follow_logs(host: KAMAL.primary_host, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options)
|
||||
exec app.follow_logs(host: KAMAL.primary_host, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options)
|
||||
info app.follow_logs(host: KAMAL.primary_host, container_id: container_id, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options)
|
||||
exec app.follow_logs(host: KAMAL.primary_host, container_id: container_id, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options)
|
||||
end
|
||||
else
|
||||
lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
|
||||
@@ -218,7 +226,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
||||
|
||||
roles.each do |role|
|
||||
begin
|
||||
puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(timestamps: timestamps, since: since, lines: lines, grep: grep, grep_options: grep_options))
|
||||
puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(container_id: container_id, timestamps: timestamps, since: since, lines: lines, grep: grep, grep_options: grep_options))
|
||||
rescue SSHKit::Command::Failed
|
||||
puts_by_host host, "Nothing found"
|
||||
end
|
||||
|
||||
@@ -45,7 +45,7 @@ class Kamal::Cli::App::Boot
|
||||
|
||||
def start_new_version
|
||||
audit "Booted app version #{version}"
|
||||
hostname = "#{host.to_s[0...51].gsub(/\.+$/, '')}-#{SecureRandom.hex(6)}"
|
||||
hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
|
||||
|
||||
execute *app.ensure_env_directory
|
||||
upload! role.secrets_io(host), role.secrets_path, mode: "0600"
|
||||
@@ -91,7 +91,7 @@ class Kamal::Cli::App::Boot
|
||||
if barrier.close
|
||||
info "First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting any other roles"
|
||||
begin
|
||||
error capture_with_info(*app.logs(version: version))
|
||||
error capture_with_info(*app.logs(container_id: app.container_id_for_version(version)))
|
||||
error capture_with_info(*app.container_health_log(version: version))
|
||||
rescue SSHKit::Command::Failed
|
||||
error "Could not fetch logs for #{version}"
|
||||
|
||||
@@ -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}" }
|
||||
]
|
||||
@@ -67,9 +68,6 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
|
||||
execute *KAMAL.auditor.record("Rebooted proxy"), verbosity: :debug
|
||||
execute *KAMAL.registry.login
|
||||
|
||||
"Stopping and removing Traefik on #{host}, if running..."
|
||||
execute *KAMAL.proxy.cleanup_traefik
|
||||
|
||||
"Stopping and removing kamal-proxy on #{host}, if running..."
|
||||
execute *KAMAL.proxy.stop, raise_on_non_zero_exit: false
|
||||
execute *KAMAL.proxy.remove_container
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,7 +38,7 @@ builder:
|
||||
arch: amd64
|
||||
# Pass in additional build args needed for your Dockerfile.
|
||||
# args:
|
||||
# RUBY_VERSION: <%= File.read('.ruby-version').strip %>
|
||||
# RUBY_VERSION: <%= ENV["RBENV_VERSION"] || ENV["rvm_ruby_string"] || "#{RUBY_ENGINE}-#{RUBY_ENGINE_VERSION}" %>
|
||||
|
||||
# Inject ENV variables into containers (secrets come from .kamal/secrets).
|
||||
#
|
||||
@@ -49,7 +49,7 @@ builder:
|
||||
# - RAILS_MASTER_KEY
|
||||
|
||||
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
|
||||
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
|
||||
# "bin/kamal app logs -r job" will tail logs from the first server in the job section.
|
||||
#
|
||||
# aliases:
|
||||
# shell: app exec --interactive --reuse "bash"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -7,13 +7,15 @@ module Kamal::Commands::App::Execution
|
||||
*command
|
||||
end
|
||||
|
||||
def execute_in_new_container(*command, interactive: false, env:)
|
||||
def execute_in_new_container(*command, interactive: false, detach: false, env:)
|
||||
docker :run,
|
||||
("-it" if interactive),
|
||||
"--rm",
|
||||
("--detach" if detach),
|
||||
("--rm" unless detach),
|
||||
"--network", "kamal",
|
||||
*role&.env_args(host),
|
||||
*argumentize("--env", env),
|
||||
*role.logging_args,
|
||||
*config.volume_args,
|
||||
*role&.option_args,
|
||||
config.absolute_image,
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
module Kamal::Commands::App::Logging
|
||||
def logs(version: nil, timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil)
|
||||
def logs(container_id: nil, timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil)
|
||||
pipe \
|
||||
version ? container_id_for_version(version) : current_running_container_id,
|
||||
container_id_command(container_id),
|
||||
"xargs docker logs#{" --timestamps" if timestamps}#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1",
|
||||
("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep)
|
||||
end
|
||||
|
||||
def follow_logs(host:, timestamps: true, lines: nil, grep: nil, grep_options: nil)
|
||||
def follow_logs(host:, container_id: nil, timestamps: true, lines: nil, grep: nil, grep_options: nil)
|
||||
run_over_ssh \
|
||||
pipe(
|
||||
current_running_container_id,
|
||||
container_id_command(container_id),
|
||||
"xargs docker logs#{" --timestamps" if timestamps}#{" --tail #{lines}" if lines} --follow 2>&1",
|
||||
(%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep)
|
||||
),
|
||||
host: host
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def container_id_command(container_id)
|
||||
case container_id
|
||||
when Array then container_id
|
||||
when String, Symbol then "echo #{container_id}"
|
||||
else current_running_container_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,7 +11,7 @@ module Kamal::Commands
|
||||
end
|
||||
|
||||
def run_over_ssh(*command, host:)
|
||||
"ssh#{ssh_proxy_args} -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ").gsub("'", "'\\\\''")}'"
|
||||
"ssh#{ssh_proxy_args}#{ssh_keys_args} -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ").gsub("'", "'\\\\''")}'"
|
||||
end
|
||||
|
||||
def container_id_for(container_name:, only_running: false)
|
||||
@@ -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
|
||||
@@ -98,5 +104,23 @@ module Kamal::Commands
|
||||
" -o ProxyCommand='#{config.ssh.proxy.command_line_template}'"
|
||||
end
|
||||
end
|
||||
|
||||
def ssh_keys_args
|
||||
"#{ ssh_keys.join("") if ssh_keys}" + "#{" -o IdentitiesOnly=yes" if config.ssh&.keys_only}"
|
||||
end
|
||||
|
||||
def ssh_keys
|
||||
config.ssh.keys&.map do |key|
|
||||
" -i #{key}"
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_local_docker_installed
|
||||
docker "--version"
|
||||
end
|
||||
|
||||
def ensure_local_buildx_installed
|
||||
docker :buildx, "version"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@ require "active_support/core_ext/string/filters"
|
||||
|
||||
class Kamal::Commands::Builder < Kamal::Commands::Base
|
||||
delegate :create, :remove, :push, :clean, :pull, :info, :inspect_builder, :validate_image, :first_mirror, to: :target
|
||||
delegate :local?, :remote?, :pack?, to: "config.builder"
|
||||
delegate :local?, :remote?, :pack?, :cloud?, to: "config.builder"
|
||||
|
||||
include Clone
|
||||
|
||||
@@ -19,6 +19,8 @@ class Kamal::Commands::Builder < Kamal::Commands::Base
|
||||
end
|
||||
elsif pack?
|
||||
pack
|
||||
elsif cloud?
|
||||
cloud
|
||||
else
|
||||
local
|
||||
end
|
||||
@@ -40,22 +42,7 @@ class Kamal::Commands::Builder < Kamal::Commands::Base
|
||||
@pack ||= Kamal::Commands::Builder::Pack.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
|
||||
def cloud
|
||||
@cloud ||= Kamal::Commands::Builder::Cloud.new(config)
|
||||
end
|
||||
|
||||
private
|
||||
def ensure_local_docker_installed
|
||||
docker "--version"
|
||||
end
|
||||
|
||||
def ensure_local_buildx_installed
|
||||
docker :buildx, "version"
|
||||
end
|
||||
end
|
||||
|
||||
22
lib/kamal/commands/builder/cloud.rb
Normal file
22
lib/kamal/commands/builder/cloud.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class Kamal::Commands::Builder::Cloud < Kamal::Commands::Builder::Base
|
||||
# Expects `driver` to be of format "cloud docker-org-name/builder-name"
|
||||
|
||||
def create
|
||||
docker :buildx, :create, "--driver", driver
|
||||
end
|
||||
|
||||
def remove
|
||||
docker :buildx, :rm, builder_name
|
||||
end
|
||||
|
||||
private
|
||||
def builder_name
|
||||
driver.gsub(/[ \/]/, "-")
|
||||
end
|
||||
|
||||
def inspect_buildx
|
||||
pipe \
|
||||
docker(:buildx, :inspect, builder_name),
|
||||
grep("-q", "Endpoint:.*cloud://.*")
|
||||
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
|
||||
|
||||
@@ -14,7 +14,7 @@ class Kamal::Configuration
|
||||
|
||||
include Validation
|
||||
|
||||
PROXY_MINIMUM_VERSION = "v0.8.2"
|
||||
PROXY_MINIMUM_VERSION = "v0.8.4"
|
||||
PROXY_HTTP_PORT = 80
|
||||
PROXY_HTTPS_PORT = 443
|
||||
PROXY_LOG_MAX_SIZE = "10m"
|
||||
@@ -37,7 +37,7 @@ class Kamal::Configuration
|
||||
if file.exist?
|
||||
# Newer Psych doesn't load aliases by default
|
||||
load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load
|
||||
YAML.send(load_method, ERB.new(IO.read(file)).result).symbolize_keys
|
||||
YAML.send(load_method, ERB.new(File.read(file)).result).symbolize_keys
|
||||
else
|
||||
raise "Configuration file not found in #{file}"
|
||||
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 }
|
||||
@@ -142,7 +153,7 @@ class Kamal::Configuration::Accessory
|
||||
end
|
||||
|
||||
def read_dynamic_file(local_file)
|
||||
StringIO.new(ERB.new(IO.read(local_file)).result)
|
||||
StringIO.new(ERB.new(File.read(local_file)).result)
|
||||
end
|
||||
|
||||
def expand_remote_file(remote_file)
|
||||
|
||||
@@ -53,6 +53,10 @@ class Kamal::Configuration::Builder
|
||||
!local_disabled? && (arches.empty? || local_arches.any?)
|
||||
end
|
||||
|
||||
def cloud?
|
||||
driver.start_with? "cloud"
|
||||
end
|
||||
|
||||
def cached?
|
||||
!!builder_config["cache"]
|
||||
end
|
||||
|
||||
@@ -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`:
|
||||
@@ -43,8 +61,8 @@ accessories:
|
||||
|
||||
# Port mappings
|
||||
#
|
||||
# See https://docs.docker.com/network/, and especially note the warning about the security
|
||||
# implications of exposing ports publicly.
|
||||
# See [https://docs.docker.com/network/](https://docs.docker.com/network/), and
|
||||
# especially note the warning about the security implications of exposing ports publicly.
|
||||
port: "127.0.0.1:3306:3306"
|
||||
|
||||
# Labels
|
||||
@@ -101,4 +119,4 @@ accessories:
|
||||
# Proxy
|
||||
#
|
||||
proxy:
|
||||
...
|
||||
...
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
# For example, for a Rails app, you might open a console with:
|
||||
#
|
||||
# ```shell
|
||||
# kamal app exec -i -r console "rails console"
|
||||
# kamal app exec -i --reuse "bin/rails console"
|
||||
# ```
|
||||
#
|
||||
# By defining an alias, like this:
|
||||
aliases:
|
||||
console: app exec -r console -i "rails console"
|
||||
console: app exec -i --reuse "bin/rails console"
|
||||
# You can now open the console with:
|
||||
#
|
||||
# ```shell
|
||||
|
||||
@@ -114,6 +114,9 @@ builder:
|
||||
#
|
||||
# The build driver to use, defaults to `docker-container`:
|
||||
driver: docker
|
||||
#
|
||||
# If you want to use Docker Build Cloud (https://www.docker.com/products/build-cloud/), you can set the driver to:
|
||||
driver: cloud org-name/builder-name
|
||||
|
||||
# Provenance
|
||||
#
|
||||
|
||||
@@ -46,9 +46,22 @@ proxy:
|
||||
# The host value must point to the server we are deploying to, and port 443 must be
|
||||
# open for the Let's Encrypt challenge to succeed.
|
||||
#
|
||||
# If you set `ssl` to `true`, `kamal-proxy` will stop forwarding headers to your app,
|
||||
# unless you explicitly set `forward_headers: true`
|
||||
#
|
||||
# Defaults to `false`:
|
||||
ssl: true
|
||||
|
||||
# Forward headers
|
||||
#
|
||||
# Whether to forward the `X-Forwarded-For` and `X-Forwarded-Proto` headers.
|
||||
#
|
||||
# If you are behind a trusted proxy, you can set this to `true` to forward the headers.
|
||||
#
|
||||
# By default, kamal-proxy will not forward the headers if the `ssl` option is set to `true`, and
|
||||
# will forward them if it is set to `false`.
|
||||
forward_headers: true
|
||||
|
||||
# Response timeout
|
||||
#
|
||||
# How long to wait for requests to complete before timing out, defaults to 30 seconds:
|
||||
@@ -93,13 +106,3 @@ proxy:
|
||||
response_headers:
|
||||
- X-Request-ID
|
||||
- X-Request-Start
|
||||
|
||||
# Forward headers
|
||||
#
|
||||
# Whether to forward the `X-Forwarded-For` and `X-Forwarded-Proto` headers.
|
||||
#
|
||||
# If you are behind a trusted proxy, you can set this to `true` to forward the headers.
|
||||
#
|
||||
# By default, kamal-proxy will not forward the headers if the `ssl` option is set to `true`, and
|
||||
# will forward them if it is set to `false`.
|
||||
forward_headers: true
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
#
|
||||
# The default registry is Docker Hub, but you can change it using `registry/server`.
|
||||
#
|
||||
# By default, Docker Hub creates public repositories. To avoid making your images public,
|
||||
# set up a private repository before deploying, or change the default repository privacy
|
||||
# settings to private in your [Docker Hub settings](https://hub.docker.com/repository-settings/default-privacy).
|
||||
#
|
||||
# A reference to a secret (in this case, `DOCKER_REGISTRY_TOKEN`) will look up the secret
|
||||
# in the local environment:
|
||||
registry:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,7 +32,7 @@ class Kamal::Secrets
|
||||
private
|
||||
def secrets
|
||||
@secrets ||= secrets_files.inject({}) do |secrets, secrets_file|
|
||||
secrets.merge!(::Dotenv.parse(secrets_file))
|
||||
secrets.merge!(::Dotenv.parse(secrets_file, overwrite: true))
|
||||
end
|
||||
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,22 +4,30 @@ 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|
|
||||
JSON.parse(get_from_secrets_manager(secrets, account: account))["SecretValues"].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"])
|
||||
|
||||
secret_string.each do |key, value|
|
||||
results["#{secret_name}/#{key}"] = value
|
||||
end
|
||||
rescue JSON::ParserError
|
||||
results["#{secret_name}"] = secret["SecretString"]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_from_secrets_manager(secrets, account:)
|
||||
`aws secretsmanager batch-get-secret-value --secret-id-list #{secrets.map(&:shellescape).join(" ")} --profile #{account.shellescape}`.tap do
|
||||
raise RuntimeError, "Could not read #{secret} from AWS Secrets Manager" unless $?.success?
|
||||
`aws secretsmanager batch-get-secret-value --secret-id-list #{secrets.map(&:shellescape).join(" ")} --profile #{account.shellescape}`.tap do |secrets|
|
||||
raise RuntimeError, "Could not read #{secrets} from AWS Secrets Manager" unless $?.success?
|
||||
|
||||
secrets = JSON.parse(secrets)
|
||||
|
||||
return secrets["SecretValues"] unless secrets["Errors"].present?
|
||||
|
||||
raise RuntimeError, secrets["Errors"].map { |error| "#{error['SecretId']}: #{error['Message']}" }.join(" ")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -23,7 +24,7 @@ class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base
|
||||
end
|
||||
|
||||
if (missing_items = secrets - results.keys).any?
|
||||
raise RuntimeError, "Could not find #{missing_items.join(", ")} in LassPass"
|
||||
raise RuntimeError, "Could not find #{missing_items.join(", ")} in LastPass"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -1,3 +1,3 @@
|
||||
module Kamal
|
||||
VERSION = "2.3.0"
|
||||
VERSION = "2.4.0"
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user