Merge branch 'basecamp:main' into buildpacks

This commit is contained in:
Nick Hammond
2024-09-16 18:11:33 -07:00
committed by GitHub
85 changed files with 1539 additions and 746 deletions

View File

@@ -6,7 +6,7 @@ PATH
base64 (~> 0.2) base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)
concurrent-ruby (~> 1.2) concurrent-ruby (~> 1.2)
dotenv (~> 2.8) dotenv (~> 3.1)
ed25519 (~> 1.2) ed25519 (~> 1.2)
net-ssh (~> 7.0) net-ssh (~> 7.0)
sshkit (>= 1.23.0, < 2.0) sshkit (>= 1.23.0, < 2.0)
@@ -16,9 +16,9 @@ PATH
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actionpack (7.1.2) actionpack (7.1.3.4)
actionview (= 7.1.2) actionview (= 7.1.3.4)
activesupport (= 7.1.2) activesupport (= 7.1.3.4)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc racc
rack (>= 2.2.4) rack (>= 2.2.4)
@@ -26,13 +26,13 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
actionview (7.1.2) actionview (7.1.3.4)
activesupport (= 7.1.2) activesupport (= 7.1.3.4)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activesupport (7.1.2) activesupport (7.1.3.4)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
@@ -44,54 +44,55 @@ GEM
tzinfo (~> 2.0) tzinfo (~> 2.0)
ast (2.4.2) ast (2.4.2)
base64 (0.2.0) base64 (0.2.0)
bcrypt_pbkdf (1.1.0) bcrypt_pbkdf (1.1.1)
bigdecimal (3.1.5) bcrypt_pbkdf (1.1.1-arm64-darwin)
builder (3.2.4) bcrypt_pbkdf (1.1.1-x86_64-darwin)
concurrent-ruby (1.2.2) bigdecimal (3.1.8)
builder (3.3.0)
concurrent-ruby (1.3.3)
connection_pool (2.4.1) connection_pool (2.4.1)
crass (1.0.6) crass (1.0.6)
debug (1.9.1) debug (1.9.2)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
dotenv (2.8.1) dotenv (3.1.2)
drb (2.2.0) drb (2.2.1)
ruby2_keywords
ed25519 (1.3.0) ed25519 (1.3.0)
erubi (1.12.0) erubi (1.13.0)
i18n (1.14.1) i18n (1.14.5)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
io-console (0.7.1) io-console (0.7.2)
irb (1.11.0) irb (1.14.0)
rdoc rdoc (>= 4.0.0)
reline (>= 0.3.8) reline (>= 0.4.2)
json (2.7.1) json (2.7.2)
language_server-protocol (3.17.0.3) language_server-protocol (3.17.0.3)
loofah (2.22.0) loofah (2.22.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
minitest (5.20.0) minitest (5.24.1)
mocha (2.1.0) mocha (2.4.5)
ruby2_keywords (>= 0.0.5) ruby2_keywords (>= 0.0.5)
mutex_m (0.2.0) mutex_m (0.2.0)
net-scp (4.0.0) net-scp (4.0.0)
net-ssh (>= 2.6.5, < 8.0.0) net-ssh (>= 2.6.5, < 8.0.0)
net-sftp (4.0.0) net-sftp (4.0.0)
net-ssh (>= 5.0.0, < 8.0.0) net-ssh (>= 5.0.0, < 8.0.0)
net-ssh (7.2.1) net-ssh (7.2.3)
nokogiri (1.16.0-arm64-darwin) nokogiri (1.16.7-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.0-x86_64-darwin) nokogiri (1.16.7-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.0-x86_64-linux) nokogiri (1.16.7-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
parallel (1.24.0) parallel (1.25.1)
parser (3.3.0.5) parser (3.3.4.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
psych (5.1.2) psych (5.1.2)
stringio stringio
racc (1.7.3) racc (1.8.1)
rack (3.0.8) rack (3.1.7)
rack-session (2.0.0) rack-session (2.0.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.1.0) rack-test (2.1.0)
@@ -106,42 +107,43 @@ GEM
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.6.0)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (~> 1.14) nokogiri (~> 1.14)
railties (7.1.2) railties (7.1.3.4)
actionpack (= 7.1.2) actionpack (= 7.1.3.4)
activesupport (= 7.1.2) activesupport (= 7.1.3.4)
irb irb
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.1.0) rake (13.2.1)
rdoc (6.6.2) rdoc (6.7.0)
psych (>= 4.0.0) psych (>= 4.0.0)
regexp_parser (2.9.0) regexp_parser (2.9.2)
reline (0.4.2) reline (0.5.9)
io-console (~> 0.5) io-console (~> 0.5)
rexml (3.2.6) rexml (3.3.4)
rubocop (1.62.1) strscan
rubocop (1.65.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 2.4, < 3.0)
rexml (>= 3.2.5, < 4.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.31.2) rubocop-ast (1.32.0)
parser (>= 3.3.0.4) parser (>= 3.3.1.0)
rubocop-minitest (0.35.0) rubocop-minitest (0.35.1)
rubocop (>= 1.61, < 2.0) rubocop (>= 1.61, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
rubocop-performance (1.20.2) rubocop-performance (1.21.1)
rubocop (>= 1.48.1, < 2.0) rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.30.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.24.0) rubocop-rails (2.25.1)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0) rubocop (>= 1.33.0, < 2.0)
@@ -158,13 +160,14 @@ GEM
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-sftp (>= 2.1.2) net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
stringio (3.1.0) stringio (3.1.1)
thor (1.3.0) strscan (3.1.0)
thor (1.3.1)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0) unicode-display_width (2.5.0)
webrick (1.8.1) webrick (1.8.1)
zeitwerk (2.6.12) zeitwerk (2.6.17)
PLATFORMS PLATFORMS
arm64-darwin arm64-darwin

View File

@@ -15,7 +15,7 @@ Gem::Specification.new do |spec|
spec.add_dependency "sshkit", ">= 1.23.0", "< 2.0" spec.add_dependency "sshkit", ">= 1.23.0", "< 2.0"
spec.add_dependency "net-ssh", "~> 7.0" spec.add_dependency "net-ssh", "~> 7.0"
spec.add_dependency "thor", "~> 1.3" spec.add_dependency "thor", "~> 1.3"
spec.add_dependency "dotenv", "~> 2.8" spec.add_dependency "dotenv", "~> 3.1"
spec.add_dependency "zeitwerk", "~> 2.5" spec.add_dependency "zeitwerk", "~> 2.5"
spec.add_dependency "ed25519", "~> 1.2" spec.add_dependency "ed25519", "~> 1.2"
spec.add_dependency "bcrypt_pbkdf", "~> 1.0" spec.add_dependency "bcrypt_pbkdf", "~> 1.0"

View File

@@ -5,8 +5,10 @@ end
require "active_support" require "active_support"
require "zeitwerk" require "zeitwerk"
require "yaml" require "yaml"
require "tmpdir"
require "pathname"
loader = Zeitwerk::Loader.for_gem loader = Zeitwerk::Loader.for_gem
loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb")) loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb"))
loader.setup loader.setup
loader.eager_load # We need all commands loaded. loader.eager_load_namespace(Kamal::Cli) # We need all commands loaded.

View File

@@ -12,6 +12,8 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
on(hosts) do on(hosts) do
execute *KAMAL.registry.login if login execute *KAMAL.registry.login if login
execute *KAMAL.auditor.record("Booted #{name} accessory"), verbosity: :debug execute *KAMAL.auditor.record("Booted #{name} accessory"), verbosity: :debug
execute *accessory.ensure_env_directory
upload! accessory.secrets_io, accessory.secrets_path, mode: "0600"
execute *accessory.run execute *accessory.run
end end
end end

View File

@@ -1,6 +1,6 @@
class Kamal::Cli::App::Boot class Kamal::Cli::App::Boot
attr_reader :host, :role, :version, :barrier, :sshkit attr_reader :host, :role, :version, :barrier, :sshkit
delegate :execute, :capture_with_info, :capture_with_pretty_json, :info, :error, to: :sshkit delegate :execute, :capture_with_info, :capture_with_pretty_json, :info, :error, :upload!, to: :sshkit
delegate :uses_cord?, :assets?, :running_traefik?, to: :role delegate :uses_cord?, :assets?, :running_traefik?, to: :role
def initialize(host, role, sshkit, version, barrier) def initialize(host, role, sshkit, version, barrier)
@@ -48,7 +48,11 @@ class Kamal::Cli::App::Boot
execute *app.tie_cord(role.cord_host_file) if uses_cord? execute *app.tie_cord(role.cord_host_file) if uses_cord?
hostname = "#{host.to_s[0...51].gsub(/\.+$/, '')}-#{SecureRandom.hex(6)}" hostname = "#{host.to_s[0...51].gsub(/\.+$/, '')}-#{SecureRandom.hex(6)}"
execute *app.ensure_env_directory
upload! role.secrets_io(host), role.secrets_path, mode: "0600"
execute *app.run(hostname: hostname) execute *app.run(hostname: hostname)
Kamal::Cli::Healthcheck::Poller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) } Kamal::Cli::Healthcheck::Poller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) }
end end
@@ -88,8 +92,12 @@ class Kamal::Cli::App::Boot
def close_barrier def close_barrier
if barrier.close if barrier.close
info "First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting any other roles" info "First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting any other roles"
error capture_with_info(*app.logs(version: version)) begin
error capture_with_info(*app.container_health_log(version: version)) error capture_with_info(*app.logs(version: version))
error capture_with_info(*app.container_health_log(version: version))
rescue SSHKit::Command::Failed
error "Could not fetch logs for #{version}"
end
end end
end end

View File

@@ -1,5 +1,4 @@
require "thor" require "thor"
require "dotenv"
require "kamal/sshkit_with_ext" require "kamal/sshkit_with_ext"
module Kamal::Cli module Kamal::Cli
@@ -31,53 +30,15 @@ module Kamal::Cli
else else
super super
end end
@original_env = ENV.to_h.dup initialize_commander unless KAMAL.configured?
load_env
initialize_commander(options_with_subcommand_class_options)
end end
private private
def reload_env
reset_env
load_env
end
def load_env
if destination = options[:destination]
Dotenv.load(".env.#{destination}", ".env")
else
Dotenv.load(".env")
end
end
def reset_env
replace_env @original_env
end
def replace_env(env)
ENV.clear
ENV.update(env)
end
def with_original_env
keeping_current_env do
reset_env
yield
end
end
def keeping_current_env
current_env = ENV.to_h.dup
yield
ensure
replace_env(current_env)
end
def options_with_subcommand_class_options def options_with_subcommand_class_options
options.merge(@_initializer.last[:class_options] || {}) options.merge(@_initializer.last[:class_options] || {})
end end
def initialize_commander(options) def initialize_commander
KAMAL.tap do |commander| KAMAL.tap do |commander|
if options[:verbose] if options[:verbose]
ENV["VERBOSE"] = "1" # For backtraces via cli/start ENV["VERBOSE"] = "1" # For backtraces via cli/start
@@ -112,8 +73,6 @@ module Kamal::Cli
if KAMAL.holding_lock? if KAMAL.holding_lock?
yield yield
else else
ensure_run_and_locks_directory
acquire_lock acquire_lock
begin begin
@@ -142,6 +101,8 @@ module Kamal::Cli
end end
def acquire_lock def acquire_lock
ensure_run_and_locks_directory
raise_if_locked do raise_if_locked do
say "Acquiring the deploy lock...", :magenta say "Acquiring the deploy lock...", :magenta
on(KAMAL.primary_host) { execute *KAMAL.lock.acquire("Automatic deploy lock", KAMAL.config.version), verbosity: :debug } on(KAMAL.primary_host) { execute *KAMAL.lock.acquire("Automatic deploy lock", KAMAL.config.version), verbosity: :debug }

View File

@@ -51,7 +51,7 @@ class Kamal::Cli::Build < Kamal::Cli::Base
push = KAMAL.builder.push push = KAMAL.builder.push
KAMAL.with_verbosity(:debug) do KAMAL.with_verbosity(:debug) do
Dir.chdir(KAMAL.config.builder.build_directory) { execute *push } Dir.chdir(KAMAL.config.builder.build_directory) { execute *push, env: KAMAL.config.builder.secrets }
end end
end end
end end

View File

@@ -1,54 +0,0 @@
require "tempfile"
class Kamal::Cli::Env < Kamal::Cli::Base
desc "push", "Push the env file to the remote hosts"
def push
with_lock do
on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Pushed env files"), verbosity: :debug
KAMAL.roles_on(host).each do |role|
execute *KAMAL.app(role: role, host: host).make_env_directory
upload! role.env(host).secrets_io, role.env(host).secrets_file, mode: 400
end
end
on(KAMAL.traefik_hosts) do
execute *KAMAL.traefik.make_env_directory
upload! KAMAL.traefik.env.secrets_io, KAMAL.traefik.env.secrets_file, mode: 400
end
on(KAMAL.accessory_hosts) do
KAMAL.accessories_on(host).each do |accessory|
accessory_config = KAMAL.config.accessory(accessory)
execute *KAMAL.accessory(accessory).make_env_directory
upload! accessory_config.env.secrets_io, accessory_config.env.secrets_file, mode: 400
end
end
end
end
desc "delete", "Delete the env file from the remote hosts"
def delete
with_lock do
on(KAMAL.hosts) do
execute *KAMAL.auditor.record("Deleted env files"), verbosity: :debug
KAMAL.roles_on(host).each do |role|
execute *KAMAL.app(role: role, host: host).remove_env_file
end
end
on(KAMAL.traefik_hosts) do
execute *KAMAL.traefik.remove_env_file
end
on(KAMAL.accessory_hosts) do
KAMAL.accessories_on(host).each do |accessory|
accessory_config = KAMAL.config.accessory(accessory)
execute *KAMAL.accessory(accessory).remove_env_file
end
end
end
end
end

View File

@@ -1,3 +1,5 @@
require "concurrent/ivar"
class Kamal::Cli::Healthcheck::Barrier class Kamal::Cli::Healthcheck::Barrier
def initialize def initialize
@ivar = Concurrent::IVar.new @ivar = Concurrent::IVar.new

View File

@@ -3,7 +3,6 @@ class Kamal::Cli::Lock < Kamal::Cli::Base
def status def status
handle_missing_lock do handle_missing_lock do
on(KAMAL.primary_host) do on(KAMAL.primary_host) do
execute *KAMAL.server.ensure_run_directory
puts capture_with_debug(*KAMAL.lock.status) puts capture_with_debug(*KAMAL.lock.status)
end end
end end
@@ -13,9 +12,10 @@ class Kamal::Cli::Lock < Kamal::Cli::Base
option :message, aliases: "-m", type: :string, desc: "A lock message", required: true option :message, aliases: "-m", type: :string, desc: "A lock message", required: true
def acquire def acquire
message = options[:message] message = options[:message]
ensure_run_and_locks_directory
raise_if_locked do raise_if_locked do
on(KAMAL.primary_host) do on(KAMAL.primary_host) do
execute *KAMAL.server.ensure_run_directory
execute *KAMAL.lock.acquire(message, KAMAL.config.version), verbosity: :debug execute *KAMAL.lock.acquire(message, KAMAL.config.version), verbosity: :debug
end end
say "Acquired the deploy lock" say "Acquired the deploy lock"
@@ -26,7 +26,6 @@ class Kamal::Cli::Lock < Kamal::Cli::Base
def release def release
handle_missing_lock do handle_missing_lock do
on(KAMAL.primary_host) do on(KAMAL.primary_host) do
execute *KAMAL.server.ensure_run_directory
execute *KAMAL.lock.release, verbosity: :debug execute *KAMAL.lock.release, verbosity: :debug
end end
say "Released the deploy lock" say "Released the deploy lock"

View File

