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
73 changes: 73 additions & 0 deletions bin/sync-proxy-flags
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env ruby
# Regenerate test/fixtures/kamal_proxy_flags.yml — the flag manifest that
# test/proxy_flag_coverage_test.rb checks the gem's deploy.yml surface against.
#
# Run this whenever Kamal::Configuration::Proxy::Run::MINIMUM_VERSION moves. The
# coverage test refuses to pass while the manifest names a different version, so
# forgetting is loud rather than silent.
#
# bin/sync-proxy-flags # pull the image at MINIMUM_VERSION
# bin/sync-proxy-flags --binary ./kamal-proxy # use a locally built binary
#
# The flag list comes from Cobra's own `--help` output, not from a regex over the
# Go source. That is deliberate: the source has flags registered via Int64Var,
# Uint16Var and friends, and a "good enough" regex silently misses them. Cobra
# prints what Cobra will accept.

require "yaml"
require "open3"

# Read the constant textually rather than requiring the file — proxy/run.rb pulls
# in the whole Zeitwerk/ActiveSupport stack, which this script has no use for.
RUN_CONFIG = File.expand_path("../lib/kamal/configuration/proxy/run.rb", __dir__)
MINIMUM_VERSION = File.read(RUN_CONFIG)[/MINIMUM_VERSION\s*=\s*"([^"]+)"/, 1] or
abort "could not read MINIMUM_VERSION from #{RUN_CONFIG}"

# The two subcommands that carry configuration. Other subcommands (list, stop,
# pause, drain, ...) take no operator-tunable options, so they are out of scope
# for deploy.yml coverage.
SUBCOMMANDS = %w[ deploy run ].freeze

# Cobra adds --help to every command. It is not a configuration surface.
BUILTIN_FLAGS = %w[ help ].freeze

def run!(*command)
stdout, stderr, status = Open3.capture3(*command)
abort "#{command.join(" ")} failed:\n#{stderr}" unless status.success?
stdout
end

def flags_for(invocation, subcommand)
help = run!(*invocation, subcommand, "--help")

help.scan(/^\s+(?:-\w, )?--([a-z0-9-]+)/).flatten.uniq.sort - BUILTIN_FLAGS
end

version = MINIMUM_VERSION

invocation =
if (index = ARGV.index("--binary"))
[ ARGV.fetch(index + 1) { abort "--binary needs a path" } ]
else
image = "ghcr.io/mhenrixon/kamal-proxy:#{version}"
warn "Pulling #{image}..."
run!("docker", "pull", "--quiet", image)
[ "docker", "run", "--rm", image, "kamal-proxy" ]
end

manifest = {
"proxy_version" => version,
"generated_by" => "bin/sync-proxy-flags",
"flags" => SUBCOMMANDS.to_h { |subcommand| [ subcommand, flags_for(invocation, subcommand) ] }
}

path = File.expand_path("../test/fixtures/kamal_proxy_flags.yml", __dir__)
File.write path, <<~HEADER + manifest.to_yaml.sub(/\A---\n/, "")
---
# Generated by bin/sync-proxy-flags — do not edit by hand.
# Refresh whenever MINIMUM_VERSION moves; test/proxy_flag_coverage_test.rb
# fails while proxy_version here disagrees with MINIMUM_VERSION.
HEADER

SUBCOMMANDS.each { |sub| puts "#{sub}: #{manifest["flags"][sub].size} flags" }
puts "wrote #{path} for #{version}"
11 changes: 11 additions & 0 deletions lib/kamal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ class ConfigurationError < StandardError; end
require "tmpdir"
require "pathname"
require "uri"
# Proxy::Run.digest reaches for Digest::SHA256 from inside `on(KAMAL.proxy_hosts)`,
# so the first reference happens in several SSHKit threads at once. Digest defines
# SHA256 lazily — `Digest.const_missing(:SHA256)` runs `require "digest/sha2"`, and
# that path is not mutex-guarded — so the threads race it and one loses with
# "Digest::Base cannot be directly inherited in Ruby", failing `kamal proxy boot`
# on a random host.
#
# Requiring "digest" alone does NOT fix this: it defines the module but leaves
# SHA256 to const_missing. Pull in the implementation itself, before any threads
# exist to race it.
require "digest/sha2"

