From 5c4c33e0a842d909d9e8e5d9635e1fb46eb13d82 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 31 Jul 2024 10:06:33 +0100 Subject: [PATCH 01/40] Replace .env* with .kamal/env* By default look for the env file in .kamal/env to avoid clashes with other tools using .env. For now we'll still load .env and issue a deprecation warning, but in future we'll stop reading those. --- lib/kamal/cli/base.rb | 17 ++++++++-- lib/kamal/cli/main.rb | 22 ++++++++++--- test/cli/main_test.rb | 31 ++++++++++--------- .../deployer/app/{.env.erb => .kamal/env.erb} | 0 .../{.env.erb => .kamal/env.erb} | 0 test/integration/main_test.rb | 4 +-- 6 files changed, 52 insertions(+), 22 deletions(-) rename test/integration/docker/deployer/app/{.env.erb => .kamal/env.erb} (100%) rename test/integration/docker/deployer/app_with_roles/{.env.erb => .kamal/env.erb} (100%) diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index 8032a4cb6..16a8c9157 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -44,9 +44,22 @@ def reload_env def load_env if destination = options[:destination] - Dotenv.load(".env.#{destination}", ".env") + if File.exist?(".kamal/env.#{destination}") || File.exist?(".kamal/env") + Dotenv.load(".kamal/env.#{destination}", ".kamal/env") + else + loading_files = [ (".env" if File.exist?(".env")), (".env.#{destination}" if File.exist?(".env.#{destination}")) ].compact + if loading_files.any? + warn "Loading #{loading_files.join(" and ")} from the project root, use .kamal/env* instead" + Dotenv.load(".env.#{destination}", ".env") + end + end else - Dotenv.load(".env") + if File.exist?(".kamal/env") + Dotenv.load(".kamal/env") + elsif File.exist?(".env") + warn "Loading .env from the project root is deprecated, use .kamal/env instead" + Dotenv.load(".env") + end end end diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 598d6c422..08c0e714a 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -183,11 +183,25 @@ def init 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}" + env_template_path = ".kamal/env.#{destination}.erb" + env_path = ".kamal/env.#{destination}" else - env_template_path = ".env.erb" - env_path = ".env" + env_template_path = ".kamal/env.erb" + env_path = ".kamal/env" + end + + unless Pathname.new(File.expand_path(env_template_path)).exist? + 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? + warn "Loading #{env_template_path} from the project root is deprecated, use .kamal/env[.].erb instead" + end end if Pathname.new(File.expand_path(env_template_path)).exist? diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 2b87191c7..a93320b38 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -439,9 +439,9 @@ class CliMainTest < CliTestCase end test "envify" do - with_test_dotenv(".env.erb": "HELLO=<%= 'world' %>") do + with_test_env_files("env.erb": "HELLO=<%= 'world' %>") do run_command("envify") - assert_equal("HELLO=world", File.read(".env")) + assert_equal("HELLO=world", File.read(".kamal/env")) end end @@ -453,32 +453,32 @@ class CliMainTest < CliTestCase <% end -%> EOF - with_test_dotenv(".env.erb": file) do + with_test_env_files("env.erb": file) do run_command("envify") - assert_equal("HELLO=world\nKEY=value\n", File.read(".env")) + assert_equal("HELLO=world\nKEY=value\n", File.read(".kamal/env")) end end test "envify with destination" do - with_test_dotenv(".env.world.erb": "HELLO=<%= 'world' %>") do + with_test_env_files("env.world.erb": "HELLO=<%= 'world' %>") do run_command("envify", "-d", "world", config_file: "deploy_for_dest") - assert_equal "HELLO=world", File.read(".env.world") + assert_equal "HELLO=world", File.read(".kamal/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) + Pathname.any_instance.expects(:exist?).returns(true).times(2) + File.expects(:read).with(".kamal/env.erb").returns("HELLO=<%= 'world' %>") + File.expects(:write).with(".kamal/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 + with_test_env_files("env": "HELLO=already", "env.erb": "HELLO=<%= ENV.fetch 'HELLO', 'never' %>") do run_command("envify", "--skip-push") - assert_equal "HELLO=never", File.read(".env") + assert_equal "HELLO=never", File.read(".kamal/env") end end @@ -572,15 +572,18 @@ def run_command(*command, config_file: "deploy_simple") end end - def with_test_dotenv(**files) + def with_test_env_files(**files) Dir.mktmpdir do |dir| fixtures_dup = File.join(dir, "test") FileUtils.mkdir_p(fixtures_dup) FileUtils.cp_r("test/fixtures/", fixtures_dup) Dir.chdir(dir) do - files.each do |filename, contents| - File.binwrite(filename.to_s, contents) + FileUtils.mkdir_p(".kamal") + Dir.chdir(".kamal") do + files.each do |filename, contents| + File.binwrite(filename.to_s, contents) + end end yield end diff --git a/test/integration/docker/deployer/app/.env.erb b/test/integration/docker/deployer/app/.kamal/env.erb similarity index 100% rename from test/integration/docker/deployer/app/.env.erb rename to test/integration/docker/deployer/app/.kamal/env.erb diff --git a/test/integration/docker/deployer/app_with_roles/.env.erb b/test/integration/docker/deployer/app_with_roles/.kamal/env.erb similarity index 100% rename from test/integration/docker/deployer/app_with_roles/.env.erb rename to test/integration/docker/deployer/app_with_roles/.kamal/env.erb diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index a1e814936..5525faa59 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -113,7 +113,7 @@ class MainTest < IntegrationTest private def assert_local_env_file(contents) - assert_equal contents, deployer_exec("cat .env", capture: true) + assert_equal contents, deployer_exec("cat .kamal/env", capture: true) end def assert_envs(version:) @@ -143,7 +143,7 @@ def assert_env_files end def remove_local_env_file - deployer_exec("rm .env") + deployer_exec("rm .kamal/env") end def assert_remote_env_file(contents, vm:) From 6a06efc9d9aaa8e4a457716e7c25f3f6798233d2 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 5 Aug 2024 08:48:38 +0100 Subject: [PATCH 02/40] Strip out env loading, envify, env push --- lib/kamal/cli/base.rb | 48 ++--------------------- lib/kamal/cli/env.rb | 54 -------------------------- lib/kamal/cli/main.rb | 43 -------------------- test/cli/main_test.rb | 47 ---------------------- test/integration/accessory_test.rb | 4 -- test/integration/app_test.rb | 2 - test/integration/broken_deploy_test.rb | 2 - test/integration/lock_test.rb | 2 - test/integration/main_test.rb | 17 +++----- test/integration/traefik_test.rb | 4 -- 10 files changed, 8 insertions(+), 215 deletions(-) delete mode 100644 lib/kamal/cli/env.rb diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index 16a8c9157..a583ebad5 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -32,60 +32,18 @@ def initialize(args = [], local_options = {}, config = {}) super end @original_env = ENV.to_h.dup - load_env initialize_commander(options_with_subcommand_class_options) end private - def reload_env - reset_env - load_env - end - - def load_env + def load_secrets if destination = options[:destination] - if File.exist?(".kamal/env.#{destination}") || File.exist?(".kamal/env") - Dotenv.load(".kamal/env.#{destination}", ".kamal/env") - else - loading_files = [ (".env" if File.exist?(".env")), (".env.#{destination}" if File.exist?(".env.#{destination}")) ].compact - if loading_files.any? - warn "Loading #{loading_files.join(" and ")} from the project root, use .kamal/env* instead" - Dotenv.load(".env.#{destination}", ".env") - end - end + Dotenv.parse(".kamal/secrets.#{destination}", ".kamal/secrets") else - if File.exist?(".kamal/env") - Dotenv.load(".kamal/env") - elsif File.exist?(".env") - warn "Loading .env from the project root is deprecated, use .kamal/env instead" - Dotenv.load(".env") - end - 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 + Dotenv.parse(".kamal/secrets") 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 options.merge(@_initializer.last[:class_options] || {}) end diff --git a/lib/kamal/cli/env.rb b/lib/kamal/cli/env.rb deleted file mode 100644 index f12174a7b..000000000 --- a/lib/kamal/cli/env.rb +++ /dev/null @@ -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 diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 08c0e714a..b72c1c8f2 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -9,10 +9,6 @@ def setup say "Ensure Docker is installed...", :magenta 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 deploy end @@ -179,45 +175,6 @@ def init 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 = ".kamal/env.#{destination}.erb" - env_path = ".kamal/env.#{destination}" - else - env_template_path = ".kamal/env.erb" - env_path = ".kamal/env" - end - - unless Pathname.new(File.expand_path(env_template_path)).exist? - 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? - warn "Loading #{env_template_path} from the project root is deprecated, use .kamal/env[.].erb instead" - end - 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" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" def remove diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index a93320b38..8272f67bf 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -8,8 +8,6 @@ class CliMainTest < CliTestCase 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: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(:deploy) @@ -24,7 +22,6 @@ class CliMainTest < CliTestCase 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) # deploy Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: true)) @@ -438,50 +435,6 @@ class CliMainTest < CliTestCase end end - test "envify" do - with_test_env_files("env.erb": "HELLO=<%= 'world' %>") do - run_command("envify") - assert_equal("HELLO=world", File.read(".kamal/env")) - end - end - - test "envify with blank line trimming" do - file = <<~EOF - HELLO=<%= 'world' %> - <% if true -%> - KEY=value - <% end -%> - EOF - - with_test_env_files("env.erb": file) do - run_command("envify") - assert_equal("HELLO=world\nKEY=value\n", File.read(".kamal/env")) - end - end - - test "envify with destination" do - with_test_env_files("env.world.erb": "HELLO=<%= 'world' %>") do - run_command("envify", "-d", "world", config_file: "deploy_for_dest") - assert_equal "HELLO=world", File.read(".kamal/env.world") - end - end - - test "envify with skip_push" do - Pathname.any_instance.expects(:exist?).returns(true).times(2) - File.expects(:read).with(".kamal/env.erb").returns("HELLO=<%= 'world' %>") - File.expects(:write).with(".kamal/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_env_files("env": "HELLO=already", "env.erb": "HELLO=<%= ENV.fetch 'HELLO', 'never' %>") do - run_command("envify", "--skip-push") - assert_equal "HELLO=never", File.read(".kamal/env") - end - end - test "remove with confirmation" do run_command("remove", "-y", config_file: "deploy_with_accessories").tap do |output| assert_match /docker container stop traefik/, output diff --git a/test/integration/accessory_test.rb b/test/integration/accessory_test.rb index 8c23703b3..bc2fb3788 100644 --- a/test/integration/accessory_test.rb +++ b/test/integration/accessory_test.rb @@ -2,8 +2,6 @@ class AccessoryTest < IntegrationTest test "boot, stop, start, restart, logs, remove" do - kamal :envify - kamal :accessory, :boot, :busybox assert_accessory_running :busybox @@ -21,8 +19,6 @@ class AccessoryTest < IntegrationTest kamal :accessory, :remove, :busybox, "-y" assert_accessory_not_running :busybox - - kamal :env, :delete end private diff --git a/test/integration/app_test.rb b/test/integration/app_test.rb index 9824ce0d2..b7dcdc343 100644 --- a/test/integration/app_test.rb +++ b/test/integration/app_test.rb @@ -2,8 +2,6 @@ class AppTest < IntegrationTest test "stop, start, boot, logs, images, containers, exec, remove" do - kamal :envify - kamal :deploy assert_app_is_up diff --git a/test/integration/broken_deploy_test.rb b/test/integration/broken_deploy_test.rb index 5ab24f554..77f0ff96a 100644 --- a/test/integration/broken_deploy_test.rb +++ b/test/integration/broken_deploy_test.rb @@ -4,8 +4,6 @@ class BrokenDeployTest < IntegrationTest test "deploying a bad image" do @app = "app_with_roles" - kamal :envify - first_version = latest_app_version kamal :deploy diff --git a/test/integration/lock_test.rb b/test/integration/lock_test.rb index db086251c..4a53ba519 100644 --- a/test/integration/lock_test.rb +++ b/test/integration/lock_test.rb @@ -2,8 +2,6 @@ class LockTest < IntegrationTest test "acquire, release, status" do - kamal :envify - kamal :lock, :acquire, "-m 'Integration Tests'" status = kamal :lock, :status, capture: true diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index 5525faa59..79e39a6b9 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -1,8 +1,7 @@ require_relative "integration_test" class MainTest < IntegrationTest - test "envify, deploy, redeploy, rollback, details and audit" do - kamal :envify + test "deploy, redeploy, rollback, details and audit" do assert_env_files remove_local_env_file @@ -37,16 +36,11 @@ class MainTest < IntegrationTest audit = kamal :audit, capture: true 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 test "app with roles" do @app = "app_with_roles" - kamal :envify - version = latest_app_version assert_app_is_down @@ -103,7 +97,6 @@ class MainTest < IntegrationTest kamal :remove, "-y" assert_no_images_or_containers - kamal :envify kamal :setup assert_images_and_containers @@ -113,7 +106,7 @@ class MainTest < IntegrationTest private def assert_local_env_file(contents) - assert_equal contents, deployer_exec("cat .kamal/env", capture: true) + assert_equal contents, deployer_exec("cat .kamal/secrets", capture: true) end def assert_envs(version:) @@ -143,15 +136,15 @@ def assert_env_files end def remove_local_env_file - deployer_exec("rm .kamal/env") + deployer_exec("rm .kamal/secrets") 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) + assert_equal contents, docker_compose("exec #{vm} cat /root/.kamal/secrets/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) + assert_equal "nofile", docker_compose("exec vm1 stat /root/.kamal/secrets/roles/app-web.env 2> /dev/null || echo nofile", capture: true) end def assert_accumulated_assets(*versions) diff --git a/test/integration/traefik_test.rb b/test/integration/traefik_test.rb index d2aa2a97c..48f9ea024 100644 --- a/test/integration/traefik_test.rb +++ b/test/integration/traefik_test.rb @@ -2,8 +2,6 @@ class TraefikTest < IntegrationTest test "boot, reboot, stop, start, restart, logs, remove" do - kamal :envify - kamal :traefik, :boot assert_traefik_running @@ -46,8 +44,6 @@ class TraefikTest < IntegrationTest kamal :traefik, :remove assert_traefik_not_running - - kamal :env, :delete end private From 56754fe40cff73aa62ce3c6d21b7b55ddd037986 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 5 Aug 2024 14:41:50 +0100 Subject: [PATCH 03/40] Lazily load secrets whenever needed --- lib/kamal.rb | 1 + lib/kamal/cli/app/boot.rb | 8 +- lib/kamal/cli/base.rb | 13 +- lib/kamal/cli/build.rb | 2 +- lib/kamal/cli/main.rb | 3 - lib/kamal/commander.rb | 4 + lib/kamal/commands/accessory.rb | 8 - lib/kamal/commands/app.rb | 10 - lib/kamal/commands/auditor.rb | 9 +- lib/kamal/commands/builder/base.rb | 2 +- lib/kamal/commands/traefik.rb | 8 - lib/kamal/configuration.rb | 8 +- lib/kamal/configuration/accessory.rb | 2 +- lib/kamal/configuration/builder.rb | 2 +- lib/kamal/configuration/env.rb | 36 ++- lib/kamal/configuration/env/tag.rb | 7 +- lib/kamal/configuration/registry.rb | 5 +- lib/kamal/configuration/role.rb | 2 +- lib/kamal/configuration/secrets.rb | 25 ++ lib/kamal/configuration/traefik.rb | 2 +- lib/kamal/env_file.rb | 38 --- lib/kamal/utils.rb | 6 + test/cli/accessory_test.rb | 24 +- test/cli/app_test.rb | 6 +- test/cli/build_test.rb | 4 +- test/cli/env_test.rb | 37 --- test/cli/main_test.rb | 21 -- test/cli/traefik_test.rb | 4 +- test/commands/accessory_test.rb | 26 +- test/commands/app_test.rb | 46 ++-- test/commands/auditor_test.rb | 4 + test/commands/builder_test.rb | 22 +- test/commands/registry_test.rb | 49 ++-- test/commands/traefik_test.rb | 52 ++-- test/configuration/accessory_test.rb | 23 +- test/configuration/builder_test.rb | 8 +- test/configuration/env/tags_test.rb | 26 +- test/configuration/env_test.rb | 64 ++--- test/configuration/role_test.rb | 248 +++++++++--------- .../deployer/app/.kamal/{env.erb => secrets} | 0 .../.kamal/{env.erb => secrets} | 0 test/integration/main_test.rb | 27 +- test/test_helper.rb | 28 ++ 43 files changed, 391 insertions(+), 529 deletions(-) create mode 100644 lib/kamal/configuration/secrets.rb delete mode 100644 lib/kamal/env_file.rb delete mode 100644 test/cli/env_test.rb rename test/integration/docker/deployer/app/.kamal/{env.erb => secrets} (100%) rename test/integration/docker/deployer/app_with_roles/.kamal/{env.erb => secrets} (100%) diff --git a/lib/kamal.rb b/lib/kamal.rb index 2da2bbf2c..b197408e0 100644 --- a/lib/kamal.rb +++ b/lib/kamal.rb @@ -5,6 +5,7 @@ class ConfigurationError < StandardError; end require "active_support" require "zeitwerk" require "yaml" +require "tmpdir" loader = Zeitwerk::Loader.for_gem loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb")) diff --git a/lib/kamal/cli/app/boot.rb b/lib/kamal/cli/app/boot.rb index b78763cee..d5b76d4eb 100644 --- a/lib/kamal/cli/app/boot.rb +++ b/lib/kamal/cli/app/boot.rb @@ -88,8 +88,12 @@ def wait_at_barrier def close_barrier if barrier.close info "First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting any other roles" - error capture_with_info(*app.logs(version: version)) - error capture_with_info(*app.container_health_log(version: version)) + begin + 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 diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index a583ebad5..d815560e8 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -31,24 +31,15 @@ def initialize(args = [], local_options = {}, config = {}) else super end - @original_env = ENV.to_h.dup - initialize_commander(options_with_subcommand_class_options) + initialize_commander unless KAMAL.configured? end private - def load_secrets - if destination = options[:destination] - Dotenv.parse(".kamal/secrets.#{destination}", ".kamal/secrets") - else - Dotenv.parse(".kamal/secrets") - end - end - def options_with_subcommand_class_options options.merge(@_initializer.last[:class_options] || {}) end - def initialize_commander(options) + def initialize_commander KAMAL.tap do |commander| if options[:verbose] ENV["VERBOSE"] = "1" # For backtraces via cli/start diff --git a/lib/kamal/cli/build.rb b/lib/kamal/cli/build.rb index 93e1efd9a..0347d18c8 100644 --- a/lib/kamal/cli/build.rb +++ b/lib/kamal/cli/build.rb @@ -51,7 +51,7 @@ def push push = KAMAL.builder.push 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 diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index b72c1c8f2..65222eb77 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -202,9 +202,6 @@ def version desc "build", "Build application image" subcommand "build", Kamal::Cli::Build - desc "env", "Manage environment files" - subcommand "env", Kamal::Cli::Env - desc "lock", "Manage the deploy lock" subcommand "lock", Kamal::Cli::Lock diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index ae98e0f88..ffe140c4c 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -23,6 +23,10 @@ def configure(**kwargs) @config, @config_kwargs = nil, kwargs end + def configured? + @config || @config_kwargs + end + attr_reader :specific_roles, :specific_hosts def specific_primary! diff --git a/lib/kamal/commands/accessory.rb b/lib/kamal/commands/accessory.rb index 23377ab51..d34377c7a 100644 --- a/lib/kamal/commands/accessory.rb +++ b/lib/kamal/commands/accessory.rb @@ -98,14 +98,6 @@ def remove_image docker :image, :rm, "--force", image end - def make_env_directory - make_directory accessory_config.env.secrets_directory - end - - def remove_env_file - [ :rm, "-f", accessory_config.env.secrets_file ] - end - private def service_filter [ "--filter", "label=service=#{service_name}" ] diff --git a/lib/kamal/commands/app.rb b/lib/kamal/commands/app.rb index 37fa86ab6..4fe8ead73 100644 --- a/lib/kamal/commands/app.rb +++ b/lib/kamal/commands/app.rb @@ -69,16 +69,6 @@ def list_versions(*docker_args, statuses: nil) extract_version_from_name end - - def make_env_directory - make_directory role.env(host).secrets_directory - end - - def remove_env_file - [ :rm, "-f", role.env(host).secrets_file ] - end - - private def container_name(version = nil) [ role.container_prefix, version || config.version ].compact.join("-") diff --git a/lib/kamal/commands/auditor.rb b/lib/kamal/commands/auditor.rb index f0c0850db..9846d8e2e 100644 --- a/lib/kamal/commands/auditor.rb +++ b/lib/kamal/commands/auditor.rb @@ -8,9 +8,12 @@ def initialize(config, **details) # Runs remotely def record(line, **details) - append \ - [ :echo, audit_tags(**details).except(:version, :service_version, :service).to_s, line ], - audit_log_file + combine \ + [ :mkdir, "-p", config.run_directory ], + append( + [ :echo, audit_tags(**details).except(:version, :service_version, :service).to_s, line ], + audit_log_file + ) end def reveal diff --git a/lib/kamal/commands/builder/base.rb b/lib/kamal/commands/builder/base.rb index 1e6f5be3d..636fe4f4b 100644 --- a/lib/kamal/commands/builder/base.rb +++ b/lib/kamal/commands/builder/base.rb @@ -78,7 +78,7 @@ def build_args end def build_secrets - argumentize "--secret", secrets.collect { |secret| [ "id", secret ] } + argumentize "--secret", secrets.keys.collect { |secret| [ "id", secret ] } end def build_dockerfile diff --git a/lib/kamal/commands/traefik.rb b/lib/kamal/commands/traefik.rb index 07e0e6eac..dd08ef508 100644 --- a/lib/kamal/commands/traefik.rb +++ b/lib/kamal/commands/traefik.rb @@ -54,14 +54,6 @@ def remove_image docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik" end - def make_env_directory - make_directory(env.secrets_directory) - end - - def remove_env_file - [ :rm, "-f", env.secrets_file ] - end - private def publish_args argumentize "--publish", port if publish? diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index af3754eb3..d19a77867 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -57,7 +57,7 @@ def initialize(raw_config, destination: nil, version: nil, validate: true) @aliases = @raw_config.aliases&.keys&.to_h { |name| [ name, Alias.new(name, config: self) ] } || {} @boot = Boot.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) @logging = Logging.new(logging_config: @raw_config.logging) @@ -224,7 +224,7 @@ def host_env_directory def 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 [] end @@ -254,6 +254,10 @@ def to_h }.compact end + def secrets + @secrets ||= Secrets.new(destination: destination) + end + private # Will raise ArgumentError if any required config keys are missing def ensure_destination_if_required diff --git a/lib/kamal/configuration/accessory.rb b/lib/kamal/configuration/accessory.rb index 07f40b564..5d69af7ab 100644 --- a/lib/kamal/configuration/accessory.rb +++ b/lib/kamal/configuration/accessory.rb @@ -16,7 +16,7 @@ def initialize(name, config:) @env = Kamal::Configuration::Env.new \ config: accessory_config.fetch("env", {}), - secrets_file: File.join(config.host_env_directory, "accessories", "#{service_name}.env"), + secrets: config.secrets, context: "accessories/#{name}/env" end diff --git a/lib/kamal/configuration/builder.rb b/lib/kamal/configuration/builder.rb index bad3b3862..a395e2283 100644 --- a/lib/kamal/configuration/builder.rb +++ b/lib/kamal/configuration/builder.rb @@ -62,7 +62,7 @@ def args end def secrets - builder_config["secrets"] || [] + (builder_config["secrets"] || []).to_h { |key| [ key, config.secrets[key] ] } end def dockerfile diff --git a/lib/kamal/configuration/env.rb b/lib/kamal/configuration/env.rb index 1c0fb1e93..d8f27ece0 100644 --- a/lib/kamal/configuration/env.rb +++ b/lib/kamal/configuration/env.rb @@ -1,36 +1,34 @@ class Kamal::Configuration::Env 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 - 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) - @secrets_keys = config.fetch("secret", []) - @secrets_file = secrets_file + @secrets = secrets + @secret_keys = config.fetch("secret", []) @context = context validate! config, context: context, with: Kamal::Configuration::Validator::Env end def args - [ "--env-file", secrets_file, *argumentize("--env", clear) ] - end - - def secrets_io - StringIO.new(Kamal::EnvFile.new(secrets).to_s) - end - - def secrets - @secrets ||= secrets_keys.to_h { |key| [ key, ENV.fetch(key) ] } - end - - def secrets_directory - File.dirname(secrets_file) + [ *clear_args, *secret_args ] end def merge(other) self.class.new \ - config: { "clear" => clear.merge(other.clear), "secret" => secrets_keys | other.secrets_keys }, - secrets_file: secrets_file || other.secrets_file + config: { "clear" => clear.merge(other.clear), "secret" => secret_keys | other.secret_keys }, + secrets: secrets end + + private + def clear_args + argumentize("--env", clear) + end + + def secret_args + argumentize("--env", secret_keys.to_h { |key| [ key, secrets[key] ] }, sensitive: true) + end end diff --git a/lib/kamal/configuration/env/tag.rb b/lib/kamal/configuration/env/tag.rb index c41512022..30160edc4 100644 --- a/lib/kamal/configuration/env/tag.rb +++ b/lib/kamal/configuration/env/tag.rb @@ -1,12 +1,13 @@ 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 @config = config + @secrets = secrets end def env - Kamal::Configuration::Env.new(config: config) + Kamal::Configuration::Env.new(config: config, secrets: secrets) end end diff --git a/lib/kamal/configuration/registry.rb b/lib/kamal/configuration/registry.rb index fa0ba04a8..763cf976a 100644 --- a/lib/kamal/configuration/registry.rb +++ b/lib/kamal/configuration/registry.rb @@ -1,10 +1,11 @@ class Kamal::Configuration::Registry include Kamal::Configuration::Validation - attr_reader :registry_config + attr_reader :registry_config, :secrets def initialize(config:) @registry_config = config.raw_config.registry || {} + @secrets = config.secrets validate! registry_config, with: Kamal::Configuration::Validator::Registry end @@ -23,7 +24,7 @@ def password private def lookup(key) if registry_config[key].is_a?(Array) - ENV.fetch(registry_config[key].first).dup + secrets[registry_config[key].first] else registry_config[key] end diff --git a/lib/kamal/configuration/role.rb b/lib/kamal/configuration/role.rb index e9e520a7b..60bee1a63 100644 --- a/lib/kamal/configuration/role.rb +++ b/lib/kamal/configuration/role.rb @@ -18,7 +18,7 @@ def initialize(name, config:) @specialized_env = Kamal::Configuration::Env.new \ config: specializations.fetch("env", {}), - secrets_file: File.join(config.host_env_directory, "roles", "#{container_prefix}.env"), + secrets: config.secrets, context: "servers/#{name}/env" @specialized_logging = Kamal::Configuration::Logging.new \ diff --git a/lib/kamal/configuration/secrets.rb b/lib/kamal/configuration/secrets.rb new file mode 100644 index 000000000..215bcaf28 --- /dev/null +++ b/lib/kamal/configuration/secrets.rb @@ -0,0 +1,25 @@ +class Kamal::Configuration::Secrets + attr_reader :secret_files + + def initialize(destination: nil) + @secret_files = \ + (destination ? [ ".kamal/secrets", ".kamal/secrets.#{destination}" ] : [ ".kamal/secrets" ]) + .select { |file| File.exist?(file) } + end + + def [](key) + @secrets ||= load + @secrets.fetch(key) + rescue KeyError + if secret_files.any? + raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secret_files.join(', ')}" + else + raise Kamal::ConfigurationError, "Secret '#{key}' not found, no secret files provided" + end + end + + private + def load + secret_files.any? ? Dotenv.parse(*secret_files) : {} + end +end diff --git a/lib/kamal/configuration/traefik.rb b/lib/kamal/configuration/traefik.rb index c958afdfd..e046a1e3c 100644 --- a/lib/kamal/configuration/traefik.rb +++ b/lib/kamal/configuration/traefik.rb @@ -34,7 +34,7 @@ def labels def env Kamal::Configuration::Env.new \ config: traefik_config.fetch("env", {}), - secrets_file: File.join(config.host_env_directory, "traefik", "traefik.env"), + secrets: config.secrets, context: "traefik/env" end diff --git a/lib/kamal/env_file.rb b/lib/kamal/env_file.rb deleted file mode 100644 index 2228be098..000000000 --- a/lib/kamal/env_file.rb +++ /dev/null @@ -1,38 +0,0 @@ -# Encode an env hash as a string where secret values have been looked up and all values escaped for Docker. -class Kamal::EnvFile - def initialize(env) - @env = env - end - - def to_s - env_file = StringIO.new.tap do |contents| - @env.each do |key, value| - contents << docker_env_file_line(key, value) - end - end.string - - # Ensure the file has some contents to avoid the SSHKIT empty file warning - env_file.presence || "\n" - end - - alias to_str to_s - - private - def docker_env_file_line(key, value) - "#{key}=#{escape_docker_env_file_value(value)}\n" - end - - # Escape a value to make it safe to dump in a docker file. - def escape_docker_env_file_value(value) - # keep non-ascii(UTF-8) characters as it is - value.to_s.scan(/[\x00-\x7F]+|[^\x00-\x7F]+/).map do |part| - part.ascii_only? ? escape_docker_env_file_ascii_value(part) : part - end.join - end - - def escape_docker_env_file_ascii_value(value) - # Doublequotes are treated literally in docker env files - # so remove leading and trailing ones and unescape any others - value.to_s.dump[1..-2].gsub(/\\"/, "\"") - end -end diff --git a/lib/kamal/utils.rb b/lib/kamal/utils.rb index 46736ba54..8c6c93213 100644 --- a/lib/kamal/utils.rb +++ b/lib/kamal/utils.rb @@ -54,6 +54,12 @@ def redacted(value) # Escape a value to make it safe for shell use. 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 .gsub(/`/, '\\\\`') .gsub(DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX, '\$') diff --git a/test/cli/accessory_test.rb b/test/cli/accessory_test.rb index e56eef2d9..9a130551c 100644 --- a/test/cli/accessory_test.rb +++ b/test/cli/accessory_test.rb @@ -1,13 +1,21 @@ require_relative "cli_test_case" class CliAccessoryTest < CliTestCase + setup do + setup_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret") + end + + teardown do + teardown_test_secrets + end + test "boot" do Kamal::Cli::Accessory.any_instance.expects(:directories).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:upload).with("mysql") run_command("boot", "mysql").tap do |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 [REDACTED] --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 @@ -21,9 +29,9 @@ class CliAccessoryTest < CliTestCase 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.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-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-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env [REDACTED] --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 --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 --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output end end @@ -192,8 +200,8 @@ class CliAccessoryTest < CliTestCase run_command("boot", "redis", "--hosts", "1.1.1.1").tap do |output| assert_match /docker login.*on 1.1.1.1/, output assert_no_match /docker login.*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.1", output - assert_no_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 --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output + assert_no_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output end end @@ -204,8 +212,8 @@ class CliAccessoryTest < CliTestCase run_command("boot", "redis", "--hosts", "1.1.1.1,1.1.1.3").tap do |output| assert_match /docker login.*on 1.1.1.1/, output assert_no_match /docker login.*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_no_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.3", output + assert_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output + assert_no_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.3", output end end diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index 8218dba81..d1344b812 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -113,7 +113,7 @@ class CliAppTest < CliTestCase run_command("boot", config: :with_env_tags).tap do |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 end end @@ -243,7 +243,7 @@ class CliAppTest < CliTestCase test "exec" do run_command("exec", "ruby -v").tap do |output| - assert_match "docker run --rm --env-file .kamal/env/roles/app-web.env dhh/app:latest ruby -v", output + assert_match "docker run --rm dhh/app:latest ruby -v", output end end @@ -262,7 +262,7 @@ class CliAppTest < CliTestCase test "exec interactive" do SSHKit::Backend::Abstract.any_instance.expects(:exec) - .with("ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --env-file .kamal/env/roles/app-web.env dhh/app:latest ruby -v'") + .with("ssh -t root@1.1.1.1 -p 22 'docker run -it --rm dhh/app:latest ruby -v'") run_command("exec", "-i", "ruby -v").tap do |output| assert_match "Get most recent version available as an image...", output assert_match "Launching interactive command with version latest via SSH from new container on 1.1.1.1...", output diff --git a/test/cli/build_test.rb b/test/cli/build_test.rb index 4259fa5bb..2d5d10512 100644 --- a/test/cli/build_test.rb +++ b/test/cli/build_test.rb @@ -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(: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) .with(:git, "-C", anything, :"rev-parse", :HEAD) @@ -140,7 +140,7 @@ class CliBuildTest < CliTestCase .returns("") 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| assert_match /WARN Missing compatible builder, so creating a new one first/, output diff --git a/test/cli/env_test.rb b/test/cli/env_test.rb deleted file mode 100644 index 299b2aaf7..000000000 --- a/test/cli/env_test.rb +++ /dev/null @@ -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 diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 8272f67bf..8b9f129c6 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -13,7 +13,6 @@ class CliMainTest < CliTestCase run_command("setup").tap do |output| assert_match /Ensure Docker is installed.../, output - assert_match /Evaluate and push env files.../, output end end @@ -21,7 +20,6 @@ class CliMainTest < CliTestCase 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:env:push", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options) # deploy Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: true)) @@ -33,7 +31,6 @@ class CliMainTest < CliTestCase run_command("setup", "--skip_push").tap do |output| assert_match /Ensure Docker is installed.../, output - assert_match /Evaluate and push env files.../, output # deploy assert_match /Acquiring the deploy lock/, output assert_match /Log into image registry/, output @@ -524,22 +521,4 @@ def run_command(*command, config_file: "deploy_simple") stdouted { Kamal::Cli::Main.start } end end - - def with_test_env_files(**files) - Dir.mktmpdir do |dir| - fixtures_dup = File.join(dir, "test") - FileUtils.mkdir_p(fixtures_dup) - FileUtils.cp_r("test/fixtures/", fixtures_dup) - - Dir.chdir(dir) do - FileUtils.mkdir_p(".kamal") - Dir.chdir(".kamal") do - files.each do |filename, contents| - File.binwrite(filename.to_s, contents) - end - end - yield - end - end - end end diff --git a/test/cli/traefik_test.rb b/test/cli/traefik_test.rb index 291711509..41921f96a 100644 --- a/test/cli/traefik_test.rb +++ b/test/cli/traefik_test.rb @@ -4,7 +4,7 @@ class CliTraefikTest < CliTestCase test "boot" do run_command("boot").tap do |output| assert_match "docker login", output - assert_match "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\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output + assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output end end @@ -14,7 +14,7 @@ class CliTraefikTest < CliTestCase run_command("reboot", "-y").tap do |output| assert_match "docker container stop traefik", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=Traefik", output - assert_match "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\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output + assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output end end diff --git a/test/commands/accessory_test.rb b/test/commands/accessory_test.rb index 400029884..d63fcd766 100644 --- a/test/commands/accessory_test.rb +++ b/test/commands/accessory_test.rb @@ -2,6 +2,8 @@ class CommandsAccessoryTest < ActiveSupport::TestCase setup do + setup_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123") + @config = { service: "app", image: "dhh/app", registry: { "server" => "private.registry", "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], @@ -41,25 +43,23 @@ class CommandsAccessoryTest < ActiveSupport::TestCase } } } - - ENV["MYSQL_ROOT_PASSWORD"] = "secret123" end teardown do - ENV.delete("MYSQL_ROOT_PASSWORD") + teardown_test_secrets end test "run" do 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 MYSQL_ROOT_PASSWORD=\"secret123\" --label service=\"app-mysql\" private.registry/mysql:8.0", new_command(:mysql).run.join(" ") 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\" --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest", new_command(:redis).run.join(" ") assert_equal \ - "docker run --name custom-busybox --detach --restart unless-stopped --log-opt max-size=\"10m\" --env-file .kamal/env/accessories/custom-busybox.env --label service=\"custom-busybox\" busybox:latest", + "docker run --name custom-busybox --detach --restart unless-stopped --log-opt max-size=\"10m\" --label service=\"custom-busybox\" busybox:latest", new_command(:busybox).run.join(" ") end @@ -67,7 +67,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } assert_equal \ - "docker run --name custom-busybox --detach --restart unless-stopped --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --env-file .kamal/env/accessories/custom-busybox.env --label service=\"custom-busybox\" busybox:latest", + "docker run --name custom-busybox --detach --restart unless-stopped --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"custom-busybox\" busybox:latest", new_command(:busybox).run.join(" ") end @@ -92,7 +92,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase test "execute in new container" do 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 MYSQL_ROOT_PASSWORD=\"secret123\" private.registry/mysql:8.0 mysql -u root", new_command(:mysql).execute_in_new_container("mysql", "-u", "root").join(" ") end @@ -104,7 +104,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase test "execute in new container over ssh" 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 MYSQL_ROOT_PASSWORD=\"secret123\" private.registry/mysql:8.0 mysql -u root}, new_command(:mysql).execute_in_new_container_over_ssh("mysql", "-u", "root") end end @@ -150,14 +150,6 @@ class CommandsAccessoryTest < ActiveSupport::TestCase new_command(:mysql).remove_image.join(" ") 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 def new_command(accessory) Kamal::Commands::Accessory.new(Kamal::Configuration.new(@config), name: accessory) diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index bee7dc34b..2ccb6033c 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -2,25 +2,25 @@ class CommandsAppTest < ActiveSupport::TestCase setup do - ENV["RAILS_MASTER_KEY"] = "456" + setup_test_secrets("secrets" => "RAILS_MASTER_KEY=456") 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" } } end teardown do - ENV.delete("RAILS_MASTER_KEY") + teardown_test_secrets end test "run" do 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 --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 RAILS_MASTER_KEY=\"456\" --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(" ") end test "run with hostname" do assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 --hostname myhost -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --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", + "docker run --detach --restart unless-stopped --name app-web-999 --hostname myhost -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env RAILS_MASTER_KEY=\"456\" --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(hostname: "myhost").join(" ") end @@ -28,7 +28,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:volumes] = [ "/local/path:/container/path" ] 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 --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\" --volume /local/path:/container/path --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 RAILS_MASTER_KEY=\"456\" --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\" --volume /local/path:/container/path --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(" ") end @@ -36,7 +36,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:healthcheck] = { "path" => "/healthz" } 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 --health-cmd \"(curl -f http://localhost:3000/healthz || 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 RAILS_MASTER_KEY=\"456\" --health-cmd \"(curl -f http://localhost:3000/healthz || 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(" ") end @@ -44,7 +44,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:healthcheck] = { "cmd" => "/bin/up" } 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 --health-cmd \"(/bin/up) && (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 RAILS_MASTER_KEY=\"456\" --health-cmd \"(/bin/up) && (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(" ") end @@ -52,14 +52,14 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "healthcheck" => { "cmd" => "/bin/healthy" } } } 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 --health-cmd \"(/bin/healthy) && (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 RAILS_MASTER_KEY=\"456\" --health-cmd \"(/bin/healthy) && (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(" ") end test "run with custom options" do @config[:servers] = { "web" => [ "1.1.1.1" ], "jobs" => { "hosts" => [ "1.1.1.2" ], "cmd" => "bin/jobs", "options" => { "mount" => "somewhere", "cap-add" => true } } } assert_equal \ - "docker run --detach --restart unless-stopped --name app-jobs-999 -e KAMAL_CONTAINER_NAME=\"app-jobs-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-jobs.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"jobs\" --label destination --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs", + "docker run --detach --restart unless-stopped --name app-jobs-999 -e KAMAL_CONTAINER_NAME=\"app-jobs-999\" -e KAMAL_VERSION=\"999\" --env RAILS_MASTER_KEY=\"456\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"jobs\" --label destination --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs", new_command(role: "jobs", host: "1.1.1.2").run.join(" ") end @@ -67,7 +67,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } 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 --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-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --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 RAILS_MASTER_KEY=\"456\" --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-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --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(" ") end @@ -76,7 +76,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "logging" => { "driver" => "local", "options" => { "max-size" => "100m" } } } } 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 --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-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --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 RAILS_MASTER_KEY=\"456\" --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-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --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(" ") end @@ -85,7 +85,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } 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 RAILS_MASTER_KEY=\"456\" --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(" ") end @@ -204,13 +204,13 @@ class CommandsAppTest < ActiveSupport::TestCase test "execute in new container" do assert_equal \ - "docker run --rm --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails db:setup", + "docker run --rm --env RAILS_MASTER_KEY=\"456\" dhh/app:999 bin/rails db:setup", new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") end test "execute in new container with env" do assert_equal \ - "docker run --rm --env-file .kamal/env/roles/app-web.env --env foo=\"bar\" dhh/app:999 bin/rails db:setup", + "docker run --rm --env RAILS_MASTER_KEY=\"456\" --env foo=\"bar\" dhh/app:999 bin/rails db:setup", new_command.execute_in_new_container("bin/rails", "db:setup", env: { "foo" => "bar" }).join(" ") end @@ -219,14 +219,14 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } 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 RAILS_MASTER_KEY=\"456\" dhh/app:999 bin/rails db:setup", new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") end test "execute in new container with custom options" do @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } assert_equal \ - "docker run --rm --env-file .kamal/env/roles/app-web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails db:setup", + "docker run --rm --env RAILS_MASTER_KEY=\"456\" --mount \"somewhere\" --cap-add dhh/app:999 bin/rails db:setup", new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") end @@ -243,7 +243,7 @@ class CommandsAppTest < ActiveSupport::TestCase end test "execute in new container over ssh" do - assert_match %r{docker run -it --rm --env-file .kamal/env/roles/app-web.env dhh/app:999 bin/rails c}, + assert_match %r{docker run -it --rm --env RAILS_MASTER_KEY=\"456\" dhh/app:999 bin/rails c}, new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) end @@ -251,13 +251,13 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:servers] = [ { "1.1.1.1" => "tag1" } ] @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 RAILS_MASTER_KEY=\"456\" dhh/app:999 bin/rails c'", new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) end test "execute in new container with custom options over ssh" do @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } - assert_match %r{docker run -it --rm --env-file .kamal/env/roles/app-web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails c}, + assert_match %r{docker run -it --rm --env RAILS_MASTER_KEY=\"456\" --mount \"somewhere\" --cap-add dhh/app:999 bin/rails c}, new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) end @@ -412,14 +412,6 @@ class CommandsAppTest < ActiveSupport::TestCase new_command.tag_latest_image.join(" ") 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 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 diff --git a/test/commands/auditor_test.rb b/test/commands/auditor_test.rb index 2aaafd677..2abc8d815 100644 --- a/test/commands/auditor_test.rb +++ b/test/commands/auditor_test.rb @@ -18,6 +18,7 @@ class CommandsAuditorTest < ActiveSupport::TestCase test "record" do assert_equal [ + :mkdir, "-p", ".kamal", "&&", :echo, "[#{@recorded_at}] [#{@performer}]", "app removed container", @@ -28,6 +29,7 @@ class CommandsAuditorTest < ActiveSupport::TestCase test "record with destination" do new_command(destination: "staging").tap do |auditor| assert_equal [ + :mkdir, "-p", ".kamal", "&&", :echo, "[#{@recorded_at}] [#{@performer}] [staging]", "app removed container", @@ -39,6 +41,7 @@ class CommandsAuditorTest < ActiveSupport::TestCase test "record with command details" do new_command(role: "web").tap do |auditor| assert_equal [ + :mkdir, "-p", ".kamal", "&&", :echo, "[#{@recorded_at}] [#{@performer}] [web]", "app removed container", @@ -49,6 +52,7 @@ class CommandsAuditorTest < ActiveSupport::TestCase test "record with arg details" do assert_equal [ + :mkdir, "-p", ".kamal", "&&", :echo, "[#{@recorded_at}] [#{@performer}] [value]", "app removed container", diff --git a/test/commands/builder_test.rb b/test/commands/builder_test.rb index 45c403888..e5daddfd2 100644 --- a/test/commands/builder_test.rb +++ b/test/commands/builder_test.rb @@ -69,10 +69,13 @@ class CommandsBuilderTest < ActiveSupport::TestCase end test "build secrets" do - builder = new_builder_command(builder: { "secrets" => [ "token_a", "token_b" ] }) - 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(" ") + with_test_secrets("secrets" => "token_a=foo\ntoken_b=bar") do + FileUtils.touch("Dockerfile") + builder = new_builder_command(builder: { "secrets" => [ "token_a", "token_b" ] }) + 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 test "build dockerfile" do @@ -113,10 +116,13 @@ class CommandsBuilderTest < ActiveSupport::TestCase end test "push with build secrets" do - builder = new_builder_command(builder: { "secrets" => [ "a", "b" ] }) - 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(" ") + with_test_secrets("secrets" => "a=foo\nb=bar") do + FileUtils.touch("Dockerfile") + builder = new_builder_command(builder: { "secrets" => [ "a", "b" ] }) + 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 test "build with ssh agent socket" do diff --git a/test/commands/registry_test.rb b/test/commands/registry_test.rb index 17376fef9..cf2734b72 100755 --- a/test/commands/registry_test.rb +++ b/test/commands/registry_test.rb @@ -11,51 +11,52 @@ class CommandsRegistryTest < ActiveSupport::TestCase builder: { "arch" => "amd64" }, servers: [ "1.1.1.1" ] } - @registry = Kamal::Commands::Registry.new Kamal::Configuration.new(@config) end test "registry login" do assert_equal \ "docker login hub.docker.com -u \"dhh\" -p \"secret\"", - @registry.login.join(" ") + registry.login.join(" ") end test "registry login with ENV password" do - ENV["KAMAL_REGISTRY_PASSWORD"] = "more-secret" - @config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ] + with_test_secrets("secrets" => "KAMAL_REGISTRY_PASSWORD=more-secret") do + @config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ] - assert_equal \ - "docker login hub.docker.com -u \"dhh\" -p \"more-secret\"", - @registry.login.join(" ") - ensure - ENV.delete("KAMAL_REGISTRY_PASSWORD") + assert_equal \ + "docker login hub.docker.com -u \"dhh\" -p \"more-secret\"", + registry.login.join(" ") + end end test "registry login escape password" do - ENV["KAMAL_REGISTRY_PASSWORD"] = "more-secret'\"" - @config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ] + with_test_secrets("secrets" => "KAMAL_REGISTRY_PASSWORD=more-secret'\"") do + @config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ] - assert_equal \ - "docker login hub.docker.com -u \"dhh\" -p \"more-secret'\\\"\"", - @registry.login.join(" ") - ensure - ENV.delete("KAMAL_REGISTRY_PASSWORD") + assert_equal \ + "docker login hub.docker.com -u \"dhh\" -p \"more-secret'\\\"\"", + registry.login.join(" ") + end end test "registry login with ENV username" do - ENV["KAMAL_REGISTRY_USERNAME"] = "also-secret" - @config[:registry]["username"] = [ "KAMAL_REGISTRY_USERNAME" ] + with_test_secrets("secrets" => "KAMAL_REGISTRY_USERNAME=also-secret") do + @config[:registry]["username"] = [ "KAMAL_REGISTRY_USERNAME" ] - assert_equal \ - "docker login hub.docker.com -u \"also-secret\" -p \"secret\"", - @registry.login.join(" ") - ensure - ENV.delete("KAMAL_REGISTRY_USERNAME") + assert_equal \ + "docker login hub.docker.com -u \"also-secret\" -p \"secret\"", + registry.login.join(" ") + end end test "registry logout" do assert_equal \ "docker logout hub.docker.com", - @registry.logout.join(" ") + registry.logout.join(" ") end + + private + def registry + Kamal::Commands::Registry.new Kamal::Configuration.new(@config) + end end diff --git a/test/commands/traefik_test.rb b/test/commands/traefik_test.rb index 446c3077e..3e90cd500 100644 --- a/test/commands/traefik_test.rb +++ b/test/commands/traefik_test.rb @@ -9,81 +9,81 @@ 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" } } } - ENV["EXAMPLE_API_KEY"] = "456" + setup_test_secrets("secrets" => "EXAMPLE_API_KEY=456") end teardown do - ENV.delete("EXAMPLE_API_KEY") + teardown_test_secrets end test "run" do 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 --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(" ") @config[:traefik]["host_port"] = "8080" assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 8080: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 8080:80 --volume /var/run/docker.sock:/var/run/docker.sock --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(" ") @config[:traefik]["publish"] = false assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --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 --volume /var/run/docker.sock:/var/run/docker.sock --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(" ") end test "run with ports configured" do 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 --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(" ") @config[:traefik]["options"] = { "publish" => %w[9000:9000 9001:9001] } 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\" --publish \"9000:9000\" --publish \"9001:9001\" #{@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 --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\" --publish \"9000:9000\" --publish \"9001:9001\" #{@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(" ") end test "run with volumes configured" do 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 --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(" ") @config[:traefik]["options"] = { "volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json] } 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\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" #{@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 --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\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" #{@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(" ") end test "run with several options configured" do 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 --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(" ") @config[:traefik]["options"] = { "volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json], "publish" => %w[8080:8080], "memory" => "512m" } 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\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" --publish \"8080:8080\" --memory \"512m\" #{@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 --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\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" --publish \"8080:8080\" --memory \"512m\" #{@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(" ") end test "run with labels configured" do 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 --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(" ") @config[:traefik]["labels"] = { "traefik.http.routers.dashboard.service" => "api@internal", "traefik.http.routers.dashboard.middlewares" => "auth" } 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\" --label traefik.http.routers.dashboard.service=\"api@internal\" --label traefik.http.routers.dashboard.middlewares=\"auth\" #{@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 --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\" --label traefik.http.routers.dashboard.service=\"api@internal\" --label traefik.http.routers.dashboard.middlewares=\"auth\" #{@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(" ") end test "run with env configured" do 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 --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(" ") @config[:traefik]["env"] = { "secret" => %w[EXAMPLE_API_KEY] } 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\" --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(" ") end @@ -91,7 +91,7 @@ class CommandsTraefikTest < ActiveSupport::TestCase @config.delete(:traefik) 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\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", + "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", new_command.run.join(" ") end @@ -99,7 +99,7 @@ class CommandsTraefikTest < ActiveSupport::TestCase @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } 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-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --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 --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --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(" ") end @@ -107,13 +107,13 @@ class CommandsTraefikTest < ActiveSupport::TestCase @config[:traefik]["args"]["log.level"] = "ERROR" 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=\"ERROR\" --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 --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=\"ERROR\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", new_command.run.join(" ") end test "run with args array" do @config[:traefik]["args"] = { "entrypoints.web.forwardedheaders.trustedips" => %w[ 127.0.0.1 127.0.0.2 ] } - 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\" traefik:test --providers.docker --log.level=\"DEBUG\" --entrypoints.web.forwardedheaders.trustedips=\"127.0.0.1\" --entrypoints.web.forwardedheaders.trustedips=\"127.0.0.2\"", new_command.run.join(" ") + assert_equal "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" traefik:test --providers.docker --log.level=\"DEBUG\" --entrypoints.web.forwardedheaders.trustedips=\"127.0.0.1\" --entrypoints.web.forwardedheaders.trustedips=\"127.0.0.2\"", new_command.run.join(" ") end test "traefik start" do @@ -188,20 +188,6 @@ class CommandsTraefikTest < ActiveSupport::TestCase new_command.follow_logs(host: @config[:servers].first, grep: "hello!") 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 def new_command Kamal::Commands::Traefik.new(Kamal::Configuration.new(@config, version: "123")) diff --git a/test/configuration/accessory_test.rb b/test/configuration/accessory_test.rb index 3f939607d..3497e6c10 100644 --- a/test/configuration/accessory_test.rb +++ b/test/configuration/accessory_test.rb @@ -116,25 +116,12 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase end test "env args" do - assert_equal [ "--env-file", ".kamal/env/accessories/app-mysql.env", "--env", "MYSQL_ROOT_HOST=\"%\"" ], @config.accessory(:mysql).env_args - assert_equal [ "--env-file", ".kamal/env/accessories/app-redis.env", "--env", "SOMETHING=\"else\"" ], @config.accessory(:redis).env_args - end - - test "env with secrets" do - ENV["MYSQL_ROOT_PASSWORD"] = "secret123" - - expected_secrets_file = <<~ENV - MYSQL_ROOT_PASSWORD=secret123 - ENV + with_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123") do + config = Kamal::Configuration.new(@deploy) - 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 + assert_equal [ "--env", "MYSQL_ROOT_HOST=\"%\"", "--env", "MYSQL_ROOT_PASSWORD=\"secret123\"" ], config.accessory(:mysql).env_args.map(&:to_s) + assert_equal [ "--env", "SOMETHING=\"else\"" ], @config.accessory(:redis).env_args + end end test "volume args" do diff --git a/test/configuration/builder_test.rb b/test/configuration/builder_test.rb index a4fa7fbb8..53740ca84 100644 --- a/test/configuration/builder_test.rb +++ b/test/configuration/builder_test.rb @@ -93,13 +93,15 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase end test "secrets" do - assert_equal [], config.builder.secrets + assert_equal({}, config.builder.secrets) end 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 test "dockerfile" do diff --git a/test/configuration/env/tags_test.rb b/test/configuration/env/tags_test.rb index 0fb649d1d..7db617773 100644 --- a/test/configuration/env/tags_test.rb +++ b/test/configuration/env/tags_test.rb @@ -79,23 +79,21 @@ class ConfigurationEnvTagsTest < ActiveSupport::TestCase end test "tag secret env" do - ENV["PASSWORD"] = "hello" - - deploy = { - service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, - servers: [ { "1.1.1.1" => "secrets" } ], - builder: { "arch" => "amd64" }, - env: { - "tags" => { - "secrets" => { "secret" => [ "PASSWORD" ] } + with_test_secrets("secrets" => "PASSWORD=hello") do + deploy = { + service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, + servers: [ { "1.1.1.1" => "secrets" } ], + builder: { "arch" => "amd64" }, + env: { + "tags" => { + "secrets" => { "secret" => [ "PASSWORD" ] } + } } } - } - config = Kamal::Configuration.new(deploy) - assert_equal "hello", config.role("web").env("1.1.1.1").secrets["PASSWORD"] - ensure - ENV.delete "PASSWORD" + config = Kamal::Configuration.new(deploy) + assert_equal "hello", config.role("web").env("1.1.1.1").secrets["PASSWORD"] + end end test "tag clear env" do diff --git a/test/configuration/env_test.rb b/test/configuration/env_test.rb index 49d800ef3..c3f0b929e 100644 --- a/test/configuration/env_test.rb +++ b/test/configuration/env_test.rb @@ -6,27 +6,21 @@ class ConfigurationEnvTest < ActiveSupport::TestCase test "simple" do assert_config \ config: { "foo" => "bar", "baz" => "haz" }, - clear: { "foo" => "bar", "baz" => "haz" }, - secrets: {} + results: { "foo" => "bar", "baz" => "haz" } end test "clear" do assert_config \ config: { "clear" => { "foo" => "bar", "baz" => "haz" } }, - clear: { "foo" => "bar", "baz" => "haz" }, - secrets: {} + results: { "foo" => "bar", "baz" => "haz" } end test "secret" do - ENV["PASSWORD"] = "hello" - env = Kamal::Configuration::Env.new config: { "secret" => [ "PASSWORD" ] } - - assert_config \ - config: { "secret" => [ "PASSWORD" ] }, - clear: {}, - secrets: { "PASSWORD" => "hello" } - ensure - ENV.delete "PASSWORD" + with_test_secrets("secrets" => "PASSWORD=hello") do + assert_config \ + config: { "secret" => [ "PASSWORD" ] }, + results: { "PASSWORD" => "hello" } + end end test "missing secret" do @@ -34,41 +28,29 @@ class ConfigurationEnvTest < ActiveSupport::TestCase "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::Configuration::Secrets.new).args } end test "secret and clear" do - ENV["PASSWORD"] = "hello" - config = { - "secret" => [ "PASSWORD" ], - "clear" => { - "foo" => "bar", - "baz" => "haz" + with_test_secrets("secrets" => "PASSWORD=hello") do + config = { + "secret" => [ "PASSWORD" ], + "clear" => { + "foo" => "bar", + "baz" => "haz" + } } - } - assert_config \ - config: config, - clear: { "foo" => "bar", "baz" => "haz" }, - secrets: { "PASSWORD" => "hello" } - ensure - 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 + assert_config \ + config: config, + results: { "foo" => "bar", "baz" => "haz", "PASSWORD" => "hello" } + end end private - def assert_config(config:, clear:, secrets:) - env = Kamal::Configuration::Env.new config: config, secrets_file: "secrets.env" - assert_equal clear, env.clear - assert_equal secrets, env.secrets + def assert_config(config:, results:) + env = Kamal::Configuration::Env.new config: config, secrets: Kamal::Configuration::Secrets.new + expected_args = results.to_a.flat_map { |key, value| [ "--env", "#{key}=\"#{value}\"" ] } + assert_equal expected_args, env.args.map(&:to_s) #  to_s removes the redactions end end diff --git a/test/configuration/role_test.rb b/test/configuration/role_test.rb index 37c26fcdf..d3b54ca68 100644 --- a/test/configuration/role_test.rb +++ b/test/configuration/role_test.rb @@ -9,8 +9,6 @@ class ConfigurationRoleTest < ActiveSupport::TestCase env: { "REDIS_URL" => "redis://x/y" } } - @config = Kamal::Configuration.new(@deploy) - @deploy_with_roles = @deploy.dup.merge({ servers: { "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 test "hosts" do - 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.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 end test "cmd" do - assert_nil @config.role(:web).cmd - assert_equal "bin/jobs", @config_with_roles.role(:workers).cmd + assert_nil config.role(:web).cmd + assert_equal "bin/jobs", config_with_roles.role(:workers).cmd end 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 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 test "custom labels" do @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 test "custom labels via role specialization" do @@ -59,7 +55,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase test "overwriting default traefik label" do @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 test "default traefik label on non-web role" do @@ -71,166 +67,149 @@ class ConfigurationRoleTest < ActiveSupport::TestCase end 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 [ "--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\"" ], + config_with_roles.role(:workers).env_args("1.1.1.3") end test "container name" do ENV["VERSION"] = "12345" - 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-workers-12345", config_with_roles.role(:workers).container_name + assert_equal "app-web-12345", config_with_roles.role(:web).container_name ensure ENV.delete("VERSION") end 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\"" ], config_with_roles.role(:workers).env_args("1.1.1.3") end test "env secret overwritten by role" do - @deploy_with_roles[:env] = { - "clear" => { - "REDIS_URL" => "redis://a/b" - }, - "secret" => [ - "REDIS_PASSWORD" - ] - } - - @deploy_with_roles[:servers]["workers"]["env"] = { - "clear" => { - "REDIS_URL" => "redis://a/b", - "WEB_CONCURRENCY" => "4" - }, - "secret" => [ - "DB_PASSWORD" - ] - } - - ENV["REDIS_PASSWORD"] = "secret456" - ENV["DB_PASSWORD"] = "secret&\"123" + with_test_secrets("secrets" => "REDIS_PASSWORD=secret456\nDB_PASSWORD=secret&\"123") do + @deploy_with_roles[:env] = { + "clear" => { + "REDIS_URL" => "redis://a/b" + }, + "secret" => [ + "REDIS_PASSWORD" + ] + } - expected_secrets_file = <<~ENV - REDIS_PASSWORD=secret456 - DB_PASSWORD=secret&\"123 - ENV + @deploy_with_roles[:servers]["workers"]["env"] = { + "clear" => { + "REDIS_URL" => "redis://a/b", + "WEB_CONCURRENCY" => "4" + }, + "secret" => [ + "DB_PASSWORD" + ] + } - 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 + assert_equal [ + "--env", "REDIS_URL=\"redis://a/b\"", + "--env", "WEB_CONCURRENCY=\"4\"", + "--env", "REDIS_PASSWORD=\"secret456\"", + "--env", "DB_PASSWORD=\"secret&\\\"123\"" ], + config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) + end end test "env secrets only in role" do - @deploy_with_roles[:servers]["workers"]["env"] = { - "clear" => { - "REDIS_URL" => "redis://a/b", - "WEB_CONCURRENCY" => "4" - }, - "secret" => [ - "DB_PASSWORD" - ] - } - - ENV["DB_PASSWORD"] = "secret123" - - expected_secrets_file = <<~ENV - DB_PASSWORD=secret123 - ENV + with_test_secrets("secrets" => "DB_PASSWORD=secret123") do + @deploy_with_roles[:servers]["workers"]["env"] = { + "clear" => { + "REDIS_URL" => "redis://a/b", + "WEB_CONCURRENCY" => "4" + }, + "secret" => [ + "DB_PASSWORD" + ] + } - 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 + assert_equal [ + "--env", "REDIS_URL=\"redis://a/b\"", + "--env", "WEB_CONCURRENCY=\"4\"", + "--env", "DB_PASSWORD=\"secret123\"" ], + config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) + end end test "env secrets only at top level" do - @deploy_with_roles[:env] = { - "clear" => { - "REDIS_URL" => "redis://a/b" - }, - "secret" => [ - "REDIS_PASSWORD" - ] - } - - ENV["REDIS_PASSWORD"] = "secret456" - - expected_secrets_file = <<~ENV - REDIS_PASSWORD=secret456 - ENV + with_test_secrets("secrets" => "REDIS_PASSWORD=secret456") do + @deploy_with_roles[:env] = { + "clear" => { + "REDIS_URL" => "redis://a/b" + }, + "secret" => [ + "REDIS_PASSWORD" + ] + } - 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 + assert_equal [ + "--env", "REDIS_URL=\"redis://a/b\"", + "--env", "WEB_CONCURRENCY=\"4\"", + "--env", "REDIS_PASSWORD=\"secret456\"" ], + config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) + end end test "env overwritten by role with secrets" do - @deploy_with_roles[:env] = { - "clear" => { - "REDIS_URL" => "redis://a/b" - }, - "secret" => [ - "REDIS_PASSWORD" - ] - } - - @deploy_with_roles[:servers]["workers"]["env"] = { - "clear" => { - "REDIS_URL" => "redis://c/d" + with_test_secrets("secrets" => "REDIS_PASSWORD=secret456") do + @deploy_with_roles[:env] = { + "clear" => { + "REDIS_URL" => "redis://a/b" + }, + "secret" => [ + "REDIS_PASSWORD" + ] } - } - - ENV["REDIS_PASSWORD"] = "secret456" - expected_secrets_file = <<~ENV - REDIS_PASSWORD=secret456 - ENV - - config = Kamal::Configuration.new(@deploy_with_roles) - assert_equal expected_secrets_file, config.role(:workers).env("1.1.1.3").secrets_io.string - 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") - ensure - ENV["REDIS_PASSWORD"] = nil - end + @deploy_with_roles[:servers]["workers"]["env"] = { + "clear" => { + "REDIS_URL" => "redis://c/d" + } + } - 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 + config = config_with_roles + assert_equal [ + "--env", "REDIS_URL=\"redis://c/d\"", + "--env", "REDIS_PASSWORD=\"secret456\"" ], + config.role(:workers).env_args("1.1.1.3").map(&:to_s) + end end test "uses cord" do - assert @config_with_roles.role(:web).uses_cord? - assert_not @config_with_roles.role(:workers).uses_cord? + assert config_with_roles.role(:web).uses_cord? + assert_not config_with_roles.role(:workers).uses_cord? end 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 test "cord volume" do - 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_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_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_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] end 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 test "asset path and volume args" do ENV["VERSION"] = "12345" - 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(:web).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(:workers).assets? + 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(:web).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(:workers).assets? config_with_assets = Kamal::Configuration.new(@deploy_with_roles.dup.tap { |c| c[:asset_path] = "foo" @@ -258,17 +237,26 @@ class ConfigurationRoleTest < ActiveSupport::TestCase test "asset extracted path" do 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-workers-12345", @config_with_roles.role(:workers).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 ensure ENV.delete("VERSION") end test "asset volume path" do 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-workers-12345", @config_with_roles.role(:workers).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 ensure ENV.delete("VERSION") end + + private + def config + Kamal::Configuration.new(@deploy) + end + + def config_with_roles + Kamal::Configuration.new(@deploy_with_roles) + end end diff --git a/test/integration/docker/deployer/app/.kamal/env.erb b/test/integration/docker/deployer/app/.kamal/secrets similarity index 100% rename from test/integration/docker/deployer/app/.kamal/env.erb rename to test/integration/docker/deployer/app/.kamal/secrets diff --git a/test/integration/docker/deployer/app_with_roles/.kamal/env.erb b/test/integration/docker/deployer/app_with_roles/.kamal/secrets similarity index 100% rename from test/integration/docker/deployer/app_with_roles/.kamal/env.erb rename to test/integration/docker/deployer/app_with_roles/.kamal/secrets diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index 79e39a6b9..6e8d3bf11 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -2,9 +2,6 @@ class MainTest < IntegrationTest test "deploy, redeploy, rollback, details and audit" do - assert_env_files - remove_local_env_file - first_version = latest_app_version assert_app_is_down @@ -105,11 +102,7 @@ class MainTest < IntegrationTest end private - def assert_local_env_file(contents) - assert_equal contents, deployer_exec("cat .kamal/secrets", capture: true) - end - - def assert_envs(version:) + def assert_envs(version:) assert_env :CLEAR_TOKEN, "4321", version: version, vm: :vm1 assert_env :HOST_TOKEN, "abcd", version: version, vm: :vm1 assert_env :SECRET_TOKEN, "1234 with \"中文\"", version: version, vm: :vm1 @@ -129,24 +122,6 @@ def assert_no_env(key, vm:, version:) 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 .kamal/secrets") - end - - def assert_remote_env_file(contents, vm:) - assert_equal contents, docker_compose("exec #{vm} cat /root/.kamal/secrets/roles/app-web.env", capture: true) - end - - def assert_no_remote_env_file - assert_equal "nofile", docker_compose("exec vm1 stat /root/.kamal/secrets/roles/app-web.env 2> /dev/null || echo nofile", capture: true) - end - def assert_accumulated_assets(*versions) versions.each do |version| assert_equal "200", Net::HTTP.get_response(URI.parse("http://localhost:12345/versions/#{version}")).code diff --git a/test/test_helper.rb b/test/test_helper.rb index 5f7a25c4c..ff1ad43a1 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -34,4 +34,32 @@ def stdouted def stderred capture(:stderr) { yield }.strip 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 From b464c4fd4a620857ec67158a699d4a300ca98f58 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 6 Aug 2024 11:08:58 +0100 Subject: [PATCH 04/40] Include dotenv upgrade --- Gemfile.lock | 105 ++++++++++++++++++++++++++------------------------ kamal.gemspec | 2 +- 2 files changed, 55 insertions(+), 52 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b3946bdc8..8a8a10976 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,7 +6,7 @@ PATH base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) concurrent-ruby (~> 1.2) - dotenv (~> 2.8) + dotenv (~> 3.1) ed25519 (~> 1.2) net-ssh (~> 7.0) sshkit (>= 1.23.0, < 2.0) @@ -16,9 +16,9 @@ PATH GEM remote: https://rubygems.org/ specs: - actionpack (7.1.2) - actionview (= 7.1.2) - activesupport (= 7.1.2) + actionpack (7.1.3.4) + actionview (= 7.1.3.4) + activesupport (= 7.1.3.4) nokogiri (>= 1.8.5) racc rack (>= 2.2.4) @@ -26,13 +26,13 @@ GEM rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actionview (7.1.2) - activesupport (= 7.1.2) + actionview (7.1.3.4) + activesupport (= 7.1.3.4) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activesupport (7.1.2) + activesupport (7.1.3.4) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -44,54 +44,55 @@ GEM tzinfo (~> 2.0) ast (2.4.2) base64 (0.2.0) - bcrypt_pbkdf (1.1.0) - bigdecimal (3.1.5) - builder (3.2.4) - concurrent-ruby (1.2.2) + bcrypt_pbkdf (1.1.1) + bcrypt_pbkdf (1.1.1-arm64-darwin) + bcrypt_pbkdf (1.1.1-x86_64-darwin) + bigdecimal (3.1.8) + builder (3.3.0) + concurrent-ruby (1.3.3) connection_pool (2.4.1) crass (1.0.6) - debug (1.9.1) + debug (1.9.2) irb (~> 1.10) reline (>= 0.3.8) - dotenv (2.8.1) - drb (2.2.0) - ruby2_keywords + dotenv (3.1.2) + drb (2.2.1) ed25519 (1.3.0) - erubi (1.12.0) - i18n (1.14.1) + erubi (1.13.0) + i18n (1.14.5) concurrent-ruby (~> 1.0) - io-console (0.7.1) - irb (1.11.0) - rdoc - reline (>= 0.3.8) - json (2.7.1) + io-console (0.7.2) + irb (1.14.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.7.2) language_server-protocol (3.17.0.3) loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) - minitest (5.20.0) - mocha (2.1.0) + minitest (5.24.1) + mocha (2.4.5) ruby2_keywords (>= 0.0.5) mutex_m (0.2.0) net-scp (4.0.0) net-ssh (>= 2.6.5, < 8.0.0) net-sftp (4.0.0) net-ssh (>= 5.0.0, < 8.0.0) - net-ssh (7.2.1) - nokogiri (1.16.0-arm64-darwin) + net-ssh (7.2.3) + nokogiri (1.16.7-arm64-darwin) racc (~> 1.4) - nokogiri (1.16.0-x86_64-darwin) + nokogiri (1.16.7-x86_64-darwin) racc (~> 1.4) - nokogiri (1.16.0-x86_64-linux) + nokogiri (1.16.7-x86_64-linux) racc (~> 1.4) - parallel (1.24.0) - parser (3.3.0.5) + parallel (1.25.1) + parser (3.3.4.0) ast (~> 2.4.1) racc psych (5.1.2) stringio - racc (1.7.3) - rack (3.0.8) + racc (1.8.1) + rack (3.1.7) rack-session (2.0.0) rack (>= 3.0.0) rack-test (2.1.0) @@ -106,42 +107,43 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - railties (7.1.2) - actionpack (= 7.1.2) - activesupport (= 7.1.2) + railties (7.1.3.4) + actionpack (= 7.1.3.4) + activesupport (= 7.1.3.4) irb rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.1.0) - rdoc (6.6.2) + rake (13.2.1) + rdoc (6.7.0) psych (>= 4.0.0) - regexp_parser (2.9.0) - reline (0.4.2) + regexp_parser (2.9.2) + reline (0.5.9) io-console (~> 0.5) - rexml (3.2.6) - rubocop (1.62.1) + rexml (3.3.4) + strscan + rubocop (1.65.1) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) + regexp_parser (>= 2.4, < 3.0) rexml (>= 3.2.5, < 4.0) rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.2) - parser (>= 3.3.0.4) - rubocop-minitest (0.35.0) + rubocop-ast (1.32.0) + parser (>= 3.3.1.0) + rubocop-minitest (0.35.1) rubocop (>= 1.61, < 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-ast (>= 1.30.0, < 2.0) - rubocop-rails (2.24.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rails (2.25.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) @@ -158,13 +160,14 @@ GEM net-scp (>= 1.1.2) net-sftp (>= 2.1.2) net-ssh (>= 2.8.0) - stringio (3.1.0) - thor (1.3.0) + stringio (3.1.1) + strscan (3.1.0) + thor (1.3.1) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) webrick (1.8.1) - zeitwerk (2.6.12) + zeitwerk (2.6.17) PLATFORMS arm64-darwin diff --git a/kamal.gemspec b/kamal.gemspec index ff499f4e0..0dfab60b9 100644 --- a/kamal.gemspec +++ b/kamal.gemspec @@ -15,7 +15,7 @@ Gem::Specification.new do |spec| spec.add_dependency "sshkit", ">= 1.23.0", "< 2.0" spec.add_dependency "net-ssh", "~> 7.0" 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 "ed25519", "~> 1.2" spec.add_dependency "bcrypt_pbkdf", "~> 1.0" From 5910249d02fdbbb8dead8631bcf5404c360ab0fa Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 7 Aug 2024 14:00:59 +0100 Subject: [PATCH 05/40] Add secrets command + 1password integration --- lib/kamal/cli/main.rb | 3 ++ lib/kamal/cli/secrets.rb | 36 ++++++++++++++ lib/kamal/secrets/adapters.rb | 12 +++++ lib/kamal/secrets/adapters/one_password.rb | 56 ++++++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 lib/kamal/cli/secrets.rb create mode 100644 lib/kamal/secrets/adapters.rb create mode 100644 lib/kamal/secrets/adapters/one_password.rb diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 65222eb77..39d431ad9 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -211,6 +211,9 @@ def version desc "registry", "Login and -out of the image registry" subcommand "registry", Kamal::Cli::Registry + desc "secrets", "Helpers for extracting secrets", hide: true + subcommand "secrets", Kamal::Cli::Secrets + desc "server", "Bootstrap servers with curl and Docker" subcommand "server", Kamal::Cli::Server diff --git a/lib/kamal/cli/secrets.rb b/lib/kamal/cli/secrets.rb new file mode 100644 index 000000000..1b192b0ab --- /dev/null +++ b/lib/kamal/cli/secrets.rb @@ -0,0 +1,36 @@ +class Kamal::Cli::Secrets < Kamal::Cli::Base + desc "login", "Login to a secrets vault" + option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" + option :adapter_options, type: :hash, aliases: "-O", required: true, desc: "Options to pass to the vault adapter" + def login + puts adapter(options).login(**adapter_options(options)) + end + + desc "fetch", "Fetch a secret from a vault" + option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" + option :adapter_options, type: :hash, aliases: "-O", required: true, desc: "Options to pass to the vault adapter" + def fetch(name) + puts adapter(options).fetch(name, **adapter_options(options)) + end + + desc "fetch_all", "Fetch multiple secrets from a vault" + option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" + option :adapter_options, type: :hash, aliases: "-O", required: true, desc: "Options to pass to the vault adapter" + def fetch_all(*names) + puts JSON.dump(adapter(options).fetch_all(*names, **adapter_options(options))).shellescape + end + + desc "extract", "Extract a single secret from the results of a fetch_all call" + def extract(name, secrets) + puts JSON.parse(secrets).fetch(name) + end + + private + def adapter(options) + Kamal::Secrets::Adapters.lookup(options[:adapter]) + end + + def adapter_options(options) + options[:adapter_options].transform_keys(&:to_sym) + end +end diff --git a/lib/kamal/secrets/adapters.rb b/lib/kamal/secrets/adapters.rb new file mode 100644 index 000000000..2ad1dcdf3 --- /dev/null +++ b/lib/kamal/secrets/adapters.rb @@ -0,0 +1,12 @@ +module Kamal::Secrets::Adapters + def self.lookup(name) + case name + when "1password" + Kamal::Secrets::Adapters::OnePassword.new + else + Object.const_get("Kamal::Secrets::Adapters::#{name.camelize}").new + end + rescue NameError + raise RuntimeError, "Unknown secrets adapter: #{name}" + end +end diff --git a/lib/kamal/secrets/adapters/one_password.rb b/lib/kamal/secrets/adapters/one_password.rb new file mode 100644 index 000000000..36a439c20 --- /dev/null +++ b/lib/kamal/secrets/adapters/one_password.rb @@ -0,0 +1,56 @@ +class Kamal::Secrets::Adapters::OnePassword + delegate :optionize, to: Kamal::Utils + + def login(account:) + `op signin #{to_options(account: account, force: true, raw: true)}`.tap do + raise RuntimeError, "Failed to login to 1Password: #{output}" unless $?.success? + end + end + + def fetch(name, account:, session: nil) + `op read #{name} #{to_options(account: account, session: session)}`.tap do + raise RuntimeError, "Could not read #{name} from 1Password" unless $?.success? + end + end + + def fetch_all(*names, account:, session: nil) + secrets = {} + + vaults_items_fields(names).each do |vault, items| + items.each do |item, fields| + labels = fields.map { |field| "label=#{field}" }.join(",") + secrets_json = `op item get #{item} #{to_options(vault: vault, fields: labels, format: "json", account: account, session: session.presence)}`.tap do + raise RuntimeError, "Could not read #{labels} from #{item} in the #{vault} 1Password vault" unless $?.success? + end + + JSON.parse(secrets_json).each do |secret_json| + secrets[secret_json["reference"]] = secret_json["value"] + end + end + end + + secrets + end + + private + def vaults_items_fields(names) + {}.tap do |vaults| + names.each do |name| + vault, item, field = vault_item_field(name) + vaults[vault] ||= {} + vaults[vault][item] ||= [] + vaults[vault][item] << field + end + end + end + + def vault_item_field(name) + parts = name.delete_prefix("op://").split("/") + + [ parts[0], parts[1], parts[2..-1].join(".") ] + end + + def to_options(**options) + optionize(options.compact).join(" ") + end +end From 1d0e81b00ab7db26acc12c635c764df4c67eb653 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 7 Aug 2024 14:01:28 +0100 Subject: [PATCH 06/40] Eager load only CLI for faster commands --- lib/kamal.rb | 3 ++- lib/kamal/cli/base.rb | 4 ++-- lib/kamal/cli/healthcheck/barrier.rb | 2 ++ lib/kamal/cli/lock.rb | 2 ++ lib/kamal/commander.rb | 1 + lib/kamal/configuration.rb | 1 - lib/kamal/sshkit_with_ext.rb | 1 + lib/kamal/utils.rb | 2 ++ test/integration/main_test.rb | 2 +- 9 files changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/kamal.rb b/lib/kamal.rb index b197408e0..6625a9e4e 100644 --- a/lib/kamal.rb +++ b/lib/kamal.rb @@ -6,8 +6,9 @@ class ConfigurationError < StandardError; end require "zeitwerk" require "yaml" require "tmpdir" +require "pathname" loader = Zeitwerk::Loader.for_gem loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb")) loader.setup -loader.eager_load # We need all commands loaded. +loader.eager_load_namespace(Kamal::Cli) # We need all commands loaded. diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index d815560e8..d4cac48dc 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -74,8 +74,6 @@ def with_lock if KAMAL.holding_lock? yield else - ensure_run_and_locks_directory - acquire_lock begin @@ -104,6 +102,8 @@ def confirming(question) end def acquire_lock + ensure_run_and_locks_directory + raise_if_locked do say "Acquiring the deploy lock...", :magenta on(KAMAL.primary_host) { execute *KAMAL.lock.acquire("Automatic deploy lock", KAMAL.config.version), verbosity: :debug } diff --git a/lib/kamal/cli/healthcheck/barrier.rb b/lib/kamal/cli/healthcheck/barrier.rb index 0fbfb511b..a5db919c1 100644 --- a/lib/kamal/cli/healthcheck/barrier.rb +++ b/lib/kamal/cli/healthcheck/barrier.rb @@ -1,3 +1,5 @@ +require "concurrent/ivar" + class Kamal::Cli::Healthcheck::Barrier def initialize @ivar = Concurrent::IVar.new diff --git a/lib/kamal/cli/lock.rb b/lib/kamal/cli/lock.rb index 1e4b52cf2..7598b662f 100644 --- a/lib/kamal/cli/lock.rb +++ b/lib/kamal/cli/lock.rb @@ -13,6 +13,8 @@ def status option :message, aliases: "-m", type: :string, desc: "A lock message", required: true def acquire message = options[:message] + ensure_run_and_locks_directory + raise_if_locked do on(KAMAL.primary_host) do execute *KAMAL.server.ensure_run_directory diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index ffe140c4c..11914a676 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -1,5 +1,6 @@ require "active_support/core_ext/enumerable" require "active_support/core_ext/module/delegation" +require "active_support/core_ext/object/blank" class Kamal::Commander attr_accessor :verbosity, :holding_lock, :connected diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index d19a77867..c5133f9a2 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -2,7 +2,6 @@ require "active_support/core_ext/string/inquiry" require "active_support/core_ext/module/delegation" require "active_support/core_ext/hash/keys" -require "pathname" require "erb" require "net/ssh/proxy/jump" diff --git a/lib/kamal/sshkit_with_ext.rb b/lib/kamal/sshkit_with_ext.rb index 2d0257a87..ab6795e75 100644 --- a/lib/kamal/sshkit_with_ext.rb +++ b/lib/kamal/sshkit_with_ext.rb @@ -3,6 +3,7 @@ require "net/scp" require "active_support/core_ext/hash/deep_merge" require "json" +require "concurrent/atomic/semaphore" class SSHKit::Backend::Abstract def capture_with_info(*args, **kwargs) diff --git a/lib/kamal/utils.rb b/lib/kamal/utils.rb index 8c6c93213..266d6a96b 100644 --- a/lib/kamal/utils.rb +++ b/lib/kamal/utils.rb @@ -1,3 +1,5 @@ +require "active_support/core_ext/object/try" + module Kamal::Utils extend self diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index 6e8d3bf11..7ed6ee8f7 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -107,7 +107,7 @@ def assert_envs(version:) assert_env :HOST_TOKEN, "abcd", 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 :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 :SECRET_TAG, "TAGME", version: version, vm: :vm2 end From 5480b40ba381a90b2047a45f5dbb054e06911ada Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 7 Aug 2024 14:32:43 +0100 Subject: [PATCH 07/40] Correct secret files order --- lib/kamal/configuration/secrets.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/kamal/configuration/secrets.rb b/lib/kamal/configuration/secrets.rb index 215bcaf28..0e52f9ae0 100644 --- a/lib/kamal/configuration/secrets.rb +++ b/lib/kamal/configuration/secrets.rb @@ -3,8 +3,7 @@ class Kamal::Configuration::Secrets def initialize(destination: nil) @secret_files = \ - (destination ? [ ".kamal/secrets", ".kamal/secrets.#{destination}" ] : [ ".kamal/secrets" ]) - .select { |file| File.exist?(file) } + (destination ? [ ".kamal/secrets.#{destination}", ".kamal/secrets" ] : [ ".kamal/secrets" ]) end def [](key) From fcdef5fa062bca90bf9f13a4902323248072e015 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 7 Aug 2024 14:45:47 +0100 Subject: [PATCH 08/40] Set KAMAL_DESTINATION for dotenv parsing --- lib/kamal/configuration/secrets.rb | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/kamal/configuration/secrets.rb b/lib/kamal/configuration/secrets.rb index 0e52f9ae0..c4d9406bd 100644 --- a/lib/kamal/configuration/secrets.rb +++ b/lib/kamal/configuration/secrets.rb @@ -1,24 +1,29 @@ class Kamal::Configuration::Secrets - attr_reader :secret_files + attr_reader :secret_file, :destination def initialize(destination: nil) - @secret_files = \ - (destination ? [ ".kamal/secrets.#{destination}", ".kamal/secrets" ] : [ ".kamal/secrets" ]) + @destination = destination + @secret_file = (destination ? [ ".kamal/secrets.#{destination}", ".kamal/secrets" ] : [ ".kamal/secrets" ]) + .find { |file| File.exist?(file) } end def [](key) @secrets ||= load @secrets.fetch(key) rescue KeyError - if secret_files.any? - raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secret_files.join(', ')}" + if secret_file + raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secret_file}" else - raise Kamal::ConfigurationError, "Secret '#{key}' not found, no secret files provided" + raise Kamal::ConfigurationError, "Secret '#{key}' not found, no secret file provided" end end private def load - secret_files.any? ? Dotenv.parse(*secret_files) : {} + original_env = ENV.to_hash + ENV["KAMAL_DESTINATION"] = destination if destination + secret_file ? Dotenv.parse(*secret_file) : {} + ensure + ENV.replace(original_env) end end From 7daaabd4d4772118eb24709861140befef1bb8a0 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 7 Aug 2024 15:33:05 +0100 Subject: [PATCH 09/40] One file, no destination env --- lib/kamal/configuration/secrets.rb | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/lib/kamal/configuration/secrets.rb b/lib/kamal/configuration/secrets.rb index c4d9406bd..088e54d53 100644 --- a/lib/kamal/configuration/secrets.rb +++ b/lib/kamal/configuration/secrets.rb @@ -1,29 +1,18 @@ class Kamal::Configuration::Secrets - attr_reader :secret_file, :destination + attr_reader :secrets_file def initialize(destination: nil) - @destination = destination - @secret_file = (destination ? [ ".kamal/secrets.#{destination}", ".kamal/secrets" ] : [ ".kamal/secrets" ]) - .find { |file| File.exist?(file) } + @secrets_file = [ *(".kamal/secrets.#{destination}" if destination), ".kamal/secrets" ].find { |f| File.exist?(f) } end def [](key) - @secrets ||= load + @secrets ||= secrets_file ? Dotenv.parse(*secrets_file) : {} @secrets.fetch(key) rescue KeyError - if secret_file - raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secret_file}" + if secrets_file + raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secrets_file}" else - raise Kamal::ConfigurationError, "Secret '#{key}' not found, no secret file provided" + raise Kamal::ConfigurationError, "Secret '#{key}' not found, no secret files provided" end end - - private - def load - original_env = ENV.to_hash - ENV["KAMAL_DESTINATION"] = destination if destination - secret_file ? Dotenv.parse(*secret_file) : {} - ensure - ENV.replace(original_env) - end end From 3f37fea7c33fa55e5c31df7899b34df72b30c0a1 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 7 Aug 2024 15:39:12 +0100 Subject: [PATCH 10/40] Configuration::Secrets -> Secrets --- lib/kamal/configuration.rb | 2 +- lib/kamal/{configuration => }/secrets.rb | 2 +- test/cli/secrets_test.rb | 0 test/configuration/env_test.rb | 4 ++-- 4 files changed, 4 insertions(+), 4 deletions(-) rename lib/kamal/{configuration => }/secrets.rb (93%) create mode 100644 test/cli/secrets_test.rb diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index c5133f9a2..4ed1d56f2 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -254,7 +254,7 @@ def to_h end def secrets - @secrets ||= Secrets.new(destination: destination) + @secrets ||= Kamal::Secrets.new(destination: destination) end private diff --git a/lib/kamal/configuration/secrets.rb b/lib/kamal/secrets.rb similarity index 93% rename from lib/kamal/configuration/secrets.rb rename to lib/kamal/secrets.rb index 088e54d53..e195cc379 100644 --- a/lib/kamal/configuration/secrets.rb +++ b/lib/kamal/secrets.rb @@ -1,4 +1,4 @@ -class Kamal::Configuration::Secrets +class Kamal::Secrets attr_reader :secrets_file def initialize(destination: nil) diff --git a/test/cli/secrets_test.rb b/test/cli/secrets_test.rb new file mode 100644 index 000000000..e69de29bb diff --git a/test/configuration/env_test.rb b/test/configuration/env_test.rb index c3f0b929e..b4e924a7e 100644 --- a/test/configuration/env_test.rb +++ b/test/configuration/env_test.rb @@ -28,7 +28,7 @@ class ConfigurationEnvTest < ActiveSupport::TestCase "secret" => [ "PASSWORD" ] } - assert_raises(Kamal::ConfigurationError) { Kamal::Configuration::Env.new(config: { "secret" => [ "PASSWORD" ] }, secrets: Kamal::Configuration::Secrets.new).args } + assert_raises(Kamal::ConfigurationError) { Kamal::Configuration::Env.new(config: { "secret" => [ "PASSWORD" ] }, secrets: Kamal::Secrets.new).args } end test "secret and clear" do @@ -49,7 +49,7 @@ class ConfigurationEnvTest < ActiveSupport::TestCase private def assert_config(config:, results:) - env = Kamal::Configuration::Env.new config: config, secrets: Kamal::Configuration::Secrets.new + env = Kamal::Configuration::Env.new config: config, secrets: Kamal::Secrets.new expected_args = results.to_a.flat_map { |key, value| [ "--env", "#{key}=\"#{value}\"" ] } assert_equal expected_args, env.args.map(&:to_s) #  to_s removes the redactions end From 0c6a593554e2e795fa5ba106d62b3229342c0364 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 7 Aug 2024 15:42:57 +0100 Subject: [PATCH 11/40] Remove redundant test --- test/cli/secrets_test.rb | 50 ++++++++++++++++++++++++++ test/env_file_test.rb | 76 ---------------------------------------- 2 files changed, 50 insertions(+), 76 deletions(-) delete mode 100644 test/env_file_test.rb diff --git a/test/cli/secrets_test.rb b/test/cli/secrets_test.rb index e69de29bb..0fa78eb6b 100644 --- a/test/cli/secrets_test.rb +++ b/test/cli/secrets_test.rb @@ -0,0 +1,50 @@ +require_relative "cli_test_case" + +class CliSecretsTest < CliTestCase + test "login" do + run_command("login").tap do |output| + assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output + assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output + end + end + + test "fetch" do + run_command("login", "-L").tap do |output| + assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output + assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output + end + end + + test "fetch_all" do + run_command("login", "-R").tap do |output| + assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output + assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output + end + end + + test "extract" do + run_command("logout").tap do |output| + assert_match /docker logout as .*@localhost/, output + assert_match /docker logout on 1.1.1.\d/, output + end + end + + test "logout skip local" do + run_command("logout", "-L").tap do |output| + assert_no_match /docker logout as .*@localhost/, output + assert_match /docker logout on 1.1.1.\d/, output + end + end + + test "logout skip remote" do + run_command("logout", "-R").tap do |output| + assert_match /docker logout as .*@localhost/, output + assert_no_match /docker logout on 1.1.1.\d/, output + end + end + + private + def run_command(*command) + stdouted { Kamal::Cli::Secrets.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } + end +end diff --git a/test/env_file_test.rb b/test/env_file_test.rb deleted file mode 100644 index c6b9e66ec..000000000 --- a/test/env_file_test.rb +++ /dev/null @@ -1,76 +0,0 @@ -require "test_helper" - -class EnvFileTest < ActiveSupport::TestCase - test "to_s" do - env = { - "foo" => "bar", - "baz" => "haz" - } - - assert_equal "foo=bar\nbaz=haz\n", \ - Kamal::EnvFile.new(env).to_s - end - - test "to_str won't escape chinese characters" do - env = { - "foo" => '你好 means hello, "欢迎" means welcome, that\'s simple! 😃 {smile}' - } - - assert_equal "foo=你好 means hello, \"欢迎\" means welcome, that's simple! 😃 {smile}\n", - Kamal::EnvFile.new(env).to_s - end - - test "to_s won't escape japanese characters" do - env = { - "foo" => 'こんにちは means hello, "ようこそ" means welcome, that\'s simple! 😃 {smile}' - } - - assert_equal "foo=こんにちは means hello, \"ようこそ\" means welcome, that's simple! 😃 {smile}\n", \ - Kamal::EnvFile.new(env).to_s - end - - test "to_s won't escape korean characters" do - env = { - "foo" => '안녕하세요 means hello, "어서 오십시오" means welcome, that\'s simple! 😃 {smile}' - } - - assert_equal "foo=안녕하세요 means hello, \"어서 오십시오\" means welcome, that's simple! 😃 {smile}\n", \ - Kamal::EnvFile.new(env).to_s - end - - test "to_s empty" do - assert_equal "\n", Kamal::EnvFile.new({}).to_s - end - - test "to_s escaped newline" do - env = { - "foo" => "hello\\nthere" - } - - assert_equal "foo=hello\\\\nthere\n", \ - Kamal::EnvFile.new(env).to_s - ensure - ENV.delete "PASSWORD" - end - - test "to_s newline" do - env = { - "foo" => "hello\nthere" - } - - assert_equal "foo=hello\\nthere\n", \ - Kamal::EnvFile.new(env).to_s - ensure - 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 From d5ecca0fd44f66e256bddaf07f33aaf65e696916 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 14 Aug 2024 09:49:36 +0100 Subject: [PATCH 12/40] Add tests --- lib/kamal/cli/secrets.rb | 8 +++---- test/cli/secrets_test.rb | 46 ++++++++++++++++++---------------------- test/test_helper.rb | 17 +++++++++++++++ 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/lib/kamal/cli/secrets.rb b/lib/kamal/cli/secrets.rb index 1b192b0ab..5becfdc50 100644 --- a/lib/kamal/cli/secrets.rb +++ b/lib/kamal/cli/secrets.rb @@ -1,21 +1,21 @@ class Kamal::Cli::Secrets < Kamal::Cli::Base desc "login", "Login to a secrets vault" option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" - option :adapter_options, type: :hash, aliases: "-O", required: true, desc: "Options to pass to the vault adapter" + option :adapter_options, type: :hash, aliases: "-O", required: false, desc: "Options to pass to the vault adapter" def login puts adapter(options).login(**adapter_options(options)) end desc "fetch", "Fetch a secret from a vault" option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" - option :adapter_options, type: :hash, aliases: "-O", required: true, desc: "Options to pass to the vault adapter" + option :adapter_options, type: :hash, aliases: "-O", required: false, desc: "Options to pass to the vault adapter" def fetch(name) puts adapter(options).fetch(name, **adapter_options(options)) end desc "fetch_all", "Fetch multiple secrets from a vault" option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" - option :adapter_options, type: :hash, aliases: "-O", required: true, desc: "Options to pass to the vault adapter" + option :adapter_options, type: :hash, aliases: "-O", required: false, desc: "Options to pass to the vault adapter" def fetch_all(*names) puts JSON.dump(adapter(options).fetch_all(*names, **adapter_options(options))).shellescape end @@ -31,6 +31,6 @@ def adapter(options) end def adapter_options(options) - options[:adapter_options].transform_keys(&:to_sym) + options.fetch(:adapter_options, {}).transform_keys(&:to_sym) end end diff --git a/test/cli/secrets_test.rb b/test/cli/secrets_test.rb index 0fa78eb6b..7ac5f9d75 100644 --- a/test/cli/secrets_test.rb +++ b/test/cli/secrets_test.rb @@ -2,45 +2,41 @@ class CliSecretsTest < CliTestCase test "login" do - run_command("login").tap do |output| - assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output - assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output + assert_equal "LOGIN_TOKEN", run_command("login", "--adapter", "test") + end + + test "login failed" do + assert_raises("Boom!") do + run_command("login", "--adapter", "test", "--adapter-options", "boom:true") end end test "fetch" do - run_command("login", "-L").tap do |output| - assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output - assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output - end + assert_equal "oof", run_command("fetch", "foo", "--adapter", "test") end - test "fetch_all" do - run_command("login", "-R").tap do |output| - assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output - assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output + test "fetch failed" do + assert_raises("Boom!") do + run_command("fetch", "foo", "--adapter", "test", "--adapter-options", "boom:true") end end - test "extract" do - run_command("logout").tap do |output| - assert_match /docker logout as .*@localhost/, output - assert_match /docker logout on 1.1.1.\d/, output - end + test "fetch_all" do + assert_equal \ + "\\{\\\"foo\\\":\\\"oof\\\",\\\"bar\\\":\\\"rab\\\",\\\"baz\\\":\\\"zab\\\"\\}", + run_command("fetch_all", "foo", "bar", "baz", "--adapter", "test") end - test "logout skip local" do - run_command("logout", "-L").tap do |output| - assert_no_match /docker logout as .*@localhost/, output - assert_match /docker logout on 1.1.1.\d/, output + test "fetch_all failed" do + assert_raises("Boom!") do + assert_equal \ + "\\{\\\"foo\\\":\\\"oof\\\",\\\"bar\\\":\\\"rab\\\",\\\"baz\\\":\\\"zab\\\"\\}", + run_command("fetch_all", "foo", "bar", "baz", "--adapter", "test", "--adapter-options", "boom:true") end end - test "logout skip remote" do - run_command("logout", "-R").tap do |output| - assert_match /docker logout as .*@localhost/, output - assert_no_match /docker logout on 1.1.1.\d/, output - end + test "extract" do + assert_equal "oof", run_command("extract", "foo", "{\"foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}") end private diff --git a/test/test_helper.rb b/test/test_helper.rb index ff1ad43a1..10063acc2 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -63,3 +63,20 @@ def teardown_test_secrets FileUtils.rm_rf(@secrets_tmpdir) end end + +class Kamal::Secrets::Adapters::Test + def login(boom: false) + raise "Boom!" if boom + "LOGIN_TOKEN" + end + + def fetch(name, boom: false) + raise "Boom!" if boom + name.reverse + end + + def fetch_all(*names, boom: false) + raise "Boom!" if boom + names.to_h { |name| [ name, name.reverse ] } + end +end From 0ae8046905bd52b7927113078468c8259cdd9a37 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 22 Aug 2024 13:49:28 +0100 Subject: [PATCH 13/40] Add secret tests --- test/secrets/one_password_adapter_test.rb | 62 +++++++++++++++++++++++ test/secrets_test.rb | 30 +++++++++++ 2 files changed, 92 insertions(+) create mode 100644 test/secrets/one_password_adapter_test.rb create mode 100644 test/secrets_test.rb diff --git a/test/secrets/one_password_adapter_test.rb b/test/secrets/one_password_adapter_test.rb new file mode 100644 index 000000000..41b6fe17d --- /dev/null +++ b/test/secrets/one_password_adapter_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +class SecretsOnePasswordAdapterTest < ActiveSupport::TestCase + test "login" do + `true` # Ensure $? is 0 + Object.any_instance.stubs(:`).with("op signin --account \"myaccount\" --force --raw").returns("Logged in") + + assert_equal "Logged in", run_command("login") + end + + test "fetch" do + `true` # Ensure $? is 0 + Object.any_instance.stubs(:`).with("op read op://vault/item/section/foo --account \"myaccount\"").returns("bar") + + assert_equal "bar", run_command("fetch", "op://vault/item/section/foo") + end + + test "fetch_all" do + `true` # Ensure $? is 0 + Object.any_instance.stubs(:`) + .with("op item get item --vault \"vault\" --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://vault/item/section/SECRET1" + }, + { + "id": "bbbbbbbbbbbbbbbbbbbbbbbbbb", + "section": { + "id": "dddddddddddddddddddddddddd", + "label": "section" + }, + "type": "CONCEALED", + "label": "SECRET2", + "value": "VALUE2", + "reference": "op://vault/item/section/SECRET2" + } + ] + JSON + + assert_equal "bar", run_command("fetch_all", "op://vault/item/section/SECRET1", "op://vault/item/section/SECRET2") + end + + private + def run_command(*command) + stdouted do + Kamal::Cli::Secrets.start \ + [ *command, + "-c", "test/fixtures/deploy_with_accessories.yml", + "--adapter", "1password", + "--adapter-options", "account:myaccount" ] + end + end +end diff --git a/test/secrets_test.rb b/test/secrets_test.rb new file mode 100644 index 000000000..5909b0e12 --- /dev/null +++ b/test/secrets_test.rb @@ -0,0 +1,30 @@ +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") do + assert_equal "ABC", Kamal::Secrets.new["SECRET"] + assert_equal "DEF", Kamal::Secrets.new(destination: "dest")["SECRET"] + assert_equal "ABC", Kamal::Secrets.new(destination: "nodest")["SECRET"] + end + end +end From 79731da6195b7f6d1606e64bc78401c72adb5af8 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 26 Aug 2024 15:20:13 +0100 Subject: [PATCH 14/40] Single fetch command --- lib/kamal/cli/secrets.rb | 33 +++--------- lib/kamal/secrets/adapters/one_password.rb | 60 ++++++++-------------- 2 files changed, 30 insertions(+), 63 deletions(-) diff --git a/lib/kamal/cli/secrets.rb b/lib/kamal/cli/secrets.rb index 5becfdc50..0c9dd5cb7 100644 --- a/lib/kamal/cli/secrets.rb +++ b/lib/kamal/cli/secrets.rb @@ -1,36 +1,19 @@ class Kamal::Cli::Secrets < Kamal::Cli::Base - desc "login", "Login to a secrets vault" + desc "fetch [ITEM] [FIELDS...]", "Fetch secrets from a vault" option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" - option :adapter_options, type: :hash, aliases: "-O", required: false, desc: "Options to pass to the vault adapter" - def login - puts adapter(options).login(**adapter_options(options)) + option :account, type: :string, aliases: "-a", required: true, desc: "The account identifier or username" + def fetch(item, *fields) + ENV["KAMAL_SECRETS_KILL_PARENT"] = "1" + puts JSON.dump(adapter(options[:adapter]).fetch(item, fields, account: options[:account])).shellescape end - desc "fetch", "Fetch a secret from a vault" - option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" - option :adapter_options, type: :hash, aliases: "-O", required: false, desc: "Options to pass to the vault adapter" - def fetch(name) - puts adapter(options).fetch(name, **adapter_options(options)) - end - - desc "fetch_all", "Fetch multiple secrets from a vault" - option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" - option :adapter_options, type: :hash, aliases: "-O", required: false, desc: "Options to pass to the vault adapter" - def fetch_all(*names) - puts JSON.dump(adapter(options).fetch_all(*names, **adapter_options(options))).shellescape - end - - desc "extract", "Extract a single secret from the results of a fetch_all call" + desc "extract", "Extract a single secret from the results of a fetch call" def extract(name, secrets) puts JSON.parse(secrets).fetch(name) end private - def adapter(options) - Kamal::Secrets::Adapters.lookup(options[:adapter]) - end - - def adapter_options(options) - options.fetch(:adapter_options, {}).transform_keys(&:to_sym) + def adapter(adapter) + Kamal::Secrets::Adapters.lookup(adapter) end end diff --git a/lib/kamal/secrets/adapters/one_password.rb b/lib/kamal/secrets/adapters/one_password.rb index 36a439c20..f5f1f8e15 100644 --- a/lib/kamal/secrets/adapters/one_password.rb +++ b/lib/kamal/secrets/adapters/one_password.rb @@ -1,55 +1,39 @@ class Kamal::Secrets::Adapters::OnePassword delegate :optionize, to: Kamal::Utils - def login(account:) - `op signin #{to_options(account: account, force: true, raw: true)}`.tap do - raise RuntimeError, "Failed to login to 1Password: #{output}" unless $?.success? + def fetch(item, fields, account: nil) + # session may be nil if logging in with the app CLI integration + session = signin(account) + vault, vault_item = item.split("/") + labels = fields.map { |field| "label=#{field}" }.join(",") + options = to_options(vault: vault, fields: labels, format: "json", account: account, session: session.presence) + + secrets_json = `op item get #{vault_item} #{options}`.tap do + raise RuntimeError, "Could not read #{labels} from #{vault_item} in the #{vault} 1Password vault" unless $?.success? end - end - - def fetch(name, account:, session: nil) - `op read #{name} #{to_options(account: account, session: session)}`.tap do - raise RuntimeError, "Could not read #{name} from 1Password" unless $?.success? - end - end - - def fetch_all(*names, account:, session: nil) - secrets = {} - vaults_items_fields(names).each do |vault, items| - items.each do |item, fields| - labels = fields.map { |field| "label=#{field}" }.join(",") - secrets_json = `op item get #{item} #{to_options(vault: vault, fields: labels, format: "json", account: account, session: session.presence)}`.tap do - raise RuntimeError, "Could not read #{labels} from #{item} in the #{vault} 1Password vault" unless $?.success? - end - - JSON.parse(secrets_json).each do |secret_json| - secrets[secret_json["reference"]] = secret_json["value"] - end + {}.tap do |secrets| + JSON.parse(secrets_json).each do |secret_json| + # The reference is in the form `op://vault/item/field[/field]` + field = secret_json["reference"].delete_prefix("op://#{item}/") + secrets[field] = secret_json["value"] + secrets[field.split("/").last] = secret_json["value"] end end + rescue => e + $stderr.puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m" - secrets + Process.kill("INT", Process.ppid) if ENV["KAMAL_SECRETS_KILL_PARENT"] + exit 1 end private - def vaults_items_fields(names) - {}.tap do |vaults| - names.each do |name| - vault, item, field = vault_item_field(name) - vaults[vault] ||= {} - vaults[vault][item] ||= [] - vaults[vault][item] << field - end + def signin(account) + `op signin #{to_options(account: account, force: true, raw: true)}`.tap do + raise RuntimeError, "Failed to login to 1Password" unless $?.success? end end - def vault_item_field(name) - parts = name.delete_prefix("op://").split("/") - - [ parts[0], parts[1], parts[2..-1].join(".") ] - end - def to_options(**options) optionize(options.compact).join(" ") end From 9ade79fc84a6278e83235b7efa9355941edf9e1d Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 29 Aug 2024 15:11:04 +0100 Subject: [PATCH 15/40] OnePassword, LastPass + Bitwarden adapters --- lib/kamal/cli/secrets.rb | 7 ++- lib/kamal/secrets/adapters.rb | 16 ++--- lib/kamal/secrets/adapters/one_password.rb | 73 ++++++++++++++-------- 3 files changed, 61 insertions(+), 35 deletions(-) diff --git a/lib/kamal/cli/secrets.rb b/lib/kamal/cli/secrets.rb index 0c9dd5cb7..d7e99b316 100644 --- a/lib/kamal/cli/secrets.rb +++ b/lib/kamal/cli/secrets.rb @@ -2,9 +2,12 @@ class Kamal::Cli::Secrets < Kamal::Cli::Base desc "fetch [ITEM] [FIELDS...]", "Fetch secrets from a vault" option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" option :account, type: :string, aliases: "-a", required: true, desc: "The account identifier or username" - def fetch(item, *fields) + option :location, type: :string, aliases: "-a", required: false, desc: "A vault or folder to fetch the secrets from" + def fetch(*secrets) ENV["KAMAL_SECRETS_KILL_PARENT"] = "1" - puts JSON.dump(adapter(options[:adapter]).fetch(item, fields, account: options[:account])).shellescape + + results = adapter(options[:adapter]).fetch(secrets, **options.slice(:account, :location).symbolize_keys) + puts JSON.dump(results).shellescape end desc "extract", "Extract a single secret from the results of a fetch call" diff --git a/lib/kamal/secrets/adapters.rb b/lib/kamal/secrets/adapters.rb index 2ad1dcdf3..439c7208a 100644 --- a/lib/kamal/secrets/adapters.rb +++ b/lib/kamal/secrets/adapters.rb @@ -1,12 +1,14 @@ +require "active_support/core_ext/string/inflections" module Kamal::Secrets::Adapters def self.lookup(name) - case name - when "1password" - Kamal::Secrets::Adapters::OnePassword.new - else - Object.const_get("Kamal::Secrets::Adapters::#{name.camelize}").new - end - rescue NameError + 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 diff --git a/lib/kamal/secrets/adapters/one_password.rb b/lib/kamal/secrets/adapters/one_password.rb index f5f1f8e15..ee9c9ce8c 100644 --- a/lib/kamal/secrets/adapters/one_password.rb +++ b/lib/kamal/secrets/adapters/one_password.rb @@ -1,40 +1,61 @@ -class Kamal::Secrets::Adapters::OnePassword +class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base delegate :optionize, to: Kamal::Utils - def fetch(item, fields, account: nil) - # session may be nil if logging in with the app CLI integration - session = signin(account) - vault, vault_item = item.split("/") - labels = fields.map { |field| "label=#{field}" }.join(",") - options = to_options(vault: vault, fields: labels, format: "json", account: account, session: session.presence) - - secrets_json = `op item get #{vault_item} #{options}`.tap do - raise RuntimeError, "Could not read #{labels} from #{vault_item} in the #{vault} 1Password vault" unless $?.success? + 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 - {}.tap do |secrets| - JSON.parse(secrets_json).each do |secret_json| - # The reference is in the form `op://vault/item/field[/field]` - field = secret_json["reference"].delete_prefix("op://#{item}/") - secrets[field] = secret_json["value"] - secrets[field.split("/").last] = secret_json["value"] - end + def loggedin?(account) + `op account get --account #{account}` + $?.success? end - rescue => e - $stderr.puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m" - Process.kill("INT", Process.ppid) if ENV["KAMAL_SECRETS_KILL_PARENT"] - exit 1 - end + def fetch_from_vault(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? - private - def signin(account) - `op signin #{to_options(account: account, force: true, raw: true)}`.tap do - raise RuntimeError, "Failed to login to 1Password" unless $?.success? + fields_json.each do |field_json| + # The reference is in the form `op://vault/item/field[/field]` + field = field_json["reference"].delete_suffix("/password") + results[field] = field_json["value"] + results[field.split("/").last] = 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| + 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} #{options}`.tap do + raise RuntimeError, "Could not read #{fields.join(", ")} from #{item} in the #{vault} 1Password vault" unless $?.success? + end + end end From b2e1a4d4c13750ec069661b34aa1a03b7b129ed9 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 29 Aug 2024 15:29:18 +0100 Subject: [PATCH 16/40] Secrets test --- test/cli/app_test.rb | 2 +- test/cli/secrets_test.rb | 30 +----------------------------- test/test_helper.rb | 19 +++++++------------ 3 files changed, 9 insertions(+), 42 deletions(-) diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index d1344b812..0a9ec4857 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -249,7 +249,7 @@ class CliAppTest < CliTestCase test "exec separate arguments" do run_command("exec", "ruby", " -v").tap do |output| - assert_match "docker run --rm --env-file .kamal/env/roles/app-web.env dhh/app:latest ruby -v", output + assert_match "docker run --rm dhh/app:latest ruby -v", output end end diff --git a/test/cli/secrets_test.rb b/test/cli/secrets_test.rb index 7ac5f9d75..733ac0b47 100644 --- a/test/cli/secrets_test.rb +++ b/test/cli/secrets_test.rb @@ -1,38 +1,10 @@ require_relative "cli_test_case" class CliSecretsTest < CliTestCase - test "login" do - assert_equal "LOGIN_TOKEN", run_command("login", "--adapter", "test") - end - - test "login failed" do - assert_raises("Boom!") do - run_command("login", "--adapter", "test", "--adapter-options", "boom:true") - end - end - test "fetch" do - assert_equal "oof", run_command("fetch", "foo", "--adapter", "test") - end - - test "fetch failed" do - assert_raises("Boom!") do - run_command("fetch", "foo", "--adapter", "test", "--adapter-options", "boom:true") - end - end - - test "fetch_all" do assert_equal \ "\\{\\\"foo\\\":\\\"oof\\\",\\\"bar\\\":\\\"rab\\\",\\\"baz\\\":\\\"zab\\\"\\}", - run_command("fetch_all", "foo", "bar", "baz", "--adapter", "test") - end - - test "fetch_all failed" do - assert_raises("Boom!") do - assert_equal \ - "\\{\\\"foo\\\":\\\"oof\\\",\\\"bar\\\":\\\"rab\\\",\\\"baz\\\":\\\"zab\\\"\\}", - run_command("fetch_all", "foo", "bar", "baz", "--adapter", "test", "--adapter-options", "boom:true") - end + run_command("fetch", "foo", "bar", "baz", "--account", "myaccount", "--adapter", "test") end test "extract" do diff --git a/test/test_helper.rb b/test/test_helper.rb index 10063acc2..94bb767ec 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -64,19 +64,14 @@ def teardown_test_secrets end end -class Kamal::Secrets::Adapters::Test - def login(boom: false) - raise "Boom!" if boom - "LOGIN_TOKEN" +class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base + def login(account) + "MYSESSION" end - def fetch(name, boom: false) - raise "Boom!" if boom - name.reverse - end - - def fetch_all(*names, boom: false) - raise "Boom!" if boom - names.to_h { |name| [ name, name.reverse ] } + def fetch_from_vault(secrets, account:, session:) + raise "No Session" unless session == "MYSESSION" + raise "Boom!" if ENV["BOOM"] + secrets.to_h { |name| [ name, name.reverse ] } end end From a726a86a17dda1f41f7c0d857096dd73fa529e3a Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 29 Aug 2024 15:29:39 +0100 Subject: [PATCH 17/40] Add lastpass, bitwarden adapters --- lib/kamal/secrets/adapters/base.rb | 24 ++++++++++++ lib/kamal/secrets/adapters/bitwarden.rb | 52 +++++++++++++++++++++++++ lib/kamal/secrets/adapters/last_pass.rb | 29 ++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 lib/kamal/secrets/adapters/base.rb create mode 100644 lib/kamal/secrets/adapters/bitwarden.rb create mode 100644 lib/kamal/secrets/adapters/last_pass.rb diff --git a/lib/kamal/secrets/adapters/base.rb b/lib/kamal/secrets/adapters/base.rb new file mode 100644 index 000000000..9432913d0 --- /dev/null +++ b/lib/kamal/secrets/adapters/base.rb @@ -0,0 +1,24 @@ +class Kamal::Secrets::Adapters::Base + delegate :optionize, to: Kamal::Utils + + def fetch(secrets, account:, location: nil) + session = login(account) + full_secrets = secrets.map { |secret| [ location, secret ].compact.join("/") } + fetch_from_vault(full_secrets, account: account, session: session) + rescue => e + $stderr.puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m" + $stderr.puts e.backtrace if ENV["VERBOSE"] + + Process.kill("INT", Process.ppid) if ENV["KAMAL_SECRETS_KILL_PARENT"] + exit 1 + end + + private + def login(...) + raise NotImplementedError + end + + def fetch_from_vault(...) + raise NotImplementedError + end +end diff --git a/lib/kamal/secrets/adapters/bitwarden.rb b/lib/kamal/secrets/adapters/bitwarden.rb new file mode 100644 index 000000000..e48ce82b9 --- /dev/null +++ b/lib/kamal/secrets/adapters/bitwarden.rb @@ -0,0 +1,52 @@ +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}") + status = run_command("status") + end + + if status["status"] == "locked" + session = run_command("unlock --raw", raw: true) + status = run_command("status", session: session) + end + + raise RuntimeError, "Failed to login to and unlock Bitwarden" unless status["status"] == "unlocked" + + run_command("sync", raw: true) + raise RuntimeError, "Failed to sync Bitwarden" unless $?.success? + + session + end + + def fetch_from_vault(secrets, account:, session:) + {}.tap do |results| + secrets.each do |secret| + item, field = secret.split("/") + item = run_command("get item #{item}", session: session) + raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success? + if field + item_field = item["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[secret] = value + results[field] = value + else + results[secret] = item["login"]["password"] + end + end + end + end + + def signedin?(account) + JSON.parse(`bw status`.strip)["status"] != "unauthenticated" + end + + def run_command(command, session: nil, raw: false) + full_command = [ *("BW_SESSION=#{session}" if session), "bw", command ].join(" ") + result = `#{full_command}`.strip + raw ? result : JSON.parse(result) + end +end diff --git a/lib/kamal/secrets/adapters/last_pass.rb b/lib/kamal/secrets/adapters/last_pass.rb new file mode 100644 index 000000000..984a684ae --- /dev/null +++ b/lib/kamal/secrets/adapters/last_pass.rb @@ -0,0 +1,29 @@ +class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base + private + def login(account) + unless loggedin?(account) + `lpass login #{account}` + 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_from_vault(secrets, account:, session:) + items = JSON.parse(`lpass show #{secrets.join(" ")} --json` + raise RuntimeError, "Could not read #{fields} from 1Password" unless $?.success? + + {}.tap do |results| + items.each do |item| + results[item["name"]] = item["password"] + 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 From 068aaa0bd0a155d5e7cf70d32b7e1d7064bab748 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 29 Aug 2024 15:31:56 +0100 Subject: [PATCH 18/40] Fix options --- lib/kamal/cli/secrets.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/kamal/cli/secrets.rb b/lib/kamal/cli/secrets.rb index d7e99b316..763941840 100644 --- a/lib/kamal/cli/secrets.rb +++ b/lib/kamal/cli/secrets.rb @@ -1,8 +1,8 @@ class Kamal::Cli::Secrets < Kamal::Cli::Base - desc "fetch [ITEM] [FIELDS...]", "Fetch secrets from a vault" + 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, aliases: "-a", required: true, desc: "The account identifier or username" - option :location, type: :string, aliases: "-a", required: false, desc: "A vault or folder to fetch the secrets from" + option :account, type: :string, required: true, desc: "The account identifier or username" + option :location, type: :string, required: false, desc: "A vault or folder to fetch the secrets from" def fetch(*secrets) ENV["KAMAL_SECRETS_KILL_PARENT"] = "1" From 9deb8af4a001cbac8a3ecee8de094cc467017048 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 29 Aug 2024 15:34:42 +0100 Subject: [PATCH 19/40] Don't hide command --- lib/kamal/cli/main.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 39d431ad9..9fa9ba922 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -211,7 +211,7 @@ def version desc "registry", "Login and -out of the image registry" subcommand "registry", Kamal::Cli::Registry - desc "secrets", "Helpers for extracting secrets", hide: true + desc "secrets", "Helpers for extracting secrets" subcommand "secrets", Kamal::Cli::Secrets desc "server", "Bootstrap servers with curl and Docker" From 5226d52f8a946804d1d30e31072634dbdd638ad7 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 4 Sep 2024 12:14:47 +0100 Subject: [PATCH 20/40] Interrupting parent on error --- lib/kamal/cli/secrets.rb | 26 ++- lib/kamal/secrets.rb | 21 +- lib/kamal/secrets/adapters/base.rb | 6 +- lib/kamal/secrets/adapters/bitwarden.rb | 40 ++-- lib/kamal/secrets/adapters/last_pass.rb | 7 +- lib/kamal/secrets/adapters/one_password.rb | 4 +- test/cli/secrets_test.rb | 4 + test/secrets/bitwarden_adapter_test.rb | 211 +++++++++++++++++++++ test/secrets/last_pass_adapter_test.rb | 152 +++++++++++++++ test/secrets/one_password_adapter_test.rb | 151 +++++++++++++-- test/test_helper.rb | 21 ++ 11 files changed, 598 insertions(+), 45 deletions(-) create mode 100644 test/secrets/bitwarden_adapter_test.rb create mode 100644 test/secrets/last_pass_adapter_test.rb diff --git a/lib/kamal/cli/secrets.rb b/lib/kamal/cli/secrets.rb index 763941840..e5c3b7d5b 100644 --- a/lib/kamal/cli/secrets.rb +++ b/lib/kamal/cli/secrets.rb @@ -2,21 +2,39 @@ 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 :location, type: :string, required: false, desc: "A vault or folder to fetch the secrets from" + option :from, type: :string, required: false, desc: "A vault or folder to fetch the secrets from" def fetch(*secrets) - ENV["KAMAL_SECRETS_KILL_PARENT"] = "1" - - results = adapter(options[:adapter]).fetch(secrets, **options.slice(:account, :location).symbolize_keys) + results = adapter(options[:adapter]).fetch(secrets, **options.slice(:account, :from).symbolize_keys) puts JSON.dump(results).shellescape + rescue => e + handle_error(e) end desc "extract", "Extract a single secret from the results of a fetch call" def extract(name, secrets) + parsed_secrets = JSON.parse(secrets) + + if (value = parsed_secrets[name]).nil? + value = parsed_secrets.find { |k, v| k.end_with?("/#{name}") }&.last + end + + raise "Could not find secret #{name}" if value.nil? + puts JSON.parse(secrets).fetch(name) + rescue => e + handle_error(e) end private def adapter(adapter) Kamal::Secrets::Adapters.lookup(adapter) end + + def handle_error(e) + $stderr.puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m" + $stderr.puts e.backtrace if ENV["VERBOSE"] + + Process.kill("INT", Process.ppid) if ENV["KAMAL_SECRETS_INT_PARENT"] + exit 1 + end end diff --git a/lib/kamal/secrets.rb b/lib/kamal/secrets.rb index e195cc379..5c15bc9b3 100644 --- a/lib/kamal/secrets.rb +++ b/lib/kamal/secrets.rb @@ -6,7 +6,10 @@ def initialize(destination: nil) end def [](key) - @secrets ||= secrets_file ? Dotenv.parse(*secrets_file) : {} + # If dot env interpolates any `kamal secrets` calls, this tells it to interrupt this process if there are errors + ENV["KAMAL_SECRETS_INT_PARENT"] = "1" + + @secrets ||= secrets_file ? Dotenv.parse(secrets_file) : {} @secrets.fetch(key) rescue KeyError if secrets_file @@ -15,4 +18,20 @@ def [](key) raise Kamal::ConfigurationError, "Secret '#{key}' not found, no secret files provided" end end + + private + def parse_secrets + if secrets_file + interrupting_parent_on_error { Dotenv.parse(secrets_file) } + else + {} + end + end + + def interrupting_parent_on_error + ENV["KAMAL_SECRETS_INT_PARENT"] = "1" + yield + ensure + ENV.delete("KAMAL_SECRETS_INT_PARENT") + end end diff --git a/lib/kamal/secrets/adapters/base.rb b/lib/kamal/secrets/adapters/base.rb index 9432913d0..93ddca476 100644 --- a/lib/kamal/secrets/adapters/base.rb +++ b/lib/kamal/secrets/adapters/base.rb @@ -1,15 +1,15 @@ class Kamal::Secrets::Adapters::Base delegate :optionize, to: Kamal::Utils - def fetch(secrets, account:, location: nil) + def fetch(secrets, account:, from: nil) session = login(account) - full_secrets = secrets.map { |secret| [ location, secret ].compact.join("/") } + full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") } fetch_from_vault(full_secrets, account: account, session: session) rescue => e $stderr.puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m" $stderr.puts e.backtrace if ENV["VERBOSE"] - Process.kill("INT", Process.ppid) if ENV["KAMAL_SECRETS_KILL_PARENT"] + Process.kill("INT", Process.ppid) if ENV["KAMAL_SECRETS_INT_PARENT"] exit 1 end diff --git a/lib/kamal/secrets/adapters/bitwarden.rb b/lib/kamal/secrets/adapters/bitwarden.rb index e48ce82b9..42fad2e89 100644 --- a/lib/kamal/secrets/adapters/bitwarden.rb +++ b/lib/kamal/secrets/adapters/bitwarden.rb @@ -9,13 +9,13 @@ def login(account) end if status["status"] == "locked" - session = run_command("unlock --raw", raw: true) + 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", raw: true) + run_command("sync", session: session, raw: true) raise RuntimeError, "Failed to sync Bitwarden" unless $?.success? session @@ -23,25 +23,37 @@ def login(account) def fetch_from_vault(secrets, account:, session:) {}.tap do |results| - secrets.each do |secret| - item, field = secret.split("/") - item = run_command("get item #{item}", session: session) - raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success? - if field - item_field = item["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[secret] = value - results[field] = value + items_fields(secrets).each do |item, fields| + item_json = run_command("get item #{item}", 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[secret] = item["login"]["password"] + 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) - JSON.parse(`bw status`.strip)["status"] != "unauthenticated" + run_command("status")["status"] != "unauthenticated" end def run_command(command, session: nil, raw: false) diff --git a/lib/kamal/secrets/adapters/last_pass.rb b/lib/kamal/secrets/adapters/last_pass.rb index 984a684ae..ab46e2cda 100644 --- a/lib/kamal/secrets/adapters/last_pass.rb +++ b/lib/kamal/secrets/adapters/last_pass.rb @@ -12,12 +12,13 @@ def loggedin?(account) end def fetch_from_vault(secrets, account:, session:) - items = JSON.parse(`lpass show #{secrets.join(" ")} --json` - raise RuntimeError, "Could not read #{fields} from 1Password" unless $?.success? + items = `lpass show #{secrets.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["name"]] = item["password"] results[item["fullname"]] = item["password"] end diff --git a/lib/kamal/secrets/adapters/one_password.rb b/lib/kamal/secrets/adapters/one_password.rb index ee9c9ce8c..9b68ca689 100644 --- a/lib/kamal/secrets/adapters/one_password.rb +++ b/lib/kamal/secrets/adapters/one_password.rb @@ -24,9 +24,8 @@ def fetch_from_vault(secrets, account:, session:) fields_json.each do |field_json| # The reference is in the form `op://vault/item/field[/field]` - field = field_json["reference"].delete_suffix("/password") + field = field_json["reference"].delete_prefix("op://").delete_suffix("/password") results[field] = field_json["value"] - results[field.split("/").last] = field_json["value"] end end end @@ -40,6 +39,7 @@ def to_options(**options) 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? diff --git a/test/cli/secrets_test.rb b/test/cli/secrets_test.rb index 733ac0b47..35d205004 100644 --- a/test/cli/secrets_test.rb +++ b/test/cli/secrets_test.rb @@ -11,6 +11,10 @@ class CliSecretsTest < CliTestCase 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" ]) } diff --git a/test/secrets/bitwarden_adapter_test.rb b/test/secrets/bitwarden_adapter_test.rb new file mode 100644 index 000000000..ff3f2a1c6 --- /dev/null +++ b/test/secrets/bitwarden_adapter_test.rb @@ -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 diff --git a/test/secrets/last_pass_adapter_test.rb b/test/secrets/last_pass_adapter_test.rb new file mode 100644 index 000000000..3801d486e --- /dev/null +++ b/test/secrets/last_pass_adapter_test.rb @@ -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 diff --git a/test/secrets/one_password_adapter_test.rb b/test/secrets/one_password_adapter_test.rb index 41b6fe17d..e36cf8a22 100644 --- a/test/secrets/one_password_adapter_test.rb +++ b/test/secrets/one_password_adapter_test.rb @@ -1,24 +1,65 @@ require "test_helper" -class SecretsOnePasswordAdapterTest < ActiveSupport::TestCase - test "login" do - `true` # Ensure $? is 0 - Object.any_instance.stubs(:`).with("op signin --account \"myaccount\" --force --raw").returns("Logged in") +class SecretsOnePasswordAdapterTest < SecretAdapterTestCase + test "fetch" do + stub_ticks.with("op account get --account myaccount") - assert_equal "Logged in", run_command("login") - end + 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 - test "fetch" do - `true` # Ensure $? is 0 - Object.any_instance.stubs(:`).with("op read op://vault/item/section/foo --account \"myaccount\"").returns("bar") + 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 "bar", run_command("fetch", "op://vault/item/section/foo") + assert_equal expected_json, json end - test "fetch_all" do - `true` # Ensure $? is 0 - Object.any_instance.stubs(:`) - .with("op item get item --vault \"vault\" --fields \"label=section.SECRET1,label=section.SECRET2\" --format \"json\" --account \"myaccount\"") + test "fetch with multiple items" do + stub_ticks.with("op account get --account myaccount") + + stub_ticks + .with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1,label=section.SECRET2\" --format \"json\" --account \"myaccount\"") .returns(<<~JSON) [ { @@ -30,7 +71,7 @@ class SecretsOnePasswordAdapterTest < ActiveSupport::TestCase "type": "CONCEALED", "label": "SECRET1", "value": "VALUE1", - "reference": "op://vault/item/section/SECRET1" + "reference": "op://myvault/myitem/section/SECRET1" }, { "id": "bbbbbbbbbbbbbbbbbbbbbbbbbb", @@ -41,12 +82,70 @@ class SecretsOnePasswordAdapterTest < ActiveSupport::TestCase "type": "CONCEALED", "label": "SECRET2", "value": "VALUE2", - "reference": "op://vault/item/section/SECRET2" + "reference": "op://myvault/myitem/section/SECRET2" } ] JSON - assert_equal "bar", run_command("fetch_all", "op://vault/item/section/SECRET1", "op://vault/item/section/SECRET2") + 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", 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", 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 @@ -56,7 +155,23 @@ def run_command(*command) [ *command, "-c", "test/fixtures/deploy_with_accessories.yml", "--adapter", "1password", - "--adapter-options", "account:myaccount" ] + "--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 diff --git a/test/test_helper.rb b/test/test_helper.rb index 94bb767ec..bb585280e 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -75,3 +75,24 @@ def fetch_from_vault(secrets, account:, session:) secrets.to_h { |name| [ name, name.reverse ] } 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 From 3d502ab12db7bdfe4aae44075a97651561f14ada Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 4 Sep 2024 12:40:27 +0100 Subject: [PATCH 21/40] Add test adapter and interpolate secrets in integration tests --- lib/kamal/cli/secrets.rb | 2 +- lib/kamal/secrets/adapters/test.rb | 10 ++++++++++ test/integration/docker/deployer/app/.kamal/secrets | 3 +++ test/integration/docker/deployer/app/config/deploy.yml | 2 ++ test/integration/main_test.rb | 4 +++- 5 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 lib/kamal/secrets/adapters/test.rb diff --git a/lib/kamal/cli/secrets.rb b/lib/kamal/cli/secrets.rb index e5c3b7d5b..572476f98 100644 --- a/lib/kamal/cli/secrets.rb +++ b/lib/kamal/cli/secrets.rb @@ -20,7 +20,7 @@ def extract(name, secrets) raise "Could not find secret #{name}" if value.nil? - puts JSON.parse(secrets).fetch(name) + puts value rescue => e handle_error(e) end diff --git a/lib/kamal/secrets/adapters/test.rb b/lib/kamal/secrets/adapters/test.rb new file mode 100644 index 000000000..8750a2e2b --- /dev/null +++ b/lib/kamal/secrets/adapters/test.rb @@ -0,0 +1,10 @@ +class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base + private + def login(account) + true + end + + def fetch_from_vault(secrets, account:, session:) + secrets.to_h { |secret| [ secret, secret.reverse ] } + end +end diff --git a/test/integration/docker/deployer/app/.kamal/secrets b/test/integration/docker/deployer/app/.kamal/secrets index ea15ab06e..454ba88f4 100644 --- a/test/integration/docker/deployer/app/.kamal/secrets +++ b/test/integration/docker/deployer/app/.kamal/secrets @@ -1,2 +1,5 @@ SECRET_TOKEN='1234 with "中文"' SECRET_TAG='TAGME' +SECRETS=$(kamal secrets fetch --adapter test --account test INTERPOLATED_SECRET1 INTERPOLATED_SECRET2) +INTERPOLATED_SECRET1=$(kamal secrets extract INTERPOLATED_SECRET1 ${SECRETS}) +INTERPOLATED_SECRET2=$(kamal secrets extract INTERPOLATED_SECRET2 ${SECRETS}) diff --git a/test/integration/docker/deployer/app/config/deploy.yml b/test/integration/docker/deployer/app/config/deploy.yml index 887de8251..ae67ea3ed 100644 --- a/test/integration/docker/deployer/app/config/deploy.yml +++ b/test/integration/docker/deployer/app/config/deploy.yml @@ -10,6 +10,8 @@ env: HOST_TOKEN: "${HOST_TOKEN}" secret: - SECRET_TOKEN + - INTERPOLATED_SECRET1 + - INTERPOLATED_SECRET2 tags: tag1: CLEAR_TAG: tagged diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index 7ed6ee8f7..4967002d6 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -102,7 +102,7 @@ class MainTest < IntegrationTest end private - def assert_envs(version:) + def assert_envs(version:) assert_env :CLEAR_TOKEN, "4321", version: version, vm: :vm1 assert_env :HOST_TOKEN, "abcd", version: version, vm: :vm1 assert_env :SECRET_TOKEN, "1234 with \"中文\"", version: version, vm: :vm1 @@ -110,6 +110,8 @@ def assert_envs(version:) assert_no_env :SECRET_TAG, version: version, vm: :vm1 assert_env :CLEAR_TAG, "tagged", 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 end def assert_env(key, value, vm:, version:) From 31a347c285f6f362dae673111152972cc4306260 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 4 Sep 2024 12:52:30 +0100 Subject: [PATCH 22/40] Move int parent comment --- lib/kamal/secrets.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/kamal/secrets.rb b/lib/kamal/secrets.rb index 5c15bc9b3..151dd3b2a 100644 --- a/lib/kamal/secrets.rb +++ b/lib/kamal/secrets.rb @@ -6,10 +6,7 @@ def initialize(destination: nil) end def [](key) - # If dot env interpolates any `kamal secrets` calls, this tells it to interrupt this process if there are errors - ENV["KAMAL_SECRETS_INT_PARENT"] = "1" - - @secrets ||= secrets_file ? Dotenv.parse(secrets_file) : {} + @secrets ||= parse_secrets @secrets.fetch(key) rescue KeyError if secrets_file @@ -29,6 +26,7 @@ def parse_secrets end def interrupting_parent_on_error + # Make any `kamal secrets` calls in dotenv interpolation interrupt this process if there are errors ENV["KAMAL_SECRETS_INT_PARENT"] = "1" yield ensure From a68294c38460c27548285a7488433b4b965f3a7c Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 4 Sep 2024 12:57:25 +0100 Subject: [PATCH 23/40] Remote test adapter from test_helper.rb --- test/test_helper.rb | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index bb585280e..f8f4f4e48 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -64,18 +64,6 @@ def teardown_test_secrets end end -class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base - def login(account) - "MYSESSION" - end - - def fetch_from_vault(secrets, account:, session:) - raise "No Session" unless session == "MYSESSION" - raise "Boom!" if ENV["BOOM"] - secrets.to_h { |name| [ name, name.reverse ] } - end -end - class SecretAdapterTestCase < ActiveSupport::TestCase setup do `true` # Ensure $? is 0 From 1522d94ac91abf839a2f3fe76f312fc0c3a59d15 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 4 Sep 2024 16:24:10 +0100 Subject: [PATCH 24/40] Pass secrets to pre/post deploy hooks --- lib/kamal/cli/main.rb | 12 +++++------ lib/kamal/commands/hook.rb | 7 ++++-- lib/kamal/secrets.rb | 11 ++++++++-- test/cli/cli_test_case.rb | 3 ++- test/cli/main_test.rb | 44 ++++++++++++++++++++------------------ test/commands/hook_test.rb | 15 +++++++++++++ 6 files changed, 60 insertions(+), 32 deletions(-) diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 9fa9ba922..b1f01238f 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -33,7 +33,7 @@ def deploy end with_lock do - run_hook "pre-deploy" + run_hook "pre-deploy", secrets: true say "Ensure Traefik is running...", :magenta invoke "kamal:cli:traefik:boot", [], invoke_options @@ -48,7 +48,7 @@ def deploy end end - run_hook "post-deploy", runtime: runtime.round + run_hook "post-deploy", secrets: true, runtime: runtime.round end desc "redeploy", "Deploy app to servers without bootstrapping servers, starting Traefik, pruning, and registry login" @@ -66,7 +66,7 @@ def redeploy end with_lock do - run_hook "pre-deploy" + run_hook "pre-deploy", secrets: true say "Detect stale containers...", :magenta invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true) @@ -75,7 +75,7 @@ def redeploy end end - run_hook "post-deploy", runtime: runtime.round + run_hook "post-deploy", secrets: true, runtime: runtime.round end desc "rollback [VERSION]", "Rollback app to VERSION" @@ -89,7 +89,7 @@ def rollback(version) old_version = nil if container_available?(version) - run_hook "pre-deploy" + run_hook "pre-deploy", secrets: true invoke "kamal:cli:app:boot", [], invoke_options.merge(version: version) rolled_back = true @@ -99,7 +99,7 @@ def rollback(version) 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 desc "details", "Show details about all containers" diff --git a/lib/kamal/commands/hook.rb b/lib/kamal/commands/hook.rb index 66fe8b8c2..eb710d5e4 100644 --- a/lib/kamal/commands/hook.rb +++ b/lib/kamal/commands/hook.rb @@ -1,6 +1,9 @@ class Kamal::Commands::Hook < Kamal::Commands::Base - def run(hook, **details) - [ hook_file(hook), env: tags(**details).env ] + def run(hook, secrets: false, **details) + env = tags(**details).env + env.merge!(config.secrets.to_h) if secrets + + [ hook_file(hook), env: env ] end def hook_exists?(hook) diff --git a/lib/kamal/secrets.rb b/lib/kamal/secrets.rb index 151dd3b2a..25d24934d 100644 --- a/lib/kamal/secrets.rb +++ b/lib/kamal/secrets.rb @@ -6,8 +6,7 @@ def initialize(destination: nil) end def [](key) - @secrets ||= parse_secrets - @secrets.fetch(key) + secrets.fetch(key) rescue KeyError if secrets_file raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secrets_file}" @@ -16,7 +15,15 @@ def [](key) end end + def to_h + secrets + end + private + def secrets + @secrets ||= parse_secrets + end + def parse_secrets if secrets_file interrupting_parent_on_error { Dotenv.parse(secrets_file) } diff --git a/test/cli/cli_test_case.rb b/test/cli/cli_test_case.rb index c522a32f8..3f3e9294e 100644 --- a/test/cli/cli_test_case.rb +++ b/test/cli/cli_test_case.rb @@ -40,7 +40,7 @@ def stub_setup .with(:docker, :buildx, :inspect, "kamal-local-docker-container") 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 performer = Kamal::Git.email.presence || whoami service = service_version.split("@").first @@ -58,6 +58,7 @@ def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, KAMAL_COMMAND=\"#{command}\"\s #{"KAMAL_SUBCOMMAND=\\\"#{subcommand}\\\"\\s" if subcommand} #{"KAMAL_RUNTIME=\\\"\\d+\\\"\\s" if runtime} + #{"DB_PASSWORD=\"secret\"\\s" if secrets} ;\s/usr/bin/env\s\.kamal/hooks/#{hook} }x assert_match expected, output diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 8b9f129c6..c742afe99 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -43,27 +43,29 @@ class CliMainTest < CliTestCase end test "deploy" 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: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: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:prune:all", [], invoke_options) - - 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" } - - run_command("deploy", "--verbose").tap do |output| - assert_hook_ran "pre-connect", output, **hook_variables - assert_match /Log into image registry/, output - assert_match /Build and push app image/, output - assert_hook_ran "pre-deploy", output, **hook_variables - assert_match /Ensure Traefik is running/, output - assert_match /Detect stale containers/, output - assert_match /Prune old containers and images/, output - assert_hook_ran "post-deploy", output, **hook_variables, runtime: 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: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: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:prune:all", [], invoke_options) + + 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" } + + run_command("deploy", "--verbose").tap do |output| + assert_hook_ran "pre-connect", output, **hook_variables + assert_match /Log into image registry/, output + assert_match /Build and push app image/, output + assert_hook_ran "pre-deploy", output, **hook_variables, secrets: true + assert_match /Ensure Traefik is running/, output + assert_match /Detect stale containers/, output + assert_match /Prune old containers and images/, output + assert_hook_ran "post-deploy", output, **hook_variables, runtime: true, secrets: true + end end end diff --git a/test/commands/hook_test.rb b/test/commands/hook_test.rb index 60438c66f..f6234d6a1 100644 --- a/test/commands/hook_test.rb +++ b/test/commands/hook_test.rb @@ -39,6 +39,21 @@ class CommandsHookTest < ActiveSupport::TestCase ], new_command(hooks_path: "custom/hooks/path").run("foo") 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 def new_command(**extra_config) Kamal::Commands::Hook.new(Kamal::Configuration.new(@config.merge(**extra_config), version: "123")) From 9b96ef2412844b02c8f8f4d2294f8f4ad1d63886 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 5 Sep 2024 08:37:50 +0100 Subject: [PATCH 25/40] Shellescape command input --- lib/kamal/secrets/adapters/bitwarden.rb | 6 +++--- lib/kamal/secrets/adapters/last_pass.rb | 4 ++-- lib/kamal/secrets/adapters/one_password.rb | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/kamal/secrets/adapters/bitwarden.rb b/lib/kamal/secrets/adapters/bitwarden.rb index 42fad2e89..37717ef55 100644 --- a/lib/kamal/secrets/adapters/bitwarden.rb +++ b/lib/kamal/secrets/adapters/bitwarden.rb @@ -4,7 +4,7 @@ def login(account) status = run_command("status") if status["status"] == "unauthenticated" - run_command("login #{account}") + run_command("login #{account.shellescape}", raw: true) status = run_command("status") end @@ -24,7 +24,7 @@ def login(account) def fetch_from_vault(secrets, account:, session:) {}.tap do |results| items_fields(secrets).each do |item, fields| - item_json = run_command("get item #{item}", session: session, raw: true) + 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) @@ -57,7 +57,7 @@ def signedin?(account) end def run_command(command, session: nil, raw: false) - full_command = [ *("BW_SESSION=#{session}" if session), "bw", command ].join(" ") + full_command = [ *("BW_SESSION=#{session.shellescape}" if session), "bw", command ].join(" ") result = `#{full_command}`.strip raw ? result : JSON.parse(result) end diff --git a/lib/kamal/secrets/adapters/last_pass.rb b/lib/kamal/secrets/adapters/last_pass.rb index ab46e2cda..dd4dd06a5 100644 --- a/lib/kamal/secrets/adapters/last_pass.rb +++ b/lib/kamal/secrets/adapters/last_pass.rb @@ -2,7 +2,7 @@ class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base private def login(account) unless loggedin?(account) - `lpass login #{account}` + `lpass login #{account.shellescape}` raise RuntimeError, "Failed to login to 1Password" unless $?.success? end end @@ -12,7 +12,7 @@ def loggedin?(account) end def fetch_from_vault(secrets, account:, session:) - items = `lpass show #{secrets.join(" ")} --json` + items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json` raise RuntimeError, "Could not read #{secrets} from 1Password" unless $?.success? items = JSON.parse(items) diff --git a/lib/kamal/secrets/adapters/one_password.rb b/lib/kamal/secrets/adapters/one_password.rb index 9b68ca689..02287a385 100644 --- a/lib/kamal/secrets/adapters/one_password.rb +++ b/lib/kamal/secrets/adapters/one_password.rb @@ -11,7 +11,7 @@ def login(account) end def loggedin?(account) - `op account get --account #{account}` + `op account get --account #{account.shellescape}` $?.success? end @@ -54,7 +54,7 @@ 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} #{options}`.tap do + `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 From 8210e8e768815e22f99871994746796cac373e80 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 5 Sep 2024 09:53:18 +0100 Subject: [PATCH 26/40] Drop redundant rescue --- lib/kamal/secrets/adapters/base.rb | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/kamal/secrets/adapters/base.rb b/lib/kamal/secrets/adapters/base.rb index 93ddca476..97b2a458e 100644 --- a/lib/kamal/secrets/adapters/base.rb +++ b/lib/kamal/secrets/adapters/base.rb @@ -4,13 +4,7 @@ class Kamal::Secrets::Adapters::Base def fetch(secrets, account:, from: nil) session = login(account) full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") } - fetch_from_vault(full_secrets, account: account, session: session) - rescue => e - $stderr.puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m" - $stderr.puts e.backtrace if ENV["VERBOSE"] - - Process.kill("INT", Process.ppid) if ENV["KAMAL_SECRETS_INT_PARENT"] - exit 1 + fetch_secrets(full_secrets, account: account, session: session) end private @@ -18,7 +12,7 @@ def login(...) raise NotImplementedError end - def fetch_from_vault(...) + def fetch_secrets(...) raise NotImplementedError end end From be1df4356a604eb5d349453a1f92df9ff0ecd442 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 5 Sep 2024 09:53:33 +0100 Subject: [PATCH 27/40] fetch_from_vault -> fetch_secrets --- lib/kamal/secrets/adapters/bitwarden.rb | 2 +- lib/kamal/secrets/adapters/last_pass.rb | 2 +- lib/kamal/secrets/adapters/one_password.rb | 2 +- lib/kamal/secrets/adapters/test.rb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/kamal/secrets/adapters/bitwarden.rb b/lib/kamal/secrets/adapters/bitwarden.rb index 37717ef55..957169971 100644 --- a/lib/kamal/secrets/adapters/bitwarden.rb +++ b/lib/kamal/secrets/adapters/bitwarden.rb @@ -21,7 +21,7 @@ def login(account) session end - def fetch_from_vault(secrets, account:, session:) + 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) diff --git a/lib/kamal/secrets/adapters/last_pass.rb b/lib/kamal/secrets/adapters/last_pass.rb index dd4dd06a5..16dad1507 100644 --- a/lib/kamal/secrets/adapters/last_pass.rb +++ b/lib/kamal/secrets/adapters/last_pass.rb @@ -11,7 +11,7 @@ def loggedin?(account) `lpass status --color never`.strip == "Logged in as #{account}." end - def fetch_from_vault(secrets, account:, session:) + def fetch_secrets(secrets, account:, session:) items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json` raise RuntimeError, "Could not read #{secrets} from 1Password" unless $?.success? diff --git a/lib/kamal/secrets/adapters/one_password.rb b/lib/kamal/secrets/adapters/one_password.rb index 02287a385..f3db373f2 100644 --- a/lib/kamal/secrets/adapters/one_password.rb +++ b/lib/kamal/secrets/adapters/one_password.rb @@ -15,7 +15,7 @@ def loggedin?(account) $?.success? end - def fetch_from_vault(secrets, account:, session:) + def fetch_secrets(secrets, account:, session:) {}.tap do |results| vaults_items_fields(secrets).map do |vault, items| items.each do |item, fields| diff --git a/lib/kamal/secrets/adapters/test.rb b/lib/kamal/secrets/adapters/test.rb index 8750a2e2b..fc0903d99 100644 --- a/lib/kamal/secrets/adapters/test.rb +++ b/lib/kamal/secrets/adapters/test.rb @@ -4,7 +4,7 @@ def login(account) true end - def fetch_from_vault(secrets, account:, session:) + def fetch_secrets(secrets, account:, session:) secrets.to_h { |secret| [ secret, secret.reverse ] } end end From 8b62e2694ae7fb1bda558b97f4093cf098f75af3 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 5 Sep 2024 10:01:56 +0100 Subject: [PATCH 28/40] Test non-ascii secret interpolation --- test/integration/docker/deployer/app/.kamal/secrets | 3 ++- test/integration/docker/deployer/app/config/deploy.yml | 1 + test/integration/main_test.rb | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/test/integration/docker/deployer/app/.kamal/secrets b/test/integration/docker/deployer/app/.kamal/secrets index 454ba88f4..ee55e94c2 100644 --- a/test/integration/docker/deployer/app/.kamal/secrets +++ b/test/integration/docker/deployer/app/.kamal/secrets @@ -1,5 +1,6 @@ SECRET_TOKEN='1234 with "中文"' SECRET_TAG='TAGME' -SECRETS=$(kamal secrets fetch --adapter test --account test INTERPOLATED_SECRET1 INTERPOLATED_SECRET2) +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}) diff --git a/test/integration/docker/deployer/app/config/deploy.yml b/test/integration/docker/deployer/app/config/deploy.yml index ae67ea3ed..a85412ad2 100644 --- a/test/integration/docker/deployer/app/config/deploy.yml +++ b/test/integration/docker/deployer/app/config/deploy.yml @@ -12,6 +12,7 @@ env: - SECRET_TOKEN - INTERPOLATED_SECRET1 - INTERPOLATED_SECRET2 + - INTERPOLATED_SECRET3 tags: tag1: CLEAR_TAG: tagged diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index 4967002d6..5b12d8576 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -112,6 +112,7 @@ def assert_envs(version:) 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 def assert_env(key, value, vm:, version:) From 8ad6a0ed16ad9da24bdeb1868718bcfe2e65217f Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 6 Sep 2024 11:54:12 +0100 Subject: [PATCH 29/40] Add .kamal/secrets on kamal init --- lib/kamal/cli/main.rb | 13 ++++-- lib/kamal/cli/templates/secrets | 6 +++ lib/kamal/cli/templates/template.env | 2 - test/cli/main_test.rb | 62 +++++++++++++++++----------- 4 files changed, 54 insertions(+), 29 deletions(-) create mode 100644 lib/kamal/cli/templates/secrets delete mode 100644 lib/kamal/cli/templates/template.env diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index b1f01238f..f5ad83975 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -148,9 +148,16 @@ def init puts "Created configuration file in config/deploy.yml" end - unless (deploy_file = Pathname.new(File.expand_path(".env"))).exist? - FileUtils.cp_r Pathname.new(File.expand_path("templates/template.env", __dir__)), deploy_file - puts "Created .env file" + unless (secrets_file = Pathname.new(File.expand_path(".kamal/secrets"))).exist? + FileUtils.mkdir_p secrets_file.dirname + FileUtils.cp_r Pathname.new(File.expand_path("templates/secrets", __dir__)), secrets_file + puts "Created .kamal/secrets file" + + gitignore = Pathname.new(File.expand_path(".gitignore")) + if gitignore.exist? && !gitignore.read.include?(".kamal/secrets") + gitignore.open("a") { |f| f.puts "\n.kamal/secrets*" } + puts "Added .kamal/secrets* to .gitignore" + end end unless (hooks_dir = Pathname.new(File.expand_path(".kamal/hooks"))).exist? diff --git a/lib/kamal/cli/templates/secrets b/lib/kamal/cli/templates/secrets new file mode 100644 index 000000000..cfa312ef0 --- /dev/null +++ b/lib/kamal/cli/templates/secrets @@ -0,0 +1,6 @@ +# SECRETS=$(kamal secrets --adapter 1password --from Vault/Item Section1/KAMAL_REGISTRY_PASSWORD Section2/RAILS_MASTER_KEY) +# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS}) +# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) + +KAMAL_REGISTRY_PASSWORD=change-this +RAILS_MASTER_KEY=another-env diff --git a/lib/kamal/cli/templates/template.env b/lib/kamal/cli/templates/template.env deleted file mode 100644 index 89411448d..000000000 --- a/lib/kamal/cli/templates/template.env +++ /dev/null @@ -1,2 +0,0 @@ -KAMAL_REGISTRY_PASSWORD=change-this -RAILS_MASTER_KEY=another-env diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index c742afe99..43e24ced9 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -384,40 +384,40 @@ class CliMainTest < CliTestCase end test "init" do - Pathname.any_instance.expects(:exist?).returns(false).times(3) - Pathname.any_instance.stubs(:mkpath) - FileUtils.stubs(:mkdir_p) - FileUtils.stubs(:cp_r) - FileUtils.stubs(:cp) + in_dummy_git_repo do + run_command("init").tap do |output| + assert_match "Created configuration file in config/deploy.yml", output + assert_match "Created .kamal/secrets file", output + assert_match "Added .kamal/secrets* to .gitignore", output + end - run_command("init").tap do |output| - assert_match /Created configuration file in config\/deploy.yml/, output - assert_match /Created \.env file/, output + assert_file "config/deploy.yml", "service: my-app" + assert_file ".kamal/secrets", "KAMAL_REGISTRY_PASSWORD=change-this" + assert_file ".gitignore", %r{\n.kamal/secrets\*\n} end end 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| - assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, 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_no_match /Added .kamal\/secrets/, output + end end end test "init with bundle option" do - Pathname.any_instance.expects(:exist?).returns(false).times(4) - Pathname.any_instance.stubs(:mkpath) - FileUtils.stubs(:mkdir_p) - FileUtils.stubs(:cp_r) - FileUtils.stubs(:cp) - - run_command("init", "--bundle").tap do |output| - assert_match /Created configuration file in config\/deploy.yml/, output - assert_match /Created \.env file/, output - 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 + in_dummy_git_repo do + run_command("init", "--bundle").tap do |output| + assert_match "Created configuration file in config/deploy.yml", output + assert_match "Created .kamal/secrets file", output + 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 @@ -523,4 +523,18 @@ def run_command(*command, config_file: "deploy_simple") stdouted { Kamal::Cli::Main.start } end end + + def in_dummy_git_repo + Dir.mktmpdir do |tmpdir| + Dir.chdir(tmpdir) do + `git init` + `echo '/.bundle\n/log/*\n/tmp/*' > .gitignore` + yield + end + end + end + + def assert_file(file, content) + assert_match content, File.read(file) + end end From b99c0443278e4b72a98f97ca9f95e0055da9b640 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 6 Sep 2024 13:25:39 +0100 Subject: [PATCH 30/40] Update lib/kamal/cli/templates/secrets Co-authored-by: Sijawusz Pur Rahnama --- lib/kamal/cli/templates/secrets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/kamal/cli/templates/secrets b/lib/kamal/cli/templates/secrets index cfa312ef0..33f308be3 100644 --- a/lib/kamal/cli/templates/secrets +++ b/lib/kamal/cli/templates/secrets @@ -1,6 +1,6 @@ # SECRETS=$(kamal secrets --adapter 1password --from Vault/Item Section1/KAMAL_REGISTRY_PASSWORD Section2/RAILS_MASTER_KEY) # KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS}) -# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) +# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) KAMAL_REGISTRY_PASSWORD=change-this RAILS_MASTER_KEY=another-env From 57cbf7cdb5b9f1619450066e8e3c8565a289d73a Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 6 Sep 2024 16:56:54 +0100 Subject: [PATCH 31/40] Inline dotenv kamal secrets calls --- lib/kamal/cli/base.rb | 1 - lib/kamal/secrets.rb | 6 ++- .../dotenv/inline_command_substitution.rb | 37 +++++++++++++++++++ ...dotenv_inline_command_substitution_test.rb | 15 ++++++++ 4 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 lib/kamal/secrets/dotenv/inline_command_substitution.rb create mode 100644 test/secrets/dotenv_inline_command_substitution_test.rb diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index d4cac48dc..85815506b 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -1,5 +1,4 @@ require "thor" -require "dotenv" require "kamal/sshkit_with_ext" module Kamal::Cli diff --git a/lib/kamal/secrets.rb b/lib/kamal/secrets.rb index 25d24934d..dc135331d 100644 --- a/lib/kamal/secrets.rb +++ b/lib/kamal/secrets.rb @@ -1,6 +1,10 @@ +require "dotenv" + class Kamal::Secrets attr_reader :secrets_file + Kamal::Secrets::Dotenv::InlineCommandSubstitution.install! + def initialize(destination: nil) @secrets_file = [ *(".kamal/secrets.#{destination}" if destination), ".kamal/secrets" ].find { |f| File.exist?(f) } end @@ -26,7 +30,7 @@ def secrets def parse_secrets if secrets_file - interrupting_parent_on_error { Dotenv.parse(secrets_file) } + interrupting_parent_on_error { ::Dotenv.parse(secrets_file) } else {} end diff --git a/lib/kamal/secrets/dotenv/inline_command_substitution.rb b/lib/kamal/secrets/dotenv/inline_command_substitution.rb new file mode 100644 index 000000000..e8e12d5ca --- /dev/null +++ b/lib/kamal/secrets/dotenv/inline_command_substitution.rb @@ -0,0 +1,37 @@ +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 + capture_stdout { Kamal::Cli::Main.start(command.shellsplit[1..]) }.chomp + else + # Execute the command and return the value + `#{command}`.chomp + end + end + end + end + + def capture_stdout + old_stdout = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = old_stdout + end + end +end diff --git a/test/secrets/dotenv_inline_command_substitution_test.rb b/test/secrets/dotenv_inline_command_substitution_test.rb new file mode 100644 index 000000000..041ada293 --- /dev/null +++ b/test/secrets/dotenv_inline_command_substitution_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class SecretsInlineCommandSubstitution < SecretAdapterTestCase + test "inlines kamal secrets commands" do + Kamal::Cli::Main.expects(:start).with { |command| puts "results"; command == [ "secrets", "fetch", "..." ] } + 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 From aed2ef99d0c662fcf2bd228a8a97a5c158303966 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 9 Sep 2024 14:43:12 +0100 Subject: [PATCH 32/40] Use env files for secrets Add env files back in for secrets - hides them from process lists and allows you to pick up the latest env file when running `kamal app exec` without reusing. --- lib/kamal/cli/accessory.rb | 2 + lib/kamal/cli/app/boot.rb | 6 ++- lib/kamal/cli/lock.rb | 3 -- lib/kamal/cli/traefik.rb | 2 + lib/kamal/commands/accessory.rb | 8 ++- lib/kamal/commands/app.rb | 4 ++ lib/kamal/commands/base.rb | 4 ++ lib/kamal/commands/traefik.rb | 10 ++-- lib/kamal/configuration.rb | 2 +- lib/kamal/configuration/accessory.rb | 14 ++++- lib/kamal/configuration/env.rb | 17 +++---- lib/kamal/configuration/role.rb | 14 ++++- lib/kamal/configuration/traefik.rb | 18 +++++++ lib/kamal/env_file.rb | 42 +++++++++++++++ test/cli/accessory_test.rb | 16 +++--- test/cli/app_test.rb | 6 +-- test/cli/traefik_test.rb | 4 +- test/commands/accessory_test.rb | 12 ++--- test/commands/app_test.rb | 34 ++++++------- test/commands/traefik_test.rb | 36 ++++++------- test/configuration/accessory_test.rb | 6 ++- test/configuration/env_test.rb | 19 ++++--- test/configuration/role_test.rb | 62 ++++++++++++++--------- test/env_file_test.rb | 76 ++++++++++++++++++++++++++++ test/integration/main_test.rb | 2 +- 25 files changed, 307 insertions(+), 112 deletions(-) create mode 100644 lib/kamal/env_file.rb create mode 100644 test/env_file_test.rb diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb index b3ff10f57..2bf9a7860 100644 --- a/lib/kamal/cli/accessory.rb +++ b/lib/kamal/cli/accessory.rb @@ -12,6 +12,8 @@ def boot(name, login: true) on(hosts) do execute *KAMAL.registry.login if login 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 end end diff --git a/lib/kamal/cli/app/boot.rb b/lib/kamal/cli/app/boot.rb index d5b76d4eb..df3e69254 100644 --- a/lib/kamal/cli/app/boot.rb +++ b/lib/kamal/cli/app/boot.rb @@ -1,6 +1,6 @@ class Kamal::Cli::App::Boot 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 def initialize(host, role, sshkit, version, barrier) @@ -48,7 +48,11 @@ def start_new_version execute *app.tie_cord(role.cord_host_file) if uses_cord? 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) + Kamal::Cli::Healthcheck::Poller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) } end diff --git a/lib/kamal/cli/lock.rb b/lib/kamal/cli/lock.rb index 7598b662f..306c8a078 100644 --- a/lib/kamal/cli/lock.rb +++ b/lib/kamal/cli/lock.rb @@ -3,7 +3,6 @@ class Kamal::Cli::Lock < Kamal::Cli::Base def status handle_missing_lock do on(KAMAL.primary_host) do - execute *KAMAL.server.ensure_run_directory puts capture_with_debug(*KAMAL.lock.status) end end @@ -17,7 +16,6 @@ def acquire raise_if_locked do on(KAMAL.primary_host) do - execute *KAMAL.server.ensure_run_directory execute *KAMAL.lock.acquire(message, KAMAL.config.version), verbosity: :debug end say "Acquired the deploy lock" @@ -28,7 +26,6 @@ def acquire def release handle_missing_lock do on(KAMAL.primary_host) do - execute *KAMAL.server.ensure_run_directory execute *KAMAL.lock.release, verbosity: :debug end say "Released the deploy lock" diff --git a/lib/kamal/cli/traefik.rb b/lib/kamal/cli/traefik.rb index a8bd21269..41ffbc045 100644 --- a/lib/kamal/cli/traefik.rb +++ b/lib/kamal/cli/traefik.rb @@ -4,6 +4,8 @@ def boot with_lock do on(KAMAL.traefik_hosts) do 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 end end diff --git a/lib/kamal/commands/accessory.rb b/lib/kamal/commands/accessory.rb index d34377c7a..f3b676d14 100644 --- a/lib/kamal/commands/accessory.rb +++ b/lib/kamal/commands/accessory.rb @@ -1,7 +1,9 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base attr_reader :accessory_config 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:) super(config) @@ -98,6 +100,10 @@ def remove_image docker :image, :rm, "--force", image end + def ensure_env_directory + make_directory env_directory + end + private def service_filter [ "--filter", "label=service=#{service_name}" ] diff --git a/lib/kamal/commands/app.rb b/lib/kamal/commands/app.rb index 4fe8ead73..f1991e481 100644 --- a/lib/kamal/commands/app.rb +++ b/lib/kamal/commands/app.rb @@ -69,6 +69,10 @@ def list_versions(*docker_args, statuses: nil) extract_version_from_name end + def ensure_env_directory + make_directory role.env_directory + end + private def container_name(version = nil) [ role.container_prefix, version || config.version ].compact.join("-") diff --git a/lib/kamal/commands/base.rb b/lib/kamal/commands/base.rb index 39e60d508..7521780ad 100644 --- a/lib/kamal/commands/base.rb +++ b/lib/kamal/commands/base.rb @@ -37,6 +37,10 @@ def remove_directory(path) [ :rm, "-r", path ] end + def remove_file(path) + [ :rm, path ] + end + private def combine(*commands, by: "&&") commands diff --git a/lib/kamal/commands/traefik.rb b/lib/kamal/commands/traefik.rb index dd08ef508..964ef3eb9 100644 --- a/lib/kamal/commands/traefik.rb +++ b/lib/kamal/commands/traefik.rb @@ -1,6 +1,6 @@ class Kamal::Commands::Traefik < Kamal::Commands::Base 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 docker :run, "--name traefik", @@ -54,6 +54,10 @@ def remove_image docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik" end + def ensure_env_directory + make_directory env_directory + end + private def publish_args argumentize "--publish", port if publish? @@ -63,10 +67,6 @@ def label_args argumentize "--label", labels end - def env_args - env.args - end - def docker_options_args optionize(options) end diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 4ed1d56f2..0194bdd24 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -217,7 +217,7 @@ def asset_path end - def host_env_directory + def env_directory File.join(run_directory, "env") end diff --git a/lib/kamal/configuration/accessory.rb b/lib/kamal/configuration/accessory.rb index 5d69af7ab..57489f17e 100644 --- a/lib/kamal/configuration/accessory.rb +++ b/lib/kamal/configuration/accessory.rb @@ -51,7 +51,19 @@ def label_args end 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 def files diff --git a/lib/kamal/configuration/env.rb b/lib/kamal/configuration/env.rb index d8f27ece0..8e52d9e4d 100644 --- a/lib/kamal/configuration/env.rb +++ b/lib/kamal/configuration/env.rb @@ -13,8 +13,12 @@ def initialize(config:, secrets:, context: "env") validate! config, context: context, with: Kamal::Configuration::Validator::Env end - def args - [ *clear_args, *secret_args ] + def clear_args + argumentize("--env", clear) + end + + def secrets_io + Kamal::EnvFile.new(secret_keys.to_h { |key| [ key, secrets[key] ] }).to_io end def merge(other) @@ -22,13 +26,4 @@ def merge(other) config: { "clear" => clear.merge(other.clear), "secret" => secret_keys | other.secret_keys }, secrets: secrets end - - private - def clear_args - argumentize("--env", clear) - end - - def secret_args - argumentize("--env", secret_keys.to_h { |key| [ key, secrets[key] ] }, sensitive: true) - end end diff --git a/lib/kamal/configuration/role.rb b/lib/kamal/configuration/role.rb index 60bee1a63..ef651898f 100644 --- a/lib/kamal/configuration/role.rb +++ b/lib/kamal/configuration/role.rb @@ -77,7 +77,19 @@ def env(host) end 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 def asset_volume_args diff --git a/lib/kamal/configuration/traefik.rb b/lib/kamal/configuration/traefik.rb index e046a1e3c..45d8bac54 100644 --- a/lib/kamal/configuration/traefik.rb +++ b/lib/kamal/configuration/traefik.rb @@ -1,4 +1,6 @@ class Kamal::Configuration::Traefik + delegate :argumentize, to: Kamal::Utils + DEFAULT_IMAGE = "traefik:v2.10" CONTAINER_PORT = 80 DEFAULT_ARGS = { @@ -57,4 +59,20 @@ def args def image traefik_config.fetch("image", DEFAULT_IMAGE) 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 diff --git a/lib/kamal/env_file.rb b/lib/kamal/env_file.rb new file mode 100644 index 000000000..6a4a80e34 --- /dev/null +++ b/lib/kamal/env_file.rb @@ -0,0 +1,42 @@ +# Encode an env hash as a string where secret values have been looked up and all values escaped for Docker. +class Kamal::EnvFile + def initialize(env) + @env = env + end + + def to_s + env_file = StringIO.new.tap do |contents| + @env.each do |key, value| + contents << docker_env_file_line(key, value) + end + end.string + + # Ensure the file has some contents to avoid the SSHKIT empty file warning + env_file.presence || "\n" + end + + def to_io + StringIO.new(to_s) + end + + alias to_str to_s + + private + def docker_env_file_line(key, value) + "#{key}=#{escape_docker_env_file_value(value)}\n" + end + + # Escape a value to make it safe to dump in a docker file. + def escape_docker_env_file_value(value) + # keep non-ascii(UTF-8) characters as it is + value.to_s.scan(/[\x00-\x7F]+|[^\x00-\x7F]+/).map do |part| + part.ascii_only? ? escape_docker_env_file_ascii_value(part) : part + end.join + end + + def escape_docker_env_file_ascii_value(value) + # Doublequotes are treated literally in docker env files + # so remove leading and trailing ones and unescape any others + value.to_s.dump[1..-2].gsub(/\\"/, "\"") + end +end diff --git a/test/cli/accessory_test.rb b/test/cli/accessory_test.rb index 9a130551c..0e3abc467 100644 --- a/test/cli/accessory_test.rb +++ b/test/cli/accessory_test.rb @@ -15,7 +15,7 @@ class CliAccessoryTest < CliTestCase run_command("boot", "mysql").tap do |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 MYSQL_ROOT_HOST=\"%\" --env [REDACTED] --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 @@ -29,9 +29,9 @@ class CliAccessoryTest < CliTestCase 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.2/, 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 [REDACTED] --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 --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 --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest 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 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.2", output end end @@ -200,8 +200,8 @@ class CliAccessoryTest < CliTestCase run_command("boot", "redis", "--hosts", "1.1.1.1").tap do |output| assert_match /docker login.*on 1.1.1.1/, output assert_no_match /docker login.*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 --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output - assert_no_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --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.1", output + assert_no_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 @@ -212,8 +212,8 @@ class CliAccessoryTest < CliTestCase run_command("boot", "redis", "--hosts", "1.1.1.1,1.1.1.3").tap do |output| assert_match /docker login.*on 1.1.1.1/, output assert_no_match /docker login.*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 --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output - assert_no_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest 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_no_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.3", output end end diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index 0a9ec4857..46a067f36 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -243,13 +243,13 @@ class CliAppTest < CliTestCase test "exec" do run_command("exec", "ruby -v").tap do |output| - assert_match "docker run --rm dhh/app:latest ruby -v", output + assert_match "docker run --rm --env-file .kamal/env/roles/app-web.env dhh/app:latest ruby -v", output end end test "exec separate arguments" do run_command("exec", "ruby", " -v").tap do |output| - assert_match "docker run --rm dhh/app:latest ruby -v", output + assert_match "docker run --rm --env-file .kamal/env/roles/app-web.env dhh/app:latest ruby -v", output end end @@ -262,7 +262,7 @@ class CliAppTest < CliTestCase test "exec interactive" do SSHKit::Backend::Abstract.any_instance.expects(:exec) - .with("ssh -t root@1.1.1.1 -p 22 'docker run -it --rm dhh/app:latest ruby -v'") + .with("ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --env-file .kamal/env/roles/app-web.env dhh/app:latest ruby -v'") run_command("exec", "-i", "ruby -v").tap do |output| assert_match "Get most recent version available as an image...", output assert_match "Launching interactive command with version latest via SSH from new container on 1.1.1.1...", output diff --git a/test/cli/traefik_test.rb b/test/cli/traefik_test.rb index 41921f96a..291711509 100644 --- a/test/cli/traefik_test.rb +++ b/test/cli/traefik_test.rb @@ -4,7 +4,7 @@ class CliTraefikTest < CliTestCase test "boot" do run_command("boot").tap do |output| assert_match "docker login", output - assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output + assert_match "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\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output end end @@ -14,7 +14,7 @@ class CliTraefikTest < CliTestCase run_command("reboot", "-y").tap do |output| assert_match "docker container stop traefik", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=Traefik", output - assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output + assert_match "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\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output end end diff --git a/test/commands/accessory_test.rb b/test/commands/accessory_test.rb index d63fcd766..23d304dac 100644 --- a/test/commands/accessory_test.rb +++ b/test/commands/accessory_test.rb @@ -51,15 +51,15 @@ class CommandsAccessoryTest < ActiveSupport::TestCase test "run" do assert_equal \ - "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env MYSQL_ROOT_PASSWORD=\"secret123\" --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(" ") assert_equal \ - "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --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(" ") assert_equal \ - "docker run --name custom-busybox --detach --restart unless-stopped --log-opt max-size=\"10m\" --label service=\"custom-busybox\" busybox:latest", + "docker run --name custom-busybox --detach --restart unless-stopped --log-opt max-size=\"10m\" --env-file .kamal/env/accessories/custom-busybox.env --label service=\"custom-busybox\" busybox:latest", new_command(:busybox).run.join(" ") end @@ -67,7 +67,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } assert_equal \ - "docker run --name custom-busybox --detach --restart unless-stopped --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"custom-busybox\" busybox:latest", + "docker run --name custom-busybox --detach --restart unless-stopped --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --env-file .kamal/env/accessories/custom-busybox.env --label service=\"custom-busybox\" busybox:latest", new_command(:busybox).run.join(" ") end @@ -92,7 +92,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase test "execute in new container" do assert_equal \ - "docker run --rm --env MYSQL_ROOT_HOST=\"%\" --env MYSQL_ROOT_PASSWORD=\"secret123\" 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(" ") end @@ -104,7 +104,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase test "execute in new container over ssh" do new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do - assert_match %r{docker run -it --rm --env MYSQL_ROOT_HOST=\"%\" --env MYSQL_ROOT_PASSWORD=\"secret123\" 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") end end diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index 2ccb6033c..69ed6d9b5 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -14,13 +14,13 @@ class CommandsAppTest < ActiveSupport::TestCase test "run" do assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env RAILS_MASTER_KEY=\"456\" --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-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(" ") end test "run with hostname" do assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 --hostname myhost -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env RAILS_MASTER_KEY=\"456\" --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 --hostname myhost -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --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(hostname: "myhost").join(" ") end @@ -28,7 +28,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:volumes] = [ "/local/path:/container/path" ] assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env RAILS_MASTER_KEY=\"456\" --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\" --volume /local/path:/container/path --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-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\" --volume /local/path:/container/path --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(" ") end @@ -36,7 +36,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:healthcheck] = { "path" => "/healthz" } assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env RAILS_MASTER_KEY=\"456\" --health-cmd \"(curl -f http://localhost:3000/healthz || 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-file .kamal/env/roles/app-web.env --health-cmd \"(curl -f http://localhost:3000/healthz || 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(" ") end @@ -44,7 +44,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:healthcheck] = { "cmd" => "/bin/up" } assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env RAILS_MASTER_KEY=\"456\" --health-cmd \"(/bin/up) && (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-file .kamal/env/roles/app-web.env --health-cmd \"(/bin/up) && (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(" ") end @@ -52,14 +52,14 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "healthcheck" => { "cmd" => "/bin/healthy" } } } assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env RAILS_MASTER_KEY=\"456\" --health-cmd \"(/bin/healthy) && (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-file .kamal/env/roles/app-web.env --health-cmd \"(/bin/healthy) && (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(" ") end test "run with custom options" do @config[:servers] = { "web" => [ "1.1.1.1" ], "jobs" => { "hosts" => [ "1.1.1.2" ], "cmd" => "bin/jobs", "options" => { "mount" => "somewhere", "cap-add" => true } } } assert_equal \ - "docker run --detach --restart unless-stopped --name app-jobs-999 -e KAMAL_CONTAINER_NAME=\"app-jobs-999\" -e KAMAL_VERSION=\"999\" --env RAILS_MASTER_KEY=\"456\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"jobs\" --label destination --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs", + "docker run --detach --restart unless-stopped --name app-jobs-999 -e KAMAL_CONTAINER_NAME=\"app-jobs-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/env/roles/app-jobs.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"jobs\" --label destination --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs", new_command(role: "jobs", host: "1.1.1.2").run.join(" ") end @@ -67,7 +67,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env RAILS_MASTER_KEY=\"456\" --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-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --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-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-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --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(" ") end @@ -76,7 +76,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "logging" => { "driver" => "local", "options" => { "max-size" => "100m" } } } } assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env RAILS_MASTER_KEY=\"456\" --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-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --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-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-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --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(" ") end @@ -85,7 +85,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } assert_equal \ - "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 RAILS_MASTER_KEY=\"456\" --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(" ") end @@ -204,13 +204,13 @@ class CommandsAppTest < ActiveSupport::TestCase test "execute in new container" do assert_equal \ - "docker run --rm --env RAILS_MASTER_KEY=\"456\" dhh/app:999 bin/rails db:setup", + "docker run --rm --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(" ") end test "execute in new container with env" do assert_equal \ - "docker run --rm --env RAILS_MASTER_KEY=\"456\" --env foo=\"bar\" dhh/app:999 bin/rails db:setup", + "docker run --rm --env-file .kamal/env/roles/app-web.env --env foo=\"bar\" dhh/app:999 bin/rails db:setup", new_command.execute_in_new_container("bin/rails", "db:setup", env: { "foo" => "bar" }).join(" ") end @@ -219,14 +219,14 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } assert_equal \ - "docker run --rm --env ENV1=\"value1\" --env RAILS_MASTER_KEY=\"456\" 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(" ") end test "execute in new container with custom options" do @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } assert_equal \ - "docker run --rm --env RAILS_MASTER_KEY=\"456\" --mount \"somewhere\" --cap-add dhh/app:999 bin/rails db:setup", + "docker run --rm --env-file .kamal/env/roles/app-web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails db:setup", new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") end @@ -243,7 +243,7 @@ class CommandsAppTest < ActiveSupport::TestCase end test "execute in new container over ssh" do - assert_match %r{docker run -it --rm --env RAILS_MASTER_KEY=\"456\" dhh/app:999 bin/rails c}, + assert_match %r{docker run -it --rm --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: {}) end @@ -251,13 +251,13 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:servers] = [ { "1.1.1.1" => "tag1" } ] @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } - assert_equal "ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --env ENV1=\"value1\" --env RAILS_MASTER_KEY=\"456\" 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: {}) end test "execute in new container with custom options over ssh" do @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } - assert_match %r{docker run -it --rm --env RAILS_MASTER_KEY=\"456\" --mount \"somewhere\" --cap-add dhh/app:999 bin/rails c}, + assert_match %r{docker run -it --rm --env-file .kamal/env/roles/app-web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails c}, new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) end diff --git a/test/commands/traefik_test.rb b/test/commands/traefik_test.rb index 3e90cd500..b13e37005 100644 --- a/test/commands/traefik_test.rb +++ b/test/commands/traefik_test.rb @@ -18,72 +18,72 @@ class CommandsTraefikTest < ActiveSupport::TestCase test "run" do assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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(" ") @config[:traefik]["host_port"] = "8080" assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 8080:80 --volume /var/run/docker.sock:/var/run/docker.sock --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 8080: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(" ") @config[:traefik]["publish"] = false assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --volume /var/run/docker.sock:/var/run/docker.sock --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 --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(" ") end test "run with ports configured" do assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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(" ") @config[:traefik]["options"] = { "publish" => %w[9000:9000 9001:9001] } assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" --publish \"9000:9000\" --publish \"9001:9001\" #{@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\" --publish \"9000:9000\" --publish \"9001:9001\" #{@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(" ") end test "run with volumes configured" do assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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(" ") @config[:traefik]["options"] = { "volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json] } assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" #{@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\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" #{@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(" ") end test "run with several options configured" do assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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(" ") @config[:traefik]["options"] = { "volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json], "publish" => %w[8080:8080], "memory" => "512m" } assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" --publish \"8080:8080\" --memory \"512m\" #{@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\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" --publish \"8080:8080\" --memory \"512m\" #{@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(" ") end test "run with labels configured" do assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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(" ") @config[:traefik]["labels"] = { "traefik.http.routers.dashboard.service" => "api@internal", "traefik.http.routers.dashboard.middlewares" => "auth" } assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" --label traefik.http.routers.dashboard.service=\"api@internal\" --label traefik.http.routers.dashboard.middlewares=\"auth\" #{@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\" --label traefik.http.routers.dashboard.service=\"api@internal\" --label traefik.http.routers.dashboard.middlewares=\"auth\" #{@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(" ") end test "run with env configured" do assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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(" ") - @config[:traefik]["env"] = { "secret" => %w[EXAMPLE_API_KEY] } + @config[:traefik]["env"] = { "EXAMPLE_API_KEY" => "456" } assert_equal \ - "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\" --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(" ") end @@ -91,7 +91,7 @@ class CommandsTraefikTest < ActiveSupport::TestCase @config.delete(:traefik) assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", + "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\" #{Kamal::Configuration::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", new_command.run.join(" ") end @@ -99,7 +99,7 @@ class CommandsTraefikTest < ActiveSupport::TestCase @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --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-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --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(" ") end @@ -107,13 +107,13 @@ class CommandsTraefikTest < ActiveSupport::TestCase @config[:traefik]["args"]["log.level"] = "ERROR" assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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=\"ERROR\" --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=\"ERROR\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", new_command.run.join(" ") end test "run with args array" do @config[:traefik]["args"] = { "entrypoints.web.forwardedheaders.trustedips" => %w[ 127.0.0.1 127.0.0.2 ] } - assert_equal "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --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\" traefik:test --providers.docker --log.level=\"DEBUG\" --entrypoints.web.forwardedheaders.trustedips=\"127.0.0.1\" --entrypoints.web.forwardedheaders.trustedips=\"127.0.0.2\"", new_command.run.join(" ") + 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\" traefik:test --providers.docker --log.level=\"DEBUG\" --entrypoints.web.forwardedheaders.trustedips=\"127.0.0.1\" --entrypoints.web.forwardedheaders.trustedips=\"127.0.0.2\"", new_command.run.join(" ") end test "traefik start" do diff --git a/test/configuration/accessory_test.rb b/test/configuration/accessory_test.rb index 3497e6c10..acfe991fc 100644 --- a/test/configuration/accessory_test.rb +++ b/test/configuration/accessory_test.rb @@ -119,8 +119,10 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase with_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123") do config = Kamal::Configuration.new(@deploy) - assert_equal [ "--env", "MYSQL_ROOT_HOST=\"%\"", "--env", "MYSQL_ROOT_PASSWORD=\"secret123\"" ], config.accessory(:mysql).env_args.map(&:to_s) - assert_equal [ "--env", "SOMETHING=\"else\"" ], @config.accessory(:redis).env_args + assert_equal [ "--env", "MYSQL_ROOT_HOST=\"%\"", "--env-file", ".kamal/env/accessories/app-mysql.env" ], config.accessory(:mysql).env_args.map(&:to_s) + 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 + assert_equal "\n", config.accessory(:redis).secrets_io.string end end diff --git a/test/configuration/env_test.rb b/test/configuration/env_test.rb index b4e924a7e..627d3a6ce 100644 --- a/test/configuration/env_test.rb +++ b/test/configuration/env_test.rb @@ -6,20 +6,20 @@ class ConfigurationEnvTest < ActiveSupport::TestCase test "simple" do assert_config \ config: { "foo" => "bar", "baz" => "haz" }, - results: { "foo" => "bar", "baz" => "haz" } + clear: { "foo" => "bar", "baz" => "haz" } end test "clear" do assert_config \ config: { "clear" => { "foo" => "bar", "baz" => "haz" } }, - results: { "foo" => "bar", "baz" => "haz" } + clear: { "foo" => "bar", "baz" => "haz" } end test "secret" do with_test_secrets("secrets" => "PASSWORD=hello") do assert_config \ config: { "secret" => [ "PASSWORD" ] }, - results: { "PASSWORD" => "hello" } + secrets: { "PASSWORD" => "hello" } end end @@ -28,7 +28,7 @@ class ConfigurationEnvTest < ActiveSupport::TestCase "secret" => [ "PASSWORD" ] } - assert_raises(Kamal::ConfigurationError) { Kamal::Configuration::Env.new(config: { "secret" => [ "PASSWORD" ] }, secrets: Kamal::Secrets.new).args } + assert_raises(Kamal::ConfigurationError) { Kamal::Configuration::Env.new(config: { "secret" => [ "PASSWORD" ] }, secrets: Kamal::Secrets.new).secrets_io } end test "secret and clear" do @@ -43,14 +43,17 @@ class ConfigurationEnvTest < ActiveSupport::TestCase assert_config \ config: config, - results: { "foo" => "bar", "baz" => "haz", "PASSWORD" => "hello" } + clear: { "foo" => "bar", "baz" => "haz" }, + secrets: { "PASSWORD" => "hello" } end end private - def assert_config(config:, results:) + def assert_config(config:, clear: {}, secrets: {}) env = Kamal::Configuration::Env.new config: config, secrets: Kamal::Secrets.new - expected_args = results.to_a.flat_map { |key, value| [ "--env", "#{key}=\"#{value}\"" ] } - assert_equal expected_args, env.args.map(&:to_s) #  to_s removes the redactions + expected_clear_args = clear.to_a.flat_map { |key, value| [ "--env", "#{key}=\"#{value}\"" ] } + 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 diff --git a/test/configuration/role_test.rb b/test/configuration/role_test.rb index d3b54ca68..c0b643bfa 100644 --- a/test/configuration/role_test.rb +++ b/test/configuration/role_test.rb @@ -69,10 +69,13 @@ class ConfigurationRoleTest < ActiveSupport::TestCase 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 [ - "--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 test "container name" do @@ -85,7 +88,13 @@ class ConfigurationRoleTest < ActiveSupport::TestCase end test "env args" do - assert_equal [ "--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 test "env secret overwritten by role" do @@ -109,12 +118,13 @@ class ConfigurationRoleTest < ActiveSupport::TestCase ] } - assert_equal [ - "--env", "REDIS_URL=\"redis://a/b\"", - "--env", "WEB_CONCURRENCY=\"4\"", - "--env", "REDIS_PASSWORD=\"secret456\"", - "--env", "DB_PASSWORD=\"secret&\\\"123\"" ], + 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 \ + "REDIS_PASSWORD=secret456\nDB_PASSWORD=secret&\"123\n", + config_with_roles.role(:workers).secrets_io("1.1.1.3").read end end @@ -130,11 +140,13 @@ class ConfigurationRoleTest < ActiveSupport::TestCase ] } - assert_equal [ - "--env", "REDIS_URL=\"redis://a/b\"", - "--env", "WEB_CONCURRENCY=\"4\"", - "--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) + + assert_equal \ + "DB_PASSWORD=secret123\n", + config_with_roles.role(:workers).secrets_io("1.1.1.3").read end end @@ -149,11 +161,13 @@ class ConfigurationRoleTest < ActiveSupport::TestCase ] } - assert_equal [ - "--env", "REDIS_URL=\"redis://a/b\"", - "--env", "WEB_CONCURRENCY=\"4\"", - "--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) + + assert_equal \ + "REDIS_PASSWORD=secret456\n", + config_with_roles.role(:workers).secrets_io("1.1.1.3").read end end @@ -174,11 +188,13 @@ class ConfigurationRoleTest < ActiveSupport::TestCase } } - config = config_with_roles - assert_equal [ - "--env", "REDIS_URL=\"redis://c/d\"", - "--env", "REDIS_PASSWORD=\"secret456\"" ], - config.role(:workers).env_args("1.1.1.3").map(&:to_s) + assert_equal \ + [ "--env", "REDIS_URL=\"redis://c/d\"", "--env-file", ".kamal/env/roles/app-workers.env" ], + config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) + + assert_equal \ + "REDIS_PASSWORD=secret456\n", + config_with_roles.role(:workers).secrets_io("1.1.1.3").read end end diff --git a/test/env_file_test.rb b/test/env_file_test.rb new file mode 100644 index 000000000..c6b9e66ec --- /dev/null +++ b/test/env_file_test.rb @@ -0,0 +1,76 @@ +require "test_helper" + +class EnvFileTest < ActiveSupport::TestCase + test "to_s" do + env = { + "foo" => "bar", + "baz" => "haz" + } + + assert_equal "foo=bar\nbaz=haz\n", \ + Kamal::EnvFile.new(env).to_s + end + + test "to_str won't escape chinese characters" do + env = { + "foo" => '你好 means hello, "欢迎" means welcome, that\'s simple! 😃 {smile}' + } + + assert_equal "foo=你好 means hello, \"欢迎\" means welcome, that's simple! 😃 {smile}\n", + Kamal::EnvFile.new(env).to_s + end + + test "to_s won't escape japanese characters" do + env = { + "foo" => 'こんにちは means hello, "ようこそ" means welcome, that\'s simple! 😃 {smile}' + } + + assert_equal "foo=こんにちは means hello, \"ようこそ\" means welcome, that's simple! 😃 {smile}\n", \ + Kamal::EnvFile.new(env).to_s + end + + test "to_s won't escape korean characters" do + env = { + "foo" => '안녕하세요 means hello, "어서 오십시오" means welcome, that\'s simple! 😃 {smile}' + } + + assert_equal "foo=안녕하세요 means hello, \"어서 오십시오\" means welcome, that's simple! 😃 {smile}\n", \ + Kamal::EnvFile.new(env).to_s + end + + test "to_s empty" do + assert_equal "\n", Kamal::EnvFile.new({}).to_s + end + + test "to_s escaped newline" do + env = { + "foo" => "hello\\nthere" + } + + assert_equal "foo=hello\\\\nthere\n", \ + Kamal::EnvFile.new(env).to_s + ensure + ENV.delete "PASSWORD" + end + + test "to_s newline" do + env = { + "foo" => "hello\nthere" + } + + assert_equal "foo=hello\\nthere\n", \ + Kamal::EnvFile.new(env).to_s + ensure + 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 diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index 5b12d8576..b58aeeb9b 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -68,7 +68,7 @@ class MainTest < IntegrationTest assert_equal "app-#{version}", config[:service_with_version] assert_equal [], config[:volume_args] 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({ "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 From a4d668cd393fc31b42e8ed08e9fde23fab827f38 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 10 Sep 2024 10:02:10 +0100 Subject: [PATCH 33/40] Revert "Integration test insecure registry" --- test/integration/docker-compose.yml | 2 ++ test/integration/docker/deployer/Dockerfile | 1 + test/integration/docker/deployer/boot.sh | 2 +- test/integration/docker/registry/boot.sh | 2 ++ test/integration/docker/shared/Dockerfile | 2 ++ test/integration/docker/vm/Dockerfile | 1 + test/integration/docker/vm/boot.sh | 2 +- 7 files changed, 10 insertions(+), 2 deletions(-) diff --git a/test/integration/docker-compose.yml b/test/integration/docker-compose.yml index 8f095f0f1..6df0935df 100644 --- a/test/integration/docker-compose.yml +++ b/test/integration/docker-compose.yml @@ -29,6 +29,8 @@ services: context: docker/registry environment: - REGISTRY_HTTP_ADDR=0.0.0.0:4443 + - REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt + - REGISTRY_HTTP_TLS_KEY=/certs/domain.key volumes: - shared:/shared - registry:/var/lib/registry/ diff --git a/test/integration/docker/deployer/Dockerfile b/test/integration/docker/deployer/Dockerfile index a809a6ed4..bb6b462a7 100644 --- a/test/integration/docker/deployer/Dockerfile +++ b/test/integration/docker/deployer/Dockerfile @@ -22,6 +22,7 @@ COPY app_with_roles/ app_with_roles/ RUN rm -rf /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.name "Deployer" diff --git a/test/integration/docker/deployer/boot.sh b/test/integration/docker/deployer/boot.sh index 20e1375d8..77d6d1ead 100755 --- a/test/integration/docker/deployer/boot.sh +++ b/test/integration/docker/deployer/boot.sh @@ -1,5 +1,5 @@ #!/bin/bash -dockerd --max-concurrent-downloads 1 --insecure-registry registry:4443 & +dockerd --max-concurrent-downloads 1 & exec sleep infinity diff --git a/test/integration/docker/registry/boot.sh b/test/integration/docker/registry/boot.sh index 6eb8a518e..895838f5a 100755 --- a/test/integration/docker/registry/boot.sh +++ b/test/integration/docker/registry/boot.sh @@ -1,3 +1,5 @@ #!/bin/sh +while [ ! -f /certs/domain.crt ]; do sleep 1; done + exec /entrypoint.sh /etc/docker/registry/config.yml diff --git a/test/integration/docker/shared/Dockerfile b/test/integration/docker/shared/Dockerfile index 348fa4f31..f672fbe1a 100644 --- a/test/integration/docker/shared/Dockerfile +++ b/test/integration/docker/shared/Dockerfile @@ -10,6 +10,8 @@ RUN mkdir ssh && \ COPY registry-dns.conf . 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 CMD ["./boot.sh"] diff --git a/test/integration/docker/vm/Dockerfile b/test/integration/docker/vm/Dockerfile index 014794b0e..584a7b6ac 100644 --- a/test/integration/docker/vm/Dockerfile +++ b/test/integration/docker/vm/Dockerfile @@ -5,6 +5,7 @@ WORKDIR /work 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 -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 diff --git a/test/integration/docker/vm/boot.sh b/test/integration/docker/vm/boot.sh index ecdbdb3c5..681a8a4e2 100755 --- a/test/integration/docker/vm/boot.sh +++ b/test/integration/docker/vm/boot.sh @@ -4,6 +4,6 @@ while [ ! -f /root/.ssh/authorized_keys ]; do echo "Waiting for ssh keys"; sleep service ssh restart -dockerd --max-concurrent-downloads 1 --insecure-registry registry:4443 & +dockerd --max-concurrent-downloads 1 & exec sleep infinity From 06f4caa866a3ffef2c40a50093963dd8eae4a18e Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 10 Sep 2024 10:24:14 +0100 Subject: [PATCH 34/40] Make the secrets commands inline aware Rather than redirecting the global $stdout, which is not never clever in a threaded program, we'll make the secrets commands aware they are being inlined, so they return the value instead of printing it. Additionally we no longer need to interrupt the parent process on error as we've inlined the command - exit 1 is enough. --- lib/kamal/cli/secrets.rb | 33 +++++++++++-------- lib/kamal/secrets.rb | 10 +----- .../dotenv/inline_command_substitution.rb | 11 ++----- ...dotenv_inline_command_substitution_test.rb | 2 +- 4 files changed, 25 insertions(+), 31 deletions(-) diff --git a/lib/kamal/cli/secrets.rb b/lib/kamal/cli/secrets.rb index 572476f98..cab53ca87 100644 --- a/lib/kamal/cli/secrets.rb +++ b/lib/kamal/cli/secrets.rb @@ -3,26 +3,26 @@ class Kamal::Cli::Secrets < Kamal::Cli::Base 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) - puts JSON.dump(results).shellescape - rescue => e - handle_error(e) + handle_output(inline: options[:inline]) do + results = adapter(options[:adapter]).fetch(secrets, **options.slice(:account, :from).symbolize_keys) + JSON.dump(results).shellescape + end 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) + handle_output(inline: options[:inline]) do + parsed_secrets = JSON.parse(secrets) - if (value = parsed_secrets[name]).nil? - value = parsed_secrets.find { |k, v| k.end_with?("/#{name}") }&.last - end + value = parsed_secrets[name] || parsed_secrets.find { |k, v| k.end_with?("/#{name}") }&.last - raise "Could not find secret #{name}" if value.nil? + raise "Could not find secret #{name}" if value.nil? - puts value - rescue => e - handle_error(e) + value + end end private @@ -30,11 +30,18 @@ def adapter(adapter) Kamal::Secrets::Adapters.lookup(adapter) end + def handle_output(inline: nil) + yield.tap do |output| + puts output unless inline + end + rescue => e + handle_error(e) + end + def handle_error(e) $stderr.puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m" $stderr.puts e.backtrace if ENV["VERBOSE"] - Process.kill("INT", Process.ppid) if ENV["KAMAL_SECRETS_INT_PARENT"] exit 1 end end diff --git a/lib/kamal/secrets.rb b/lib/kamal/secrets.rb index dc135331d..34d30aaea 100644 --- a/lib/kamal/secrets.rb +++ b/lib/kamal/secrets.rb @@ -30,17 +30,9 @@ def secrets def parse_secrets if secrets_file - interrupting_parent_on_error { ::Dotenv.parse(secrets_file) } + ::Dotenv.parse(secrets_file) else {} end end - - def interrupting_parent_on_error - # Make any `kamal secrets` calls in dotenv interpolation interrupt this process if there are errors - ENV["KAMAL_SECRETS_INT_PARENT"] = "1" - yield - ensure - ENV.delete("KAMAL_SECRETS_INT_PARENT") - end end diff --git a/lib/kamal/secrets/dotenv/inline_command_substitution.rb b/lib/kamal/secrets/dotenv/inline_command_substitution.rb index e8e12d5ca..c9ef98799 100644 --- a/lib/kamal/secrets/dotenv/inline_command_substitution.rb +++ b/lib/kamal/secrets/dotenv/inline_command_substitution.rb @@ -16,7 +16,7 @@ def call(value, _env, overwrite: false) else if command =~ /\A\s*kamal\s*secrets\s+/ # Inline the command - capture_stdout { Kamal::Cli::Main.start(command.shellsplit[1..]) }.chomp + inline_secrets_command(command) else # Execute the command and return the value `#{command}`.chomp @@ -25,13 +25,8 @@ def call(value, _env, overwrite: false) end end - def capture_stdout - old_stdout = $stdout - $stdout = StringIO.new - yield - $stdout.string - ensure - $stdout = old_stdout + def inline_secrets_command(command) + Kamal::Cli::Main.start(command.shellsplit[1..] + [ "--inline" ]).chomp end end end diff --git a/test/secrets/dotenv_inline_command_substitution_test.rb b/test/secrets/dotenv_inline_command_substitution_test.rb index 041ada293..3ae10ca94 100644 --- a/test/secrets/dotenv_inline_command_substitution_test.rb +++ b/test/secrets/dotenv_inline_command_substitution_test.rb @@ -2,7 +2,7 @@ class SecretsInlineCommandSubstitution < SecretAdapterTestCase test "inlines kamal secrets commands" do - Kamal::Cli::Main.expects(:start).with { |command| puts "results"; command == [ "secrets", "fetch", "..." ] } + 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 From aa630f156aa08f5b7546d438ceb5f4b2f2c4ddc1 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 11 Sep 2024 12:02:53 +0100 Subject: [PATCH 35/40] Hide the 1password login error Avoid outputting this login error message, it wasn't an error and you don't need to follow those instructions. ``` [ERROR] 2024/09/11 11:57:08 You are not currently signed in. Please run `op signin --help` for instructions ``` --- lib/kamal/secrets/adapters/one_password.rb | 2 +- test/secrets/one_password_adapter_test.rb | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/kamal/secrets/adapters/one_password.rb b/lib/kamal/secrets/adapters/one_password.rb index f3db373f2..c7e9b28df 100644 --- a/lib/kamal/secrets/adapters/one_password.rb +++ b/lib/kamal/secrets/adapters/one_password.rb @@ -11,7 +11,7 @@ def login(account) end def loggedin?(account) - `op account get --account #{account.shellescape}` + `op account get --account #{account.shellescape} 2> /dev/null` $?.success? end diff --git a/test/secrets/one_password_adapter_test.rb b/test/secrets/one_password_adapter_test.rb index e36cf8a22..59ad511db 100644 --- a/test/secrets/one_password_adapter_test.rb +++ b/test/secrets/one_password_adapter_test.rb @@ -2,7 +2,7 @@ class SecretsOnePasswordAdapterTest < SecretAdapterTestCase test "fetch" do - stub_ticks.with("op account get --account myaccount") + 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\"") @@ -56,7 +56,7 @@ class SecretsOnePasswordAdapterTest < SecretAdapterTestCase end test "fetch with multiple items" do - stub_ticks.with("op account get --account myaccount") + 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\"") @@ -115,7 +115,7 @@ class SecretsOnePasswordAdapterTest < SecretAdapterTestCase end test "fetch with signin, no session" do - stub_ticks_with("op account get --account myaccount", succeed: false) + 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 @@ -132,7 +132,7 @@ class SecretsOnePasswordAdapterTest < SecretAdapterTestCase end test "fetch with signin and session" do - stub_ticks_with("op account get --account myaccount", succeed: false) + 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 From 0cb69a84f56ef587ea855cd99fdc0f9efee5e692 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 11 Sep 2024 12:16:18 +0100 Subject: [PATCH 36/40] Don't git ignore .kamal/secrets Secrets should be interpolated at runtime so we do want the file in git. But add a warning at the top to avoid adding secrets or git ignore the file if you do. Also provide examples of the three options for interpolating secrets. --- lib/kamal/cli/main.rb | 6 ------ lib/kamal/cli/templates/secrets | 20 +++++++++++++++----- test/cli/main_test.rb | 5 +---- test/integration/docker/deployer/Dockerfile | 4 ++-- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index f5ad83975..3bd6dc24f 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -152,12 +152,6 @@ def init FileUtils.mkdir_p secrets_file.dirname FileUtils.cp_r Pathname.new(File.expand_path("templates/secrets", __dir__)), secrets_file puts "Created .kamal/secrets file" - - gitignore = Pathname.new(File.expand_path(".gitignore")) - if gitignore.exist? && !gitignore.read.include?(".kamal/secrets") - gitignore.open("a") { |f| f.puts "\n.kamal/secrets*" } - puts "Added .kamal/secrets* to .gitignore" - end end unless (hooks_dir = Pathname.new(File.expand_path(".kamal/hooks"))).exist? diff --git a/lib/kamal/cli/templates/secrets b/lib/kamal/cli/templates/secrets index 33f308be3..91f4f239e 100644 --- a/lib/kamal/cli/templates/secrets +++ b/lib/kamal/cli/templates/secrets @@ -1,6 +1,16 @@ -# SECRETS=$(kamal secrets --adapter 1password --from Vault/Item Section1/KAMAL_REGISTRY_PASSWORD Section2/RAILS_MASTER_KEY) -# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS}) -# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) +# WARNING: Avoid adding secrets directly to this file +# If you must, then add `.kamal/secrets*` to your .gitignore file -KAMAL_REGISTRY_PASSWORD=change-this -RAILS_MASTER_KEY=another-env +# 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) diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 43e24ced9..2115f4180 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -388,12 +388,10 @@ class CliMainTest < CliTestCase run_command("init").tap do |output| assert_match "Created configuration file in config/deploy.yml", output assert_match "Created .kamal/secrets file", output - assert_match "Added .kamal/secrets* to .gitignore", output end assert_file "config/deploy.yml", "service: my-app" - assert_file ".kamal/secrets", "KAMAL_REGISTRY_PASSWORD=change-this" - assert_file ".gitignore", %r{\n.kamal/secrets\*\n} + assert_file ".kamal/secrets", "KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD" end end @@ -528,7 +526,6 @@ def in_dummy_git_repo Dir.mktmpdir do |tmpdir| Dir.chdir(tmpdir) do `git init` - `echo '/.bundle\n/log/*\n/tmp/*' > .gitignore` yield end end diff --git a/test/integration/docker/deployer/Dockerfile b/test/integration/docker/deployer/Dockerfile index bb6b462a7..269f78b09 100644 --- a/test/integration/docker/deployer/Dockerfile +++ b/test/integration/docker/deployer/Dockerfile @@ -26,8 +26,8 @@ RUN mkdir -p /etc/docker/certs.d/registry:4443 && ln -s /shared/certs/domain.crt RUN git config --global user.email "deployer@example.com" 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_with_roles && 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 && git add . && git commit -am "Initial version" HEALTHCHECK --interval=1s CMD pgrep sleep From 9089c41f30f484b04e208bbc0fe164d0c2cfb026 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 11 Sep 2024 13:38:18 +0100 Subject: [PATCH 37/40] Add secrets-common for shared secrets Add a shared secrets file used across all destinations. Useful for things Github tokens or registry passwords. The secrets are added to a new file called `secrets-common` to highlight they are shared, and to avoid acciedentally inheriting a secret from the `secrets` file to `secrets.destination`. --- lib/kamal/secrets.rb | 19 +++++++------------ .../docker/deployer/app/.kamal/secrets | 2 -- .../docker/deployer/app/.kamal/secrets-common | 2 ++ test/secrets_test.rb | 8 ++++++-- 4 files changed, 15 insertions(+), 16 deletions(-) create mode 100644 test/integration/docker/deployer/app/.kamal/secrets-common diff --git a/lib/kamal/secrets.rb b/lib/kamal/secrets.rb index 34d30aaea..2e9dd23dc 100644 --- a/lib/kamal/secrets.rb +++ b/lib/kamal/secrets.rb @@ -1,19 +1,20 @@ require "dotenv" class Kamal::Secrets - attr_reader :secrets_file + attr_reader :secrets_files Kamal::Secrets::Dotenv::InlineCommandSubstitution.install! def initialize(destination: nil) - @secrets_file = [ *(".kamal/secrets.#{destination}" if destination), ".kamal/secrets" ].find { |f| File.exist?(f) } + @secrets_files = \ + [ ".kamal/secrets-common", ".kamal/secrets#{(".#{destination}" if destination)}" ].select { |f| File.exist?(f) } end def [](key) secrets.fetch(key) rescue KeyError - if secrets_file - raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secrets_file}" + 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 @@ -25,14 +26,8 @@ def to_h private def secrets - @secrets ||= parse_secrets - end - - def parse_secrets - if secrets_file - ::Dotenv.parse(secrets_file) - else - {} + @secrets ||= secrets_files.inject({}) do |secrets, secrets_file| + secrets.merge!(::Dotenv.parse(secrets_file)) end end end diff --git a/test/integration/docker/deployer/app/.kamal/secrets b/test/integration/docker/deployer/app/.kamal/secrets index ee55e94c2..429499ce3 100644 --- a/test/integration/docker/deployer/app/.kamal/secrets +++ b/test/integration/docker/deployer/app/.kamal/secrets @@ -1,5 +1,3 @@ -SECRET_TOKEN='1234 with "中文"' -SECRET_TAG='TAGME' 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}) diff --git a/test/integration/docker/deployer/app/.kamal/secrets-common b/test/integration/docker/deployer/app/.kamal/secrets-common new file mode 100644 index 000000000..ea15ab06e --- /dev/null +++ b/test/integration/docker/deployer/app/.kamal/secrets-common @@ -0,0 +1,2 @@ +SECRET_TOKEN='1234 with "中文"' +SECRET_TAG='TAGME' diff --git a/test/secrets_test.rb b/test/secrets_test.rb index 5909b0e12..bb77a1965 100644 --- a/test/secrets_test.rb +++ b/test/secrets_test.rb @@ -21,10 +21,14 @@ class SecretsTest < ActiveSupport::TestCase end test "destinations" do - with_test_secrets("secrets.dest" => "SECRET=DEF", "secrets" => "SECRET=ABC") 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 "ABC", Kamal::Secrets.new(destination: "nodest")["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 From 0660895e7589fef56a174d5027eb587bb9b60342 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 11 Sep 2024 16:10:52 +0100 Subject: [PATCH 38/40] Don't exit from failed secrets commands We can let the exception bubble up. We'll still get an error message and it ensures that any cleanup we need is done (i.e. releasing deploy locks). --- lib/kamal/cli/secrets.rb | 35 ++++++++++++----------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/lib/kamal/cli/secrets.rb b/lib/kamal/cli/secrets.rb index cab53ca87..8b919f9a3 100644 --- a/lib/kamal/cli/secrets.rb +++ b/lib/kamal/cli/secrets.rb @@ -5,24 +5,20 @@ class Kamal::Cli::Secrets < Kamal::Cli::Base 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) - handle_output(inline: options[:inline]) do - results = adapter(options[:adapter]).fetch(secrets, **options.slice(:account, :from).symbolize_keys) - JSON.dump(results).shellescape - end + 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) - handle_output(inline: options[:inline]) do - parsed_secrets = JSON.parse(secrets) + parsed_secrets = JSON.parse(secrets) + value = parsed_secrets[name] || parsed_secrets.find { |k, v| k.end_with?("/#{name}") }&.last - value = parsed_secrets[name] || parsed_secrets.find { |k, v| k.end_with?("/#{name}") }&.last + raise "Could not find secret #{name}" if value.nil? - raise "Could not find secret #{name}" if value.nil? - - value - end + return_or_puts value, inline: options[:inline] end private @@ -30,18 +26,11 @@ def adapter(adapter) Kamal::Secrets::Adapters.lookup(adapter) end - def handle_output(inline: nil) - yield.tap do |output| - puts output unless inline + def return_or_puts(value, inline: nil) + if inline + value + else + puts value end - rescue => e - handle_error(e) - end - - def handle_error(e) - $stderr.puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m" - $stderr.puts e.backtrace if ENV["VERBOSE"] - - exit 1 end end From dc1bbac3c8e693f4484c6ee961ed3b108a495ead Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 12 Sep 2024 19:31:18 +0100 Subject: [PATCH 39/40] Override the entrypoint when extracting assets When overriding the command, docker will still run the entrypoint. We want to avoid that here - we just want to get the assets out as quickly as possible. Otherwise maybe something important is going on when we stop the container. --- lib/kamal/commands/app/assets.rb | 2 +- test/cli/app_test.rb | 2 +- test/commands/app_test.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/kamal/commands/app/assets.rb b/lib/kamal/commands/app/assets.rb index 26ca6e0ca..9841f4fbb 100644 --- a/lib/kamal/commands/app/assets.rb +++ b/lib/kamal/commands/app/assets.rb @@ -5,7 +5,7 @@ def extract_assets combine \ make_directory(role.asset_extracted_path), [ *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(:stop, "-t 1", asset_container), by: "&&" diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index 46a067f36..85a966fd6 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -85,7 +85,7 @@ class CliAppTest < CliTestCase run_command("boot", config: :with_assets).tap do |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/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 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 diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index 69ed6d9b5..e385764e0 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -430,7 +430,7 @@ class CommandsAppTest < ActiveSupport::TestCase assert_equal [ :mkdir, "-p", ".kamal/assets/extracted/app-web-999", "&&", :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, :stop, "-t 1", "app-web-assets" ], new_command(asset_path: "/public/assets").extract_assets From 6bbbd81da16d288832b4dd1de262a3f5da1407aa Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 16 Sep 2024 14:44:39 +0100 Subject: [PATCH 40/40] Add a mutex around loading secrets Loading secrets may ask for use input, so we need to ensure only one thread does it at a time. --- lib/kamal/configuration.rb | 4 +++- lib/kamal/secrets.rb | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 0194bdd24..2758d15ab 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -9,7 +9,7 @@ class Kamal::Configuration delegate :service, :image, :labels, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true 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 include Validation @@ -64,6 +64,8 @@ def initialize(raw_config, destination: nil, version: nil, validate: true) @ssh = Ssh.new(config: self) @sshkit = Sshkit.new(config: self) + @secrets = Kamal::Secrets.new(destination: destination) + ensure_destination_if_required ensure_required_keys_present ensure_valid_kamal_version diff --git a/lib/kamal/secrets.rb b/lib/kamal/secrets.rb index 2e9dd23dc..c7d4cc03f 100644 --- a/lib/kamal/secrets.rb +++ b/lib/kamal/secrets.rb @@ -8,10 +8,14 @@ class Kamal::Secrets 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) - secrets.fetch(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(", ")}"