Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions lib/kamal/cli/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions lib/kamal/commands/loadbalancer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions lib/kamal/commands/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions lib/kamal/configuration/docs/proxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
76 changes: 76 additions & 0 deletions test/cli/proxy_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?)

Expand Down
14 changes: 14 additions & 0 deletions test/commands/loadbalancer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions test/commands/proxy_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}}'",
Expand Down