diff --git a/lib/kamal/commands/traefik.rb b/lib/kamal/commands/traefik.rb index fbbcea0fd..39ba796d1 100644 --- a/lib/kamal/commands/traefik.rb +++ b/lib/kamal/commands/traefik.rb @@ -1,5 +1,5 @@ class Kamal::Commands::Traefik < Kamal::Commands::Base - delegate :argumentize, :env_file_with_secrets, :optionize, to: Kamal::Utils + delegate :argumentize, :optionize, to: Kamal::Utils DEFAULT_IMAGE = "traefik:v2.9" CONTAINER_PORT = 80 @@ -64,7 +64,7 @@ def port end def env_file - env_file_with_secrets config.traefik.fetch("env", {}) + Kamal::EnvFile.new(config.traefik.fetch("env", {})) end def host_env_file_path diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index aaa351554..085f3cf41 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -206,7 +206,7 @@ def valid? # Will raise KeyError if any secret ENVs are missing def ensure_env_available - roles.each(&:env_file) + roles.collect(&:env_file).each(&:to_s) true end diff --git a/lib/kamal/configuration/accessory.rb b/lib/kamal/configuration/accessory.rb index aa5ccfbdc..50592b316 100644 --- a/lib/kamal/configuration/accessory.rb +++ b/lib/kamal/configuration/accessory.rb @@ -1,5 +1,5 @@ class Kamal::Configuration::Accessory - delegate :argumentize, :env_file_with_secrets, :optionize, to: Kamal::Utils + delegate :argumentize, :optionize, to: Kamal::Utils attr_accessor :name, :specifics @@ -46,7 +46,7 @@ def env end def env_file - env_file_with_secrets env + Kamal::EnvFile.new(env) end def host_env_directory diff --git a/lib/kamal/configuration/role.rb b/lib/kamal/configuration/role.rb index 04ff7a4b1..2945f87b9 100644 --- a/lib/kamal/configuration/role.rb +++ b/lib/kamal/configuration/role.rb @@ -1,6 +1,6 @@ class Kamal::Configuration::Role CORD_FILE = "cord" - delegate :argumentize, :env_file_with_secrets, :optionize, to: Kamal::Utils + delegate :argumentize, :optionize, to: Kamal::Utils attr_accessor :name @@ -46,7 +46,7 @@ def env end def env_file - env_file_with_secrets env + Kamal::EnvFile.new(env) end def host_env_directory diff --git a/lib/kamal/env_file.rb b/lib/kamal/env_file.rb new file mode 100644 index 000000000..9c56a3361 --- /dev/null +++ b/lib/kamal/env_file.rb @@ -0,0 +1,41 @@ +# 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| + if (secrets = @env["secret"]).present? + @env.fetch("secret", @env)&.each do |key| + contents << docker_env_file_line(key, ENV.fetch(key)) + end + + @env["clear"]&.each do |key, value| + contents << docker_env_file_line(key, value) + end + else + @env.fetch("clear", @env)&.each do |key, value| + contents << docker_env_file_line(key, value) + end + 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.to_s}=#{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) + # 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 540f576b7..3f70d2c9e 100644 --- a/lib/kamal/utils.rb +++ b/lib/kamal/utils.rb @@ -16,26 +16,6 @@ def argumentize(argument, attributes, sensitive: false) end end - def env_file_with_secrets(env) - env_file = StringIO.new.tap do |contents| - if (secrets = env["secret"]).present? - env.fetch("secret", env)&.each do |key| - contents << docker_env_file_line(key, ENV.fetch(key)) - end - env["clear"]&.each do |key, value| - contents << docker_env_file_line(key, value) - end - else - env.fetch("clear", env)&.each do |key, value| - contents << docker_env_file_line(key, value) - end - end - end.string - - # Ensure the file has some contents to avoid the SSHKIT empty file warning - env_file.presence || "\n" - end - # Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option. def optionize(args, with: nil) options = if with @@ -79,18 +59,7 @@ def escape_shell_value(value) .gsub(DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX, '\$') end - # Escape a value to make it safe to dump in a docker file. - def escape_docker_env_file_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 - def uncommitted_changes `git status --porcelain`.strip end - - def docker_env_file_line(key, value) - "#{key.to_s}=#{escape_docker_env_file_value(value)}\n" - end end diff --git a/test/commands/traefik_test.rb b/test/commands/traefik_test.rb index 5a651dee1..7dfcff97a 100644 --- a/test/commands/traefik_test.rb +++ b/test/commands/traefik_test.rb @@ -180,7 +180,7 @@ class CommandsTraefikTest < ActiveSupport::TestCase test "env_file" do @config[:traefik]["env"] = { "secret" => %w[EXAMPLE_API_KEY] } - assert_equal "EXAMPLE_API_KEY=456\n", new_command.env_file + assert_equal "EXAMPLE_API_KEY=456\n", new_command.env_file.to_s end test "host_env_file_path" do diff --git a/test/configuration/accessory_test.rb b/test/configuration/accessory_test.rb index 73136239b..6a250b176 100644 --- a/test/configuration/accessory_test.rb +++ b/test/configuration/accessory_test.rb @@ -123,7 +123,7 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase MYSQL_ROOT_HOST=% ENV - assert_equal expected, @config.accessory(:mysql).env_file + assert_equal expected, @config.accessory(:mysql).env_file.to_s ensure ENV["MYSQL_ROOT_PASSWORD"] = nil end diff --git a/test/configuration/role_test.rb b/test/configuration/role_test.rb index 49396b74d..6966e8938 100644 --- a/test/configuration/role_test.rb +++ b/test/configuration/role_test.rb @@ -77,7 +77,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase WEB_CONCURRENCY=4 ENV - assert_equal expected_env, @config_with_roles.role(:workers).env_file + assert_equal expected_env, @config_with_roles.role(:workers).env_file.to_s end test "container name" do @@ -123,7 +123,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase WEB_CONCURRENCY=4 ENV - assert_equal expected, @config_with_roles.role(:workers).env_file + assert_equal expected, @config_with_roles.role(:workers).env_file.to_s ensure ENV["REDIS_PASSWORD"] = nil ENV["DB_PASSWORD"] = nil @@ -148,7 +148,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase WEB_CONCURRENCY=4 ENV - assert_equal expected, @config_with_roles.role(:workers).env_file + assert_equal expected, @config_with_roles.role(:workers).env_file.to_s ensure ENV["DB_PASSWORD"] = nil end @@ -171,7 +171,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase WEB_CONCURRENCY=4 ENV - assert_equal expected, @config_with_roles.role(:workers).env_file + assert_equal expected, @config_with_roles.role(:workers).env_file.to_s ensure ENV["REDIS_PASSWORD"] = nil end diff --git a/test/env_file_test.rb b/test/env_file_test.rb new file mode 100644 index 000000000..e4a1322c7 --- /dev/null +++ b/test/env_file_test.rb @@ -0,0 +1,102 @@ +require "test_helper" + +class EnvFileTest < ActiveSupport::TestCase + test "env file simple" do + env = { + "foo" => "bar", + "baz" => "haz" + } + + assert_equal "foo=bar\nbaz=haz\n", \ + Kamal::EnvFile.new(env).to_s + end + + test "env file clear" do + env = { + "clear" => { + "foo" => "bar", + "baz" => "haz" + } + } + + assert_equal "foo=bar\nbaz=haz\n", \ + Kamal::EnvFile.new(env).to_s + end + + test "env file empty" do + assert_equal "\n", Kamal::EnvFile.new({}).to_s + end + + test "env file secret" do + ENV["PASSWORD"] = "hello" + env = { + "secret" => [ "PASSWORD" ] + } + + assert_equal "PASSWORD=hello\n", \ + Kamal::EnvFile.new(env).to_s + ensure + ENV.delete "PASSWORD" + end + + test "env file secret escaped newline" do + ENV["PASSWORD"] = "hello\\nthere" + env = { + "secret" => [ "PASSWORD" ] + } + + assert_equal "PASSWORD=hello\\\\nthere\n", \ + Kamal::EnvFile.new(env).to_s + ensure + ENV.delete "PASSWORD" + end + + test "env file secret newline" do + ENV["PASSWORD"] = "hello\nthere" + env = { + "secret" => [ "PASSWORD" ] + } + + assert_equal "PASSWORD=hello\\nthere\n", \ + Kamal::EnvFile.new(env).to_s + ensure + ENV.delete "PASSWORD" + end + + test "env file missing secret" do + env = { + "secret" => [ "PASSWORD" ] + } + + assert_raises(KeyError) { Kamal::EnvFile.new(env).to_s } + + ensure + ENV.delete "PASSWORD" + end + + test "env file secret and clear" do + ENV["PASSWORD"] = "hello" + env = { + "secret" => [ "PASSWORD" ], + "clear" => { + "foo" => "bar", + "baz" => "haz" + } + } + + assert_equal "PASSWORD=hello\nfoo=bar\nbaz=haz\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/utils_test.rb b/test/utils_test.rb index b268fb0fe..b45a0fd67 100644 --- a/test/utils_test.rb +++ b/test/utils_test.rb @@ -11,95 +11,6 @@ class UtilsTest < ActiveSupport::TestCase Kamal::Utils.argumentize("--label", { foo: "bar" }, sensitive: true).last end - test "env file simple" do - env = { - "foo" => "bar", - "baz" => "haz" - } - - assert_equal "foo=bar\nbaz=haz\n", \ - Kamal::Utils.env_file_with_secrets(env) - end - - test "env file clear" do - env = { - "clear" => { - "foo" => "bar", - "baz" => "haz" - } - } - - assert_equal "foo=bar\nbaz=haz\n", \ - Kamal::Utils.env_file_with_secrets(env) - end - - test "env file empty" do - assert_equal "\n", Kamal::Utils.env_file_with_secrets({}) - end - - test "env file secret" do - ENV["PASSWORD"] = "hello" - env = { - "secret" => [ "PASSWORD" ] - } - - assert_equal "PASSWORD=hello\n", \ - Kamal::Utils.env_file_with_secrets(env) - ensure - ENV.delete "PASSWORD" - end - - test "env file secret escaped newline" do - ENV["PASSWORD"] = "hello\\nthere" - env = { - "secret" => [ "PASSWORD" ] - } - - assert_equal "PASSWORD=hello\\\\nthere\n", \ - Kamal::Utils.env_file_with_secrets(env) - ensure - ENV.delete "PASSWORD" - end - - test "env file secret newline" do - ENV["PASSWORD"] = "hello\nthere" - env = { - "secret" => [ "PASSWORD" ] - } - - assert_equal "PASSWORD=hello\\nthere\n", \ - Kamal::Utils.env_file_with_secrets(env) - ensure - ENV.delete "PASSWORD" - end - - test "env file missing secret" do - env = { - "secret" => [ "PASSWORD" ] - } - - assert_raises(KeyError) { Kamal::Utils.env_file_with_secrets(env) } - - ensure - ENV.delete "PASSWORD" - end - - test "env file secret and clear" do - ENV["PASSWORD"] = "hello" - env = { - "secret" => [ "PASSWORD" ], - "clear" => { - "foo" => "bar", - "baz" => "haz" - } - } - - assert_equal "PASSWORD=hello\nfoo=bar\nbaz=haz\n", \ - Kamal::Utils.env_file_with_secrets(env) - ensure - ENV.delete "PASSWORD" - end - test "optionize" do assert_equal [ "--foo", "\"bar\"", "--baz", "\"qux\"", "--quux" ], \ Kamal::Utils.optionize({ foo: "bar", baz: "qux", quux: true })