loader = Zeitwerk::Loader.for_gem
loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb"))
Expand Down
2 changes: 1 addition & 1 deletion lib/kamal/configuration/docs/proxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ proxy:
# below already embeds its ghcr.io host
repository: ghcr.io/mhenrixon/kamal-proxy # Container repository for the
# kamal-proxy image (this is the default)
version: v0.9.2.2 # Version tag of the kamal-proxy image to use.
version: v1.0.0.0 # Version tag of the kamal-proxy image to use.
# Defaults to the minimum version this gem
# requires - only pin it to roll forward early,
# never below the default
Expand Down
2 changes: 1 addition & 1 deletion lib/kamal/configuration/proxy/run.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class Kamal::Configuration::Proxy::Run
MINIMUM_VERSION = "v0.9.2.2"
MINIMUM_VERSION = "v1.0.0.0"
DEFAULT_HTTP_PORT = 80
DEFAULT_HTTPS_PORT = 443
DEFAULT_LOG_MAX_SIZE = "10m"
Expand Down
32 changes: 32 additions & 0 deletions test/eager_requires_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
require "test_helper"

class EagerRequiresTest < ActiveSupport::TestCase
# Kamal::Configuration::Proxy::Run.digest calls Digest::SHA256 from inside
# `on(KAMAL.proxy_hosts)` — one SSHKit thread per host, all reaching for the
# constant at once. If `digest` is only autoloaded on first reference, those
# threads race the extension's class hierarchy and one loses with
# "Digest::Base cannot be directly inherited in Ruby", failing `kamal proxy boot`
# on a random host. Intermittent, so no ordinary test catches it.
#
# This has to run in a subprocess: the test suite pulls in `digest` through
# other paths (test/sshkit_patch_drift_test.rb requires it outright), so an
# in-process `defined?(Digest)` would pass whether or not lib/kamal.rb requires
# it — exactly the false green this test exists to prevent.
test "requiring kamal loads digest, so SSHKit threads cannot race its autoload" do
loaded = sh_ruby 'require "kamal"; print defined?(Digest::SHA256) ? "loaded" : "missing"'

assert_equal "loaded", loaded,
"lib/kamal.rb no longer requires \"digest\". Kamal::Configuration::Proxy::Run.digest " \
"is called concurrently from Kamal::Cli::Proxy#boot, and a lazy autoload there fails " \
"intermittently with \"Digest::Base cannot be directly inherited in Ruby\"."
end

private
def sh_ruby(script)
lib = File.expand_path("../lib", __dir__)
output = IO.popen([ RbConfig.ruby, "-I#{lib}", "-e", script ], err: :err, &:read)

