diff --git a/lib/kamal/cli/proxy.rb b/lib/kamal/cli/proxy.rb index 381bf2616..d58eb5459 100644 --- a/lib/kamal/cli/proxy.rb +++ b/lib/kamal/cli/proxy.rb @@ -448,6 +448,49 @@ def loadbalancer(status) end end + desc "cache SUBCOMMAND", "Manage the response cache (stats, purge)" + option :count, type: :boolean, default: false, desc: "stats: measure entries and bytes per service (walks a shared store's keyspace)" + option :json, type: :boolean, default: false, desc: "stats: print the raw report as JSON" + option :path_prefix, type: :string, default: nil, desc: "purge: drop only the entries below this path, e.g. /assets" + def cache(subcommand) + count, json, path_prefix = options[:count], options[:json], options[:path_prefix] + + case subcommand + when "stats" + # The cache lives where its policy applies: at the loadbalancer when + # load balancing (cache is edge-only in the layering contract), + # otherwise on each proxy host. + if KAMAL.config.proxy.load_balancing? + on(KAMAL.config.proxy.effective_loadbalancer) do |host| + puts_by_host host, capture_with_info(*KAMAL.loadbalancer.cache_stats(count: count, json: json)), type: "Loadbalancer" + end + else + on(KAMAL.proxy_hosts) do |host| + puts_by_host host, capture_with_info(*KAMAL.proxy(host).cache_stats(count: count, json: json)), type: "Proxy" + end + end + when "purge" + if KAMAL.config.proxy.load_balancing? + # The loadbalancer registers one service under the bare service name. + on(KAMAL.config.proxy.effective_loadbalancer) do |host| + execute *KAMAL.auditor.record("Purged the response cache"), verbosity: :debug + puts_by_host host, capture_with_info(*KAMAL.loadbalancer.cache_purge(KAMAL.config.service, path_prefix: path_prefix)), type: "Loadbalancer" + end + else + # Each proxied role registered its own service, so purge walks them. + on(KAMAL.proxy_hosts) do |host| + execute *KAMAL.auditor.record("Purged the response cache"), verbosity: :debug + + KAMAL.roles_on(host).select(&:running_proxy?).each do |role| + puts_by_host host, capture_with_info(*KAMAL.proxy(host).cache_purge(role.container_prefix, path_prefix: path_prefix)), type: "Proxy" + end + end + end + else + puts "Unknown cache subcommand: #{subcommand}. Available: stats, purge" + end + end + desc "domains SUBCOMMAND", "Manage dynamic TLS domains (refresh, list, stats)" def domains(subcommand) case subcommand diff --git a/lib/kamal/commands/loadbalancer.rb b/lib/kamal/commands/loadbalancer.rb index 2584e72e3..44ff13673 100644 --- a/lib/kamal/commands/loadbalancer.rb +++ b/lib/kamal/commands/loadbalancer.rb @@ -48,6 +48,17 @@ def list docker :exec, container_name, "kamal-proxy", :list end + # Cache policy is edge-only under load balancing (see the layering contract), + # so the cache admin surface lives here - registered under the bare service + # name, unlike the per-role services on the proxy hosts. + def cache_stats(count: false, json: false) + docker :exec, container_name, "kamal-proxy", :cache, :stats, *optionize({ count: count || nil, json: json || nil }.compact) + end + + def cache_purge(service, path_prefix: nil) + docker :exec, container_name, "kamal-proxy", :cache, :purge, service, *optionize({ "path-prefix": path_prefix }.compact) + end + def config_digest docker :inspect, container_name, "--format", "'{{ index .Config.Labels \"#{Kamal::Commands::Proxy::CONFIG_DIGEST_LABEL}\" }}'" end diff --git a/lib/kamal/commands/proxy.rb b/lib/kamal/commands/proxy.rb index c000e8bb0..79b5f3230 100644 --- a/lib/kamal/commands/proxy.rb +++ b/lib/kamal/commands/proxy.rb @@ -69,6 +69,14 @@ def list(name: container_name, json: false) docker :exec, name, "kamal-proxy", :list, *("--json" if json) end + def cache_stats(count: false, json: false) + docker :exec, container_name, "kamal-proxy", :cache, :stats, *optionize({ count: count || nil, json: json || nil }.compact) + end + + def cache_purge(service, path_prefix: nil) + docker :exec, container_name, "kamal-proxy", :cache, :purge, service, *optionize({ "path-prefix": path_prefix }.compact) + end + # One mount destination per line - what the running container was actually # booted with, as opposed to what the current configuration would mount. def mount_destinations diff --git a/lib/kamal/configuration/docs/proxy.yml b/lib/kamal/configuration/docs/proxy.yml index 61f043d27..f69ac937b 100644 --- a/lib/kamal/configuration/docs/proxy.yml +++ b/lib/kamal/configuration/docs/proxy.yml @@ -633,11 +633,20 @@ proxy: # naming one moves it into the key for every path in the service, which is # usually not what you want. # + # ### Administering it + # + # `kamal proxy cache stats` reports what the cache is holding (add `--count` + # to measure entries and bytes per service, `--json` for the raw report), + # and `kamal proxy cache purge` drops this app's cached responses + # (`--path-prefix /assets` to narrow it). Both run on the layer that owns + # the cache - the loadbalancer when load balancing, else each proxy host. + # # ### When it is not caching # - # A cache that quietly stores nothing is the usual first surprise. kamal-proxy - # explains every refusal — check `kamal proxy logs` for the reason, and the - # `cache_refusals_total` metric (by `reason`) if you run with `metrics_port`. + # A cache that quietly stores nothing is the usual first surprise. Start with + # `kamal proxy cache stats`; kamal-proxy also explains every refusal — check + # `kamal proxy logs` for the reason, and the `cache_refusals_total` metric + # (by `reason`) if you run with `metrics_port`. # The common reasons are a missing `Cache-Control: public, max-age=...` on the # app's response, a `Set-Cookie` header, a body over `max_body`, and # `variant_limit` from `max_variants`. diff --git a/test/cli/proxy_test.rb b/test/cli/proxy_test.rb index 69c090710..9dbd34c9e 100644 --- a/test/cli/proxy_test.rb +++ b/test/cli/proxy_test.rb @@ -610,6 +610,82 @@ class CliProxyTest < CliTestCase end end + # Cache admin surfaces on the layer that owns the cache: the loadbalancer + # when load balancing (cache policy is edge-only), else the proxy hosts. + test "cache stats" do + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :exec, "kamal-proxy", "kamal-proxy", :cache, :stats) + .returns("Response cache (per node): 12 entries").twice + + run_command("cache", "stats").tap do |output| + assert_match "12 entries", output + end + end + + test "cache stats passes count and json through" do + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :exec, "kamal-proxy", "kamal-proxy", :cache, :stats, "--count", "--json") + .returns("{}").twice + + run_command("cache", "stats", "--count", "--json").tap do |output| + assert_match "{}", output + end + end + + test "cache stats with loadbalancer asks the loadbalancer" do + Kamal::Configuration::Proxy.any_instance.unstub(:load_balancing?) + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :exec, "load-balancer", "kamal-proxy", :cache, :stats) + .returns("Response cache (shared): 40 entries") + + run_command("cache", "stats", fixture: :with_loadbalancer).tap do |output| + assert_match "Loadbalancer Host: lb.example.com", output + assert_match "40 entries", output + end + end + + # Without a loadbalancer, each proxied role registered its own service, so + # purge walks them per host. + test "cache purge purges each proxied role service" do + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :exec, "kamal-proxy", "kamal-proxy", :cache, :purge, "app-web") + .returns("Purged 3 cached responses").twice + + run_command("cache", "purge").tap do |output| + assert_match "Purged 3 cached responses", output + end + end + + test "cache purge passes the path prefix through" do + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :exec, "kamal-proxy", "kamal-proxy", :cache, :purge, "app-web", "--path-prefix", "\"/assets\"") + .returns("Purged 1 cached response").twice + + run_command("cache", "purge", "--path-prefix", "/assets").tap do |output| + assert_match "Purged 1 cached response", output + end + end + + test "cache purge with loadbalancer purges the bare service on the loadbalancer" do + Kamal::Configuration::Proxy.any_instance.unstub(:load_balancing?) + + SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) + .with(:docker, :exec, "load-balancer", "kamal-proxy", :cache, :purge, "app") + .returns("Purged 7 cached responses") + + run_command("cache", "purge", fixture: :with_loadbalancer).tap do |output| + assert_match "Loadbalancer Host: lb.example.com", output + assert_match "Purged 7 cached responses", output + end + end + + test "cache with an unknown subcommand" do + run_command("cache", "flush").tap do |output| + assert_match "Unknown cache subcommand: flush. Available: stats, purge", output + end + end + test "domains list with loadbalancer" do Kamal::Configuration::Proxy.any_instance.unstub(:load_balancing?) diff --git a/test/commands/loadbalancer_test.rb b/test/commands/loadbalancer_test.rb index 08bf56578..c8788e781 100644 --- a/test/commands/loadbalancer_test.rb +++ b/test/commands/loadbalancer_test.rb @@ -230,6 +230,20 @@ class CommandsLoadbalancerTest < ActiveSupport::TestCase assert_no_match(/--rewrite/, command) end + # Cache policy is edge-only under load balancing, so the admin surface + # lives on the load balancer - registered under the bare service name. + test "cache stats" do + assert_equal \ + "docker exec load-balancer kamal-proxy cache stats --count", + new_command.cache_stats(count: true).join(" ") + end + + test "cache purge" do + assert_equal \ + "docker exec load-balancer kamal-proxy cache purge app", + new_command.cache_purge("app").join(" ") + end + test "domains" do assert_equal \ "docker exec load-balancer kamal-proxy domains list", diff --git a/test/commands/proxy_test.rb b/test/commands/proxy_test.rb index 4a8439e58..7a4152902 100644 --- a/test/commands/proxy_test.rb +++ b/test/commands/proxy_test.rb @@ -278,6 +278,30 @@ class CommandsProxyTest < ActiveSupport::TestCase new_command.list(json: true).join(" ") end + test "cache stats" do + assert_equal \ + "docker exec kamal-proxy kamal-proxy cache stats", + new_command.cache_stats.join(" ") + end + + test "cache stats with count and json" do + assert_equal \ + "docker exec kamal-proxy kamal-proxy cache stats --count --json", + new_command.cache_stats(count: true, json: true).join(" ") + end + + test "cache purge" do + assert_equal \ + "docker exec kamal-proxy kamal-proxy cache purge app-web", + new_command.cache_purge("app-web").join(" ") + end + + test "cache purge with path prefix" do + assert_equal \ + "docker exec kamal-proxy kamal-proxy cache purge app-web --path-prefix \"/assets\"", + new_command.cache_purge("app-web", path_prefix: "/assets").join(" ") + end + test "mount_destinations" do assert_equal \ "docker inspect kamal-proxy --format '{{range .Mounts}}{{println .Destination}}{{end}}'",