From 5274feb580a1278fbe578753e521cef102b8e907 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Mon, 3 Aug 2026 15:10:51 +0200 Subject: [PATCH] feat(proxy): kamal proxy cache CLI - stats and purge ## Summary kamal-proxy ships a full cache admin subsystem with zero gem surface - the docs pointed operators at raw proxy logs to find out why nothing was caching. `kamal proxy cache ` closes the gap: - `stats` (--count, --json) reports what the cache is holding - `purge` (--path-prefix) drops this app's cached responses, audited Both run on the layer that owns the cache per the layering contract: the loadbalancer when load balancing (registered under the bare service name), otherwise each proxy host (walking every proxied role's service). The docs' "when it is not caching" section now starts with the stats command instead of raw logs. ## Test Coverage - command shapes on Commands::Proxy and Commands::Loadbalancer, with and without flags - CLI routing: proxy hosts vs loadbalancer for both subcommands, flag passthrough, unknown-subcommand message ## Verification - [x] bundle exec rubocop --parallel passes - [x] unit suite passes Closes #99 --- lib/kamal/cli/proxy.rb | 43 +++++++++++++++ lib/kamal/commands/loadbalancer.rb | 11 ++++ lib/kamal/commands/proxy.rb | 8 +++ lib/kamal/configuration/docs/proxy.yml | 15 ++++- test/cli/proxy_test.rb | 76 ++++++++++++++++++++++++++ test/commands/loadbalancer_test.rb | 14 +++++ test/commands/proxy_test.rb | 24 ++++++++ 7 files changed, 188 insertions(+), 3 deletions(-) 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}}'",