Merge branch 'basecamp:main' into buildpacks
This commit is contained in:
@@ -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-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
|
||||
|
||||
@@ -21,7 +29,7 @@ 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-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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,7 +40,7 @@ class CliTestCase < ActiveSupport::TestCase
|
||||
.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 @@ class CliTestCase < ActiveSupport::TestCase
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -8,14 +8,11 @@ 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)
|
||||
|
||||
run_command("setup").tap do |output|
|
||||
assert_match /Ensure Docker is installed.../, output
|
||||
assert_match /Evaluate and push env files.../, output
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,8 +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: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))
|
||||
@@ -36,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
|
||||
@@ -49,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 }
|
||||
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::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" }
|
||||
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
|
||||
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
|
||||
|
||||
@@ -388,40 +384,38 @@ 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
|
||||
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=$KAMAL_REGISTRY_PASSWORD"
|
||||
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
|
||||
|
||||
@@ -438,50 +432,6 @@ class CliMainTest < CliTestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "envify" do
|
||||
with_test_dotenv(".env.erb": "HELLO=<%= 'world' %>") do
|
||||
run_command("envify")
|
||||
assert_equal("HELLO=world", File.read(".env"))
|
||||
end
|
||||
end
|
||||
|
||||
test "envify with blank line trimming" do
|
||||
file = <<~EOF
|
||||
HELLO=<%= 'world' %>
|
||||
<% if true -%>
|
||||
KEY=value
|
||||
<% end -%>
|
||||
EOF
|
||||
|
||||
with_test_dotenv(".env.erb": file) do
|
||||
run_command("envify")
|
||||
assert_equal("HELLO=world\nKEY=value\n", File.read(".env"))
|
||||
end
|
||||
end
|
||||
|
||||
test "envify with destination" do
|
||||
with_test_dotenv(".env.world.erb": "HELLO=<%= 'world' %>") do
|
||||
run_command("envify", "-d", "world", config_file: "deploy_for_dest")
|
||||
assert_equal "HELLO=world", File.read(".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)
|
||||
|
||||
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
|
||||
run_command("envify", "--skip-push")
|
||||
assert_equal "HELLO=never", File.read(".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
|
||||
@@ -572,18 +522,16 @@ class CliMainTest < CliTestCase
|
||||
end
|
||||
end
|
||||
|
||||
def with_test_dotenv(**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)
|
||||
end
|
||||
def in_dummy_git_repo
|
||||
Dir.mktmpdir do |tmpdir|
|
||||
Dir.chdir(tmpdir) do
|
||||
`git init`
|
||||
yield
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def assert_file(file, content)
|
||||
assert_match content, File.read(file)
|
||||
end
|
||||
end
|
||||
|
||||
22
test/cli/secrets_test.rb
Normal file
22
test/cli/secrets_test.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
require_relative "cli_test_case"
|
||||
|
||||
class CliSecretsTest < CliTestCase
|
||||
test "fetch" do
|
||||
assert_equal \
|
||||
"\\{\\\"foo\\\":\\\"oof\\\",\\\"bar\\\":\\\"rab\\\",\\\"baz\\\":\\\"zab\\\"\\}",
|
||||
run_command("fetch", "foo", "bar", "baz", "--account", "myaccount", "--adapter", "test")
|
||||
end
|
||||
|
||||
test "extract" do
|
||||
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" ]) }
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,8 @@ require "test_helper"
|
||||
|
||||
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,21 +43,19 @@ 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-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-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\" --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 \
|
||||
@@ -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-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-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-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
|
||||
@@ -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)
|
||||
|
||||
@@ -2,14 +2,14 @@ require "test_helper"
|
||||
|
||||
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
|
||||
@@ -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-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
|
||||
|
||||
@@ -219,7 +219,7 @@ 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-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
|
||||
|
||||
@@ -251,7 +251,7 @@ 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-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
|
||||
|
||||
@@ -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
|
||||
@@ -438,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -77,10 +77,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
|
||||
@@ -121,10 +124,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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,11 +9,11 @@ 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
|
||||
@@ -81,9 +81,9 @@ class CommandsTraefikTest < ActiveSupport::TestCase
|
||||
"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-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\" --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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -116,25 +116,14 @@ 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
|
||||
with_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123") do
|
||||
config = Kamal::Configuration.new(@deploy)
|
||||
|
||||
test "env with secrets" do
|
||||
ENV["MYSQL_ROOT_PASSWORD"] = "secret123"
|
||||
|
||||
expected_secrets_file = <<~ENV
|
||||
MYSQL_ROOT_PASSWORD=secret123
|
||||
ENV
|
||||
|
||||
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-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
|
||||
|
||||
test "volume args" do
|
||||
|
||||
@@ -110,13 +110,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
|
||||
|
||||
26
test/configuration/env/tags_test.rb
vendored
26
test/configuration/env/tags_test.rb
vendored
@@ -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
|
||||
|
||||
@@ -6,27 +6,21 @@ class ConfigurationEnvTest < ActiveSupport::TestCase
|
||||
test "simple" do
|
||||
assert_config \
|
||||
config: { "foo" => "bar", "baz" => "haz" },
|
||||
clear: { "foo" => "bar", "baz" => "haz" },
|
||||
secrets: {}
|
||||
clear: { "foo" => "bar", "baz" => "haz" }
|
||||
end
|
||||
|
||||
test "clear" do
|
||||
assert_config \
|
||||
config: { "clear" => { "foo" => "bar", "baz" => "haz" } },
|
||||
clear: { "foo" => "bar", "baz" => "haz" },
|
||||
secrets: {}
|
||||
clear: { "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" ] },
|
||||
secrets: { "PASSWORD" => "hello" }
|
||||
end
|
||||
end
|
||||
|
||||
test "missing secret" do
|
||||
@@ -34,41 +28,32 @@ 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::Secrets.new).secrets_io }
|
||||
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,
|
||||
clear: { "foo" => "bar", "baz" => "haz" },
|
||||
secrets: { "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:, clear: {}, secrets: {})
|
||||
env = Kamal::Configuration::Env.new config: config, secrets: Kamal::Secrets.new
|
||||
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
|
||||
|
||||
@@ -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,165 @@ 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\"", "--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
|
||||
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\"", "--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
|
||||
@deploy_with_roles[:env] = {
|
||||
"clear" => {
|
||||
"REDIS_URL" => "redis://a/b"
|
||||
},
|
||||
"secret" => [
|
||||
"REDIS_PASSWORD"
|
||||
]
|
||||
}
|
||||
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"
|
||||
]
|
||||
}
|
||||
|
||||
@deploy_with_roles[:servers]["workers"]["env"] = {
|
||||
"clear" => {
|
||||
"REDIS_URL" => "redis://a/b",
|
||||
"WEB_CONCURRENCY" => "4"
|
||||
},
|
||||
"secret" => [
|
||||
"DB_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"
|
||||
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)
|
||||
|
||||
expected_secrets_file = <<~ENV
|
||||
REDIS_PASSWORD=secret456
|
||||
DB_PASSWORD=secret&\"123
|
||||
ENV
|
||||
|
||||
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 \
|
||||
"REDIS_PASSWORD=secret456\nDB_PASSWORD=secret&\"123\n",
|
||||
config_with_roles.role(:workers).secrets_io("1.1.1.3").read
|
||||
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"
|
||||
]
|
||||
}
|
||||
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"
|
||||
]
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
expected_secrets_file = <<~ENV
|
||||
DB_PASSWORD=secret123
|
||||
ENV
|
||||
|
||||
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 \
|
||||
"DB_PASSWORD=secret123\n",
|
||||
config_with_roles.role(:workers).secrets_io("1.1.1.3").read
|
||||
end
|
||||
end
|
||||
|
||||
test "env secrets only at top level" do
|
||||
@deploy_with_roles[:env] = {
|
||||
"clear" => {
|
||||
"REDIS_URL" => "redis://a/b"
|
||||
},
|
||||
"secret" => [
|
||||
"REDIS_PASSWORD"
|
||||
]
|
||||
}
|
||||
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"
|
||||
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)
|
||||
|
||||
expected_secrets_file = <<~ENV
|
||||
REDIS_PASSWORD=secret456
|
||||
ENV
|
||||
|
||||
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 \
|
||||
"REDIS_PASSWORD=secret456\n",
|
||||
config_with_roles.role(:workers).secrets_io("1.1.1.3").read
|
||||
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"
|
||||
@deploy_with_roles[:servers]["workers"]["env"] = {
|
||||
"clear" => {
|
||||
"REDIS_URL" => "redis://c/d"
|
||||
}
|
||||
}
|
||||
|
||||
expected_secrets_file = <<~ENV
|
||||
REDIS_PASSWORD=secret456
|
||||
ENV
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
assert_equal \
|
||||
"REDIS_PASSWORD=secret456\n",
|
||||
config_with_roles.role(:workers).secrets_io("1.1.1.3").read
|
||||
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 +253,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
|
||||
|
||||
@@ -2,8 +2,6 @@ require_relative "integration_test"
|
||||
|
||||
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
|
||||
|
||||
@@ -2,8 +2,6 @@ require_relative "integration_test"
|
||||
|
||||
class AppTest < IntegrationTest
|
||||
test "stop, start, boot, logs, images, containers, exec, remove" do
|
||||
kamal :envify
|
||||
|
||||
kamal :deploy
|
||||
|
||||
assert_app_is_up
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -22,11 +22,12 @@ 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"
|
||||
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
|
||||
|
||||
|
||||
4
test/integration/docker/deployer/app/.kamal/secrets
Normal file
4
test/integration/docker/deployer/app/.kamal/secrets
Normal file
@@ -0,0 +1,4 @@
|
||||
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})
|
||||
@@ -10,6 +10,9 @@ env:
|
||||
HOST_TOKEN: "${HOST_TOKEN}"
|
||||
secret:
|
||||
- SECRET_TOKEN
|
||||
- INTERPOLATED_SECRET1
|
||||
- INTERPOLATED_SECRET2
|
||||
- INTERPOLATED_SECRET3
|
||||
tags:
|
||||
tag1:
|
||||
CLEAR_TAG: tagged
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
dockerd --max-concurrent-downloads 1 --insecure-registry registry:4443 &
|
||||
dockerd --max-concurrent-downloads 1 &
|
||||
|
||||
exec sleep infinity
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
while [ ! -f /certs/domain.crt ]; do sleep 1; done
|
||||
|
||||
exec /entrypoint.sh /etc/docker/registry/config.yml
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,8 +2,6 @@ require_relative "integration_test"
|
||||
|
||||
class LockTest < IntegrationTest
|
||||
test "acquire, release, status" do
|
||||
kamal :envify
|
||||
|
||||
kamal :lock, :acquire, "-m 'Integration Tests'"
|
||||
|
||||
status = kamal :lock, :status, capture: true
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
require_relative "integration_test"
|
||||
|
||||
class MainTest < IntegrationTest
|
||||
test "envify, deploy, redeploy, rollback, details and audit" do
|
||||
kamal :envify
|
||||
assert_env_files
|
||||
remove_local_env_file
|
||||
|
||||
test "deploy, redeploy, rollback, details and audit" do
|
||||
first_version = latest_app_version
|
||||
|
||||
assert_app_is_down
|
||||
@@ -37,16 +33,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
|
||||
@@ -77,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
|
||||
@@ -103,7 +94,6 @@ class MainTest < IntegrationTest
|
||||
kamal :remove, "-y"
|
||||
assert_no_images_or_containers
|
||||
|
||||
kamal :envify
|
||||
kamal :setup
|
||||
assert_images_and_containers
|
||||
|
||||
@@ -112,18 +102,17 @@ class MainTest < IntegrationTest
|
||||
end
|
||||
|
||||
private
|
||||
def assert_local_env_file(contents)
|
||||
assert_equal contents, deployer_exec("cat .env", capture: true)
|
||||
end
|
||||
|
||||
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_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
|
||||
assert_env :INTERPOLATED_SECRET3, "文中_DETALOPRETNI", version: version, vm: :vm2
|
||||
end
|
||||
|
||||
def assert_env(key, value, vm:, version:)
|
||||
@@ -136,24 +125,6 @@ class MainTest < IntegrationTest
|
||||
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, 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
|
||||
assert_equal "nofile", docker_compose("exec vm1 stat /root/.kamal/env/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
|
||||
|
||||
@@ -2,8 +2,6 @@ require_relative "integration_test"
|
||||
|
||||
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
|
||||
|
||||
211
test/secrets/bitwarden_adapter_test.rb
Normal file
211
test/secrets/bitwarden_adapter_test.rb
Normal file
@@ -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
|
||||
15
test/secrets/dotenv_inline_command_substitution_test.rb
Normal file
15
test/secrets/dotenv_inline_command_substitution_test.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
require "test_helper"
|
||||
|
||||
class SecretsInlineCommandSubstitution < SecretAdapterTestCase
|
||||
test "inlines kamal secrets commands" do
|
||||
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
|
||||
|
||||
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
|
||||
152
test/secrets/last_pass_adapter_test.rb
Normal file
152
test/secrets/last_pass_adapter_test.rb
Normal file
@@ -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
|
||||
177
test/secrets/one_password_adapter_test.rb
Normal file
177
test/secrets/one_password_adapter_test.rb
Normal file
@@ -0,0 +1,177 @@
|
||||
require "test_helper"
|
||||
|
||||
class SecretsOnePasswordAdapterTest < SecretAdapterTestCase
|
||||
test "fetch" do
|
||||
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\"")
|
||||
.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
|
||||
|
||||
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 expected_json, json
|
||||
end
|
||||
|
||||
test "fetch with multiple items" do
|
||||
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\"")
|
||||
.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"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
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 2> /dev/null", 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 2> /dev/null", 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
|
||||
def run_command(*command)
|
||||
stdouted do
|
||||
Kamal::Cli::Secrets.start \
|
||||
[ *command,
|
||||
"-c", "test/fixtures/deploy_with_accessories.yml",
|
||||
"--adapter", "1password",
|
||||
"--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
|
||||
34
test/secrets_test.rb
Normal file
34
test/secrets_test.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
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", "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 "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
|
||||
@@ -34,4 +34,53 @@ class ActiveSupport::TestCase
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user