@@ -9,10 +9,6 @@ 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
say "Evaluate and push env files...", :magenta
invoke "kamal:cli:main:envify", [], invoke_options
invoke "kamal:cli:env:push", [], invoke_options
invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options
deploy deploy
end end
@@ -37,7 +33,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
end end
with_lock do with_lock do
run_hook "pre-deploy" run_hook "pre-deploy", secrets: true
say "Ensure Traefik is running...", :magenta say "Ensure Traefik is running...", :magenta
invoke "kamal:cli:traefik:boot", [], invoke_options invoke "kamal:cli:traefik:boot", [], invoke_options
@@ -52,7 +48,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
end end
end end
run_hook "post-deploy", runtime: runtime.round run_hook "post-deploy", secrets: true, runtime: runtime.round
end end
desc "redeploy", "Deploy app to servers without bootstrapping servers, starting Traefik, pruning, and registry login" desc "redeploy", "Deploy app to servers without bootstrapping servers, starting Traefik, pruning, and registry login"
@@ -70,7 +66,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
end end
with_lock do with_lock do
run_hook "pre-deploy" run_hook "pre-deploy", secrets: true
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)
@@ -79,7 +75,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
end end
end end
run_hook "post-deploy", runtime: runtime.round run_hook "post-deploy", secrets: true, runtime: runtime.round
end end
desc "rollback [VERSION]", "Rollback app to VERSION" desc "rollback [VERSION]", "Rollback app to VERSION"
@@ -93,7 +89,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
old_version = nil old_version = nil
if container_available?(version) if container_available?(version)
run_hook "pre-deploy" run_hook "pre-deploy", secrets: true
invoke "kamal:cli:app:boot", [], invoke_options.merge(version: version) invoke "kamal:cli:app:boot", [], invoke_options.merge(version: version)
rolled_back = true rolled_back = true
@@ -103,7 +99,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
end end
end end
run_hook "post-deploy", runtime: runtime.round if rolled_back run_hook "post-deploy", secrets: true, runtime: runtime.round if rolled_back
end end
desc "details", "Show details about all containers" desc "details", "Show details about all containers"
@@ -152,9 +148,10 @@ class Kamal::Cli::Main < Kamal::Cli::Base
puts "Created configuration file in config/deploy.yml" puts "Created configuration file in config/deploy.yml"
end end
unless (deploy_file = Pathname.new(File.expand_path(".env"))).exist? unless (secrets_file = Pathname.new(File.expand_path(".kamal/secrets"))).exist?
FileUtils.cp_r Pathname.new(File.expand_path("templates/template.env", __dir__)), deploy_file FileUtils.mkdir_p secrets_file.dirname
puts "Created .env file" FileUtils.cp_r Pathname.new(File.expand_path("templates/secrets", __dir__)), secrets_file
puts "Created .kamal/secrets file"
end end
unless (hooks_dir = Pathname.new(File.expand_path(".kamal/hooks"))).exist? unless (hooks_dir = Pathname.new(File.expand_path(".kamal/hooks"))).exist?
@@ -179,31 +176,6 @@ class Kamal::Cli::Main < Kamal::Cli::Base
end end
end end
desc "envify", "Create .env by evaluating .env.erb (or .env.staging.erb -> .env.staging when using -d staging)"
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip .env file push"
def envify
if destination = options[:destination]
env_template_path = ".env.#{destination}.erb"
env_path = ".env.#{destination}"
else
env_template_path = ".env.erb"
env_path = ".env"
end
if Pathname.new(File.expand_path(env_template_path)).exist?
# Ensure existing env doesn't pollute template evaluation
content = with_original_env { ERB.new(File.read(env_template_path), trim_mode: "-").result }
File.write(env_path, content, perm: 0600)
unless options[:skip_push]
reload_env
invoke "kamal:cli:env:push", options
end
else
puts "Skipping envify (no #{env_template_path} exist)"
end
end
desc "remove", "Remove Traefik, app, accessories, and registry session from servers" desc "remove", "Remove Traefik, app, accessories, and registry session from servers"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def remove def remove
@@ -231,9 +203,6 @@ class Kamal::Cli::Main < Kamal::Cli::Base
desc "build", "Build application image" desc "build", "Build application image"
subcommand "build", Kamal::Cli::Build subcommand "build", Kamal::Cli::Build
desc "env", "Manage environment files"
subcommand "env", Kamal::Cli::Env
desc "lock", "Manage the deploy lock" desc "lock", "Manage the deploy lock"
subcommand "lock", Kamal::Cli::Lock subcommand "lock", Kamal::Cli::Lock
@@ -243,6 +212,9 @@ class Kamal::Cli::Main < Kamal::Cli::Base
desc "registry", "Login and -out of the image registry" desc "registry", "Login and -out of the image registry"
subcommand "registry", Kamal::Cli::Registry subcommand "registry", Kamal::Cli::Registry
desc "secrets", "Helpers for extracting secrets"
subcommand "secrets", Kamal::Cli::Secrets
desc "server", "Bootstrap servers with curl and Docker" desc "server", "Bootstrap servers with curl and Docker"
subcommand "server", Kamal::Cli::Server subcommand "server", Kamal::Cli::Server

36
lib/kamal/cli/secrets.rb Normal file
View File

@@ -0,0 +1,36 @@
class Kamal::Cli::Secrets < Kamal::Cli::Base
desc "fetch [SECRETS...]", "Fetch secrets from a vault"
option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use"
option :account, type: :string, required: true, desc: "The account identifier or username"
option :from, type: :string, required: false, desc: "A vault or folder to fetch the secrets from"
option :inline, type: :boolean, required: false, hidden: true
def fetch(*secrets)
results = adapter(options[:adapter]).fetch(secrets, **options.slice(:account, :from).symbolize_keys)
return_or_puts JSON.dump(results).shellescape, inline: options[:inline]
end
desc "extract", "Extract a single secret from the results of a fetch call"
option :inline, type: :boolean, required: false, hidden: true
def extract(name, secrets)
parsed_secrets = JSON.parse(secrets)
value = parsed_secrets[name] || parsed_secrets.find { |k, v| k.end_with?("/#{name}") }&.last
raise "Could not find secret #{name}" if value.nil?
return_or_puts value, inline: options[:inline]
end
private
def adapter(adapter)
Kamal::Secrets::Adapters.lookup(adapter)
end
def return_or_puts(value, inline: nil)
if inline
value
else
puts value
end
end
end

View File

@@ -0,0 +1,16 @@
# WARNING: Avoid adding secrets directly to this file
# If you must, then add `.kamal/secrets*` to your .gitignore file
# Option 1: Read secrets from the environment
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
# Option 2: Read secrets via a command
# RAILS_MASTER_KEY=$(cat config/master.key)
# Option 3: Read secrets via kamal secrets helpers
# These will handle logging in and fetching the secrets in as few calls as possible
# There are adapters for 1Password, LastPass + Bitwarden
#
# SECRETS=$(kamal secrets fetch --adapter 1password --account my-account --from MyVault/MyItem KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY)
# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS)
# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS)

View File

@@ -1,2 +0,0 @@
KAMAL_REGISTRY_PASSWORD=change-this
RAILS_MASTER_KEY=another-env

View File

@@ -4,6 +4,8 @@ class Kamal::Cli::Traefik < Kamal::Cli::Base
with_lock do with_lock do
on(KAMAL.traefik_hosts) do on(KAMAL.traefik_hosts) do
execute *KAMAL.registry.login execute *KAMAL.registry.login
execute *KAMAL.traefik.ensure_env_directory
upload! KAMAL.traefik.secrets_io, KAMAL.traefik.secrets_path, mode: "0600"
execute *KAMAL.traefik.start_or_run execute *KAMAL.traefik.start_or_run
end end
end end

View File

@@ -1,5 +1,6 @@
require "active_support/core_ext/enumerable" require "active_support/core_ext/enumerable"
require "active_support/core_ext/module/delegation" require "active_support/core_ext/module/delegation"
require "active_support/core_ext/object/blank"
class Kamal::Commander class Kamal::Commander
attr_accessor :verbosity, :holding_lock, :connected attr_accessor :verbosity, :holding_lock, :connected
@@ -23,6 +24,10 @@ class Kamal::Commander
@config, @config_kwargs = nil, kwargs @config, @config_kwargs = nil, kwargs
end end
def configured?
@config || @config_kwargs
end
attr_reader :specific_roles, :specific_hosts attr_reader :specific_roles, :specific_hosts
def specific_primary! def specific_primary!

View File

@@ -1,7 +1,9 @@
class Kamal::Commands::Accessory < Kamal::Commands::Base 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,
:publish_args, :env_args, :volume_args, :label_args, :option_args, to: :accessory_config :publish_args, :env_args, :volume_args, :label_args, :option_args,
:secrets_io, :secrets_path, :env_directory,
to: :accessory_config
def initialize(config, name:) def initialize(config, name:)
super(config) super(config)
@@ -98,12 +100,8 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
docker :image, :rm, "--force", image docker :image, :rm, "--force", image
end end
def make_env_directory def ensure_env_directory
make_directory accessory_config.env.secrets_directory make_directory env_directory
end
def remove_env_file
[ :rm, "-f", accessory_config.env.secrets_file ]
end end
private private

View File

@@ -69,16 +69,10 @@ class Kamal::Commands::App < Kamal::Commands::Base
extract_version_from_name extract_version_from_name
end end
def ensure_env_directory
def make_env_directory make_directory role.env_directory
make_directory role.env(host).secrets_directory
end end
def remove_env_file
[ :rm, "-f", role.env(host).secrets_file ]
end
private private
def container_name(version = nil) def container_name(version = nil)
[ role.container_prefix, version || config.version ].compact.join("-") [ role.container_prefix, version || config.version ].compact.join("-")

View File

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

View File

@@ -8,9 +8,12 @@ class Kamal::Commands::Auditor < Kamal::Commands::Base
# Runs remotely # Runs remotely
def record(line, **details) def record(line, **details)
append \ combine \
[ :echo, audit_tags(**details).except(:version, :service_version, :service).to_s, line ], [ :mkdir, "-p", config.run_directory ],
audit_log_file append(
[ :echo, audit_tags(**details).except(:version, :service_version, :service).to_s, line ],
audit_log_file
)
end end
def reveal def reveal

View File

@@ -37,6 +37,10 @@ module Kamal::Commands
[ :rm, "-r", path ] [ :rm, "-r", path ]
end end
def remove_file(path)
[ :rm, path ]
end
private private
def combine(*commands, by: "&&") def combine(*commands, by: "&&")
commands commands

View File

@@ -79,7 +79,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
end end
def build_secrets def build_secrets
argumentize "--secret", secrets.collect { |secret| [ "id", secret ] } argumentize "--secret", secrets.keys.collect { |secret| [ "id", secret ] }
end end
def build_dockerfile def build_dockerfile

View File

@@ -1,6 +1,9 @@
class Kamal::Commands::Hook < Kamal::Commands::Base class Kamal::Commands::Hook < Kamal::Commands::Base
def run(hook, **details) def run(hook, secrets: false, **details)
[ hook_file(hook), env: tags(**details).env ] env = tags(**details).env
env.merge!(config.secrets.to_h) if secrets
[ hook_file(hook), env: env ]
end end
def hook_exists?(hook) def hook_exists?(hook)

View File

@@ -1,6 +1,6 @@
class Kamal::Commands::Traefik < Kamal::Commands::Base class Kamal::Commands::Traefik < Kamal::Commands::Base
delegate :argumentize, :optionize, to: Kamal::Utils delegate :argumentize, :optionize, to: Kamal::Utils
delegate :port, :publish?, :labels, :env, :image, :options, :args, to: :"config.traefik" delegate :port, :publish?, :labels, :env, :image, :options, :args, :env_args, :secrets_io, :env_directory, :secrets_path, to: :"config.traefik"
def run def run
docker :run, "--name traefik", docker :run, "--name traefik",
@@ -54,12 +54,8 @@ class Kamal::Commands::Traefik < Kamal::Commands::Base
docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik" docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik"
end end
def make_env_directory def ensure_env_directory
make_directory(env.secrets_directory) make_directory env_directory
end
def remove_env_file
[ :rm, "-f", env.secrets_file ]
end end
private private
@@ -71,10 +67,6 @@ class Kamal::Commands::Traefik < Kamal::Commands::Base
argumentize "--label", labels argumentize "--label", labels
end end
def env_args
env.args
end
def docker_options_args def docker_options_args
optionize(options) optionize(options)
end end

View File

@@ -2,7 +2,6 @@ require "active_support/ordered_options"
require "active_support/core_ext/string/inquiry" require "active_support/core_ext/string/inquiry"
require "active_support/core_ext/module/delegation" require "active_support/core_ext/module/delegation"
require "active_support/core_ext/hash/keys" require "active_support/core_ext/hash/keys"
require "pathname"
require "erb" require "erb"
require "net/ssh/proxy/jump" require "net/ssh/proxy/jump"
@@ -10,7 +9,7 @@ class Kamal::Configuration
delegate :service, :image, :labels, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true delegate :service, :image, :labels, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true
delegate :argumentize, :optionize, to: Kamal::Utils delegate :argumentize, :optionize, to: Kamal::Utils
attr_reader :destination, :raw_config attr_reader :destination, :raw_config, :secrets
attr_reader :accessories, :aliases, :boot, :builder, :env, :healthcheck, :logging, :traefik, :servers, :ssh, :sshkit, :registry attr_reader :accessories, :aliases, :boot, :builder, :env, :healthcheck, :logging, :traefik, :servers, :ssh, :sshkit, :registry
include Validation include Validation
@@ -57,7 +56,7 @@ class Kamal::Configuration
@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) ] } || {}
@boot = Boot.new(config: self) @boot = Boot.new(config: self)
@builder = Builder.new(config: self) @builder = Builder.new(config: self)
@env = Env.new(config: @raw_config.env || {}) @env = Env.new(config: @raw_config.env || {}, secrets: secrets)
@healthcheck = Healthcheck.new(healthcheck_config: @raw_config.healthcheck) @healthcheck = Healthcheck.new(healthcheck_config: @raw_config.healthcheck)
@logging = Logging.new(logging_config: @raw_config.logging) @logging = Logging.new(logging_config: @raw_config.logging)
@@ -65,6 +64,8 @@ class Kamal::Configuration
@ssh = Ssh.new(config: self) @ssh = Ssh.new(config: self)
@sshkit = Sshkit.new(config: self) @sshkit = Sshkit.new(config: self)
@secrets = Kamal::Secrets.new(destination: destination)
ensure_destination_if_required ensure_destination_if_required
ensure_required_keys_present ensure_required_keys_present
ensure_valid_kamal_version ensure_valid_kamal_version
@@ -218,13 +219,13 @@ class Kamal::Configuration
end end
def host_env_directory def env_directory
File.join(run_directory, "env") File.join(run_directory, "env")
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) } tags.collect { |name, config| Env::Tag.new(name, config: config, secrets: secrets) }
else else
[] []
end end
@@ -254,6 +255,10 @@ class Kamal::Configuration
}.compact }.compact
end end
def secrets
@secrets ||= Kamal::Secrets.new(destination: destination)
end
private private
# Will raise ArgumentError if any required config keys are missing # Will raise ArgumentError if any required config keys are missing
def ensure_destination_if_required def ensure_destination_if_required

View File

@@ -16,7 +16,7 @@ class Kamal::Configuration::Accessory
@env = Kamal::Configuration::Env.new \ @env = Kamal::Configuration::Env.new \
config: accessory_config.fetch("env", {}), config: accessory_config.fetch("env", {}),
secrets_file: File.join(config.host_env_directory, "accessories", "#{service_name}.env"), secrets: config.secrets,
context: "accessories/#{name}/env" context: "accessories/#{name}/env"
end end
@@ -51,7 +51,19 @@ class Kamal::Configuration::Accessory
end end
def env_args def env_args
env.args [ *env.clear_args, *argumentize("--env-file", secrets_path) ]
end
def env_directory
File.join(config.env_directory, "accessories")
end
def secrets_io
env.secrets_io
end
def secrets_path
File.join(config.env_directory, "accessories", "#{service_name}.env")
end end
def files def files

View File

@@ -66,7 +66,7 @@ class Kamal::Configuration::Builder
end end
def secrets def secrets
builder_config["secrets"] || [] (builder_config["secrets"] || []).to_h { |key| [ key, config.secrets[key] ] }
end end
def dockerfile def dockerfile

View File