assert_predicate $?, :success?, "subprocess failed: #{output}"
output
end
end
73 changes: 73 additions & 0 deletions test/fixtures/deploy_with_every_proxy_option.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Every proxy option the gem can express, in one config.
#
# test/proxy_flag_coverage_test.rb generates the kamal-proxy commands from this
# fixture and treats the flags that come out as the gem's real coverage. So a key
# only counts as "mapped" if setting it here actually produces a flag — a config
# accessor that exists but is never wired into deploy_options does not pass.
#
# When an R7 issue exposes a new flag, add its key here and delete the flag's
# waiver from the test. If the flag does not appear, the wiring is wrong.
service: app
image: dhh/app
servers:
web:
- "1.1.1.1"
registry:
username: user
password: pw
builder:
arch: amd64
error_pages_path: public
deploy_timeout: 6
drain_timeout: 12
proxy:
# Must stay off: load balancing deletes host/tls/basic-auth from deploy_options,
# which would understate coverage.
loadbalancer: false
app_port: 3000
hosts:
- example.com
- www.example.com
ssl:
certificate_pem: PROXY_CERT
private_key_pem: PROXY_KEY
ssl_staging: true
ssl_redirect: true
forward_headers: true
strip_path_prefix: true
path_prefixes:
- /api
response_timeout: 20
read_targets:
- "1.1.1.2"
read_target_websockets: true
writer_affinity_timeout: 30
path_timeouts:
/uploads: 300
healthcheck:
path: /up
interval: 2
timeout: 5
port: 4000
host: healthcheck.example.com
buffering:
requests: true
responses: true
memory: 2_000_000
max_request_body: 40_000_000
max_response_body: 20_000_000
basic_auth:
username: admin
password: secret
logging:
request_headers:
- Cache-Control
response_headers:
- X-Request-Id
tls_domains:
source: /api/v1/kamal/domains
interval: 300
batch_size: 5
run:
debug: true
metrics_port: 9090
120 changes: 120 additions & 0 deletions test/fixtures/kamal_proxy_flags.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
---
# Generated by bin/sync-proxy-flags — do not edit by hand.
# Refresh whenever MINIMUM_VERSION moves; test/proxy_flag_coverage_test.rb
# fails while proxy_version here disagrees with MINIMUM_VERSION.
proxy_version: v1.0.0.0
generated_by: bin/sync-proxy-flags
flags:
deploy:
- add-request-header
- add-response-header
- allow-ip
- basic-auth
- buffer-memory
- buffer-requests
- buffer-responses
- cache
- cache-allow-set-cookie
- cache-max-body
- cache-max-ttl
- cache-max-variants
- cache-vary-cookie
- cache-vary-header
- canonical-host
- client-ip-header
- compress
- compress-content-type
- compress-min-length
- deploy-timeout
- drain-timeout
- error-pages
- exclude-metrics-path
- force
- forward-headers
- health-check-host
- health-check-interval
- health-check-path
- health-check-port
- health-check-timeout
- host
- intercept-errors
- log-request-header
- log-response-header
- max-request-body
- max-response-body
- path-prefix
- path-request-timeout
- path-timeout
- rate-limit
- rate-limit-burst
- rate-limit-exempt
- read-target
- read-target-websockets
- redirect
- remove-request-header
- remove-response-header
- request-timeout
- rewrite
- scope-cookie-paths
- session-affinity
- session-affinity-cookie
- set-request-header
- set-response-header
- sleep-after
- sleep-container
- strip-path-prefix
- target
- target-dial-timeout
- target-disable-keep-alives
- target-idle-conn-timeout
- target-max-conns
- target-max-idle-conns
- target-timeout
- target-try-duration
- target-try-interval
- tls
- tls-acme-cache-path
- tls-certificate-path
- tls-client-ca-path
- tls-domains-batch-size
- tls-domains-interval
- tls-domains-source
- tls-on-demand-url
- tls-private-key-path
- tls-redirect
- tls-staging
- trusted-proxy
- wake-timeout
- writer-affinity-timeout
run:
- acme-directory
- acme-dns-provider
- acme-email
- acme-http-fallback
- acme-prefer-wildcard
- cache-lease-ttl
- cache-lease-wait
- cache-memory-size
- cache-store
- cache-store-timeout
- data-dir
- debug
- docker-socket
- http-port
- http3
- https-port
- idle-timeout
- ignore-restore-errors
- log-format
- metrics-allow-ip
- metrics-port
- min-tls
- proxy-protocol
- proxy-protocol-allow-ip
- read-header-timeout
- read-timeout
- recheck-targets-on-restore
- reuse-port
- shutdown-timeout
- trace-context
- write-timeout
23 changes: 18 additions & 5 deletions test/integration/docker/deployer/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,24 @@ install_kamal
# Seed the private registry with the proxy image (pulled directly from ghcr.io,
# which bypasses the hub_cache mirror) so the proxy.run.registry option stays
# exercised by the suite — see app_with_roles, which pulls its proxy from
# registry:4443 rather than the default registry. The tag must match
# Kamal::Configuration::Proxy::Run::MINIMUM_VERSION.
docker pull ghcr.io/mhenrixon/kamal-proxy:v0.9.2.2
docker tag ghcr.io/mhenrixon/kamal-proxy:v0.9.2.2 registry:4443/ghcr.io/mhenrixon/kamal-proxy:v0.9.2.2
docker push registry:4443/ghcr.io/mhenrixon/kamal-proxy:v0.9.2.2
# registry:4443 rather than the default registry.
#
# The tag must match Kamal::Configuration::Proxy::Run::MINIMUM_VERSION, so read it
# from the source instead of hardcoding it here. Hardcoded, the two drift apart on
# every version bump and the failure lands deep inside an unrelated deploy as
# "manifest unknown" — a long way from the line that caused it.
minimum_version=$(grep -oE 'MINIMUM_VERSION *= *"[^"]+"' /kamal/lib/kamal/configuration/proxy/run.rb | head -1 | cut -d'"' -f2)

if [ -z "$minimum_version" ]; then
echo "setup.sh: could not read MINIMUM_VERSION from lib/kamal/configuration/proxy/run.rb" >&2
exit 1
fi

proxy_image="ghcr.io/mhenrixon/kamal-proxy:${minimum_version}"

docker pull "$proxy_image"
docker tag "$proxy_image" "registry:4443/${proxy_image}"
docker push "registry:4443/${proxy_image}"

# .ssh is on a shared volume that persists between runs. Clean it up as the
# churn of temporary vm IPs can eventually create conflicts.
Expand Down
Loading