diff --git a/lib/kamal/cli/proxy/reboot.rb b/lib/kamal/cli/proxy/reboot.rb index a3fe1b324..6a56f26c9 100644 --- a/lib/kamal/cli/proxy/reboot.rb +++ b/lib/kamal/cli/proxy/reboot.rb @@ -38,6 +38,7 @@ def pull_image def replace_container execute *proxy.ensure_proxy_directory execute *proxy.ensure_apps_config_directory + sync_proxy_secrets if proxy.port_holder? replace_generation @@ -46,18 +47,26 @@ def replace_container end end - # Phase 1 replacement: stop the old container, start the new one. Brief gap. - def stop_and_replace - info "Stopping and removing kamal-proxy on #{host}, if running..." - execute *proxy.stop(timeout: KAMAL.config.drain_timeout + 10), raise_on_non_zero_exit: false - execute *proxy.remove_container - + # Every replacement path below launches a container that reads + # --env-file at docker run, so the secrets must be on disk before ANY + # generation starts. This used to live inside stop_and_replace only, and + # the port-holder paths launched their generation without it — a + # port-holder reboot on a host without the file died with + # "open .kamal/proxy/secrets.env: no such file or directory". + def sync_proxy_secrets if (run_config = proxy.proxy_run_config)&.secrets? upload! run_config.secrets_io, run_config.secrets_path, mode: "0600" else # A host keeps no secrets it no longer needs. execute *proxy.remove_proxy_secrets_file, raise_on_non_zero_exit: false end + end + + # Phase 1 replacement: stop the old container, start the new one. Brief gap. + def stop_and_replace + info "Stopping and removing kamal-proxy on #{host}, if running..." + execute *proxy.stop(timeout: KAMAL.config.drain_timeout + 10), raise_on_non_zero_exit: false + execute *proxy.remove_container execute *proxy.run(digest: drift.expected_digest) end diff --git a/test/cli/proxy_test.rb b/test/cli/proxy_test.rb index e04aadefb..70dc938a0 100644 --- a/test/cli/proxy_test.rb +++ b/test/cli/proxy_test.rb @@ -49,6 +49,23 @@ class CliProxyTest < CliTestCase end end + # Regression: the port-holder replacement paths start a generation that + # reads --env-file at docker run, but only the legacy stop_and_replace path + # uploaded the secrets file — a port-holder reboot on a fresh host died with + # "open .kamal/proxy/secrets.env: no such file or directory". + test "port-holder reboot uploads the acme credentials before starting the new generation" do + with_test_secrets("secrets" => "CF_API_TOKEN=zone-rewriting-token") do + uploads = capture_uploads + + run_command("reboot", "-y", fixture: :with_proxy_acme_port_holder).tap do |output| + assert_match "--env-file .kamal/proxy/secrets.env", output + assert_no_match(/zone-rewriting-token/, output) + end + + assert_equal [ [ "CF_API_TOKEN=zone-rewriting-token\n", ".kamal/proxy/secrets.env", "0600" ] ], uploads + end + end + test "boot delivers the cache store through the secrets env file, never printing the URL" do uploads = capture_uploads diff --git a/test/fixtures/deploy_with_proxy_acme_port_holder.yml b/test/fixtures/deploy_with_proxy_acme_port_holder.yml new file mode 100644 index 000000000..13ae29f7f --- /dev/null +++ b/test/fixtures/deploy_with_proxy_acme_port_holder.yml @@ -0,0 +1,22 @@ +service: app +image: dhh/app +servers: + web: + - "1.1.1.1" +registry: + username: user + password: pw +builder: + arch: amd64 +proxy: + host: example.com + ssl: true + run: + port_holder: true + acme: + email: admin@example.com + dns_provider: cloudflare + prefer_wildcard: true + http_fallback: false + credentials: + - CF_API_TOKEN