@@ -1,36 +1,29 @@
class Kamal::Configuration::Env class Kamal::Configuration::Env
include Kamal::Configuration::Validation include Kamal::Configuration::Validation
attr_reader :secrets_keys, :clear, :secrets_file, :context attr_reader :context, :secrets
attr_reader :clear, :secret_keys
delegate :argumentize, to: Kamal::Utils delegate :argumentize, to: Kamal::Utils
def initialize(config:, secrets_file: nil, context: "env") def initialize(config:, secrets:, context: "env")
@clear = config.fetch("clear", config.key?("secret") || config.key?("tags") ? {} : config) @clear = config.fetch("clear", config.key?("secret") || config.key?("tags") ? {} : config)
@secrets_keys = config.fetch("secret", []) @secrets = secrets
@secrets_file = secrets_file @secret_keys = config.fetch("secret", [])
@context = context @context = context
validate! config, context: context, with: Kamal::Configuration::Validator::Env validate! config, context: context, with: Kamal::Configuration::Validator::Env
end end
def args def clear_args
[ "--env-file", secrets_file, *argumentize("--env", clear) ] argumentize("--env", clear)
end end
def secrets_io def secrets_io
StringIO.new(Kamal::EnvFile.new(secrets).to_s) Kamal::EnvFile.new(secret_keys.to_h { |key| [ key, secrets[key] ] }).to_io
end
def secrets
@secrets ||= secrets_keys.to_h { |key| [ key, ENV.fetch(key) ] }
end
def secrets_directory
File.dirname(secrets_file)
end end
def merge(other) def merge(other)
self.class.new \ self.class.new \
config: { "clear" => clear.merge(other.clear), "secret" => secrets_keys | other.secrets_keys }, config: { "clear" => clear.merge(other.clear), "secret" => secret_keys | other.secret_keys },
secrets_file: secrets_file || other.secrets_file secrets: secrets
end end
end end

View File

@@ -1,12 +1,13 @@
class Kamal::Configuration::Env::Tag class Kamal::Configuration::Env::Tag
attr_reader :name, :config attr_reader :name, :config, :secrets
def initialize(name, config:) def initialize(name, config:, secrets:)
@name = name @name = name
@config = config @config = config
@secrets = secrets
end end
def env def env
Kamal::Configuration::Env.new(config: config) Kamal::Configuration::Env.new(config: config, secrets: secrets)
end end
end end

View File

@@ -1,10 +1,11 @@
class Kamal::Configuration::Registry class Kamal::Configuration::Registry
include Kamal::Configuration::Validation include Kamal::Configuration::Validation
attr_reader :registry_config attr_reader :registry_config, :secrets
def initialize(config:) def initialize(config:)
@registry_config = config.raw_config.registry || {} @registry_config = config.raw_config.registry || {}
@secrets = config.secrets
validate! registry_config, with: Kamal::Configuration::Validator::Registry validate! registry_config, with: Kamal::Configuration::Validator::Registry
end end
@@ -23,7 +24,7 @@ class Kamal::Configuration::Registry
private private
def lookup(key) def lookup(key)
if registry_config[key].is_a?(Array) if registry_config[key].is_a?(Array)
ENV.fetch(registry_config[key].first).dup secrets[registry_config[key].first]
else else
registry_config[key] registry_config[key]
end end

View File

@@ -18,7 +18,7 @@ class Kamal::Configuration::Role
@specialized_env = Kamal::Configuration::Env.new \ @specialized_env = Kamal::Configuration::Env.new \
config: specializations.fetch("env", {}), config: specializations.fetch("env", {}),
secrets_file: File.join(config.host_env_directory, "roles", "#{container_prefix}.env"), secrets: config.secrets,
context: "servers/#{name}/env" context: "servers/#{name}/env"
@specialized_logging = Kamal::Configuration::Logging.new \ @specialized_logging = Kamal::Configuration::Logging.new \
@@ -77,7 +77,19 @@ class Kamal::Configuration::Role
end end
def env_args(host) def env_args(host)
env(host).args [ *env(host).clear_args, *argumentize("--env-file", secrets_path) ]
end
def env_directory
File.join(config.env_directory, "roles")
end
def secrets_io(host)
env(host).secrets_io
end
def secrets_path
File.join(config.env_directory, "roles", "#{container_prefix}.env")
end end
def asset_volume_args def asset_volume_args

View File

