Skip to content

Commit

Permalink
Copy env files to remote hosts
Browse files Browse the repository at this point in the history
Setting env variables in the docker arguments requires having them on
the deploy host.

Instead we'll add two new commands `kamal env push` and
`kamal env delete` which will manage copying the environment as .env
files to the remote host.

Docker will pick up the file with `--env-file <path-to-file>`. Env files
will be stored under `<kamal run directory>/env`.

Running `kamal env push` will create env files for each role and
accessory, and traefik if required.

`kamal envify` has been updated to also push the env files.

By avoiding using `kamal envify` and creating the local and remote
secrets manually, you can now avoid accessing secrets needed
for the docker runtime environment locally. You will still need build
secrets.

One thing to note - the Docker doesn't parse the environment variables
in the env file, one result of this is that you can't specify multi-line
values - see moby/moby#12997.

We maybe need to look docker config or docker secrets longer term to get
around this.

Hattip to @kevinmcconnell - this was all his idea.
  • Loading branch information
djmb committed Sep 6, 2023
1 parent adc7173 commit 94bf090
Show file tree
Hide file tree
Showing 32 changed files with 453 additions and 170 deletions.
52 changes: 52 additions & 0 deletions lib/kamal/cli/env.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
require "tempfile"

class Kamal::Cli::Env < Kamal::Cli::Base
desc "push", "Push the env file to the remote hosts"
def push
mutating do
on(KAMAL.hosts) do
KAMAL.roles_on(host).each do |role|
role_config = KAMAL.config.role(role)
execute *KAMAL.app(role: role).make_env_directory
upload! StringIO.new(role_config.env_file), role_config.host_env_file_path, mode: 400
end
end

on(KAMAL.traefik_hosts) do
execute *KAMAL.traefik.make_env_directory
upload! StringIO.new(KAMAL.traefik.env_file), KAMAL.traefik.host_env_file_path, 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! StringIO.new(accessory_config.env_file), accessory_config.host_env_file_path, mode: 400
end
end
end
end

desc "delete", "Delete the env file from the remote hosts"
def delete
mutating do
on(KAMAL.hosts) do
KAMAL.roles_on(host).each do |role|
role_config = KAMAL.config.role(role)
execute *KAMAL.app(role: role).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
6 changes: 6 additions & 0 deletions lib/kamal/cli/main.rb
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ def envify
end

File.write(env_path, ERB.new(File.read(env_template_path)).result, perm: 0600)

load_envs # reload new file
invoke "kamal:cli:env:push", options
end

desc "remove", "Remove Traefik, app, accessories, and registry session from servers"
Expand Down Expand Up @@ -204,6 +207,9 @@ def version
desc "build", "Build application image"
subcommand "build", Kamal::Cli::Build

desc "env", "Manage environment files"
subcommand "env", Kamal::Cli::Env

desc "healthcheck", "Healthcheck application"
subcommand "healthcheck", Kamal::Cli::Healthcheck

Expand Down
4 changes: 4 additions & 0 deletions lib/kamal/commander.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ def accessory_names
config.accessories&.collect(&:name) || []
end

def accessories_on(host)
config.accessories.select { |accessory| accessory.hosts.include?(host.to_s) }.map(&:name)
end


def app(role: nil)
Kamal::Commands::App.new(config, role: role)
Expand Down
16 changes: 8 additions & 8 deletions lib/kamal/commands/accessory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,6 @@ def ensure_local_file_present(local_file)
end
end

def make_directory_for(remote_file)
make_directory Pathname.new(remote_file).dirname.to_s
end

def make_directory(path)
[ :mkdir, "-p", path ]
end

def remove_service_directory
[ :rm, "-rf", service_name ]
end
Expand All @@ -106,6 +98,14 @@ def remove_image
docker :image, :rm, "--force", image
end

def make_env_directory
make_directory accessory_config.host_env_directory
end

def remove_env_file
[:rm, "-f", accessory_config.host_env_file_path]
end

private
def service_filter
[ "--filter", "label=service=#{service_name}" ]
Expand Down
9 changes: 8 additions & 1 deletion lib/kamal/commands/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def execute_in_new_container(*command, interactive: false)
docker :run,
("-it" if interactive),
"--rm",
*config.env_args,
*role&.env_args,
*config.volume_args,
*role&.option_args,
config.absolute_image,
Expand Down Expand Up @@ -149,6 +149,13 @@ def tag_current_as_latest
docker :tag, config.absolute_image, config.latest_image
end

def make_env_directory
make_directory config.role(role).host_env_directory
end

def remove_env_file
[:rm, "-f", config.role(role).host_env_file_path]
end

private
def container_name(version = nil)
Expand Down
8 changes: 8 additions & 0 deletions lib/kamal/commands/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ def container_id_for(container_name:, only_running: false)
docker :container, :ls, *("--all" unless only_running), "--filter", "name=^#{container_name}$", "--quiet"
end

def make_directory_for(remote_file)
make_directory Pathname.new(remote_file).dirname.to_s
end

def make_directory(path)
[ :mkdir, "-p", path ]
end

