From 6d062ce27124dca0feb61d78688b6dad4f47fc2b Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Thu, 2 May 2024 10:36:15 +0100 Subject: [PATCH] Host specific env with tags Allow hosts to be tagged so we can have host specific env variables. We might want host specific env variables for things like datacenter specific tags or testing GC settings on a specific host. Right now you either need to set up a separate role, or have the app be host aware. Now you can define tag env variables and assign those to hosts. For example: ``` servers: - 1.1.1.1 - 1.1.1.2: tag1 - 1.1.1.2: tag2 - 1.1.1.3: [ tag1, tag2 ] env_tags: tag1: ENV1: value1 tag2: ENV2: value2 ``` The tag env supports the full env format, allowing you to set secret and clear values. --- lib/kamal/cli/app.rb | 36 ++++--- lib/kamal/cli/app/boot.rb | 2 +- lib/kamal/cli/app/prepare_assets.rb | 2 +- lib/kamal/cli/env.rb | 6 +- lib/kamal/cli/main.rb | 2 +- lib/kamal/commander.rb | 4 +- lib/kamal/commands/app.rb | 11 +- lib/kamal/commands/app/execution.rb | 6 +- lib/kamal/commands/healthcheck.rb | 2 +- lib/kamal/configuration.rb | 8 ++ lib/kamal/configuration/env/tag.rb | 12 +++ lib/kamal/configuration/role.rb | 35 ++++-- test/cli/app_test.rb | 26 +++++ test/commands/app_test.rb | 38 +++++-- test/commands/healthcheck_test.rb | 9 ++ test/configuration/env/tags_test.rb | 102 ++++++++++++++++++ test/configuration/role_test.rb | 26 ++--- test/fixtures/deploy_with_env_tags.yml | 29 +++++ test/integration/docker/deployer/app/.env.erb | 1 + .../docker/deployer/app/config/deploy.yml | 9 +- test/integration/main_test.rb | 37 +++++-- 21 files changed, 334 insertions(+), 69 deletions(-) create mode 100644 lib/kamal/configuration/env/tag.rb create mode 100644 test/configuration/env/tags_test.rb create mode 100644 test/fixtures/deploy_with_env_tags.yml diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index ec7c019a..188d5e9b 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -37,7 +37,7 @@ class Kamal::Cli::App < Kamal::Cli::Base roles.each do |role| execute *KAMAL.auditor.record("Started app version #{KAMAL.config.version}"), verbosity: :debug - execute *KAMAL.app(role: role).start, raise_on_non_zero_exit: false + execute *KAMAL.app(role: role, host: host).start, raise_on_non_zero_exit: false end end end @@ -51,7 +51,7 @@ class Kamal::Cli::App < Kamal::Cli::Base roles.each do |role| execute *KAMAL.auditor.record("Stopped app", role: role), verbosity: :debug - execute *KAMAL.app(role: role).stop, raise_on_non_zero_exit: false + execute *KAMAL.app(role: role, host: host).stop, raise_on_non_zero_exit: false end end end @@ -64,7 +64,7 @@ class Kamal::Cli::App < Kamal::Cli::Base roles = KAMAL.roles_on(host) roles.each do |role| - puts_by_host host, capture_with_info(*KAMAL.app(role: role).info) + puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).info) end end end @@ -80,7 +80,7 @@ class Kamal::Cli::App < Kamal::Cli::Base say "Get current version of running container...", :magenta unless options[:version] using_version(options[:version] || current_running_version) do |version| say "Launching interactive command with version #{version} via SSH from existing container on #{KAMAL.primary_host}...", :magenta - run_locally { exec KAMAL.app(role: KAMAL.primary_role).execute_in_existing_container_over_ssh(cmd, host: KAMAL.primary_host, env: env) } + run_locally { exec KAMAL.app(role: KAMAL.primary_role, host: KAMAL.primary_host).execute_in_existing_container_over_ssh(cmd, env: env) } end when options[:interactive] @@ -88,7 +88,7 @@ class Kamal::Cli::App < Kamal::Cli::Base using_version(version_or_latest) do |version| say "Launching interactive command with version #{version} via SSH from new container on #{KAMAL.primary_host}...", :magenta run_locally do - exec KAMAL.app(role: KAMAL.primary_role).execute_in_new_container_over_ssh(cmd, host: KAMAL.primary_host, env: env) + exec KAMAL.app(role: KAMAL.primary_role, host: KAMAL.primary_host).execute_in_new_container_over_ssh(cmd, env: env) end end @@ -102,7 +102,7 @@ class Kamal::Cli::App < Kamal::Cli::Base roles.each do |role| execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}", role: role), verbosity: :debug - puts_by_host host, capture_with_info(*KAMAL.app(role: role).execute_in_existing_container(cmd, env: env)) + puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_existing_container(cmd, env: env)) end end end @@ -116,7 +116,7 @@ class Kamal::Cli::App < Kamal::Cli::Base roles.each do |role| execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug - puts_by_host host, capture_with_info(*KAMAL.app(role: role).execute_in_new_container(cmd, env: env)) + puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_new_container(cmd, env: env)) end end end @@ -140,13 +140,14 @@ class Kamal::Cli::App < Kamal::Cli::Base roles = KAMAL.roles_on(host) roles.each do |role| - versions = capture_with_info(*KAMAL.app(role: role).list_versions, raise_on_non_zero_exit: false).split("\n") - versions -= [ capture_with_info(*KAMAL.app(role: role).current_running_version, raise_on_non_zero_exit: false).strip ] + app = KAMAL.app(role: role, host: host) + versions = capture_with_info(*app.list_versions, raise_on_non_zero_exit: false).split("\n") + versions -= [ capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip ] versions.each do |version| if stop puts_by_host host, "Stopping stale container for role #{role} with version #{version}" - execute *KAMAL.app(role: role).stop(version: version), raise_on_non_zero_exit: false + execute *app.stop(version: version), raise_on_non_zero_exit: false else puts_by_host host, "Detected stale container for role #{role} with version #{version} (use `kamal app stale_containers --stop` to stop)" end @@ -180,8 +181,9 @@ class Kamal::Cli::App < Kamal::Cli::Base KAMAL.specific_roles ||= [ "web" ] role = KAMAL.roles_on(KAMAL.primary_host).first - info KAMAL.app(role: role).follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep) - exec KAMAL.app(role: role).follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep) + app = KAMAL.app(role: role, host: host) + info app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep) + exec app.follow_logs(host: KAMAL.primary_host, lines: lines, grep: grep) end else lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set @@ -191,7 +193,7 @@ class Kamal::Cli::App < Kamal::Cli::Base roles.each do |role| begin - puts_by_host host, capture_with_info(*KAMAL.app(role: role).logs(since: since, lines: lines, grep: grep)) + puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(since: since, lines: lines, grep: grep)) rescue SSHKit::Command::Failed puts_by_host host, "Nothing found" end @@ -217,7 +219,7 @@ class Kamal::Cli::App < Kamal::Cli::Base roles.each do |role| execute *KAMAL.auditor.record("Removed app container with version #{version}", role: role), verbosity: :debug - execute *KAMAL.app(role: role).remove_container(version: version) + execute *KAMAL.app(role: role, host: host).remove_container(version: version) end end end @@ -231,7 +233,7 @@ class Kamal::Cli::App < Kamal::Cli::Base roles.each do |role| execute *KAMAL.auditor.record("Removed all app containers", role: role), verbosity: :debug - execute *KAMAL.app(role: role).remove_containers + execute *KAMAL.app(role: role, host: host).remove_containers end end end @@ -251,7 +253,7 @@ class Kamal::Cli::App < Kamal::Cli::Base def version on(KAMAL.hosts) do |host| role = KAMAL.roles_on(host).first - puts_by_host host, capture_with_info(*KAMAL.app(role: role).current_running_version).strip + puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip end end @@ -274,7 +276,7 @@ class Kamal::Cli::App < Kamal::Cli::Base version = nil on(host) do role = KAMAL.roles_on(host).first - version = capture_with_info(*KAMAL.app(role: role).current_running_version).strip + version = capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip end version.presence end diff --git a/lib/kamal/cli/app/boot.rb b/lib/kamal/cli/app/boot.rb index ed7e2ed6..6098e654 100644 --- a/lib/kamal/cli/app/boot.rb +++ b/lib/kamal/cli/app/boot.rb @@ -22,7 +22,7 @@ class Kamal::Cli::App::Boot private def app - @app ||= KAMAL.app(role: role) + @app ||= KAMAL.app(role: role, host: host) end def auditor diff --git a/lib/kamal/cli/app/prepare_assets.rb b/lib/kamal/cli/app/prepare_assets.rb index f7f44269..dd28fa41 100644 --- a/lib/kamal/cli/app/prepare_assets.rb +++ b/lib/kamal/cli/app/prepare_assets.rb @@ -19,6 +19,6 @@ class Kamal::Cli::App::PrepareAssets private def app - @app ||= KAMAL.app(role: role) + @app ||= KAMAL.app(role: role, host: host) end end diff --git a/lib/kamal/cli/env.rb b/lib/kamal/cli/env.rb index 3cfb1741..56ba505a 100644 --- a/lib/kamal/cli/env.rb +++ b/lib/kamal/cli/env.rb @@ -8,8 +8,8 @@ class Kamal::Cli::Env < Kamal::Cli::Base execute *KAMAL.auditor.record("Pushed env files"), verbosity: :debug KAMAL.roles_on(host).each do |role| - execute *KAMAL.app(role: role).make_env_directory - upload! role.env.secrets_io, role.env.secrets_file, mode: 400 + 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 @@ -35,7 +35,7 @@ class Kamal::Cli::Env < Kamal::Cli::Base execute *KAMAL.auditor.record("Deleted env files"), verbosity: :debug KAMAL.roles_on(host).each do |role| - execute *KAMAL.app(role: role).remove_env_file + execute *KAMAL.app(role: role, host: host).remove_env_file end end diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 00ffbfe3..d972da6b 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -246,7 +246,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base begin on(KAMAL.hosts) do KAMAL.roles_on(host).each do |role| - container_id = capture_with_info(*KAMAL.app(role: role).container_id_for_version(version)) + container_id = capture_with_info(*KAMAL.app(role: role, host: host).container_id_for_version(version)) raise "Container not found" unless container_id.present? end end diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index 042e8429..e7c5d21f 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -65,8 +65,8 @@ class Kamal::Commander end - def app(role: nil) - Kamal::Commands::App.new(config, role: role) + def app(role: nil, host: nil) + Kamal::Commands::App.new(config, role: role, host: host) end def accessory(name) diff --git a/lib/kamal/commands/app.rb b/lib/kamal/commands/app.rb index 15992640..37fa86ab 100644 --- a/lib/kamal/commands/app.rb +++ b/lib/kamal/commands/app.rb @@ -3,11 +3,12 @@ class Kamal::Commands::App < Kamal::Commands::Base ACTIVE_DOCKER_STATUSES = [ :running, :restarting ] - attr_reader :role, :role + attr_reader :role, :host - def initialize(config, role: nil) + def initialize(config, role: nil, host: nil) super(config) @role = role + @host = host end def run(hostname: nil) @@ -18,7 +19,7 @@ class Kamal::Commands::App < Kamal::Commands::Base *([ "--hostname", hostname ] if hostname), "-e", "KAMAL_CONTAINER_NAME=\"#{container_name}\"", "-e", "KAMAL_VERSION=\"#{config.version}\"", - *role.env_args, + *role.env_args(host), *role.health_check_args, *role.logging_args, *config.volume_args, @@ -70,11 +71,11 @@ class Kamal::Commands::App < Kamal::Commands::Base def make_env_directory - make_directory role.env.secrets_directory + make_directory role.env(host).secrets_directory end def remove_env_file - [ :rm, "-f", role.env.secrets_file ] + [ :rm, "-f", role.env(host).secrets_file ] end diff --git a/lib/kamal/commands/app/execution.rb b/lib/kamal/commands/app/execution.rb index 6d32b0c8..215821dc 100644 --- a/lib/kamal/commands/app/execution.rb +++ b/lib/kamal/commands/app/execution.rb @@ -11,7 +11,7 @@ module Kamal::Commands::App::Execution docker :run, ("-it" if interactive), "--rm", - *role&.env_args, + *role&.env_args(host), *argumentize("--env", env), *config.volume_args, *role&.option_args, @@ -19,11 +19,11 @@ module Kamal::Commands::App::Execution *command end - def execute_in_existing_container_over_ssh(*command, host:, env:) + def execute_in_existing_container_over_ssh(*command, env:) run_over_ssh execute_in_existing_container(*command, interactive: true, env: env), host: host end - def execute_in_new_container_over_ssh(*command, host:, env:) + def execute_in_new_container_over_ssh(*command, env:) run_over_ssh execute_in_new_container(*command, interactive: true, env: env), host: host end end diff --git a/lib/kamal/commands/healthcheck.rb b/lib/kamal/commands/healthcheck.rb index 70868101..2517a5e2 100644 --- a/lib/kamal/commands/healthcheck.rb +++ b/lib/kamal/commands/healthcheck.rb @@ -8,7 +8,7 @@ class Kamal::Commands::Healthcheck < Kamal::Commands::Base "--publish", "#{exposed_port}:#{config.healthcheck["port"]}", "--label", "service=#{config.healthcheck_service}", "-e", "KAMAL_CONTAINER_NAME=\"#{config.healthcheck_service}\"", - *primary.env_args, + *primary.env_args(config.primary_host), *primary.health_check_args(cord: false), *config.volume_args, *primary.option_args, diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index ec2c3f66..dd7970cd 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -233,6 +233,14 @@ class Kamal::Configuration raw_config.env || {} end + def env_tags + raw_config.env_tags.collect { |name, config| Kamal::Configuration::Env::Tag.new(name, config: config) } + end + + def env_tag(name) + env_tags.detect { |t| t.name == name.to_s } + end + def valid? ensure_destination_if_required && ensure_required_keys_present && ensure_valid_kamal_version && ensure_retain_containers_valid && ensure_valid_service_name diff --git a/lib/kamal/configuration/env/tag.rb b/lib/kamal/configuration/env/tag.rb new file mode 100644 index 00000000..2a6a1306 --- /dev/null +++ b/lib/kamal/configuration/env/tag.rb @@ -0,0 +1,12 @@ +class Kamal::Configuration::Env::Tag + attr_reader :name, :config + + def initialize(name, config:) + @name = name + @config = config + end + + def env + Kamal::Configuration::Env.from_config(config: config) + end +end diff --git a/lib/kamal/configuration/role.rb b/lib/kamal/configuration/role.rb index c726c7b8..f0df5924 100644 --- a/lib/kamal/configuration/role.rb +++ b/lib/kamal/configuration/role.rb @@ -7,6 +7,7 @@ class Kamal::Configuration::Role def initialize(name, config:) @name, @config = name.inquiry, config + @tagged_hosts ||= extract_tagged_hosts_from_config end def primary_host @@ -14,7 +15,11 @@ class Kamal::Configuration::Role end def hosts - @hosts ||= extract_hosts_from_config + tagged_hosts.keys + end + + def env_tags(host) + tagged_hosts.fetch(host).collect { |tag| config.env_tag(tag) } end def cmd @@ -50,12 +55,13 @@ class Kamal::Configuration::Role end - def env - @env ||= base_env.merge(specialized_env) + def env(host) + @envs ||= {} + @envs[host] ||= [ base_env, specialized_env, *env_tags(host).map(&:env) ].reduce(:merge) end - def env_args - env.args + def env_args(host) + env(host).args end def asset_volume_args @@ -164,7 +170,24 @@ class Kamal::Configuration::Role end private - attr_accessor :config + attr_accessor :config, :tagged_hosts + + def extract_tagged_hosts_from_config + {}.tap do |tagged_hosts| + extract_hosts_from_config.map do |host_config| + if host_config.is_a?(Hash) + raise ArgumentError, "Multiple hosts found: #{host_config.inspect}" unless host_config.size == 1 + + host, tags = host_config.first + tagged_hosts[host] = Array(tags) + elsif host_config.is_a?(String) || host_config.is_a?(Symbol) + tagged_hosts[host_config] = [] + else + raise ArgumentError, "Invalid host config: #{host_config.inspect}" + end + end + end + end def extract_hosts_from_config if config.servers.is_a?(Array) diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index dabd2ccd..2b44ddf6 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -92,6 +92,32 @@ class CliAppTest < CliTestCase end end + test "boot with host tags" do + Object.any_instance.stubs(:sleep) + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", raise_on_non_zero_exit: false) + .returns("12345678") # running version + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") + .returns("running") # health check + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) + .returns("123") # old version + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :inspect, "-f '{{ range .Mounts }}{{printf \"%s %s\\n\" .Source .Destination}}{{ end }}'", "app-web-123", "|", :awk, "'$2 == \"/tmp/kamal-cord\" {print $1}'", raise_on_non_zero_exit: false) + .returns("") # old version + + 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 "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output + end + end + test "start" do run_command("start").tap do |output| assert_match "docker start app-web-999", output diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index 3fe97d97..af7c6251 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -60,7 +60,7 @@ class CommandsAppTest < ActiveSupport::TestCase @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", - new_command(role: "jobs").run.join(" ") + new_command(role: "jobs", host: "1.1.1.2").run.join(" ") end test "run with logging config" do @@ -80,6 +80,15 @@ class CommandsAppTest < ActiveSupport::TestCase new_command.run.join(" ") end + test "run with tags" do + @config[:servers] = [ { "1.1.1.1" => "tag1" } ] + @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", + new_command.run.join(" ") + end + test "start" do assert_equal \ "docker start app-web-999", @@ -183,6 +192,15 @@ class CommandsAppTest < ActiveSupport::TestCase new_command.execute_in_new_container("bin/rails", "db:setup", env: { "foo" => "bar" }).join(" ") end + test "execute in new container with tags" do + @config[:servers] = [ { "1.1.1.1" => "tag1" } ] + @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", + 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 \ @@ -204,18 +222,26 @@ class CommandsAppTest < ActiveSupport::TestCase 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}, - new_command.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1", env: {}) + new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) + end + + test "execute in new container over ssh with tags" do + @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'", + 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}, - new_command.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1", env: {}) + new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) end test "execute in existing container over ssh" do assert_match %r{docker exec -it app-web-999 bin/rails c}, - new_command.execute_in_existing_container_over_ssh("bin/rails", "c", host: "app-1", env: {}) + new_command.execute_in_existing_container_over_ssh("bin/rails", "c", env: {}) end test "run over ssh" do @@ -418,8 +444,8 @@ class CommandsAppTest < ActiveSupport::TestCase end private - def new_command(role: "web", **additional_config) + def new_command(role: "web", host: "1.1.1.1", **additional_config) config = Kamal::Configuration.new(@config.merge(additional_config), destination: @destination, version: "999") - Kamal::Commands::App.new(config, role: config.role(role)) + Kamal::Commands::App.new(config, role: config.role(role), host: host) end end diff --git a/test/commands/healthcheck_test.rb b/test/commands/healthcheck_test.rb index 66cdda6b..5c605175 100644 --- a/test/commands/healthcheck_test.rb +++ b/test/commands/healthcheck_test.rb @@ -46,6 +46,15 @@ class CommandsHealthcheckTest < ActiveSupport::TestCase new_command.run.join(" ") end + test "run with tags" do + @config[:servers] = [ { "1.1.1.1" => "tag1" } ] + @config[:env_tags] = { "tag1" => { "ENV1" => "value1" } } + + assert_equal \ + "docker run --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app -e KAMAL_CONTAINER_NAME=\"healthcheck-app\" --env-file .kamal/env/roles/app-web.env --env ENV1=\"value1\" --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" dhh/app:123", + new_command.run.join(" ") + end + test "status" do assert_equal \ "docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'", diff --git a/test/configuration/env/tags_test.rb b/test/configuration/env/tags_test.rb new file mode 100644 index 00000000..380cb5cd --- /dev/null +++ b/test/configuration/env/tags_test.rb @@ -0,0 +1,102 @@ +require "test_helper" + +class ConfigurationEnvTagsTest < ActiveSupport::TestCase + setup do + @deploy = { + service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, + servers: [ { "1.1.1.1" => "odd" }, { "1.1.1.2" => "even" }, { "1.1.1.3" => [ "odd", "three" ] } ], + env: { "REDIS_URL" => "redis://x/y", "THREE" => "false" }, + env_tags: { + "odd" => { "TYPE" => "odd" }, + "even" => { "TYPE" => "even" }, + "three" => { "THREE" => "true" } + } + } + + @config = Kamal::Configuration.new(@deploy) + + @deploy_with_roles = @deploy.dup.merge({ + servers: { + "web" => [ { "1.1.1.1" => "odd" }, "1.1.1.2" ], + "workers" => { + "hosts" => [ { "1.1.1.3" => [ "odd", "oddjob" ] }, "1.1.1.4" ], + "cmd" => "bin/jobs", + "env" => { + "REDIS_URL" => "redis://a/b", + "WEB_CONCURRENCY" => 4 + } + } + }, + env_tags: { + "odd" => { "TYPE" => "odd" }, + "oddjob" => { "TYPE" => "oddjob" } + } + }) + + @config_with_roles = Kamal::Configuration.new(@deploy_with_roles) + end + + test "tags" do + assert_equal 3, @config.env_tags.size + assert_equal %w[ odd even three ], @config.env_tags.map(&:name) + assert_equal({ "TYPE" => "odd" }, @config.env_tag("odd").env.clear) + assert_equal({ "TYPE" => "even" }, @config.env_tag("even").env.clear) + assert_equal({ "THREE" => "true" }, @config.env_tag("three").env.clear) + end + + test "tags with roles" do + assert_equal 2, @config_with_roles.env_tags.size + assert_equal %w[ odd oddjob ], @config_with_roles.env_tags.map(&:name) + assert_equal({ "TYPE" => "odd" }, @config_with_roles.env_tag("odd").env.clear) + assert_equal({ "TYPE" => "oddjob" }, @config_with_roles.env_tag("oddjob").env.clear) + end + + test "tag overrides env" do + assert_equal "false", @config.role("web").env("1.1.1.1").clear["THREE"] + assert_equal "true", @config.role("web").env("1.1.1.3").clear["THREE"] + end + + test "later tag wins" do + deploy = { + service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, + servers: [ { "1.1.1.1" => [ "first", "second" ] } ], + env_tags: { + "first" => { "TYPE" => "first" }, + "second" => { "TYPE" => "second" } + } + } + + config = Kamal::Configuration.new(deploy) + assert_equal "second", config.role("web").env("1.1.1.1").clear["TYPE"] + 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" } ], + 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" + end + + test "tag clear env" do + deploy = { + service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, + servers: [ { "1.1.1.1" => "clearly" } ], + env_tags: { + "clearly" => { "clear" => { "FOO" => "bar" } } + } + } + + config = Kamal::Configuration.new(deploy) + assert_equal "bar", config.role("web").env("1.1.1.1").clear["FOO"] + end +end diff --git a/test/configuration/role_test.rb b/test/configuration/role_test.rb index 0952f1e8..84fdfe6b 100644 --- a/test/configuration/role_test.rb +++ b/test/configuration/role_test.rb @@ -70,10 +70,10 @@ class ConfigurationRoleTest < ActiveSupport::TestCase end test "env overwritten by role" do - assert_equal "redis://a/b", @config_with_roles.role(:workers).env.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.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 + 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") end test "container name" do @@ -86,7 +86,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase 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 + 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") end test "env secret overwritten by role" do @@ -117,8 +117,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase DB_PASSWORD=secret&\"123 ENV - assert_equal expected_secrets_file, @config_with_roles.role(:workers).env.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 + assert_equal expected_secrets_file, @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") ensure ENV["REDIS_PASSWORD"] = nil ENV["DB_PASSWORD"] = nil @@ -141,8 +141,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase DB_PASSWORD=secret123 ENV - assert_equal expected_secrets_file, @config_with_roles.role(:workers).env.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 + assert_equal expected_secrets_file, @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") ensure ENV["DB_PASSWORD"] = nil end @@ -163,8 +163,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase REDIS_PASSWORD=secret456 ENV - assert_equal expected_secrets_file, @config_with_roles.role(:workers).env.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 + assert_equal expected_secrets_file, @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") ensure ENV["REDIS_PASSWORD"] = nil end @@ -191,14 +191,14 @@ class ConfigurationRoleTest < ActiveSupport::TestCase REDIS_PASSWORD=secret456 ENV - assert_equal expected_secrets_file, @config_with_roles.role(:workers).env.secrets_io.string - assert_equal [ "--env-file", ".kamal/env/roles/app-workers.env", "--env", "REDIS_URL=\"redis://c/d\"" ], @config_with_roles.role(:workers).env_args + assert_equal expected_secrets_file, @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://c/d\"" ], @config_with_roles.role(:workers).env_args("1.1.1.3") ensure ENV["REDIS_PASSWORD"] = nil end test "env secrets_file" do - assert_equal ".kamal/env/roles/app-workers.env", @config_with_roles.role(:workers).env.secrets_file + assert_equal ".kamal/env/roles/app-workers.env", @config_with_roles.role(:workers).env("1.1.1.3").secrets_file end test "uses cord" do diff --git a/test/fixtures/deploy_with_env_tags.yml b/test/fixtures/deploy_with_env_tags.yml new file mode 100644 index 00000000..4acc839c --- /dev/null +++ b/test/fixtures/deploy_with_env_tags.yml @@ -0,0 +1,29 @@ +service: app +image: dhh/app +servers: + web: + - 1.1.1.1: site1 + - 1.1.1.2: [ site1 experimental ] + - 1.2.1.1: site2 + - 1.2.1.2: site2 + workers: + - 1.1.1.3: site1 + - 1.1.1.4: site1 + - 1.2.1.3: site2 + - 1.2.1.4: [ site2 experimental ] +env: + clear: + TEST: "root" + EXPERIMENT: "disabled" +env_tags: + site1: + SITE: site1 + site2: + SITE: site2 + experimental: + env: + EXPERIMENT: "enabled" + +registry: + username: user + password: pw diff --git a/test/integration/docker/deployer/app/.env.erb b/test/integration/docker/deployer/app/.env.erb index cb2988d6..ea15ab06 100644 --- a/test/integration/docker/deployer/app/.env.erb +++ b/test/integration/docker/deployer/app/.env.erb @@ -1 +1,2 @@ SECRET_TOKEN='1234 with "中文"' +SECRET_TAG='TAGME' diff --git a/test/integration/docker/deployer/app/config/deploy.yml b/test/integration/docker/deployer/app/config/deploy.yml index 11e2cf04..aed676cc 100644 --- a/test/integration/docker/deployer/app/config/deploy.yml +++ b/test/integration/docker/deployer/app/config/deploy.yml @@ -2,13 +2,20 @@ service: app image: app servers: - vm1 - - vm2 + - vm2: [ tag1, tag2 ] env: clear: CLEAR_TOKEN: 4321 + CLEAR_TAG: "" HOST_TOKEN: "${HOST_TOKEN}" secret: - SECRET_TOKEN +env_tags: + tag1: + CLEAR_TAG: tagged + tag2: + secret: + - SECRET_TAG asset_path: /usr/share/nginx/html/versions registry: diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index ac549b97..a2dc8a0c 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -3,8 +3,7 @@ require_relative "integration_test" class MainTest < IntegrationTest test "envify, deploy, redeploy, rollback, details and audit" do kamal :envify - assert_local_env_file "SECRET_TOKEN='1234 with \"中文\"'" - assert_remote_env_file "SECRET_TOKEN=1234 with \"中文\"" + assert_env_files remove_local_env_file first_version = latest_app_version @@ -14,9 +13,7 @@ class MainTest < IntegrationTest kamal :deploy assert_app_is_up version: first_version assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy" - assert_env :CLEAR_TOKEN, "4321", version: first_version - assert_env :HOST_TOKEN, "abcd", version: first_version - assert_env :SECRET_TOKEN, "1234 with \"中文\"", version: first_version + assert_envs version: first_version second_version = update_app_rev @@ -97,16 +94,38 @@ class MainTest < IntegrationTest assert_equal contents, deployer_exec("cat .env", capture: true) end - def assert_env(key, value, version:) - assert_equal "#{key}=#{value}", docker_compose("exec vm1 docker exec app-web-#{version} env | grep #{key}", capture: true) + 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 + assert_no_env :CLEAR_TAG, version: version, vm: :vm1 + assert_no_env :SECRET_TAG, version: version, vm: :vm11 + assert_env :CLEAR_TAG, "tagged", version: version, vm: :vm2 + assert_env :SECRET_TAG, "TAGME", version: version, vm: :vm2 + end + + def assert_env(key, value, vm:, version:) + assert_equal "#{key}=#{value}", docker_compose("exec #{vm} docker exec app-web-#{version} env | grep #{key}", capture: true) + end + + def assert_no_env(key, vm:, version:) + assert_raises(RuntimeError, /exit 1/) do + docker_compose("exec #{vm} docker exec app-web-#{version} env | grep #{key}", capture: true) + end + end + + def assert_env_files + assert_local_env_file "SECRET_TOKEN='1234 with \"中文\"'\nSECRET_TAG='TAGME'" + assert_remote_env_file "SECRET_TOKEN=1234 with \"中文\"", vm: :vm1 + assert_remote_env_file "SECRET_TOKEN=1234 with \"中文\"\nSECRET_TAG=TAGME", vm: :vm2 end def remove_local_env_file deployer_exec("rm .env") end - def assert_remote_env_file(contents) - assert_equal contents, docker_compose("exec vm1 cat /root/.kamal/env/roles/app-web.env", capture: true) + def assert_remote_env_file(contents, vm:) + assert_equal contents, docker_compose("exec #{vm} cat /root/.kamal/env/roles/app-web.env", capture: true) end def assert_no_remote_env_file