From 24a2f51641dd8c9110efe380b7dbd171c5666a5e Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 3 Nov 2023 14:20:52 +0000 Subject: [PATCH 01/15] Return a 502 when container is down If the app container is down or not responding then traefik will return a 404 response code. This is not ideal as it suggests a client rather than a server problem. To fix this, we'll define a catch all route that always returns a 502. This is not ideal as this route would take priority over a shorter route with priorty 1. TODO: up the priority of the app route. --- lib/kamal/commands/traefik.rb | 10 ++++++++- test/cli/traefik_test.rb | 4 ++-- test/commands/traefik_test.rb | 32 ++++++++++++++-------------- test/integration/app_test.rb | 6 ++---- test/integration/integration_test.rb | 6 ------ 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/lib/kamal/commands/traefik.rb b/lib/kamal/commands/traefik.rb index 39ba796d..c6ec8c8d 100644 --- a/lib/kamal/commands/traefik.rb +++ b/lib/kamal/commands/traefik.rb @@ -6,6 +6,14 @@ class Kamal::Commands::Traefik < Kamal::Commands::Base DEFAULT_ARGS = { 'log.level' => 'DEBUG' } + DEFAULT_LABELS = { + # These ensure we serve a 502 rather than a 404 if no containers are available + "traefik.http.routers.catchall.entryPoints" => "http", + "traefik.http.routers.catchall.rule" => "PathPrefix(`/`)", + "traefik.http.routers.catchall.service" => "unavailable", + "traefik.http.routers.catchall.priority" => 1, + "traefik.http.services.unavailable.loadbalancer.server.port" => "0" + } def run docker :run, "--name traefik", @@ -97,7 +105,7 @@ class Kamal::Commands::Traefik < Kamal::Commands::Base end def labels - config.traefik["labels"] || [] + DEFAULT_LABELS.merge(config.traefik["labels"] || {}) end def image diff --git a/test/cli/traefik_test.rb b/test/cli/traefik_test.rb index 6c6fbf64..b05af003 100644 --- a/test/cli/traefik_test.rb +++ b/test/cli/traefik_test.rb @@ -4,7 +4,7 @@ class CliTraefikTest < CliTestCase test "boot" do run_command("boot").tap do |output| assert_match "docker login", output - assert_match "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\" #{Kamal::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output + assert_match "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\" #{Kamal::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output end end @@ -14,7 +14,7 @@ class CliTraefikTest < CliTestCase run_command("reboot").tap do |output| assert_match "docker container stop traefik", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=Traefik", output - assert_match "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\" #{Kamal::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output + assert_match "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\" #{Kamal::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output end end diff --git a/test/commands/traefik_test.rb b/test/commands/traefik_test.rb index 7dfcff97..2d55b7e1 100644 --- a/test/commands/traefik_test.rb +++ b/test/commands/traefik_test.rb @@ -18,72 +18,72 @@ class CommandsTraefikTest < ActiveSupport::TestCase test "run" do 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\" #{@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-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]["host_port"] = "8080" assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --publish 8080:80 --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" #{@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 8080: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]["publish"] = false assert_equal \ - "docker run --name traefik --detach --restart unless-stopped --volume /var/run/docker.sock:/var/run/docker.sock --env-file .kamal/env/traefik/traefik.env --log-opt max-size=\"10m\" #{@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 --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(" ") end test "run with ports configured" do 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\" #{@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-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]["options"] = {"publish" => %w[9000:9000 9001:9001]} 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\" --publish \"9000:9000\" --publish \"9001:9001\" #{@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-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\" --publish \"9000:9000\" --publish \"9001:9001\" #{@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 test "run with volumes configured" do 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\" #{@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-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]["options"] = {"volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json] } 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\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" #{@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-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\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" #{@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 test "run with several options configured" do 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\" #{@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-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]["options"] = {"volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json], "publish" => %w[8080:8080], "memory" => "512m"} 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\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" --publish \"8080:8080\" --memory \"512m\" #{@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-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\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" --publish \"8080:8080\" --memory \"512m\" #{@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 test "run with labels configured" do 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\" #{@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-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]["labels"] = { "traefik.http.routers.dashboard.service" => "api@internal", "traefik.http.routers.dashboard.middlewares" => "auth" } 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.dashboard.service=\"api@internal\" --label traefik.http.routers.dashboard.middlewares=\"auth\" #{@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-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\" --label traefik.http.routers.dashboard.service=\"api@internal\" --label traefik.http.routers.dashboard.middlewares=\"auth\" #{@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 test "run with env configured" do 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\" #{@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-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] } 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\" #{@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-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 @@ -91,7 +91,7 @@ class CommandsTraefikTest < ActiveSupport::TestCase @config.delete(:traefik) 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\" #{Kamal::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", + "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\" #{Kamal::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", new_command.run.join(" ") end @@ -99,7 +99,7 @@ class CommandsTraefikTest < ActiveSupport::TestCase @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } 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-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" #{@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-file .kamal/env/traefik/traefik.env --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --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 @@ -107,7 +107,7 @@ class CommandsTraefikTest < ActiveSupport::TestCase @config[:traefik]["args"]["log.level"] = "ERROR" 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\" #{@image} --providers.docker --log.level=\"ERROR\" --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-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=\"ERROR\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", new_command.run.join(" ") end diff --git a/test/integration/app_test.rb b/test/integration/app_test.rb index 37a4dd71..9824ce0d 100644 --- a/test/integration/app_test.rb +++ b/test/integration/app_test.rb @@ -10,8 +10,7 @@ class AppTest < IntegrationTest kamal :app, :stop - # traefik is up and returns 404s when it can't match a route - assert_app_not_found + assert_app_is_down kamal :app, :start @@ -51,7 +50,6 @@ class AppTest < IntegrationTest kamal :app, :remove - # traefik is up and returns 404s when it can't match a route - assert_app_not_found + assert_app_is_down end end diff --git a/test/integration/integration_test.rb b/test/integration/integration_test.rb index b352b714..950e41ed 100644 --- a/test/integration/integration_test.rb +++ b/test/integration/integration_test.rb @@ -55,12 +55,6 @@ class IntegrationTest < ActiveSupport::TestCase assert_app_version(version, response) if version end - def assert_app_not_found - response = app_response - debug_response_code(response, "404") - assert_equal "404", response.code - end - def wait_for_app_to_be_up(timeout: 20, up_count: 3) timeout_at = Time.now + timeout up_times = 0 From 792aa1dbdfd0e5e5edf6a9c765dcfcee77fc8a12 Mon Sep 17 00:00:00 2001 From: Leon Date: Sun, 1 Oct 2023 13:59:48 +0200 Subject: [PATCH 02/15] Add SSH port option --- lib/kamal/configuration/ssh.rb | 6 +++++- test/configuration/ssh_test.rb | 3 +++ test/configuration_test.rb | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/kamal/configuration/ssh.rb b/lib/kamal/configuration/ssh.rb index 57a740b7..b9630102 100644 --- a/lib/kamal/configuration/ssh.rb +++ b/lib/kamal/configuration/ssh.rb @@ -9,6 +9,10 @@ class Kamal::Configuration::Ssh config.fetch("user", "root") end + def port + config.fetch("port", 22) + end + def proxy if (proxy = config["proxy"]) Net::SSH::Proxy::Jump.new(proxy.include?("@") ? proxy : "root@#{proxy}") @@ -18,7 +22,7 @@ class Kamal::Configuration::Ssh end def options - { user: user, proxy: proxy, logger: logger, keepalive: true, keepalive_interval: 30 }.compact + { user: user, port: port, proxy: proxy, logger: logger, keepalive: true, keepalive_interval: 30 }.compact end def to_h diff --git a/test/configuration/ssh_test.rb b/test/configuration/ssh_test.rb index cb3d5f5c..438a0f45 100644 --- a/test/configuration/ssh_test.rb +++ b/test/configuration/ssh_test.rb @@ -22,6 +22,9 @@ class ConfigurationSshTest < ActiveSupport::TestCase config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "log_level" => "debug" }) }) assert_equal 0, config.ssh.options[:logger].level + + config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "port" => 2222 }) }) + assert_equal 2222, config.ssh.options[:port] end test "ssh options with proxy host" do diff --git a/test/configuration_test.rb b/test/configuration_test.rb index 5b9f4cc9..e48659ed 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -227,7 +227,7 @@ class ConfigurationTest < ActiveSupport::TestCase :repository=>"dhh/app", :absolute_image=>"dhh/app:missing", :service_with_version=>"app-missing", - :ssh_options=>{ :user=>"root", log_level: :fatal, keepalive: true, keepalive_interval: 30 }, + :ssh_options=>{ :user=>"root", port: 22, log_level: :fatal, keepalive: true, keepalive_interval: 30 }, :sshkit=>{}, :volume_args=>["--volume", "/local/path:/container/path"], :builder=>{}, From 2d86d4f7cc6f894f3d2e1f42d07543c1c698f32b Mon Sep 17 00:00:00 2001 From: Leon Date: Sun, 1 Oct 2023 14:13:26 +0200 Subject: [PATCH 03/15] Add SSH port to `run_over_ssh` --- lib/kamal/commands/base.rb | 2 +- test/cli/accessory_test.rb | 2 +- test/cli/app_test.rb | 6 +++--- test/cli/traefik_test.rb | 2 +- test/commands/accessory_test.rb | 2 +- test/commands/app_test.rb | 17 +++++++++++------ test/commands/traefik_test.rb | 4 ++-- test/integration/main_test.rb | 2 +- 8 files changed, 21 insertions(+), 16 deletions(-) diff --git a/lib/kamal/commands/base.rb b/lib/kamal/commands/base.rb index 7552a92d..3ce9b325 100644 --- a/lib/kamal/commands/base.rb +++ b/lib/kamal/commands/base.rb @@ -18,7 +18,7 @@ module Kamal::Commands elsif config.ssh.proxy && config.ssh.proxy.is_a?(Net::SSH::Proxy::Command) cmd << " -o ProxyCommand='#{config.ssh.proxy.command_line_template}'" end - cmd << " -t #{config.ssh.user}@#{host} '#{command.join(" ")}'" + cmd << " -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ")}'" end end diff --git a/test/cli/accessory_test.rb b/test/cli/accessory_test.rb index 5ecf8038..78623808 100644 --- a/test/cli/accessory_test.rb +++ b/test/cli/accessory_test.rb @@ -97,7 +97,7 @@ class CliAccessoryTest < CliTestCase test "logs with follow" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) - .with("ssh -t root@1.1.1.3 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'") + .with("ssh -t root@1.1.1.3 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'") assert_match "docker logs app-mysql --timestamps --tail 10 --follow 2>&1", run_command("logs", "mysql", "--follow") end diff --git a/test/cli/app_test.rb b/test/cli/app_test.rb index fd106421..206d8524 100644 --- a/test/cli/app_test.rb +++ b/test/cli/app_test.rb @@ -172,7 +172,7 @@ class CliAppTest < CliTestCase test "exec interactive" do SSHKit::Backend::Abstract.any_instance.expects(:exec) - .with("ssh -t root@1.1.1.1 'docker run -it --rm --env-file .kamal/env/roles/app-web.env dhh/app:latest ruby -v'") + .with("ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --env-file .kamal/env/roles/app-web.env dhh/app:latest ruby -v'") run_command("exec", "-i", "ruby -v").tap do |output| assert_match "Get most recent version available as an image...", output assert_match "Launching interactive command with version latest via SSH from new container on 1.1.1.1...", output @@ -181,7 +181,7 @@ class CliAppTest < CliTestCase test "exec interactive with reuse" do SSHKit::Backend::Abstract.any_instance.expects(:exec) - .with("ssh -t root@1.1.1.1 'docker exec -it app-web-999 ruby -v'") + .with("ssh -t root@1.1.1.1 -p 22 'docker exec -it app-web-999 ruby -v'") run_command("exec", "-i", "--reuse", "ruby -v").tap do |output| assert_match "Get current version of running container...", output assert_match "Running docker ps --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done on 1.1.1.1", output @@ -210,7 +210,7 @@ class CliAppTest < CliTestCase test "logs with follow" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) - .with("ssh -t root@1.1.1.1 'docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --tail 10 --follow 2>&1'") + .with("ssh -t root@1.1.1.1 -p 22 'docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --tail 10 --follow 2>&1'") assert_match "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --tail 10 --follow 2>&1", run_command("logs", "--follow") end diff --git a/test/cli/traefik_test.rb b/test/cli/traefik_test.rb index 6c6fbf64..aadbd228 100644 --- a/test/cli/traefik_test.rb +++ b/test/cli/traefik_test.rb @@ -64,7 +64,7 @@ class CliTraefikTest < CliTestCase test "logs with follow" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) - .with("ssh -t root@1.1.1.1 'docker logs traefik --timestamps --tail 10 --follow 2>&1'") + .with("ssh -t root@1.1.1.1 -p 22 'docker logs traefik --timestamps --tail 10 --follow 2>&1'") assert_match "docker logs traefik --timestamps --tail 10 --follow", run_command("logs", "--follow") end diff --git a/test/commands/accessory_test.rb b/test/commands/accessory_test.rb index 2825f3de..687c7fe5 100644 --- a/test/commands/accessory_test.rb +++ b/test/commands/accessory_test.rb @@ -128,7 +128,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase test "follow logs" do assert_equal \ - "ssh -t root@1.1.1.5 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'", + "ssh -t root@1.1.1.5 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'", new_command(:mysql).follow_logs end diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index 13abad78..b459d222 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -190,32 +190,37 @@ class CommandsAppTest < ActiveSupport::TestCase end test "run over ssh" do - assert_equal "ssh -t root@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") + assert_equal "ssh -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with custom user" do @config[:ssh] = { "user" => "app" } - assert_equal "ssh -t app@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") + assert_equal "ssh -t app@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") + end + + test "run over ssh with custom port" do + @config[:ssh] = { "port" => "2222" } + assert_equal "ssh -t root@1.1.1.1 -p 2222 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with proxy" do @config[:ssh] = { "proxy" => "2.2.2.2" } - assert_equal "ssh -J root@2.2.2.2 -t root@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") + assert_equal "ssh -J root@2.2.2.2 -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with proxy user" do @config[:ssh] = { "proxy" => "app@2.2.2.2" } - assert_equal "ssh -J app@2.2.2.2 -t root@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") + assert_equal "ssh -J app@2.2.2.2 -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with custom user with proxy" do @config[:ssh] = { "user" => "app", "proxy" => "2.2.2.2" } - assert_equal "ssh -J root@2.2.2.2 -t app@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") + assert_equal "ssh -J root@2.2.2.2 -t app@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with proxy_command" do @config[:ssh] = { "proxy_command" => "ssh -W %h:%p user@proxy-server" } - assert_equal "ssh -o ProxyCommand='ssh -W %h:%p user@proxy-server' -t root@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") + assert_equal "ssh -o ProxyCommand='ssh -W %h:%p user@proxy-server' -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "current_running_container_id" do diff --git a/test/commands/traefik_test.rb b/test/commands/traefik_test.rb index 7dfcff97..2d97fb6c 100644 --- a/test/commands/traefik_test.rb +++ b/test/commands/traefik_test.rb @@ -167,13 +167,13 @@ class CommandsTraefikTest < ActiveSupport::TestCase test "traefik follow logs" do assert_equal \ - "ssh -t root@1.1.1.1 'docker logs traefik --timestamps --tail 10 --follow 2>&1'", + "ssh -t root@1.1.1.1 -p 22 'docker logs traefik --timestamps --tail 10 --follow 2>&1'", new_command.follow_logs(host: @config[:servers].first) end test "traefik follow logs with grep hello!" do assert_equal \ - "ssh -t root@1.1.1.1 'docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hello!\"'", + "ssh -t root@1.1.1.1 -p 22 'docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hello!\"'", new_command.follow_logs(host: @config[:servers].first, grep: 'hello!') end diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index 1171c3de..a0364fb2 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -54,7 +54,7 @@ class MainTest < IntegrationTest assert_equal "registry:4443/app:#{version}", config[:absolute_image] assert_equal "app-#{version}", config[:service_with_version] assert_equal [], config[:volume_args] - assert_equal({ user: "root", keepalive: true, keepalive_interval: 30, log_level: :fatal }, config[:ssh_options]) + assert_equal({ user: "root", port: 22, keepalive: true, keepalive_interval: 30, log_level: :fatal }, config[:ssh_options]) assert_equal({ "multiarch" => false, "args" => { "COMMIT_SHA" => version } }, config[:builder]) assert_equal [ "--log-opt", "max-size=\"10m\"" ], config[:logging] assert_equal({ "path" => "/up", "port" => 3000, "max_attempts" => 7, "exposed_port" => 3999, "cord"=>"/tmp/kamal-cord", "log_lines" => 50, "cmd"=>"wget -qO- http://localhost > /dev/null || exit 1" }, config[:healthcheck]) From 9e25d8a012fc1f4d5d90470f68c278b49b09e3b7 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 8 Nov 2023 14:12:45 +0000 Subject: [PATCH 04/15] Priority 2 for the main app --- lib/kamal/configuration/role.rb | 1 + test/commands/app_test.rb | 14 +++++++------- test/configuration/role_test.rb | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/kamal/configuration/role.rb b/lib/kamal/configuration/role.rb index 2945f87b..e4c3ff5f 100644 --- a/lib/kamal/configuration/role.rb +++ b/lib/kamal/configuration/role.rb @@ -185,6 +185,7 @@ class Kamal::Configuration::Role "traefik.http.services.#{traefik_service}.loadbalancer.server.scheme" => "http", "traefik.http.routers.#{traefik_service}.rule" => "PathPrefix(`/`)", + "traefik.http.routers.#{traefik_service}.priority" => "2", "traefik.http.middlewares.#{traefik_service}-retry.retry.attempts" => "5", "traefik.http.middlewares.#{traefik_service}-retry.retry.initialinterval" => "500ms", "traefik.http.routers.#{traefik_service}.middlewares" => "#{traefik_service}-retry@docker" diff --git a/test/commands/app_test.rb b/test/commands/app_test.rb index 13abad78..e77773c5 100644 --- a/test/commands/app_test.rb +++ b/test/commands/app_test.rb @@ -14,13 +14,13 @@ class CommandsAppTest < ActiveSupport::TestCase test "run" do 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 --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 traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --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-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 traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.routers.app-web.priority=\"2\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", new_command.run.join(" ") end test "run with hostname" do assert_equal \ - "docker run --detach --restart unless-stopped --name app-web-999 --hostname myhost -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --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 traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --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 --hostname myhost -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --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 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(hostname: "myhost").join(" ") end @@ -28,7 +28,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:volumes] = ["/local/path:/container/path" ] 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 --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\" --volume /local/path:/container/path --label service=\"app\" --label role=\"web\" --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --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-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\" --volume /local/path:/container/path --label service=\"app\" --label role=\"web\" --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 @@ -36,7 +36,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:healthcheck] = { "path" => "/healthz" } 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 --health-cmd \"(curl -f http://localhost:3000/healthz || 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 traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --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-file .kamal/env/roles/app-web.env --health-cmd \"(curl -f http://localhost:3000/healthz || 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 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 @@ -44,7 +44,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:healthcheck] = { "cmd" => "/bin/up" } 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 --health-cmd \"(/bin/up) && (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 traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --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-file .kamal/env/roles/app-web.env --health-cmd \"(/bin/up) && (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 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 @@ -52,7 +52,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "healthcheck" => { "cmd" => "/bin/healthy" } } } 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 --health-cmd \"(/bin/healthy) && (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 traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --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-file .kamal/env/roles/app-web.env --health-cmd \"(/bin/healthy) && (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 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 @@ -67,7 +67,7 @@ class CommandsAppTest < ActiveSupport::TestCase @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } 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 --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-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --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-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-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --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 diff --git a/test/configuration/role_test.rb b/test/configuration/role_test.rb index 6966e893..6aa4b343 100644 --- a/test/configuration/role_test.rb +++ b/test/configuration/role_test.rb @@ -42,7 +42,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase end test "special label args for web" do - assert_equal [ "--label", "service=\"app\"", "--label", "role=\"web\"", "--label", "traefik.http.services.app-web.loadbalancer.server.scheme=\"http\"", "--label", "traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\"", "--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", "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 @@ -66,7 +66,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase c[:servers]["beta"] = { "traefik" => "true", "hosts" => [ "1.1.1.5" ] } }) - assert_equal [ "--label", "service=\"app\"", "--label", "role=\"beta\"", "--label", "traefik.http.services.app-beta.loadbalancer.server.scheme=\"http\"", "--label", "traefik.http.routers.app-beta.rule=\"PathPrefix(\\`/\\`)\"", "--label", "traefik.http.middlewares.app-beta-retry.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-beta-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app-beta.middlewares=\"app-beta-retry@docker\"" ], config.role(:beta).label_args + assert_equal [ "--label", "service=\"app\"", "--label", "role=\"beta\"", "--label", "traefik.http.services.app-beta.loadbalancer.server.scheme=\"http\"", "--label", "traefik.http.routers.app-beta.rule=\"PathPrefix(\\`/\\`)\"", "--label", "traefik.http.routers.app-beta.priority=\"2\"", "--label", "traefik.http.middlewares.app-beta-retry.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-beta-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app-beta.middlewares=\"app-beta-retry@docker\"" ], config.role(:beta).label_args end test "env overwritten by role" do From 47f8725cf3f5d243e06fc73c6fd29387faf07bc8 Mon Sep 17 00:00:00 2001 From: Matthew Kent Date: Fri, 10 Nov 2023 16:35:25 -0800 Subject: [PATCH 05/15] Support a dynamic primary_web_role instead of assuming it's 'web'. This allows for more meaningful naming in roles. The only caution here is that we don't support the renaming of roles, so any migration is left to the user. --- lib/kamal/commands/healthcheck.rb | 2 +- lib/kamal/configuration.rb | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/kamal/commands/healthcheck.rb b/lib/kamal/commands/healthcheck.rb index 5adc0244..05379ab6 100644 --- a/lib/kamal/commands/healthcheck.rb +++ b/lib/kamal/commands/healthcheck.rb @@ -1,7 +1,7 @@ class Kamal::Commands::Healthcheck < Kamal::Commands::Base def run - web = config.role(:web) + web = config.role(config.primary_web_role) docker :run, "--detach", diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index fc881c06..64c74c63 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -90,14 +90,21 @@ class Kamal::Configuration end def primary_web_host - role(:web).primary_host + role(primary_web_role)&.primary_host + end + + def traefik_roles + roles.select(&:running_traefik?) + end + + def traefik_role_names + traefik_roles.flat_map(&:name) end def traefik_hosts - roles.select(&:running_traefik?).flat_map(&:hosts).uniq + traefik_roles.flat_map(&:hosts).uniq end - def repository [ raw_config.registry["server"], image ].compact.join("/") end @@ -199,6 +206,9 @@ class Kamal::Configuration raw_config.asset_path end + def primary_web_role + raw_config.primary_web_role || "web" + end def valid? ensure_destination_if_required && ensure_required_keys_present && ensure_valid_kamal_version @@ -253,6 +263,10 @@ class Kamal::Configuration end end + unless traefik_role_names.include?(primary_web_role) + raise ArgumentError, "Role #{primary_web_role} needs to have traefik enabled" + end + true end From 628a47ad88e0935c9fb75f5b843e07211007e255 Mon Sep 17 00:00:00 2001 From: Matthew Kent Date: Fri, 10 Nov 2023 16:39:06 -0800 Subject: [PATCH 06/15] Background for the new option. --- lib/kamal/cli/templates/deploy.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/kamal/cli/templates/deploy.yml b/lib/kamal/cli/templates/deploy.yml index 65b3062d..44fa2294 100644 --- a/lib/kamal/cli/templates/deploy.yml +++ b/lib/kamal/cli/templates/deploy.yml @@ -83,3 +83,11 @@ registry: # boot: # limit: 10 # Can also specify as a percentage of total hosts, such as "25%" # wait: 2 + +# Configure the role used to determine the primary_web_host. This host takes +# deploy locks, runs health checks during the deploy, and follow logs, etc. +# This role should have traefik enabled. +# +# Caution: there's no support for role renaming yet, so be careful to cleanup +# the previous role on the deployed hosts. +# primary_web_role: web From d0ac6507e760c67b2ae798f04e5cd75b81163288 Mon Sep 17 00:00:00 2001 From: Matthew Kent Date: Fri, 10 Nov 2023 16:49:37 -0800 Subject: [PATCH 07/15] Add test coverage. --- test/configuration_test.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/configuration_test.rb b/test/configuration_test.rb index e48659ed..63e1de4a 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -278,4 +278,22 @@ class ConfigurationTest < ActiveSupport::TestCase assert_nil @config.asset_path assert_equal "foo", Kamal::Configuration.new(@deploy.merge!(asset_path: "foo")).asset_path end + + test "primary web role" do + assert_equal "web", @config.primary_web_role + + config = Kamal::Configuration.new(@deploy_with_roles.deep_merge({ + servers: { "alternate_web" => { "hosts" => [ "1.1.1.4", "1.1.1.5" ] , "traefik" => true } }, + primary_web_role: "alternate_web" } )) + + assert_equal "alternate_web", config.primary_web_role + assert_equal "1.1.1.4", config.primary_web_host + end + + test "primary web role no traefik" do + error = assert_raises(ArgumentError) do + Kamal::Configuration.new(@deploy.merge(primary_web_role: "bar")) + end + assert_match /bar/, error.message + end end From 6898e8789e220714520393ce68a153a2bef6a87a Mon Sep 17 00:00:00 2001 From: Matthew Kent Date: Fri, 10 Nov 2023 17:17:16 -0800 Subject: [PATCH 08/15] Further test the override. --- test/cli/main_test.rb | 10 ++++++++++ .../deploy_primary_web_role_override.yml | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 test/fixtures/deploy_primary_web_role_override.yml diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index a19ff90e..07e47f8d 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -283,6 +283,16 @@ class CliMainTest < CliTestCase end end + test "config with primary web role override" do + run_command("config", config_file: "deploy_primary_web_role_override").tap do |output| + config = YAML.load(output) + + assert_equal ["web_chicago", "web_tokyo"], config[:roles] + assert_equal ["1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4"], config[:hosts] + assert_equal "1.1.1.3", config[:primary_host] + end + end + test "config with destination" do run_command("config", "-d", "world", config_file: "deploy_for_dest").tap do |output| config = YAML.load(output) diff --git a/test/fixtures/deploy_primary_web_role_override.yml b/test/fixtures/deploy_primary_web_role_override.yml new file mode 100644 index 00000000..264819fe --- /dev/null +++ b/test/fixtures/deploy_primary_web_role_override.yml @@ -0,0 +1,20 @@ +service: app +image: dhh/app +servers: + web_chicago: + traefik: enabled + hosts: + - 1.1.1.1 + - 1.1.1.2 + web_tokyo: + traefik: enabled + hosts: + - 1.1.1.3 + - 1.1.1.4 +env: + REDIS_URL: redis://x/y +registry: + server: registry.digitalocean.com + username: user + password: pw +primary_web_role: web_tokyo From a9cc7c73d2928260236dbdc5818e5b1fd901b64c Mon Sep 17 00:00:00 2001 From: Matthew Kent Date: Sat, 11 Nov 2023 12:57:31 -0800 Subject: [PATCH 09/15] Handle an undefined primary_web_role. --- lib/kamal/configuration.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index 64c74c63..f7f2233c 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -263,6 +263,10 @@ class Kamal::Configuration end end + unless role_names.include?(primary_web_role) + raise ArgumentError, "The primary_web_role #{primary_web_role} isn't defined" + end + unless traefik_role_names.include?(primary_web_role) raise ArgumentError, "Role #{primary_web_role} needs to have traefik enabled" end From 073f745677aad07d6829ea72cb78e15f8ec2073c Mon Sep 17 00:00:00 2001 From: Matthew Kent Date: Sat, 11 Nov 2023 12:57:52 -0800 Subject: [PATCH 10/15] Test for both undefined roles and missing traefik. --- test/configuration_test.rb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/configuration_test.rb b/test/configuration_test.rb index 63e1de4a..00612f24 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -291,9 +291,16 @@ class ConfigurationTest < ActiveSupport::TestCase end test "primary web role no traefik" do + error = assert_raises(ArgumentError) do + Kamal::Configuration.new(@deploy_with_roles.merge(primary_web_role: "workers")) + end + assert_match /workers needs to have traefik enabled/, error.message + end + + test "primary web role missing" do error = assert_raises(ArgumentError) do Kamal::Configuration.new(@deploy.merge(primary_web_role: "bar")) end - assert_match /bar/, error.message + assert_match /bar isn't defined/, error.message end end From 263b4a4fb8ecf10834fc12ccdddf2c267270eabe Mon Sep 17 00:00:00 2001 From: Matthew Kent Date: Sat, 11 Nov 2023 13:03:23 -0800 Subject: [PATCH 11/15] Enable aliases for more exotic templating situations. This is super useful for DRY when configuring a number of roles and you hit the limits of what's reasonable with ERB. --- lib/kamal/configuration.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index fc881c06..87c68dc5 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -25,7 +25,9 @@ class Kamal::Configuration def load_config_file(file) if file.exist? - YAML.load(ERB.new(IO.read(file)).result).symbolize_keys + # Newer Psych doesn't load aliases by default + load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load + YAML.send(load_method, ERB.new(IO.read(file)).result).symbolize_keys else raise "Configuration file not found in #{file}" end From ed58ce6e610d5ef2ae57d5fe77717f2f55ad8e4d Mon Sep 17 00:00:00 2001 From: Matthew Kent Date: Sat, 11 Nov 2023 13:24:12 -0800 Subject: [PATCH 12/15] Add test coverage with aliases. --- test/cli/main_test.rb | 13 ++++++++++ test/fixtures/deploy_with_aliases.yml | 36 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 test/fixtures/deploy_with_aliases.yml diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index a19ff90e..91f74870 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -296,6 +296,19 @@ class CliMainTest < CliTestCase end end + test "config with aliases" do + run_command("config", config_file: "deploy_with_aliases").tap do |output| + config = YAML.load(output) + + assert_equal ["web", "web_tokyo", "workers", "workers_tokyo"], config[:roles] + assert_equal ["1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4"], config[:hosts] + assert_equal "999", config[:version] + assert_equal "registry.digitalocean.com/dhh/app", config[:repository] + assert_equal "registry.digitalocean.com/dhh/app:999", config[:absolute_image] + assert_equal "app-999", config[:service_with_version] + end + end + test "init" do Pathname.any_instance.expects(:exist?).returns(false).times(3) Pathname.any_instance.stubs(:mkpath) diff --git a/test/fixtures/deploy_with_aliases.yml b/test/fixtures/deploy_with_aliases.yml new file mode 100644 index 00000000..90c18b57 --- /dev/null +++ b/test/fixtures/deploy_with_aliases.yml @@ -0,0 +1,36 @@ +# helper aliases +chicago_hosts: &chicago_hosts + hosts: + - 1.1.1.1 + - 1.1.1.2 +tokyo_hosts: &tokyo_hosts + hosts: + - 1.1.1.3 + - 1.1.1.4 +web_common: &web_common + env: + ROLE: "web" + traefik: true + +# actual config +service: app +image: dhh/app +servers: + web: + <<: *chicago_hosts + <<: *web_common + web_tokyo: + <<: *tokyo_hosts + <<: *web_common + workers: + cmd: bin/jobs + <<: *chicago_hosts + workers_tokyo: + cmd: bin/jobs + <<: *tokyo_hosts +env: + REDIS_URL: redis://x/y +registry: + server: registry.digitalocean.com + username: user + password: pw From 60187cc3a4e0ff5dfff71e774c28f10bbe492c0d Mon Sep 17 00:00:00 2001 From: Matthew Kent Date: Sun, 12 Nov 2023 08:33:08 -0800 Subject: [PATCH 13/15] Add allow_empty_roles to control aborting on roles with no hosts. This added flexibility allows you to define base roles that might not necessarily exist in each deploy destination. --- lib/kamal/cli/templates/deploy.yml | 5 +++++ lib/kamal/configuration.rb | 23 +++++++++++++++++------ test/configuration_test.rb | 10 ++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/lib/kamal/cli/templates/deploy.yml b/lib/kamal/cli/templates/deploy.yml index 44fa2294..6d356c60 100644 --- a/lib/kamal/cli/templates/deploy.yml +++ b/lib/kamal/cli/templates/deploy.yml @@ -91,3 +91,8 @@ registry: # Caution: there's no support for role renaming yet, so be careful to cleanup # the previous role on the deployed hosts. # primary_web_role: web + +# Controls if we abort when see a role with no hosts. Disabling this may be +# useful for more complex deploy configurations. +# +# allow_empty_roles: false diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index f7f2233c..bbb8f52d 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -210,6 +210,11 @@ class Kamal::Configuration raw_config.primary_web_role || "web" end + def allow_empty_roles? + raw_config.allow_empty_roles + end + + def valid? ensure_destination_if_required && ensure_required_keys_present && ensure_valid_kamal_version end @@ -257,12 +262,6 @@ class Kamal::Configuration raise ArgumentError, "You must specify a password for the registry in config/deploy.yml (or set the ENV variable if that's used)" end - roles.each do |role| - if role.hosts.empty? - raise ArgumentError, "No servers specified for the #{role.name} role" - end - end - unless role_names.include?(primary_web_role) raise ArgumentError, "The primary_web_role #{primary_web_role} isn't defined" end @@ -271,6 +270,18 @@ class Kamal::Configuration raise ArgumentError, "Role #{primary_web_role} needs to have traefik enabled" end + if role(primary_web_role).hosts.empty? + raise ArgumentError, "No servers specified for the #{primary_web_role} primary_web_role" + end + + unless allow_empty_roles? + roles.each do |role| + if role.hosts.empty? + raise ArgumentError, "No servers specified for the #{role.name} role. You can ignore this with allow_empty_roles: true" + end + end + end + true end diff --git a/test/configuration_test.rb b/test/configuration_test.rb index 00612f24..d141ad10 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -165,6 +165,16 @@ class ConfigurationTest < ActiveSupport::TestCase end end + test "allow_empty_roles" do + assert_silent do + Kamal::Configuration.new @deploy.merge(servers: { "web" => %w[ web ], "workers" => { "hosts" => %w[ ] } }, allow_empty_roles: true) + end + + assert_raises(ArgumentError) do + Kamal::Configuration.new @deploy.merge(servers: { "web" => %w[], "workers" => { "hosts" => %w[] } }, allow_empty_roles: true) + end + end + test "volume_args" do assert_equal ["--volume", "/local/path:/container/path"], @config.volume_args end From 713785035415e44bc018127c0ad39ab65d9c9f1e Mon Sep 17 00:00:00 2001 From: Matthew Kent Date: Sun, 12 Nov 2023 23:22:08 -0800 Subject: [PATCH 14/15] Add support for wildcard matches with '*' on roles and hosts. eg: --roles=*_chicago,*_tokyo --hosts=app-* Useful for targeted deploys. --- lib/kamal/commander.rb | 4 ++-- lib/kamal/utils.rb | 16 ++++++++++++++++ test/commander_test.rb | 24 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index 9ba3be12..d97f1991 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -28,11 +28,11 @@ class Kamal::Commander end def specific_roles=(role_names) - @specific_roles = config.roles.select { |r| role_names.include?(r.name) } if role_names.present? + @specific_roles = Kamal::Utils.filter_specific_items(role_names, config.roles) if role_names.present? end def specific_hosts=(hosts) - @specific_hosts = config.all_hosts & hosts if hosts.present? + @specific_hosts = Kamal::Utils.filter_specific_items(hosts, config.all_hosts) if hosts.present? end def primary_host diff --git a/lib/kamal/utils.rb b/lib/kamal/utils.rb index da16633e..54185cf4 100644 --- a/lib/kamal/utils.rb +++ b/lib/kamal/utils.rb @@ -58,4 +58,20 @@ module Kamal::Utils .gsub(/`/, '\\\\`') .gsub(DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX, '\$') end + + # Apply a list of host or role filters, including wildcard matches + def filter_specific_items(filters, items) + matches = [] + + Array(filters).select do |filter| + matches += Array(items).select do |item| + # Only allow * for a wildcard + pattern = Regexp.escape(filter).gsub('\*', '.*') + # items are roles or hosts + (item.respond_to?(:name) ? item.name : item).match(/^#{pattern}$/) + end + end + + matches + end end diff --git a/test/commander_test.rb b/test/commander_test.rb index 2290e0f0..06cc5bbb 100644 --- a/test/commander_test.rb +++ b/test/commander_test.rb @@ -14,6 +14,18 @@ class CommanderTest < ActiveSupport::TestCase @kamal.specific_hosts = [ "1.1.1.1", "1.1.1.2" ] assert_equal [ "1.1.1.1", "1.1.1.2" ], @kamal.hosts + + @kamal.specific_hosts = [ "1.1.1.1*" ] + assert_equal [ "1.1.1.1" ], @kamal.hosts + + @kamal.specific_hosts = [ "1.1.1.*", "*.1.2.*" ] + assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.hosts + + @kamal.specific_hosts = [ "*" ] + assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.hosts + + @kamal.specific_hosts = [ "*miss" ] + assert_equal [], @kamal.hosts end test "filtering hosts by filtering roles" do @@ -28,6 +40,18 @@ class CommanderTest < ActiveSupport::TestCase @kamal.specific_roles = [ "workers" ] assert_equal [ "workers" ], @kamal.roles.map(&:name) + + @kamal.specific_roles = [ "w*" ] + assert_equal [ "web", "workers" ], @kamal.roles.map(&:name) + + @kamal.specific_roles = [ "we*", "*orkers" ] + assert_equal [ "web", "workers" ], @kamal.roles.map(&:name) + + @kamal.specific_roles = [ "*" ] + assert_equal [ "web", "workers" ], @kamal.roles.map(&:name) + + @kamal.specific_roles = [ "*miss" ] + assert_equal [], @kamal.roles.map(&:name) end test "filtering roles by filtering hosts" do From efcb855db77d47d03773f7dc77d3c84d911968b5 Mon Sep 17 00:00:00 2001 From: Matthew Kent Date: Sun, 12 Nov 2023 23:42:42 -0800 Subject: [PATCH 15/15] Advertise wildcard support. --- lib/kamal/cli/base.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index 9a38d97e..7cd69fc6 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -14,8 +14,8 @@ module Kamal::Cli class_option :version, desc: "Run commands against a specific app version" class_option :primary, type: :boolean, aliases: "-p", desc: "Run commands only on primary host instead of all" - class_option :hosts, aliases: "-h", desc: "Run commands on these hosts instead of all (separate by comma)" - class_option :roles, aliases: "-r", desc: "Run commands on these roles instead of all (separate by comma)" + class_option :hosts, aliases: "-h", desc: "Run commands on these hosts instead of all (separate by comma, supports wildcards with *)" + class_option :roles, aliases: "-r", desc: "Run commands on these roles instead of all (separate by comma, supports wildcards with *)" class_option :config_file, aliases: "-c", default: "config/deploy.yml", desc: "Path to config file" class_option :destination, aliases: "-d", desc: "Specify destination to be used for config file (staging -> deploy.staging.yml)"