private
def combine(*commands, by: "&&")
commands
Expand Down
28 changes: 21 additions & 7 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, :argumentize_env_with_secrets, :optionize, to: Kamal::Utils
delegate :argumentize, :env_file_with_secrets, :optionize, to: Kamal::Utils

DEFAULT_IMAGE = "traefik:v2.9"
CONTAINER_PORT = 80
Expand Down Expand Up @@ -63,6 +63,22 @@ def port
"#{host_port}:#{CONTAINER_PORT}"
end

def env_file
env_file_with_secrets config.traefik.fetch("env", {})
end

def host_env_file_path
File.join host_env_directory, "traefik.env"
end

def make_env_directory
make_directory(host_env_directory)
end

def remove_env_file
[:rm, "-f", host_env_file_path]
end

private
def publish_args
argumentize "--publish", port unless config.traefik["publish"] == false
Expand All @@ -73,13 +89,11 @@ def label_args
end

def env_args
env_config = config.traefik["env"] || {}
argumentize "--env-file", host_env_file_path
end

if env_config.present?
argumentize_env_with_secrets(env_config)
else
[]
end
def host_env_directory
File.join config.host_env_directory, "traefik"
end

def labels
Expand Down
18 changes: 6 additions & 12 deletions lib/kamal/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

class Kamal::Configuration
delegate :service, :image, :servers, :env, :labels, :registry, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true
delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Kamal::Utils
delegate :argumentize, :optionize, to: Kamal::Utils

attr_accessor :destination
attr_accessor :raw_config
Expand Down Expand Up @@ -113,14 +113,6 @@ def service_with_version
end


def env_args
if raw_config.env.present?
argumentize_env_with_secrets(raw_config.env)
else
[]
end
end

def volume_args
if raw_config.volumes.present?
argumentize "--volume", raw_config.volumes
Expand Down Expand Up @@ -174,7 +166,6 @@ def to_h
repository: repository,
absolute_image: absolute_image,
service_with_version: service_with_version,
env_args: env_args,
volume_args: volume_args,
ssh_options: ssh.to_h,
sshkit: sshkit.to_h,
Expand All @@ -199,12 +190,15 @@ def builder

# Will raise KeyError if any secret ENVs are missing
def ensure_env_available
env_args
roles.each(&:env_args)
roles.each(&:env_file)

true
end

def host_env_directory
"#{run_directory}/env"
end

private
# Will raise ArgumentError if any required config keys are missing
def ensure_required_keys_present
Expand Down
16 changes: 14 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, :argumentize_env_with_secrets, :optionize, to: Kamal::Utils
delegate :argumentize, :env_file_with_secrets, :optionize, to: Kamal::Utils

attr_accessor :name, :specifics

Expand Down Expand Up @@ -45,8 +45,20 @@ def env
specifics["env"] || {}
end

def env_file
env_file_with_secrets env
end

def host_env_directory
File.join config.host_env_directory, "accessories"
end

def host_env_file_path
File.join host_env_directory, "#{service_name}.env"
end

def env_args
argumentize_env_with_secrets env
argumentize "--env-file", host_env_file_path
end

def files
Expand Down
16 changes: 14 additions & 2 deletions lib/kamal/configuration/role.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class Kamal::Configuration::Role
delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Kamal::Utils
delegate :argumentize, :env_file_with_secrets, :optionize, to: Kamal::Utils

attr_accessor :name

Expand Down Expand Up @@ -31,8 +31,20 @@ def env
end
end

def env_file
env_file_with_secrets env
end

def host_env_directory
File.join config.host_env_directory, "roles"
end

def host_env_file_path
File.join host_env_directory, "#{[config.service, name, config.destination].compact.join("-")}.env"
end

def env_args
argumentize_env_with_secrets env
argumentize "--env-file", host_env_file_path
end

def health_check_args
Expand Down
34 changes: 26 additions & 8 deletions lib/kamal/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,24 @@ def argumentize(argument, attributes, sensitive: false)
end
end

# Return a list of shell arguments using the same named argument against the passed attributes,
# but redacts and expands secrets.
def argumentize_env_with_secrets(env)
if (secrets = env["secret"]).present?
argumentize("-e", secrets.to_h { |key| [ key, ENV.fetch(key) ] }, sensitive: true) + argumentize("-e", env["clear"])
else
argumentize "-e", env.fetch("clear", env)
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 || "\n"
end

# Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option.
Expand Down Expand Up @@ -97,4 +107,12 @@ def abbreviate_version(version)
def uncommitted_changes
`git status --porcelain`.strip
end

def docker_env_file_line(key, value)
if key.include?("\n") || value.to_s.include?("\n")
raise ArgumentError, "docker env file format does not support newlines in keys or values, key: #{key}"
end

"#{key.to_s}=#{value.to_s}\n"
end
end
8 changes: 4 additions & 4 deletions test/cli/accessory_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,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 -e [REDACTED] -e 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-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

Expand All @@ -21,9 +21,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 -e [REDACTED] -e 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 --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-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

Expand Down
Loading

0 comments on commit 94bf090

Please sign in to comment.