@@ -1,4 +1,6 @@
class Kamal::Configuration::Traefik class Kamal::Configuration::Traefik
delegate :argumentize, to: Kamal::Utils
DEFAULT_IMAGE = "traefik:v2.10" DEFAULT_IMAGE = "traefik:v2.10"
CONTAINER_PORT = 80 CONTAINER_PORT = 80
DEFAULT_ARGS = { DEFAULT_ARGS = {
@@ -34,7 +36,7 @@ class Kamal::Configuration::Traefik
def env def env
Kamal::Configuration::Env.new \ Kamal::Configuration::Env.new \
config: traefik_config.fetch("env", {}), config: traefik_config.fetch("env", {}),
secrets_file: File.join(config.host_env_directory, "traefik", "traefik.env"), secrets: config.secrets,
context: "traefik/env" context: "traefik/env"
end end
@@ -57,4 +59,20 @@ class Kamal::Configuration::Traefik
def image def image
traefik_config.fetch("image", DEFAULT_IMAGE) traefik_config.fetch("image", DEFAULT_IMAGE)
end end
def env_args
[ *env.clear_args, *argumentize("--env-file", secrets_path) ]
end
def env_directory
File.join(config.env_directory, "traefik")
end
def secrets_io
env.secrets_io
end
def secrets_path
File.join(config.env_directory, "traefik", "traefik.env")
end
end end

View File

@@ -15,6 +15,10 @@ class Kamal::EnvFile
env_file.presence || "\n" env_file.presence || "\n"
end end
def to_io
StringIO.new(to_s)
end
alias to_str to_s alias to_str to_s
private private

37
lib/kamal/secrets.rb Normal file
View File

@@ -0,0 +1,37 @@
require "dotenv"
class Kamal::Secrets
attr_reader :secrets_files
Kamal::Secrets::Dotenv::InlineCommandSubstitution.install!
def initialize(destination: nil)
@secrets_files = \
[ ".kamal/secrets-common", ".kamal/secrets#{(".#{destination}" if destination)}" ].select { |f| File.exist?(f) }
@mutex = Mutex.new
end
def [](key)
# Fetching secrets may ask the user for input, so ensure only one thread does that
@mutex.synchronize do
secrets.fetch(key)
end
rescue KeyError
if secrets_files
raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secrets_files.join(", ")}"
else
raise Kamal::ConfigurationError, "Secret '#{key}' not found, no secret files provided"
end
end
def to_h
secrets
end
private
def secrets
@secrets ||= secrets_files.inject({}) do |secrets, secrets_file|
secrets.merge!(::Dotenv.parse(secrets_file))
end
end
end

View File

@@ -0,0 +1,14 @@
require "active_support/core_ext/string/inflections"
module Kamal::Secrets::Adapters
def self.lookup(name)
name = "one_password" if name.downcase == "1password"
name = "last_pass" if name.downcase == "lastpass"
adapter_class(name)
end
def self.adapter_class(name)
Object.const_get("Kamal::Secrets::Adapters::#{name.camelize}").new
rescue NameError => e
raise RuntimeError, "Unknown secrets adapter: #{name}"
end
end

View File

@@ -0,0 +1,18 @@
class Kamal::Secrets::Adapters::Base
delegate :optionize, to: Kamal::Utils
def fetch(secrets, account:, from: nil)
session = login(account)
full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") }
fetch_secrets(full_secrets, account: account, session: session)
end
private
def login(...)
raise NotImplementedError
end
def fetch_secrets(...)
raise NotImplementedError
end
end

View File

@@ -0,0 +1,64 @@
class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base
private
def login(account)
status = run_command("status")
if status["status"] == "unauthenticated"
run_command("login #{account.shellescape}", raw: true)
status = run_command("status")
end
if status["status"] == "locked"
session = run_command("unlock --raw", raw: true).presence
status = run_command("status", session: session)
end
raise RuntimeError, "Failed to login to and unlock Bitwarden" unless status["status"] == "unlocked"
run_command("sync", session: session, raw: true)
raise RuntimeError, "Failed to sync Bitwarden" unless $?.success?
session
end
def fetch_secrets(secrets, account:, session:)
{}.tap do |results|
items_fields(secrets).each do |item, fields|
item_json = run_command("get item #{item.shellescape}", session: session, raw: true)
raise RuntimeError, "Could not read #{secret} from Bitwarden" unless $?.success?
item_json = JSON.parse(item_json)
if fields.any?
fields.each do |field|
item_field = item_json["fields"].find { |f| f["name"] == field }
raise RuntimeError, "Could not find field #{field} in item #{item} in Bitwarden" unless item_field
value = item_field["value"]
results["#{item}/#{field}"] = value
end
else
results[item] = item_json["login"]["password"]
end
end
end
end
def items_fields(secrets)
{}.tap do |items|
secrets.each do |secret|
item, field = secret.split("/")
items[item] ||= []
items[item] << field
end
end
end
def signedin?(account)
run_command("status")["status"] != "unauthenticated"
end
def run_command(command, session: nil, raw: false)
full_command = [ *("BW_SESSION=#{session.shellescape}" if session), "bw", command ].join(" ")
result = `#{full_command}`.strip
raw ? result : JSON.parse(result)
end
end

View File

@@ -0,0 +1,30 @@
class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base
private
def login(account)
unless loggedin?(account)
`lpass login #{account.shellescape}`
raise RuntimeError, "Failed to login to 1Password" unless $?.success?
end
end
def loggedin?(account)
`lpass status --color never`.strip == "Logged in as #{account}."
end
def fetch_secrets(secrets, account:, session:)
items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json`
raise RuntimeError, "Could not read #{secrets} from 1Password" unless $?.success?
items = JSON.parse(items)
{}.tap do |results|
items.each do |item|
results[item["fullname"]] = item["password"]
end
if (missing_items = secrets - results.keys).any?
raise RuntimeError, "Could not find #{missing_items.join(", ")} in LassPass"
end
end
end
end

View File

@@ -0,0 +1,61 @@
class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base
delegate :optionize, to: Kamal::Utils
private
def login(account)
unless loggedin?(account)
`op signin #{to_options(account: account, force: true, raw: true)}`.tap do
raise RuntimeError, "Failed to login to 1Password" unless $?.success?
end
end
end
def loggedin?(account)
`op account get --account #{account.shellescape} 2> /dev/null`
$?.success?
end
def fetch_secrets(secrets, account:, session:)
{}.tap do |results|
vaults_items_fields(secrets).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?
fields_json.each do |field_json|
# The reference is in the form `op://vault/item/field[/field]`
field = field_json["reference"].delete_prefix("op://").delete_suffix("/password")
results[field] = field_json["value"]
end
end
end
end
end
def to_options(**options)
optionize(options.compact).join(" ")
end
def vaults_items_fields(secrets)
{}.tap do |vaults|
secrets.each do |secret|
secret = secret.delete_prefix("op://")
vault, item, *fields = secret.split("/")
fields << "password" if fields.empty?
vaults[vault] ||= {}
vaults[vault][item] ||= []
vaults[vault][item] << fields.join(".")
end
end
end
def op_item_get(vault, item, fields, account:, session:)
labels = fields.map { |field| "label=#{field}" }.join(",")
options = to_options(vault: vault, fields: labels, format: "json", account: account, session: session.presence)
`op item get #{item.shellescape} #{options}`.tap do
raise RuntimeError, "Could not read #{fields.join(", ")} from #{item} in the #{vault} 1Password vault" unless $?.success?
end
end
end

View File

@@ -0,0 +1,10 @@
class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base
private
def login(account)
true
end
def fetch_secrets(secrets, account:, session:)
secrets.to_h { |secret| [ secret, secret.reverse ] }
end
end

View File

@@ -0,0 +1,32 @@
class Kamal::Secrets::Dotenv::InlineCommandSubstitution
class << self
def install!
::Dotenv::Parser.substitutions.map! { |sub| sub == ::Dotenv::Substitutions::Command ? self : sub }
end
def call(value, _env, overwrite: false)
# Process interpolated shell commands
value.gsub(Dotenv::Substitutions::Command.singleton_class::INTERPOLATED_SHELL_COMMAND) do |*|
# Eliminate opening and closing parentheses
command = $LAST_MATCH_INFO[:cmd][1..-2]
if $LAST_MATCH_INFO[:backslash]
# Command is escaped, don't replace it.
$LAST_MATCH_INFO[0][1..]
else
if command =~ /\A\s*kamal\s*secrets\s+/
# Inline the command
inline_secrets_command(command)
else
# Execute the command and return the value
`#{command}`.chomp
end
end
end
end
def inline_secrets_command(command)
Kamal::Cli::Main.start(command.shellsplit[1..] + [ "--inline" ]).chomp
end
end
end

View File

@@ -3,6 +3,7 @@ require "sshkit/dsl"
require "net/scp" require "net/scp"
require "active_support/core_ext/hash/deep_merge" require "active_support/core_ext/hash/deep_merge"
require "json" require "json"
require "concurrent/atomic/semaphore"
class SSHKit::Backend::Abstract class SSHKit::Backend::Abstract
def capture_with_info(*args, **kwargs) def capture_with_info(*args, **kwargs)

View File

@@ -1,3 +1,5 @@
require "active_support/core_ext/object/try"
module Kamal::Utils module Kamal::Utils
extend self extend self
@@ -54,6 +56,12 @@ module Kamal::Utils
# Escape a value to make it safe for shell use. # Escape a value to make it safe for shell use.
def escape_shell_value(value) def escape_shell_value(value)
value.to_s.scan(/[\x00-\x7F]+|[^\x00-\x7F]+/) \
.map { |part| part.ascii_only? ? escape_ascii_shell_value(part) : part }
.join
end
def escape_ascii_shell_value(value)
value.to_s.dump value.to_s.dump
.gsub(/`/, '\\\\`') .gsub(/`/, '\\\\`')
.gsub(DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX, '\$') .gsub(DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX, '\$')

View File

@@ -1,13 +1,21 @@
require_relative "cli_test_case" require_relative "cli_test_case"
class CliAccessoryTest < CliTestCase class CliAccessoryTest < CliTestCase
setup do
setup_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret")
end
teardown do
teardown_test_secrets
end
test "boot" do test "boot" do
Kamal::Cli::Accessory.any_instance.expects(:directories).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:directories).with("mysql")
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.*on 1.1.1.3/, output
assert_match "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 --env-file .kamal/env/accessories/app-mysql.env --env MYSQL_ROOT_HOST=\"%\" --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 --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/env/accessories/app-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
end end
end end
@@ -21,7 +29,7 @@ class CliAccessoryTest < CliTestCase
assert_match /docker login.*on 1.1.1.3/, output assert_match /docker login.*on 1.1.1.3/, output
assert_match /docker login.*on 1.1.1.1/, output assert_match /docker login.*on 1.1.1.1/, output
assert_match /docker login.*on 1.1.1.2/, output assert_match /docker login.*on 1.1.1.2/, output
assert_match "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 --env-file .kamal/env/accessories/app-mysql.env --env MYSQL_ROOT_HOST=\"%\" --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 --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/env/accessories/app-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-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-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 --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-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 --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-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 --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output
end end

View File

@@ -85,7 +85,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/assets/volumes/app-web-latest ; cp -rnT .kamal/assets/extracted/app-web-latest .kamal/assets/volumes/app-web-latest ; cp -rnT .kamal/assets/extracted/app-web-latest .kamal/assets/volumes/app-web-123 || true ; cp -rnT .kamal/assets/extracted/app-web-123 .kamal/assets/volumes/app-web-latest || true", output assert_match "/usr/bin/env mkdir -p .kamal/assets/volumes/app-web-latest ; cp -rnT .kamal/assets/extracted/app-web-latest .kamal/assets/volumes/app-web-latest ; cp -rnT .kamal/assets/extracted/app-web-latest .kamal/assets/volumes/app-web-123 || true ; cp -rnT .kamal/assets/extracted/app-web-123 .kamal/assets/volumes/app-web-latest || true", output
assert_match "/usr/bin/env mkdir -p .kamal/assets/extracted/app-web-latest && docker stop -t 1 app-web-assets 2> /dev/null || true && docker run --name app-web-assets --detach --rm dhh/app:latest sleep 1000000 && docker cp -L app-web-assets:/public/assets/. .kamal/assets/extracted/app-web-latest && docker stop -t 1 app-web-assets", output assert_match "/usr/bin/env mkdir -p .kamal/assets/extracted/app-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/assets/extracted/app-web-latest && docker stop -t 1 app-web-assets", output
assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname 1.1.1.1-[0-9a-f]{12} /, output assert_match /docker run --detach --restart unless-stopped --name app-web-latest --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/assets/extracted -maxdepth 1 -name 'app-web-*' ! -name app-web-latest -exec rm -rf \"{}\" + ; find .kamal/assets/volumes -maxdepth 1 -name 'app-web-*' ! -name app-web-latest -exec rm -rf \"{}\" +", output assert_match "/usr/bin/env find .kamal/assets/extracted -maxdepth 1 -name 'app-web-*' ! -name app-web-latest -exec rm -rf \"{}\" + ; find .kamal/assets/volumes -maxdepth 1 -name 'app-web-*' ! -name app-web-latest -exec rm -rf \"{}\" +", output
@@ -113,7 +113,7 @@ class CliAppTest < CliTestCase
run_command("boot", config: :with_env_tags).tap do |output| run_command("boot", config: :with_env_tags).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 %r{docker run --detach --restart unless-stopped --name app-web-latest --hostname 1.1.1.1-[0-9a-f]{12} -e KAMAL_CONTAINER_NAME="app-web-latest" -e KAMAL_VERSION="latest" --env-file .kamal/env/roles/app-web.env --env TEST="root" --env EXPERIMENT="disabled" --env SITE="site1"}, output assert_match %r{docker run --detach --restart unless-stopped --name app-web-latest --hostname 1.1.1.1-[0-9a-f]{12} -e KAMAL_CONTAINER_NAME="app-web-latest" -e KAMAL_VERSION="latest" --env TEST="root" --env EXPERIMENT="disabled" --env SITE="site1"}, 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
end end
end end

View File

@@ -49,7 +49,7 @@ class CliBuildTest < CliTestCase
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :submodule, :update, "--init") SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :submodule, :update, "--init")
SSHKit::Backend::Abstract.any_instance.expects(:execute) SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".") .with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".", env: {})
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD) .with(:git, "-C", anything, :"rev-parse", :HEAD)
@@ -140,7 +140,7 @@ class CliBuildTest < CliTestCase
.returns("") .returns("")
SSHKit::Backend::Abstract.any_instance.expects(:execute) SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".") .with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".", env: {})
run_command("push").tap do |output| run_command("push").tap do |output|
assert_match /WARN Missing compatible builder, so creating a new one first/, output assert_match /WARN Missing compatible builder, so creating a new one first/, output

View File

@@ -40,7 +40,7 @@ class CliTestCase < ActiveSupport::TestCase
.with(:docker, :buildx, :inspect, "kamal-local-docker-container") .with(:docker, :buildx, :inspect, "kamal-local-docker-container")
end end
def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, subcommand: nil, runtime: false) def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, subcommand: nil, runtime: false, secrets: false)
whoami = `whoami`.chomp whoami = `whoami`.chomp
performer = Kamal::Git.email.presence || whoami performer = Kamal::Git.email.presence || whoami
service = service_version.split("@").first service = service_version.split("@").first
@@ -58,6 +58,7 @@ class CliTestCase < ActiveSupport::TestCase
KAMAL_COMMAND=\"#{command}\"\s KAMAL_COMMAND=\"#{command}\"\s
#{"KAMAL_SUBCOMMAND=\\\"#{subcommand}\\\"\\s" if subcommand} #{"KAMAL_SUBCOMMAND=\\\"#{subcommand}\\\"\\s" if subcommand}
#{"KAMAL_RUNTIME=\\\"\\d+\\\"\\s" if runtime} #{"KAMAL_RUNTIME=\\\"\\d+\\\"\\s" if runtime}
#{"DB_PASSWORD=\"secret\"\\s" if secrets}
;\s/usr/bin/env\s\.kamal/hooks/#{hook} }x ;\s/usr/bin/env\s\.kamal/hooks/#{hook} }x
assert_match expected, output assert_match expected, output

View File

@@ -1,37 +0,0 @@
require_relative "cli_test_case"
class CliEnvTest < CliTestCase
test "push" do
run_command("push").tap do |output|
assert_match "Running /usr/bin/env mkdir -p .kamal/env/roles on 1.1.1.1", output
assert_match "Running /usr/bin/env mkdir -p .kamal/env/traefik on 1.1.1.1", output
assert_match "Running /usr/bin/env mkdir -p .kamal/env/accessories on 1.1.1.1", output
assert_match "Running /usr/bin/env mkdir -p .kamal/env/roles on 1.1.1.1", output
assert_match "Running /usr/bin/env mkdir -p .kamal/env/traefik on 1.1.1.2", output
assert_match "Running /usr/bin/env mkdir -p .kamal/env/accessories on 1.1.1.1", output
assert_match ".kamal/env/roles/app-web.env", output
assert_match ".kamal/env/roles/app-workers.env", output
assert_match ".kamal/env/traefik/traefik.env", output
assert_match ".kamal/env/accessories/app-redis.env", output
end
end
test "delete" do
run_command("delete").tap do |output|
assert_match "Running /usr/bin/env rm -f .kamal/env/roles/app-web.env on 1.1.1.1", output
assert_match "Running /usr/bin/env rm -f .kamal/env/roles/app-web.env on 1.1.1.2", output
assert_match "Running /usr/bin/env rm -f .kamal/env/roles/app-workers.env on 1.1.1.3", output
assert_match "Running /usr/bin/env rm -f .kamal/env/roles/app-workers.env on 1.1.1.4", output
assert_match "Running /usr/bin/env rm -f .kamal/env/traefik/traefik.env on 1.1.1.1", output
assert_match "Running /usr/bin/env rm -f .kamal/env/traefik/traefik.env on 1.1.1.2", output
assert_match "Running /usr/bin/env rm -f .kamal/env/accessories/app-redis.env on 1.1.1.1", output
assert_match "Running /usr/bin/env rm -f .kamal/env/accessories/app-redis.env on 1.1.1.2", output
assert_match "Running /usr/bin/env rm -f .kamal/env/accessories/app-mysql.env on 1.1.1.3", output
end
end
private
def run_command(*command)
stdouted { Kamal::Cli::Env.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) }
end
end

View File

@@ -8,14 +8,11 @@ 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:main:envify", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:env:push", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options)
Kamal::Cli::Main.any_instance.expects(:deploy) 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
assert_match /Evaluate and push env files.../, output
end end
end end
@@ -23,8 +20,6 @@ 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:env:push", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:main:envify", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options)
# deploy # deploy
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: true))
@@ -36,7 +31,6 @@ class CliMainTest < CliTestCase
run_command("setup", "--skip_push").tap do |output| run_command("setup", "--skip_push").tap do |output|
assert_match /Ensure Docker is installed.../, output assert_match /Ensure Docker is installed.../, output
assert_match /Evaluate and push env files.../, output
# deploy # deploy
assert_match /Acquiring the deploy lock/, output assert_match /Acquiring the deploy lock/, output
assert_match /Log into image registry/, output assert_match /Log into image registry/, output
@@ -49,27 +43,29 @@ class CliMainTest < CliTestCase
end end
test "deploy" do test "deploy" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, "verbose" => true } with_test_secrets("secrets" => "DB_PASSWORD=secret") do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, "verbose" => true }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: false)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: false))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:traefik:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options)
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "deploy" } hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "deploy" }
run_command("deploy", "--verbose").tap do |output| run_command("deploy", "--verbose").tap do |output|
assert_hook_ran "pre-connect", output, **hook_variables assert_hook_ran "pre-connect", output, **hook_variables
assert_match /Log into image registry/, output assert_match /Log into image registry/, output
assert_match /Build and push app image/, output assert_match /Build and push app image/, output
assert_hook_ran "pre-deploy", output, **hook_variables assert_hook_ran "pre-deploy", output, **hook_variables, secrets: true
assert_match /Ensure Traefik is running/, output assert_match /Ensure Traefik is running/, output
assert_match /Detect stale containers/, output assert_match /Detect stale containers/, output
assert_match /Prune old containers and images/, output assert_match /Prune old containers and images/, output
assert_hook_ran "post-deploy", output, **hook_variables, runtime: true assert_hook_ran "post-deploy", output, **hook_variables, runtime: true, secrets: true
end
end end
end end
@@ -388,40 +384,38 @@ class CliMainTest < CliTestCase
end end
test "init" do test "init" do
Pathname.any_instance.expects(:exist?).returns(false).times(3) in_dummy_git_repo do
Pathname.any_instance.stubs(:mkpath) run_command("init").tap do |output|
FileUtils.stubs(:mkdir_p) assert_match "Created configuration file in config/deploy.yml", output
FileUtils.stubs(:cp_r) assert_match "Created .kamal/secrets file", output
FileUtils.stubs(:cp) end
run_command("init").tap do |output| assert_file "config/deploy.yml", "service: my-app"
assert_match /Created configuration file in config\/deploy.yml/, output assert_file ".kamal/secrets", "KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD"
assert_match /Created \.env file/, output
end end
end end
test "init with existing config" do test "init with existing config" do
Pathname.any_instance.expects(:exist?).returns(true).times(3) in_dummy_git_repo do
run_command("init")
run_command("init").tap do |output| run_command("init").tap do |output|
assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, output assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, output
assert_no_match /Added .kamal\/secrets/, output
end
end end
end end
test "init with bundle option" do test "init with bundle option" do
Pathname.any_instance.expects(:exist?).returns(false).times(4) in_dummy_git_repo do
Pathname.any_instance.stubs(:mkpath) run_command("init", "--bundle").tap do |output|
FileUtils.stubs(:mkdir_p) assert_match "Created configuration file in config/deploy.yml", output
FileUtils.stubs(:cp_r) assert_match "Created .kamal/secrets file", output
FileUtils.stubs(:cp) assert_match /Adding Kamal to Gemfile and bundle/, output
assert_match /bundle add kamal/, output
run_command("init", "--bundle").tap do |output| assert_match /bundle binstubs kamal/, output
assert_match /Created configuration file in config\/deploy.yml/, output assert_match /Created binstub file in bin\/kamal/, output
assert_match /Created \.env file/, output end
assert_match /Adding Kamal to Gemfile and bundle/, output
assert_match /bundle add kamal/, output
assert_match /bundle binstubs kamal/, output
assert_match /Created binstub file in bin\/kamal/, output
end end
end end
@@ -438,50 +432,6 @@ class CliMainTest < CliTestCase
end end
end end
test "envify" do
with_test_dotenv(".env.erb": "HELLO=<%= 'world' %>") do
run_command("envify")
assert_equal("HELLO=world", File.read(".env"))
end
end
test "envify with blank line trimming" do
file = <<~EOF
HELLO=<%= 'world' %>
<% if true -%>
KEY=value
<% end -%>
EOF
with_test_dotenv(".env.erb": file) do
run_command("envify")
assert_equal("HELLO=world\nKEY=value\n", File.read(".env"))
end
end
test "envify with destination" do
with_test_dotenv(".env.world.erb": "HELLO=<%= 'world' %>") do
run_command("envify", "-d", "world", config_file: "deploy_for_dest")
assert_equal "HELLO=world", File.read(".env.world")
end
end
test "envify with skip_push" do
Pathname.any_instance.expects(:exist?).returns(true).times(1)
File.expects(:read).with(".env.erb").returns("HELLO=<%= 'world' %>")
File.expects(:write).with(".env", "HELLO=world", perm: 0600)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:env:push").never
run_command("envify", "--skip-push")
end
test "envify with clean env" do
with_test_dotenv(".env": "HELLO=already", ".env.erb": "HELLO=<%= ENV.fetch 'HELLO', 'never' %>") do
run_command("envify", "--skip-push")
assert_equal "HELLO=never", File.read(".env")
end
end
test "remove with confirmation" do test "remove with confirmation" do
run_command("remove", "-y", config_file: "deploy_with_accessories").tap do |output| run_command("remove", "-y", config_file: "deploy_with_accessories").tap do |output|
assert_match /docker container stop traefik/, output assert_match /docker container stop traefik/, output
@@ -572,18 +522,16 @@ class CliMainTest < CliTestCase
end end
end end
def with_test_dotenv(**files) def in_dummy_git_repo
Dir.mktmpdir do |dir| Dir.mktmpdir do |tmpdir|
fixtures_dup = File.join(dir, "test") Dir.chdir(tmpdir) do
FileUtils.mkdir_p(fixtures_dup) `git init`
FileUtils.cp_r("test/fixtures/", fixtures_dup)
Dir.chdir(dir) do
files.each do |filename, contents|
File.binwrite(filename.to_s, contents)
end
yield yield
end end
end end
end end
def assert_file(file, content)
assert_match content, File.read(file)
end
end end

22
test/cli/secrets_test.rb Normal file
View File

@@ -0,0 +1,22 @@
require_relative "cli_test_case"
class CliSecretsTest < CliTestCase
test "fetch" do
assert_equal \
"\\{\\\"foo\\\":\\\"oof\\\",\\\"bar\\\":\\\"rab\\\",\\\"baz\\\":\\\"zab\\\"\\}",
run_command("fetch", "foo", "bar", "baz", "--account", "myaccount", "--adapter", "test")
end
test "extract" do
assert_equal "oof", run_command("extract", "foo", "{\"foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}")
end
test "extract match from end" do
assert_equal "oof", run_command("extract", "foo", "{\"abc/foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}")
end
private
def run_command(*command)
stdouted { Kamal::Cli::Secrets.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) }
end
end

View File

@@ -2,6 +2,8 @@ require "test_helper"
class CommandsAccessoryTest < ActiveSupport::TestCase class CommandsAccessoryTest < ActiveSupport::TestCase
setup do setup do
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" ],
@@ -41,21 +43,19 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
} }
} }
} }
ENV["MYSQL_ROOT_PASSWORD"] = "secret123"
end end
teardown do teardown do
ENV.delete("MYSQL_ROOT_PASSWORD") teardown_test_secrets
end end
test "run" do test "run" do
assert_equal \ assert_equal \
"docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 --env-file .kamal/env/accessories/app-mysql.env --env MYSQL_ROOT_HOST=\"%\" --label service=\"app-mysql\" private.registry/mysql:8.0", "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/env/accessories/app-mysql.env --label service=\"app-mysql\" private.registry/mysql:8.0",
new_command(:mysql).run.join(" ") new_command(:mysql).run.join(" ")
assert_equal \ assert_equal \
"docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/env/accessories/app-redis.env --env SOMETHING=\"else\" --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest", "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --env SOMETHING=\"else\" --env-file .kamal/env/accessories/app-redis.env --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest",
new_command(:redis).run.join(" ") new_command(:redis).run.join(" ")
assert_equal \ assert_equal \
@@ -92,7 +92,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "execute in new container" do test "execute in new container" do
assert_equal \ assert_equal \
"docker run --rm --env-file .kamal/env/accessories/app-mysql.env --env MYSQL_ROOT_HOST=\"%\" private.registry/mysql:8.0 mysql -u root", "docker run --rm --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/env/accessories/app-mysql.env private.registry/mysql:8.0 mysql -u root",
new_command(:mysql).execute_in_new_container("mysql", "-u", "root").join(" ") new_command(:mysql).execute_in_new_container("mysql", "-u", "root").join(" ")
end end
@@ -104,7 +104,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "execute in new container over ssh" do test "execute in new container over ssh" do
new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do
assert_match %r{docker run -it --rm --env-file .kamal/env/accessories/app-mysql.env --env MYSQL_ROOT_HOST=\"%\" private.registry/mysql:8.0 mysql -u root}, assert_match %r{docker run -it --rm --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/env/accessories/app-mysql.env private.registry/mysql:8.0 mysql -u root},
new_command(:mysql).execute_in_new_container_over_ssh("mysql", "-u", "root") new_command(:mysql).execute_in_new_container_over_ssh("mysql", "-u", "root")
end end
end end
@@ -150,14 +150,6 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
new_command(:mysql).remove_image.join(" ") new_command(:mysql).remove_image.join(" ")
end end
test "make_env_directory" do
assert_equal "mkdir -p .kamal/env/accessories", new_command(:mysql).make_env_directory.join(" ")
end
test "remove_env_file" do
assert_equal "rm -f .kamal/env/accessories/app-mysql.env", new_command(:mysql).remove_env_file.join(" ")
end
private private
def new_command(accessory) def new_command(accessory)
Kamal::Commands::Accessory.new(Kamal::Configuration.new(@config), name: accessory) Kamal::Commands::Accessory.new(Kamal::Configuration.new(@config), name: accessory)

View File

@@ -2,14 +2,14 @@ require "test_helper"
class CommandsAppTest < ActiveSupport::TestCase class CommandsAppTest < ActiveSupport::TestCase
setup do setup do
ENV["RAILS_MASTER_KEY"] = "456" setup_test_secrets("secrets" => "RAILS_MASTER_KEY=456")
Kamal::Configuration.any_instance.stubs(:run_id).returns("12345678901234567890123456789012") Kamal::Configuration.any_instance.stubs(:run_id).returns("12345678901234567890123456789012")
@config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], env: { "secret" => [ "RAILS_MASTER_KEY" ] }, builder: { "arch" => "amd64" } } @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], env: { "secret" => [ "RAILS_MASTER_KEY" ] }, builder: { "arch" => "amd64" } }
end end
teardown do teardown do
ENV.delete("RAILS_MASTER_KEY") teardown_test_secrets
end end
test "run" do test "run" do
@@ -85,7 +85,7 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } }
assert_equal \ assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" --health-cmd \"(curl -f http://localhost:3000/up || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env ENV1=\"value1\" --env-file .kamal/env/roles/app-web.env --health-cmd \"(curl -f http://localhost:3000/up || exit 1) && (stat /tmp/kamal-cord/cord > /dev/null || exit 1)\" --health-interval \"1s\" --volume $(pwd)/.kamal/cords/app-web-12345678901234567890123456789012:/tmp/kamal-cord --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -219,7 +219,7 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } }
assert_equal \ assert_equal \
"docker run --rm --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" dhh/app:999 bin/rails db:setup", "docker run --rm --env ENV1=\"value1\" --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails db:setup",
new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ")
end end
@@ -251,7 +251,7 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:servers] = [ { "1.1.1.1" => "tag1" } ] @config[:servers] = [ { "1.1.1.1" => "tag1" } ]
@config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } }
assert_equal "ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" dhh/app:999 bin/rails c'", assert_equal "ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --env ENV1=\"value1\" --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails c'",
new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {})
end end
@@ -412,14 +412,6 @@ class CommandsAppTest < ActiveSupport::TestCase
new_command.tag_latest_image.join(" ") new_command.tag_latest_image.join(" ")
end end
test "make_env_directory" do
assert_equal "mkdir -p .kamal/env/roles", new_command.make_env_directory.join(" ")
end
test "remove_env_file" do
assert_equal "rm -f .kamal/env/roles/app-web.env", new_command.remove_env_file.join(" ")
end
test "cord" do test "cord" do
assert_equal "docker inspect -f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}' app-web-123 | awk '$2 == \"/tmp/kamal-cord\" {print $1}'", new_command.cord(version: 123).join(" ") assert_equal "docker inspect -f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}' app-web-123 | awk '$2 == \"/tmp/kamal-cord\" {print $1}'", new_command.cord(version: 123).join(" ")
end end
@@ -438,7 +430,7 @@ class CommandsAppTest < ActiveSupport::TestCase
assert_equal [ assert_equal [
:mkdir, "-p", ".kamal/assets/extracted/app-web-999", "&&", :mkdir, "-p", ".kamal/assets/extracted/app-web-999", "&&",
:docker, :stop, "-t 1", "app-web-assets", "2> /dev/null", "|| true", "&&", :docker, :stop, "-t 1", "app-web-assets", "2> /dev/null", "|| true", "&&",
:docker, :run, "--name", "app-web-assets", "--detach", "--rm", "dhh/app:999", "sleep 1000000", "&&", :docker, :run, "--name", "app-web-assets", "--detach", "--rm", "--entrypoint", "sleep", "dhh/app:999", "1000000", "&&",
:docker, :cp, "-L", "app-web-assets:/public/assets/.", ".kamal/assets/extracted/app-web-999", "&&", :docker, :cp, "-L", "app-web-assets:/public/assets/.", ".kamal/assets/extracted/app-web-999", "&&",
:docker, :stop, "-t 1", "app-web-assets" :docker, :stop, "-t 1", "app-web-assets"
], new_command(asset_path: "/public/assets").extract_assets ], new_command(asset_path: "/public/assets").extract_assets

View File

@@ -18,6 +18,7 @@ class CommandsAuditorTest < ActiveSupport::TestCase
test "record" do test "record" do
assert_equal [ assert_equal [
:mkdir, "-p", ".kamal", "&&",
:echo, :echo,
"[#{@recorded_at}] [#{@performer}]", "[#{@recorded_at}] [#{@performer}]",
"app removed container", "app removed container",
@@ -28,6 +29,7 @@ class CommandsAuditorTest < ActiveSupport::TestCase
test "record with destination" do test "record with destination" do
new_command(destination: "staging").tap do |auditor| new_command(destination: "staging").tap do |auditor|
assert_equal [ assert_equal [
:mkdir, "-p", ".kamal", "&&",
:echo, :echo,
"[#{@recorded_at}] [#{@performer}] [staging]", "[#{@recorded_at}] [#{@performer}] [staging]",
"app removed container", "app removed container",
@@ -39,6 +41,7 @@ class CommandsAuditorTest < ActiveSupport::TestCase
test "record with command details" do test "record with command details" do
new_command(role: "web").tap do |auditor| new_command(role: "web").tap do |auditor|
assert_equal [ assert_equal [
:mkdir, "-p", ".kamal", "&&",
:echo, :echo,
"[#{@recorded_at}] [#{@performer}] [web]", "[#{@recorded_at}] [#{@performer}] [web]",
"app removed container", "app removed container",
@@ -49,6 +52,7 @@ class CommandsAuditorTest < ActiveSupport::TestCase
test "record with arg details" do test "record with arg details" do
assert_equal [ assert_equal [
:mkdir, "-p", ".kamal", "&&",
:echo, :echo,
"[#{@recorded_at}] [#{@performer}] [value]", "[#{@recorded_at}] [#{@performer}] [value]",
"app removed container", "app removed container",

View File

@@ -77,10 +77,13 @@ class CommandsBuilderTest < ActiveSupport::TestCase
end end
test "build secrets" do test "build secrets" do
builder = new_builder_command(builder: { "secrets" => [ "token_a", "token_b" ] }) with_test_secrets("secrets" => "token_a=foo\ntoken_b=bar") do
assert_equal \ FileUtils.touch("Dockerfile")
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"token_a\" --secret id=\"token_b\" --file Dockerfile", builder = new_builder_command(builder: { "secrets" => [ "token_a", "token_b" ] })
builder.target.build_options.join(" ") assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"token_a\" --secret id=\"token_b\" --file Dockerfile",
builder.target.build_options.join(" ")
end
end end
test "build dockerfile" do test "build dockerfile" do
@@ -121,10 +124,13 @@ class CommandsBuilderTest < ActiveSupport::TestCase
end end
test "push with build secrets" do test "push with build secrets" do
builder = new_builder_command(builder: { "secrets" => [ "a", "b" ] }) with_test_secrets("secrets" => "a=foo\nb=bar") do
assert_equal \ FileUtils.touch("Dockerfile")
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" --file Dockerfile .", builder = new_builder_command(builder: { "secrets" => [ "a", "b" ] })
builder.push.join(" ") assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" --file Dockerfile .",
builder.push.join(" ")
end
end end
test "build with ssh agent socket" do test "build with ssh agent socket" do

View File

@@ -39,6 +39,21 @@ class CommandsHookTest < ActiveSupport::TestCase
], new_command(hooks_path: "custom/hooks/path").run("foo") ], new_command(hooks_path: "custom/hooks/path").run("foo")
end end
test "hook with secrets" do
with_test_secrets("secrets" => "DB_PASSWORD=secret") do
assert_equal [
".kamal/hooks/foo",
{ env: {
"KAMAL_RECORDED_AT" => @recorded_at,
"KAMAL_PERFORMER" => @performer,
"KAMAL_VERSION" => "123",
"KAMAL_SERVICE_VERSION" => "app@123",
"KAMAL_SERVICE" => "app",
"DB_PASSWORD" => "secret" } }
], new_command(env: { "secret" => [ "DB_PASSWORD" ] }).run("foo", secrets: true)
end
end
private private
def new_command(**extra_config) def new_command(**extra_config)
Kamal::Commands::Hook.new(Kamal::Configuration.new(@config.merge(**extra_config), version: "123")) Kamal::Commands::Hook.new(Kamal::Configuration.new(@config.merge(**extra_config), version: "123"))

View File

@@ -11,51 +11,52 @@ class CommandsRegistryTest < ActiveSupport::TestCase
builder: { "arch" => "amd64" }, builder: { "arch" => "amd64" },
servers: [ "1.1.1.1" ] servers: [ "1.1.1.1" ]
} }
@registry = Kamal::Commands::Registry.new Kamal::Configuration.new(@config)
end end
test "registry login" do test "registry login" do
assert_equal \ assert_equal \
"docker login hub.docker.com -u \"dhh\" -p \"secret\"", "docker login hub.docker.com -u \"dhh\" -p \"secret\"",
@registry.login.join(" ") registry.login.join(" ")
end end
test "registry login with ENV password" do test "registry login with ENV password" do
ENV["KAMAL_REGISTRY_PASSWORD"] = "more-secret" with_test_secrets("secrets" => "KAMAL_REGISTRY_PASSWORD=more-secret") do
@config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ] @config[:registry]["password"] = [ "KAMAL_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(" ")
ensure end
ENV.delete("KAMAL_REGISTRY_PASSWORD")
end end
test "registry login escape password" do test "registry login escape password" do
ENV["KAMAL_REGISTRY_PASSWORD"] = "more-secret'\"" with_test_secrets("secrets" => "KAMAL_REGISTRY_PASSWORD=more-secret'\"") do
@config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ] @config[:registry]["password"] = [ "KAMAL_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(" ")
ensure end
ENV.delete("KAMAL_REGISTRY_PASSWORD")
end end
test "registry login with ENV username" do test "registry login with ENV username" do
ENV["KAMAL_REGISTRY_USERNAME"] = "also-secret" with_test_secrets("secrets" => "KAMAL_REGISTRY_USERNAME=also-secret") do
@config[:registry]["username"] = [ "KAMAL_REGISTRY_USERNAME" ] @config[:registry]["username"] = [ "KAMAL_REGISTRY_USERNAME" ]
assert_equal \ assert_equal \
"docker login hub.docker.com -u \"also-secret\" -p \"secret\"", "docker login hub.docker.com -u \"also-secret\" -p \"secret\"",
@registry.login.join(" ") registry.login.join(" ")
ensure end
ENV.delete("KAMAL_REGISTRY_USERNAME")
end end
test "registry logout" do test "registry logout" do
assert_equal \ assert_equal \
"docker logout hub.docker.com", "docker logout hub.docker.com",
@registry.logout.join(" ") registry.logout.join(" ")
end end
private
def registry
Kamal::Commands::Registry.new Kamal::Configuration.new(@config)
end
end end

View File

@@ -9,11 +9,11 @@ class CommandsTraefikTest < ActiveSupport::TestCase
traefik: { "image" => @image, "args" => { "accesslog.format" => "json", "api.insecure" => true, "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } } traefik: { "image" => @image, "args" => { "accesslog.format" => "json", "api.insecure" => true, "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
} }
ENV["EXAMPLE_API_KEY"] = "456" setup_test_secrets("secrets" => "EXAMPLE_API_KEY=456")
end end
teardown do teardown do
ENV.delete("EXAMPLE_API_KEY") teardown_test_secrets
end end
test "run" do test "run" do
@@ -81,9 +81,9 @@ class CommandsTraefikTest < ActiveSupport::TestCase
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ") new_command.run.join(" ")
@config[:traefik]["env"] = { "secret" => %w[EXAMPLE_API_KEY] } @config[:traefik]["env"] = { "EXAMPLE_API_KEY" => "456" }
assert_equal \ assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --env EXAMPLE_API_KEY=\"456\" --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" --label traefik.http.routers.catchall.entryPoints=\"http\" --label traefik.http.routers.catchall.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.catchall.service=\"unavailable\" --label traefik.http.routers.catchall.priority=\"1\" --label traefik.http.services.unavailable.loadbalancer.server.port=\"0\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -188,20 +188,6 @@ class CommandsTraefikTest < ActiveSupport::TestCase
new_command.follow_logs(host: @config[:servers].first, grep: "hello!") new_command.follow_logs(host: @config[:servers].first, grep: "hello!")
end end
test "secrets io" do
@config[:traefik]["env"] = { "secret" => %w[EXAMPLE_API_KEY] }
assert_equal "EXAMPLE_API_KEY=456\n", new_command.env.secrets_io.string
end
test "make_env_directory" do
assert_equal "mkdir -p .kamal/env/traefik", new_command.make_env_directory.join(" ")
end
test "remove_env_file" do
assert_equal "rm -f .kamal/env/traefik/traefik.env", new_command.remove_env_file.join(" ")
end
private private
def new_command def new_command
Kamal::Commands::Traefik.new(Kamal::Configuration.new(@config, version: "123")) Kamal::Commands::Traefik.new(Kamal::Configuration.new(@config, version: "123"))

View File

@@ -116,25 +116,14 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
end end
test "env args" do test "env args" do
assert_equal [ "--env-file", ".kamal/env/accessories/app-mysql.env", "--env", "MYSQL_ROOT_HOST=\"%\"" ], @config.accessory(:mysql).env_args with_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123") do
assert_equal [ "--env-file", ".kamal/env/accessories/app-redis.env", "--env", "SOMETHING=\"else\"" ], @config.accessory(:redis).env_args config = Kamal::Configuration.new(@deploy)
end
test "env with secrets" do assert_equal [ "--env", "MYSQL_ROOT_HOST=\"%\"", "--env-file", ".kamal/env/accessories/app-mysql.env" ], config.accessory(:mysql).env_args.map(&:to_s)
ENV["MYSQL_ROOT_PASSWORD"] = "secret123" assert_equal "MYSQL_ROOT_PASSWORD=secret123\n", config.accessory(:mysql).secrets_io.string
assert_equal [ "--env", "SOMETHING=\"else\"", "--env-file", ".kamal/env/accessories/app-redis.env" ], @config.accessory(:redis).env_args
expected_secrets_file = <<~ENV assert_equal "\n", config.accessory(:redis).secrets_io.string
MYSQL_ROOT_PASSWORD=secret123 end
ENV
assert_equal expected_secrets_file, @config.accessory(:mysql).env.secrets_io.string
assert_equal [ "--env-file", ".kamal/env/accessories/app-mysql.env", "--env", "MYSQL_ROOT_HOST=\"%\"" ], @config.accessory(:mysql).env_args
ensure
ENV["MYSQL_ROOT_PASSWORD"] = nil
end
test "env secrets path" do
assert_equal ".kamal/env/accessories/app-mysql.env", @config.accessory(:mysql).env.secrets_file
end end
test "volume args" do test "volume args" do

View File

@@ -110,13 +110,15 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase
end end
test "secrets" do test "secrets" do
assert_equal [], config.builder.secrets assert_equal({}, config.builder.secrets)
end end
test "setting secrets" do test "setting secrets" do
@deploy[:builder]["secrets"] = [ "GITHUB_TOKEN" ] with_test_secrets("secrets" => "GITHUB_TOKEN=secret123") do
@deploy[:builder]["secrets"] = [ "GITHUB_TOKEN" ]
assert_equal [ "GITHUB_TOKEN" ], config.builder.secrets assert_equal({ "GITHUB_TOKEN" => "secret123" }, config.builder.secrets)
end
end end
test "dockerfile" do test "dockerfile" do

View File

@@ -79,23 +79,21 @@ class ConfigurationEnvTagsTest < ActiveSupport::TestCase
end end
test "tag secret env" do test "tag secret env" do
ENV["PASSWORD"] = "hello" with_test_secrets("secrets" => "PASSWORD=hello") do
deploy = {
deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ { "1.1.1.1" => "secrets" } ],
servers: [ { "1.1.1.1" => "secrets" } ], builder: { "arch" => "amd64" },
builder: { "arch" => "amd64" }, env: {
env: { "tags" => {
"tags" => { "secrets" => { "secret" => [ "PASSWORD" ] }
"secrets" => { "secret" => [ "PASSWORD" ] } }
} }
} }
}
config = Kamal::Configuration.new(deploy) config = Kamal::Configuration.new(deploy)
assert_equal "hello", config.role("web").env("1.1.1.1").secrets["PASSWORD"] assert_equal "hello", config.role("web").env("1.1.1.1").secrets["PASSWORD"]
ensure end
ENV.delete "PASSWORD"
end end
test "tag clear env" do test "tag clear env" do

View File

@@ -6,27 +6,21 @@ class ConfigurationEnvTest < ActiveSupport::TestCase
test "simple" do test "simple" do
assert_config \ assert_config \
config: { "foo" => "bar", "baz" => "haz" }, config: { "foo" => "bar", "baz" => "haz" },
clear: { "foo" => "bar", "baz" => "haz" }, clear: { "foo" => "bar", "baz" => "haz" }
secrets: {}
end end
test "clear" do test "clear" do
assert_config \ assert_config \
config: { "clear" => { "foo" => "bar", "baz" => "haz" } }, config: { "clear" => { "foo" => "bar", "baz" => "haz" } },
clear: { "foo" => "bar", "baz" => "haz" }, clear: { "foo" => "bar", "baz" => "haz" }
secrets: {}
end end
test "secret" do test "secret" do
ENV["PASSWORD"] = "hello" with_test_secrets("secrets" => "PASSWORD=hello") do
env = Kamal::Configuration::Env.new config: { "secret" => [ "PASSWORD" ] } assert_config \
config: { "secret" => [ "PASSWORD" ] },
assert_config \ secrets: { "PASSWORD" => "hello" }
config: { "secret" => [ "PASSWORD" ] }, end
clear: {},
secrets: { "PASSWORD" => "hello" }
ensure
ENV.delete "PASSWORD"
end end
test "missing secret" do test "missing secret" do
@@ -34,41 +28,32 @@ class ConfigurationEnvTest < ActiveSupport::TestCase
"secret" => [ "PASSWORD" ] "secret" => [ "PASSWORD" ]
} }
assert_raises(KeyError) { Kamal::Configuration::Env.new(config: { "secret" => [ "PASSWORD" ] }).secrets } assert_raises(Kamal::ConfigurationError) { Kamal::Configuration::Env.new(config: { "secret" => [ "PASSWORD" ] }, secrets: Kamal::Secrets.new).secrets_io }
end end
test "secret and clear" do test "secret and clear" do
ENV["PASSWORD"] = "hello" with_test_secrets("secrets" => "PASSWORD=hello") do
config = { config = {
"secret" => [ "PASSWORD" ], "secret" => [ "PASSWORD" ],
"clear" => { "clear" => {
"foo" => "bar", "foo" => "bar",
"baz" => "haz" "baz" => "haz"
}
} }
}
assert_config \ assert_config \
config: config, config: config,
clear: { "foo" => "bar", "baz" => "haz" }, clear: { "foo" => "bar", "baz" => "haz" },
secrets: { "PASSWORD" => "hello" } secrets: { "PASSWORD" => "hello" }
ensure end
ENV.delete "PASSWORD"
end
test "stringIO conversion" do
env = {
"foo" => "bar",
"baz" => "haz"
}
assert_equal "foo=bar\nbaz=haz\n", \
StringIO.new(Kamal::EnvFile.new(env)).read
end end
private private
def assert_config(config:, clear:, secrets:) def assert_config(config:, clear: {}, secrets: {})
env = Kamal::Configuration::Env.new config: config, secrets_file: "secrets.env" env = Kamal::Configuration::Env.new config: config, secrets: Kamal::Secrets.new
assert_equal clear, env.clear expected_clear_args = clear.to_a.flat_map { |key, value| [ "--env", "#{key}=\"#{value}\"" ] }
assert_equal secrets, env.secrets assert_equal expected_clear_args, env.clear_args.map(&:to_s) #  to_s removes the redactions
expected_secrets = secrets.to_a.flat_map { |key, value| "#{key}=#{value}" }.join("\n") + "\n"
assert_equal expected_secrets, env.secrets_io.string
end end
end end

View File

@@ -9,8 +9,6 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
env: { "REDIS_URL" => "redis://x/y" } env: { "REDIS_URL" => "redis://x/y" }
} }
@config = Kamal::Configuration.new(@deploy)
@deploy_with_roles = @deploy.dup.merge({ @deploy_with_roles = @deploy.dup.merge({
servers: { servers: {
"web" => [ "1.1.1.1", "1.1.1.2" ], "web" => [ "1.1.1.1", "1.1.1.2" ],
@@ -24,31 +22,29 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
} }
} }
}) })
@config_with_roles = Kamal::Configuration.new(@deploy_with_roles)
end end
test "hosts" do test "hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2" ], @config.role(:web).hosts assert_equal [ "1.1.1.1", "1.1.1.2" ], config.role(:web).hosts
assert_equal [ "1.1.1.3", "1.1.1.4" ], @config_with_roles.role(:workers).hosts assert_equal [ "1.1.1.3", "1.1.1.4" ], config_with_roles.role(:workers).hosts
end end
test "cmd" do test "cmd" do
assert_nil @config.role(:web).cmd assert_nil config.role(:web).cmd
assert_equal "bin/jobs", @config_with_roles.role(:workers).cmd assert_equal "bin/jobs", config_with_roles.role(:workers).cmd
end end
test "label args" do test "label args" do
assert_equal [ "--label", "service=\"app\"", "--label", "role=\"workers\"", "--label", "destination" ], @config_with_roles.role(:workers).label_args assert_equal [ "--label", "service=\"app\"", "--label", "role=\"workers\"", "--label", "destination" ], config_with_roles.role(:workers).label_args
end end
test "special label args for web" do test "special label args for web" do
assert_equal [ "--label", "service=\"app\"", "--label", "role=\"web\"", "--label", "destination", "--label", "traefik.http.services.app-web.loadbalancer.server.scheme=\"http\"", "--label", "traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\"", "--label", "traefik.http.routers.app-web.priority=\"2\"", "--label", "traefik.http.middlewares.app-web-retry.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\"" ], @config.role(:web).label_args assert_equal [ "--label", "service=\"app\"", "--label", "role=\"web\"", "--label", "destination", "--label", "traefik.http.services.app-web.loadbalancer.server.scheme=\"http\"", "--label", "traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\"", "--label", "traefik.http.routers.app-web.priority=\"2\"", "--label", "traefik.http.middlewares.app-web-retry.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\"" ], config.role(:web).label_args
end end
test "custom labels" do test "custom labels" do
@deploy[:labels] = { "my.custom.label" => "50" } @deploy[:labels] = { "my.custom.label" => "50" }
assert_equal "50", @config.role(:web).labels["my.custom.label"] assert_equal "50", config.role(:web).labels["my.custom.label"]
end end
test "custom labels via role specialization" do test "custom labels via role specialization" do
@@ -59,7 +55,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
test "overwriting default traefik label" do test "overwriting default traefik label" do
@deploy[:labels] = { "traefik.http.routers.app-web.rule" => "\"Host(\\`example.com\\`) || (Host(\\`example.org\\`) && Path(\\`/traefik\\`))\"" } @deploy[:labels] = { "traefik.http.routers.app-web.rule" => "\"Host(\\`example.com\\`) || (Host(\\`example.org\\`) && Path(\\`/traefik\\`))\"" }
assert_equal "\"Host(\\`example.com\\`) || (Host(\\`example.org\\`) && Path(\\`/traefik\\`))\"", @config.role(:web).labels["traefik.http.routers.app-web.rule"] assert_equal "\"Host(\\`example.com\\`) || (Host(\\`example.org\\`) && Path(\\`/traefik\\`))\"", config.role(:web).labels["traefik.http.routers.app-web.rule"]
end end
test "default traefik label on non-web role" do test "default traefik label on non-web role" do
@@ -71,166 +67,165 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
end end
test "env overwritten by role" do test "env overwritten by role" do
assert_equal "redis://a/b", @config_with_roles.role(:workers).env("1.1.1.3").clear["REDIS_URL"] assert_equal "redis://a/b", config_with_roles.role(:workers).env("1.1.1.3").clear["REDIS_URL"]
assert_equal "\n", @config_with_roles.role(:workers).env("1.1.1.3").secrets_io.string assert_equal \
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/env/roles/app-workers.env" ],
config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s)
assert_equal \
"\n",
config_with_roles.role(:workers).secrets_io("1.1.1.3").read
end end
test "container name" do test "container name" do
ENV["VERSION"] = "12345" ENV["VERSION"] = "12345"
assert_equal "app-workers-12345", @config_with_roles.role(:workers).container_name assert_equal "app-workers-12345", config_with_roles.role(:workers).container_name
assert_equal "app-web-12345", @config_with_roles.role(:web).container_name assert_equal "app-web-12345", config_with_roles.role(:web).container_name
ensure ensure
ENV.delete("VERSION") ENV.delete("VERSION")
end end
test "env args" do test "env args" do
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") assert_equal \
[ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/env/roles/app-workers.env" ],
config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s)
assert_equal \
"\n",
config_with_roles.role(:workers).secrets_io("1.1.1.3").read
end end
test "env secret overwritten by role" do test "env secret overwritten by role" do
@deploy_with_roles[:env] = { with_test_secrets("secrets" => "REDIS_PASSWORD=secret456\nDB_PASSWORD=secret&\"123") do
"clear" => { @deploy_with_roles[:env] = {
"REDIS_URL" => "redis://a/b" "clear" => {
}, "REDIS_URL" => "redis://a/b"
"secret" => [ },
"REDIS_PASSWORD" "secret" => [
] "REDIS_PASSWORD"
} ]
}
@deploy_with_roles[:servers]["workers"]["env"] = { @deploy_with_roles[:servers]["workers"]["env"] = {
"clear" => { "clear" => {
"REDIS_URL" => "redis://a/b", "REDIS_URL" => "redis://a/b",
"WEB_CONCURRENCY" => "4" "WEB_CONCURRENCY" => "4"
}, },
"secret" => [ "secret" => [
"DB_PASSWORD" "DB_PASSWORD"
] ]
} }
ENV["REDIS_PASSWORD"] = "secret456" assert_equal \
ENV["DB_PASSWORD"] = "secret&\"123" [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/env/roles/app-workers.env" ],
config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s)
expected_secrets_file = <<~ENV assert_equal \
REDIS_PASSWORD=secret456 "REDIS_PASSWORD=secret456\nDB_PASSWORD=secret&\"123\n",
DB_PASSWORD=secret&\"123 config_with_roles.role(:workers).secrets_io("1.1.1.3").read
ENV end
assert_equal expected_secrets_file, Kamal::Configuration.new(@deploy_with_roles).role(:workers).env("1.1.1.3").secrets_io.string
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3")
ensure
ENV["REDIS_PASSWORD"] = nil
ENV["DB_PASSWORD"] = nil
end end
test "env secrets only in role" do test "env secrets only in role" do
@deploy_with_roles[:servers]["workers"]["env"] = { with_test_secrets("secrets" => "DB_PASSWORD=secret123") do
"clear" => { @deploy_with_roles[:servers]["workers"]["env"] = {
"REDIS_URL" => "redis://a/b", "clear" => {
"WEB_CONCURRENCY" => "4" "REDIS_URL" => "redis://a/b",
}, "WEB_CONCURRENCY" => "4"
"secret" => [ },
"DB_PASSWORD" "secret" => [
] "DB_PASSWORD"
} ]
}
ENV["DB_PASSWORD"] = "secret123" assert_equal \
[ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/env/roles/app-workers.env" ],
config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s)
expected_secrets_file = <<~ENV assert_equal \
DB_PASSWORD=secret123 "DB_PASSWORD=secret123\n",
ENV config_with_roles.role(:workers).secrets_io("1.1.1.3").read
end
assert_equal expected_secrets_file, Kamal::Configuration.new(@deploy_with_roles).role(:workers).env("1.1.1.3").secrets_io.string
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3")
ensure
ENV["DB_PASSWORD"] = nil
end end
test "env secrets only at top level" do test "env secrets only at top level" do
@deploy_with_roles[:env] = { with_test_secrets("secrets" => "REDIS_PASSWORD=secret456") do
"clear" => { @deploy_with_roles[:env] = {
"REDIS_URL" => "redis://a/b" "clear" => {
}, "REDIS_URL" => "redis://a/b"
"secret" => [ },
"REDIS_PASSWORD" "secret" => [
] "REDIS_PASSWORD"
} ]
}
ENV["REDIS_PASSWORD"] = "secret456" assert_equal \
[ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/env/roles/app-workers.env" ],
config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s)
expected_secrets_file = <<~ENV assert_equal \
REDIS_PASSWORD=secret456 "REDIS_PASSWORD=secret456\n",
ENV config_with_roles.role(:workers).secrets_io("1.1.1.3").read
end
assert_equal expected_secrets_file, Kamal::Configuration.new(@deploy_with_roles).role(:workers).env("1.1.1.3").secrets_io.string
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3")
ensure
ENV["REDIS_PASSWORD"] = nil
end end
test "env overwritten by role with secrets" do test "env overwritten by role with secrets" do
@deploy_with_roles[:env] = { with_test_secrets("secrets" => "REDIS_PASSWORD=secret456") do
"clear" => { @deploy_with_roles[:env] = {
"REDIS_URL" => "redis://a/b" "clear" => {
}, "REDIS_URL" => "redis://a/b"
"secret" => [ },
"REDIS_PASSWORD" "secret" => [
] "REDIS_PASSWORD"
} ]
@deploy_with_roles[:servers]["workers"]["env"] = {
"clear" => {
"REDIS_URL" => "redis://c/d"
} }
}
ENV["REDIS_PASSWORD"] = "secret456" @deploy_with_roles[:servers]["workers"]["env"] = {
"clear" => {
"REDIS_URL" => "redis://c/d"
}
}
expected_secrets_file = <<~ENV assert_equal \
REDIS_PASSWORD=secret456 [ "--env", "REDIS_URL=\"redis://c/d\"", "--env-file", ".kamal/env/roles/app-workers.env" ],
ENV config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s)
config = Kamal::Configuration.new(@deploy_with_roles) assert_equal \
assert_equal expected_secrets_file, config.role(:workers).env("1.1.1.3").secrets_io.string "REDIS_PASSWORD=secret456\n",
assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://c/d\"" ], config.role(:workers).env_args("1.1.1.3") config_with_roles.role(:workers).secrets_io("1.1.1.3").read
ensure end
ENV["REDIS_PASSWORD"] = nil
end
test "env secrets_file" do
assert_equal ".kamal/env/roles/app-workers.env", @config_with_roles.role(:workers).env("1.1.1.3").secrets_file
end end
test "uses cord" do test "uses cord" do
assert @config_with_roles.role(:web).uses_cord? assert config_with_roles.role(:web).uses_cord?
assert_not @config_with_roles.role(:workers).uses_cord? assert_not config_with_roles.role(:workers).uses_cord?
end end
test "cord host file" do test "cord host file" do
assert_match %r{.kamal/cords/app-web-[0-9a-f]{32}/cord}, @config_with_roles.role(:web).cord_host_file assert_match %r{.kamal/cords/app-web-[0-9a-f]{32}/cord}, config_with_roles.role(:web).cord_host_file
end end
test "cord volume" do test "cord volume" do
assert_equal "/tmp/kamal-cord", @config_with_roles.role(:web).cord_volume.container_path assert_equal "/tmp/kamal-cord", config_with_roles.role(:web).cord_volume.container_path
assert_match %r{.kamal/cords/app-web-[0-9a-f]{32}}, @config_with_roles.role(:web).cord_volume.host_path assert_match %r{.kamal/cords/app-web-[0-9a-f]{32}}, config_with_roles.role(:web).cord_volume.host_path
assert_equal "--volume", @config_with_roles.role(:web).cord_volume.docker_args[0] assert_equal "--volume", config_with_roles.role(:web).cord_volume.docker_args[0]
assert_match %r{\$\(pwd\)/.kamal/cords/app-web-[0-9a-f]{32}:/tmp/kamal-cord}, @config_with_roles.role(:web).cord_volume.docker_args[1] assert_match %r{\$\(pwd\)/.kamal/cords/app-web-[0-9a-f]{32}:/tmp/kamal-cord}, config_with_roles.role(:web).cord_volume.docker_args[1]
end end
test "cord container file" do test "cord container file" do
assert_equal "/tmp/kamal-cord/cord", @config_with_roles.role(:web).cord_container_file assert_equal "/tmp/kamal-cord/cord", config_with_roles.role(:web).cord_container_file
end end
test "asset path and volume args" do test "asset path and volume args" do
ENV["VERSION"] = "12345" ENV["VERSION"] = "12345"
assert_nil @config_with_roles.role(:web).asset_volume_args assert_nil config_with_roles.role(:web).asset_volume_args
assert_nil @config_with_roles.role(:workers).asset_volume_args assert_nil config_with_roles.role(:workers).asset_volume_args
assert_nil @config_with_roles.role(:web).asset_path assert_nil config_with_roles.role(:web).asset_path
assert_nil @config_with_roles.role(:workers).asset_path assert_nil config_with_roles.role(:workers).asset_path
assert_not @config_with_roles.role(:web).assets? assert_not config_with_roles.role(:web).assets?
assert_not @config_with_roles.role(:workers).assets? assert_not config_with_roles.role(:workers).assets?
config_with_assets = Kamal::Configuration.new(@deploy_with_roles.dup.tap { |c| config_with_assets = Kamal::Configuration.new(@deploy_with_roles.dup.tap { |c|
c[:asset_path] = "foo" c[:asset_path] = "foo"
@@ -258,17 +253,26 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
test "asset extracted path" do test "asset extracted path" do
ENV["VERSION"] = "12345" ENV["VERSION"] = "12345"
assert_equal ".kamal/assets/extracted/app-web-12345", @config_with_roles.role(:web).asset_extracted_path assert_equal ".kamal/assets/extracted/app-web-12345", config_with_roles.role(:web).asset_extracted_path
assert_equal ".kamal/assets/extracted/app-workers-12345", @config_with_roles.role(:workers).asset_extracted_path assert_equal ".kamal/assets/extracted/app-workers-12345", config_with_roles.role(:workers).asset_extracted_path
ensure ensure
ENV.delete("VERSION") ENV.delete("VERSION")
end end
test "asset volume path" do test "asset volume path" do
ENV["VERSION"] = "12345" ENV["VERSION"] = "12345"
assert_equal ".kamal/assets/volumes/app-web-12345", @config_with_roles.role(:web).asset_volume_path assert_equal ".kamal/assets/volumes/app-web-12345", config_with_roles.role(:web).asset_volume_path
assert_equal ".kamal/assets/volumes/app-workers-12345", @config_with_roles.role(:workers).asset_volume_path assert_equal ".kamal/assets/volumes/app-workers-12345", config_with_roles.role(:workers).asset_volume_path
ensure ensure
ENV.delete("VERSION") ENV.delete("VERSION")
end end
private
def config
Kamal::Configuration.new(@deploy)
end
def config_with_roles
Kamal::Configuration.new(@deploy_with_roles)
end
end end

View File

@@ -2,8 +2,6 @@ require_relative "integration_test"
class AccessoryTest < IntegrationTest class AccessoryTest < IntegrationTest
test "boot, stop, start, restart, logs, remove" do test "boot, stop, start, restart, logs, remove" do
kamal :envify
kamal :accessory, :boot, :busybox kamal :accessory, :boot, :busybox
assert_accessory_running :busybox assert_accessory_running :busybox
@@ -21,8 +19,6 @@ class AccessoryTest < IntegrationTest
kamal :accessory, :remove, :busybox, "-y" kamal :accessory, :remove, :busybox, "-y"
assert_accessory_not_running :busybox assert_accessory_not_running :busybox
kamal :env, :delete
end end
private private

View File

@@ -2,8 +2,6 @@ require_relative "integration_test"
class AppTest < IntegrationTest class AppTest < IntegrationTest
test "stop, start, boot, logs, images, containers, exec, remove" do test "stop, start, boot, logs, images, containers, exec, remove" do
kamal :envify
kamal :deploy kamal :deploy
assert_app_is_up assert_app_is_up

View File

@@ -4,8 +4,6 @@ class BrokenDeployTest < IntegrationTest
test "deploying a bad image" do test "deploying a bad image" do
@app = "app_with_roles" @app = "app_with_roles"
kamal :envify
first_version = latest_app_version first_version = latest_app_version
kamal :deploy kamal :deploy

View File

@@ -29,6 +29,8 @@ services:
context: docker/registry context: docker/registry
environment: environment:
- REGISTRY_HTTP_ADDR=0.0.0.0:4443 - REGISTRY_HTTP_ADDR=0.0.0.0:4443
- REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt
- REGISTRY_HTTP_TLS_KEY=/certs/domain.key
volumes: volumes:
- shared:/shared - shared:/shared
- registry:/var/lib/registry/ - registry:/var/lib/registry/

View File

@@ -22,11 +22,12 @@ COPY app_with_roles/ app_with_roles/
RUN rm -rf /root/.ssh RUN rm -rf /root/.ssh
RUN ln -s /shared/ssh /root/.ssh RUN ln -s /shared/ssh /root/.ssh
RUN mkdir -p /etc/docker/certs.d/registry:4443 && ln -s /shared/certs/domain.crt /etc/docker/certs.d/registry:4443/ca.crt
RUN git config --global user.email "deployer@example.com" RUN git config --global user.email "deployer@example.com"
RUN git config --global user.name "Deployer" RUN git config --global user.name "Deployer"
RUN cd app && git init && echo ".env" >> .gitignore && git add . && git commit -am "Initial version" RUN cd app && git init && git add . && git commit -am "Initial version"
RUN cd app_with_roles && git init && echo ".env" >> .gitignore && git add . && git commit -am "Initial version" RUN cd app_with_roles && git init && git add . && git commit -am "Initial version"
HEALTHCHECK --interval=1s CMD pgrep sleep HEALTHCHECK --interval=1s CMD pgrep sleep

View File

@@ -0,0 +1,4 @@
SECRETS=$(kamal secrets fetch --adapter test --account test INTERPOLATED_SECRET1 INTERPOLATED_SECRET2 INTERPOLATED_中文)
INTERPOLATED_SECRET1=$(kamal secrets extract INTERPOLATED_SECRET1 ${SECRETS})
INTERPOLATED_SECRET2=$(kamal secrets extract INTERPOLATED_SECRET2 ${SECRETS})
INTERPOLATED_SECRET3=$(kamal secrets extract INTERPOLATED_中文 ${SECRETS})

View File

@@ -10,6 +10,9 @@ env:
HOST_TOKEN: "${HOST_TOKEN}" HOST_TOKEN: "${HOST_TOKEN}"
secret: secret:
- SECRET_TOKEN - SECRET_TOKEN
- INTERPOLATED_SECRET1
- INTERPOLATED_SECRET2
- INTERPOLATED_SECRET3
tags: tags:
tag1: tag1:
CLEAR_TAG: tagged CLEAR_TAG: tagged

View File

@@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
dockerd --max-concurrent-downloads 1 --insecure-registry registry:4443 & dockerd --max-concurrent-downloads 1 &
exec sleep infinity exec sleep infinity

View File

@@ -1,3 +1,5 @@
#!/bin/sh #!/bin/sh
while [ ! -f /certs/domain.crt ]; do sleep 1; done
exec /entrypoint.sh /etc/docker/registry/config.yml exec /entrypoint.sh /etc/docker/registry/config.yml

View File

@@ -10,6 +10,8 @@ RUN mkdir ssh && \
COPY registry-dns.conf . COPY registry-dns.conf .
COPY boot.sh . COPY boot.sh .
RUN mkdir certs && openssl req -newkey rsa:4096 -nodes -sha256 -keyout certs/domain.key -x509 -days 365 -out certs/domain.crt -subj '/CN=registry' -extensions EXT -config registry-dns.conf
HEALTHCHECK --interval=1s CMD pgrep sleep HEALTHCHECK --interval=1s CMD pgrep sleep
CMD ["./boot.sh"] CMD ["./boot.sh"]

View File

@@ -5,6 +5,7 @@ WORKDIR /work
RUN apt-get update --fix-missing && apt-get -y install openssh-client openssh-server docker.io RUN apt-get update --fix-missing && apt-get -y install openssh-client openssh-server docker.io
RUN mkdir /root/.ssh && ln -s /shared/ssh/id_rsa.pub /root/.ssh/authorized_keys RUN mkdir /root/.ssh && ln -s /shared/ssh/id_rsa.pub /root/.ssh/authorized_keys
RUN mkdir -p /etc/docker/certs.d/registry:4443 && ln -s /shared/certs/domain.crt /etc/docker/certs.d/registry:4443/ca.crt
RUN echo "HOST_TOKEN=abcd" >> /etc/environment RUN echo "HOST_TOKEN=abcd" >> /etc/environment

View File

@@ -4,6 +4,6 @@ while [ ! -f /root/.ssh/authorized_keys ]; do echo "Waiting for ssh keys"; sleep
service ssh restart service ssh restart
dockerd --max-concurrent-downloads 1 --insecure-registry registry:4443 & dockerd --max-concurrent-downloads 1 &
exec sleep infinity exec sleep infinity

View File

@@ -2,8 +2,6 @@ require_relative "integration_test"
class LockTest < IntegrationTest class LockTest < IntegrationTest
test "acquire, release, status" do test "acquire, release, status" do
kamal :envify
kamal :lock, :acquire, "-m 'Integration Tests'" kamal :lock, :acquire, "-m 'Integration Tests'"
status = kamal :lock, :status, capture: true status = kamal :lock, :status, capture: true

View File

@@ -1,11 +1,7 @@
require_relative "integration_test" require_relative "integration_test"
class MainTest < IntegrationTest class MainTest < IntegrationTest
test "envify, deploy, redeploy, rollback, details and audit" do test "deploy, redeploy, rollback, details and audit" do
kamal :envify
assert_env_files
remove_local_env_file
first_version = latest_app_version first_version = latest_app_version
assert_app_is_down assert_app_is_down
@@ -37,16 +33,11 @@ class MainTest < IntegrationTest
audit = kamal :audit, capture: true audit = kamal :audit, capture: true
assert_match /Booted app version #{first_version}.*Booted app version #{second_version}.*Booted app version #{first_version}.*/m, audit assert_match /Booted app version #{first_version}.*Booted app version #{second_version}.*Booted app version #{first_version}.*/m, audit
kamal :env, :delete
assert_no_remote_env_file
end end
test "app with roles" do test "app with roles" do
@app = "app_with_roles" @app = "app_with_roles"
kamal :envify
version = latest_app_version version = latest_app_version
assert_app_is_down assert_app_is_down
@@ -77,7 +68,7 @@ class MainTest < IntegrationTest
assert_equal "app-#{version}", config[:service_with_version] assert_equal "app-#{version}", config[:service_with_version]
assert_equal [], config[:volume_args] assert_equal [], config[:volume_args]
assert_equal({ user: "root", port: 22, keepalive: true, keepalive_interval: 30, log_level: :fatal }, config[:ssh_options]) assert_equal({ user: "root", port: 22, keepalive: true, keepalive_interval: 30, log_level: :fatal }, config[:ssh_options])
assert_equal({ "driver" => "docker", "arch" => "amd64", "args" => { "COMMIT_SHA" => version } }, config[:builder]) assert_equal({ "driver" => "docker", "arch" => "#{Kamal::Utils.docker_arch}", "args" => { "COMMIT_SHA" => version } }, config[:builder])
assert_equal [ "--log-opt", "max-size=\"10m\"" ], config[:logging] assert_equal [ "--log-opt", "max-size=\"10m\"" ], config[:logging]
assert_equal({ "cmd"=>"wget -qO- http://localhost > /dev/null || exit 1", "interval"=>"1s", "max_attempts"=>3, "port"=>3000, "path"=>"/up", "cord"=>"/tmp/kamal-cord", "log_lines"=>50 }, config[:healthcheck]) assert_equal({ "cmd"=>"wget -qO- http://localhost > /dev/null || exit 1", "interval"=>"1s", "max_attempts"=>3, "port"=>3000, "path"=>"/up", "cord"=>"/tmp/kamal-cord", "log_lines"=>50 }, config[:healthcheck])
end end
@@ -103,7 +94,6 @@ class MainTest < IntegrationTest
kamal :remove, "-y" kamal :remove, "-y"
assert_no_images_or_containers assert_no_images_or_containers
kamal :envify
kamal :setup kamal :setup
assert_images_and_containers assert_images_and_containers
@@ -112,18 +102,17 @@ class MainTest < IntegrationTest
end end
private private
def assert_local_env_file(contents)
assert_equal contents, deployer_exec("cat .env", capture: true)
end
def assert_envs(version:) def assert_envs(version:)
assert_env :CLEAR_TOKEN, "4321", version: version, vm: :vm1 assert_env :CLEAR_TOKEN, "4321", version: version, vm: :vm1
assert_env :HOST_TOKEN, "abcd", version: version, vm: :vm1 assert_env :HOST_TOKEN, "abcd", version: version, vm: :vm1
assert_env :SECRET_TOKEN, "1234 with \"中文\"", version: version, vm: :vm1 assert_env :SECRET_TOKEN, "1234 with \"中文\"", version: version, vm: :vm1
assert_no_env :CLEAR_TAG, version: version, vm: :vm1 assert_no_env :CLEAR_TAG, version: version, vm: :vm1
assert_no_env :SECRET_TAG, version: version, vm: :vm11 assert_no_env :SECRET_TAG, version: version, vm: :vm1
assert_env :CLEAR_TAG, "tagged", version: version, vm: :vm2 assert_env :CLEAR_TAG, "tagged", version: version, vm: :vm2
assert_env :SECRET_TAG, "TAGME", version: version, vm: :vm2 assert_env :SECRET_TAG, "TAGME", version: version, vm: :vm2
assert_env :INTERPOLATED_SECRET1, "1TERCES_DETALOPRETNI", version: version, vm: :vm2
assert_env :INTERPOLATED_SECRET2, "2TERCES_DETALOPRETNI", version: version, vm: :vm2
assert_env :INTERPOLATED_SECRET3, "文中_DETALOPRETNI", version: version, vm: :vm2
end end
def assert_env(key, value, vm:, version:) def assert_env(key, value, vm:, version:)
@@ -136,24 +125,6 @@ class MainTest < IntegrationTest
end end
end end
def assert_env_files
assert_local_env_file "SECRET_TOKEN='1234 with \"中文\"'\nSECRET_TAG='TAGME'"
assert_remote_env_file "SECRET_TOKEN=1234 with \"中文\"", vm: :vm1
assert_remote_env_file "SECRET_TOKEN=1234 with \"中文\"\nSECRET_TAG=TAGME", vm: :vm2
end
def remove_local_env_file
deployer_exec("rm .env")
end
def assert_remote_env_file(contents, vm:)
assert_equal contents, docker_compose("exec #{vm} cat /root/.kamal/env/roles/app-web.env", capture: true)
end
def assert_no_remote_env_file
assert_equal "nofile", docker_compose("exec vm1 stat /root/.kamal/env/roles/app-web.env 2> /dev/null || echo nofile", capture: true)
end
def assert_accumulated_assets(*versions) def assert_accumulated_assets(*versions)
versions.each do |version| versions.each do |version|
assert_equal "200", Net::HTTP.get_response(URI.parse("http://localhost:12345/versions/#{version}")).code assert_equal "200", Net::HTTP.get_response(URI.parse("http://localhost:12345/versions/#{version}")).code

View File

@@ -2,8 +2,6 @@ require_relative "integration_test"
class TraefikTest < IntegrationTest class TraefikTest < IntegrationTest
test "boot, reboot, stop, start, restart, logs, remove" do test "boot, reboot, stop, start, restart, logs, remove" do
kamal :envify
kamal :traefik, :boot kamal :traefik, :boot
assert_traefik_running assert_traefik_running
@@ -46,8 +44,6 @@ class TraefikTest < IntegrationTest
kamal :traefik, :remove kamal :traefik, :remove
assert_traefik_not_running assert_traefik_not_running
kamal :env, :delete
end end
private private

View File

@@ -0,0 +1,211 @@
require "test_helper"
class BitwardenAdapterTest < SecretAdapterTestCase
test "fetch" do
stub_unlocked
stub_ticks.with("bw sync").returns("")
stub_mypassword
json = JSON.parse(shellunescape(run_command("fetch", "mypassword")))
expected_json = { "mypassword"=>"secret123" }
assert_equal expected_json, json
end
test "fetch with from" do
stub_unlocked
stub_ticks.with("bw sync").returns("")
stub_myitem
json = JSON.parse(shellunescape(run_command("fetch", "--from", "myitem", "field1", "field2", "field3")))
expected_json = {
"myitem/field1"=>"secret1", "myitem/field2"=>"blam", "myitem/field3"=>"fewgrwjgk"
}
assert_equal expected_json, json
end
test "fetch with multiple items" do
stub_unlocked
stub_ticks.with("bw sync").returns("")
stub_mypassword
stub_myitem
stub_ticks
.with("bw get item myitem2")
.returns(<<~JSON)
{
"passwordHistory":null,
"revisionDate":"2024-08-29T13:46:53.343Z",
"creationDate":"2024-08-29T12:02:31.156Z",
"deletedDate":null,
"object":"item",
"id":"aaaaaaaa-cccc-eeee-0000-222222222222",
"organizationId":null,
"folderId":null,
"type":1,
"reprompt":0,
"name":"myitem2",
"notes":null,
"favorite":false,
"fields":[
{"name":"field3","value":"fewgrwjgk","type":1,"linkedId":null}
],
"login":{"fido2Credentials":[],"uris":[],"username":null,"password":null,"totp":null,"passwordRevisionDate":null},"collectionIds":[]
}
JSON
json = JSON.parse(shellunescape(run_command("fetch", "mypassword", "myitem/field1", "myitem/field2", "myitem2/field3")))
expected_json = {
"mypassword"=>"secret123", "myitem/field1"=>"secret1", "myitem/field2"=>"blam", "myitem2/field3"=>"fewgrwjgk"
}
assert_equal expected_json, json
end
test "fetch unauthenticated" do
stub_ticks
.with("bw status")
.returns(
'{"serverUrl":null,"lastSync":null,"status":"unauthenticated"}',
'{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"locked"}',
'{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"unlocked"}'
)
stub_ticks.with("bw login email@example.com").returns("1234567890")
stub_ticks.with("bw unlock --raw").returns("")
stub_ticks.with("bw sync").returns("")
stub_mypassword
json = JSON.parse(shellunescape(run_command("fetch", "mypassword")))
expected_json = { "mypassword"=>"secret123" }
assert_equal expected_json, json
end
test "fetch locked" do
stub_ticks
.with("bw status")
.returns(
'{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"locked"}'
)
stub_ticks
.with("bw status")
.returns(
'{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"unlocked"}'
)
stub_ticks.with("bw login email@example.com").returns("1234567890")
stub_ticks.with("bw unlock --raw").returns("")
stub_ticks.with("bw sync").returns("")
stub_mypassword
json = JSON.parse(shellunescape(run_command("fetch", "mypassword")))
expected_json = { "mypassword"=>"secret123" }
assert_equal expected_json, json
end
test "fetch locked with session" do
stub_ticks
.with("bw status")
.returns(
'{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"locked"}'
)
stub_ticks
.with("BW_SESSION=0987654321 bw status")
.returns(
'{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"unlocked"}'
)
stub_ticks.with("bw login email@example.com").returns("1234567890")
stub_ticks.with("bw unlock --raw").returns("0987654321")
stub_ticks.with("BW_SESSION=0987654321 bw sync").returns("")
stub_mypassword(session: "0987654321")
json = JSON.parse(shellunescape(run_command("fetch", "mypassword")))
expected_json = { "mypassword"=>"secret123" }
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", "bitwarden",
"--account", "email@example.com" ]
end
end
def stub_unlocked
stub_ticks
.with("bw status")
.returns(<<~JSON)
{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"unlocked"}
JSON
end
def stub_mypassword(session: nil)
stub_ticks
.with("#{"BW_SESSION=#{session} " if session}bw get item mypassword")
.returns(<<~JSON)
{
"passwordHistory":null,
"revisionDate":"2024-08-29T13:46:53.343Z",
"creationDate":"2024-08-29T12:02:31.156Z",
"deletedDate":null,
"object":"item",
"id":"aaaaaaaa-cccc-eeee-0000-222222222222",
"organizationId":null,
"folderId":null,
"type":1,
"reprompt":0,
"name":"mypassword",
"notes":null,
"favorite":false,
"login":{"fido2Credentials":[],"uris":[],"username":null,"password":"secret123","totp":null,"passwordRevisionDate":null},"collectionIds":[]
}
JSON
end
def stub_myitem
stub_ticks
.with("bw get item myitem")
.returns(<<~JSON)
{
"passwordHistory":null,
"revisionDate":"2024-08-29T13:46:53.343Z",
"creationDate":"2024-08-29T12:02:31.156Z",
"deletedDate":null,
"object":"item",
"id":"aaaaaaaa-cccc-eeee-0000-222222222222",
"organizationId":null,
"folderId":null,
"type":1,
"reprompt":0,
"name":"myitem",
"notes":null,
"favorite":false,
"fields":[
{"name":"field1","value":"secret1","type":1,"linkedId":null},
{"name":"field2","value":"blam","type":1,"linkedId":null},
{"name":"field3","value":"fewgrwjgk","type":1,"linkedId":null}
],
"login":{"fido2Credentials":[],"uris":[],"username":null,"password":null,"totp":null,"passwordRevisionDate":null},"collectionIds":[]
}
JSON
end
end

View File

@@ -0,0 +1,15 @@
require "test_helper"
class SecretsInlineCommandSubstitution < SecretAdapterTestCase
test "inlines kamal secrets commands" do
Kamal::Cli::Main.expects(:start).with { |command| command == [ "secrets", "fetch", "...", "--inline" ] }.returns("results")
substituted = Kamal::Secrets::Dotenv::InlineCommandSubstitution.call("FOO=$(kamal secrets fetch ...)", nil, overwrite: false)
assert_equal "FOO=results", substituted
end
test "executes other commands" do
Kamal::Secrets::Dotenv::InlineCommandSubstitution.stubs(:`).with("blah").returns("results")
substituted = Kamal::Secrets::Dotenv::InlineCommandSubstitution.call("FOO=$(blah)", nil, overwrite: false)
assert_equal "FOO=results", substituted
end
end

View File

@@ -0,0 +1,152 @@
require "test_helper"
class LastPassAdapterTest < SecretAdapterTestCase
setup do
`true` # Ensure $? is 0
end
test "fetch" do
stub_ticks.with("lpass status --color never").returns("Logged in as email@example.com.")
stub_ticks
.with("lpass show SECRET1 FOLDER1/FSECRET1 FOLDER1/FSECRET2 --json")
.returns(<<~JSON)
[
{
"id": "1234567891234567891",
"name": "SECRET1",
"fullname": "SECRET1",
"username": "",
"password": "secret1",
"last_modified_gmt": "1724926054",
"last_touch": "1724926639",
"group": "",
"url": "",
"note": ""
},
{
"id": "1234567891234567892",
"name": "FSECRET1",
"fullname": "FOLDER1/FSECRET1",
"username": "",
"password": "fsecret1",
"last_modified_gmt": "1724926084",
"last_touch": "1724926635",
"group": "Folder",
"url": "",
"note": ""
},
{
"id": "1234567891234567893",
"name": "FSECRET2",
"fullname": "FOLDER1/FSECRET2",
"username": "",
"password": "fsecret2",
"last_modified_gmt": "1724926084",
"last_touch": "1724926635",
"group": "Folder",
"url": "",
"note": ""
}
]
JSON
json = JSON.parse(shellunescape(run_command("fetch", "SECRET1", "FOLDER1/FSECRET1", "FOLDER1/FSECRET2")))
expected_json = {
"SECRET1"=>"secret1",
"FOLDER1/FSECRET1"=>"fsecret1",
"FOLDER1/FSECRET2"=>"fsecret2"
}
assert_equal expected_json, json
end
test "fetch with from" do
stub_ticks.with("lpass status --color never").returns("Logged in as email@example.com.")
stub_ticks
.with("lpass show FOLDER1/FSECRET1 FOLDER1/FSECRET2 --json")
.returns(<<~JSON)
[
{
"id": "1234567891234567892",
"name": "FSECRET1",
"fullname": "FOLDER1/FSECRET1",
"username": "",
"password": "fsecret1",
"last_modified_gmt": "1724926084",
"last_touch": "1724926635",
"group": "Folder",
"url": "",
"note": ""
},
{
"id": "1234567891234567893",
"name": "FSECRET2",
"fullname": "FOLDER1/FSECRET2",
"username": "",
"password": "fsecret2",
"last_modified_gmt": "1724926084",
"last_touch": "1724926635",
"group": "Folder",
"url": "",
"note": ""
}
]
JSON
json = JSON.parse(shellunescape(run_command("fetch", "--from", "FOLDER1", "FSECRET1", "FSECRET2")))
expected_json = {
"FOLDER1/FSECRET1"=>"fsecret1",
"FOLDER1/FSECRET2"=>"fsecret2"
}
assert_equal expected_json, json
end
test "fetch with signin" do
stub_ticks_with("lpass status --color never", succeed: false).returns("Not logged in.")
stub_ticks_with("lpass login email@example.com", succeed: true).returns("")
stub_ticks.with("lpass show SECRET1 --json").returns(single_item_json)
json = JSON.parse(shellunescape(run_command("fetch", "SECRET1")))
expected_json = {
"SECRET1"=>"secret1"
}
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", "lastpass",
"--account", "email@example.com" ]
end
end
def single_item_json
<<~JSON
[
{
"id": "1234567891234567891",
"name": "SECRET1",
"fullname": "SECRET1",
"username": "",
"password": "secret1",
"last_modified_gmt": "1724926054",
"last_touch": "1724926639",
"group": "",
"url": "",
"note": ""
}
]
JSON
end
end

View File

@@ -0,0 +1,177 @@
require "test_helper"
class SecretsOnePasswordAdapterTest < SecretAdapterTestCase
test "fetch" do
stub_ticks.with("op account get --account myaccount 2> /dev/null")
stub_ticks
.with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1,label=section.SECRET2,label=section2.SECRET3\" --format \"json\" --account \"myaccount\"")
.returns(<<~JSON)
[
{
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaa",
"section": {
"id": "cccccccccccccccccccccccccc",
"label": "section"
},
"type": "CONCEALED",
"label": "SECRET1",
"value": "VALUE1",
"reference": "op://myvault/myitem/section/SECRET1"
},
{
"id": "bbbbbbbbbbbbbbbbbbbbbbbbbb",
"section": {
"id": "dddddddddddddddddddddddddd",
"label": "section"
},
"type": "CONCEALED",
"label": "SECRET2",
"value": "VALUE2",
"reference": "op://myvault/myitem/section/SECRET2"
},
{
"id": "bbbbbbbbbbbbbbbbbbbbbbbbbb",
"section": {
"id": "dddddddddddddddddddddddddd",
"label": "section2"
},
"type": "CONCEALED",
"label": "SECRET3",
"value": "VALUE3",
"reference": "op://myvault/myitem/section2/SECRET3"
}
]
JSON
json = JSON.parse(shellunescape(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1", "section/SECRET2", "section2/SECRET3")))
expected_json = {
"myvault/myitem/section/SECRET1"=>"VALUE1",
"myvault/myitem/section/SECRET2"=>"VALUE2",
"myvault/myitem/section2/SECRET3"=>"VALUE3"
}
assert_equal expected_json, json
end
test "fetch with multiple items" do
stub_ticks.with("op account get --account myaccount 2> /dev/null")
stub_ticks
.with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1,label=section.SECRET2\" --format \"json\" --account \"myaccount\"")
.returns(<<~JSON)
[
{
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaa",
"section": {
"id": "cccccccccccccccccccccccccc",
"label": "section"
},
"type": "CONCEALED",
"label": "SECRET1",
"value": "VALUE1",
"reference": "op://myvault/myitem/section/SECRET1"
},
{
"id": "bbbbbbbbbbbbbbbbbbbbbbbbbb",
"section": {
"id": "dddddddddddddddddddddddddd",
"label": "section"
},
"type": "CONCEALED",
"label": "SECRET2",
"value": "VALUE2",
"reference": "op://myvault/myitem/section/SECRET2"
}
]
JSON
stub_ticks
.with("op item get myitem2 --vault \"myvault\" --fields \"label=section2.SECRET3\" --format \"json\" --account \"myaccount\"")
.returns(<<~JSON)
{
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaa",
"section": {
"id": "cccccccccccccccccccccccccc",
"label": "section"
},
"type": "CONCEALED",
"label": "SECRET3",
"value": "VALUE3",
"reference": "op://myvault/myitem2/section/SECRET3"
}
JSON
json = JSON.parse(shellunescape(run_command("fetch", "--from", "op://myvault", "myitem/section/SECRET1", "myitem/section/SECRET2", "myitem2/section2/SECRET3")))
expected_json = {
"myvault/myitem/section/SECRET1"=>"VALUE1",
"myvault/myitem/section/SECRET2"=>"VALUE2",
"myvault/myitem2/section/SECRET3"=>"VALUE3"
}
assert_equal expected_json, json
end
test "fetch with signin, no session" do
stub_ticks_with("op account get --account myaccount 2> /dev/null", succeed: false)
stub_ticks_with("op signin --account \"myaccount\" --force --raw", succeed: true).returns("")
stub_ticks
.with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1\" --format \"json\" --account \"myaccount\"")
.returns(single_item_json)
json = JSON.parse(shellunescape(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1")))
expected_json = {
"myvault/myitem/section/SECRET1"=>"VALUE1"
}
assert_equal expected_json, json
end
test "fetch with signin and session" do
stub_ticks_with("op account get --account myaccount 2> /dev/null", succeed: false)
stub_ticks_with("op signin --account \"myaccount\" --force --raw", succeed: true).returns("1234567890")
stub_ticks
.with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1\" --format \"json\" --account \"myaccount\" --session \"1234567890\"")
.returns(single_item_json)
json = JSON.parse(shellunescape(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1")))
expected_json = {
"myvault/myitem/section/SECRET1"=>"VALUE1"
}
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", "1password",
"--account", "myaccount" ]
end
end
def single_item_json
<<~JSON
{
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaa",
"section": {
"id": "cccccccccccccccccccccccccc",
"label": "section"
},
"type": "CONCEALED",
"label": "SECRET1",
"value": "VALUE1",
"reference": "op://myvault/myitem/section/SECRET1"
}
JSON
end
end

34
test/secrets_test.rb Normal file
View File

@@ -0,0 +1,34 @@
require "test_helper"
class SecretsTest < ActiveSupport::TestCase
test "fetch" do
with_test_secrets("secrets" => "SECRET=ABC") do
assert_equal "ABC", Kamal::Secrets.new["SECRET"]
end
end
test "command interpolation" do
with_test_secrets("secrets" => "SECRET=$(echo ABC)") do
assert_equal "ABC", Kamal::Secrets.new["SECRET"]
end
end
test "variable references" do
with_test_secrets("secrets" => "SECRET1=ABC\nSECRET2=${SECRET1}DEF") do
assert_equal "ABC", Kamal::Secrets.new["SECRET1"]
assert_equal "ABCDEF", Kamal::Secrets.new["SECRET2"]
end
end
test "destinations" 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 "DEF", Kamal::Secrets.new(destination: "dest")["SECRET"]
assert_equal "GHI", Kamal::Secrets.new(destination: "nodest")["SECRET"]
assert_equal "JKL", Kamal::Secrets.new["SECRET2"]
assert_equal "JKL", Kamal::Secrets.new(destination: "dest")["SECRET2"]
assert_equal "JKL", Kamal::Secrets.new(destination: "nodest")["SECRET2"]
end
end
end

View File

@@ -34,4 +34,53 @@ class ActiveSupport::TestCase
def stderred def stderred
capture(:stderr) { yield }.strip capture(:stderr) { yield }.strip
end end
def with_test_secrets(**files)
setup_test_secrets(**files)
yield
ensure
teardown_test_secrets
end
def setup_test_secrets(**files)
@original_pwd = Dir.pwd
@secrets_tmpdir = Dir.mktmpdir
fixtures_dup = File.join(@secrets_tmpdir, "test")
FileUtils.mkdir_p(fixtures_dup)
FileUtils.cp_r("test/fixtures/", fixtures_dup)
Dir.chdir(@secrets_tmpdir)
FileUtils.mkdir_p(".kamal")
Dir.chdir(".kamal") do
files.each do |filename, contents|
File.binwrite(filename.to_s, contents)
end
end
end
def teardown_test_secrets
Dir.chdir(@original_pwd)
FileUtils.rm_rf(@secrets_tmpdir)
end
end
class SecretAdapterTestCase < ActiveSupport::TestCase
setup do
`true` # Ensure $? is 0
end
private
def stub_ticks
Kamal::Secrets::Adapters::Base.any_instance.stubs(:`)
end
def stub_ticks_with(command, succeed: true)
# Sneakily run `false`/`true` after a match to set $? to 1/0
stub_ticks.with { |c| c == command && (succeed ? `true` : `false`) }
Kamal::Secrets::Adapters::Base.any_instance.stubs(:`)
end
def shellunescape(string)
"\"#{string}\"".undump.gsub(/\\([{}])/, "\\1")
end
end end