Skip to content

Commit

Permalink
Merge pull request #471 from basecamp/extract-env-writer
Browse files Browse the repository at this point in the history
Extract Kamal::EnvFile
  • Loading branch information
dhh authored Sep 16, 2023
2 parents 12a82a6 + 3df8752 commit ff4d025
Show file tree
Hide file tree
Showing 11 changed files with 156 additions and 133 deletions.
4 changes: 2 additions & 2 deletions lib/kamal/commands/traefik.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/kamal/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/kamal/configuration/accessory.rb
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -46,7 +46,7 @@ def env
end

def env_file
env_file_with_secrets env
Kamal::EnvFile.new(env)
end

def host_env_directory
Expand Down
4 changes: 2 additions & 2 deletions lib/kamal/configuration/role.rb
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -46,7 +46,7 @@ def env
end

def env_file
env_file_with_secrets env
Kamal::EnvFile.new(env)
end

def host_env_directory
Expand Down
41 changes: 41 additions & 0 deletions lib/kamal/env_file.rb
Original file line number Diff line number Diff line change
@@ -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
31 changes: 0 additions & 31 deletions lib/kamal/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion test/commands/traefik_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion test/configuration/accessory_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions test/configuration/role_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
102 changes: 102 additions & 0 deletions test/env_file_test.rb
Original file line number Diff line number Diff line change
@@ -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
89 changes: 0 additions & 89 deletions test/utils_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down

0 comments on commit ff4d025

Please sign in to comment.