From 2683c18a9ed41c0b1432843de5193221a0174fe4 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Tue, 11 Aug 2026 11:39:27 +0200 Subject: [PATCH] fix(proxy): sync the secrets env file on every reboot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The port-holder replacement paths (generation handoff, fresh generation, holder migration) all launch a container that reads --env-file at docker run, but only the legacy stop_and_replace path uploaded the proxy secrets file first. A port-holder reboot on a host that never held the file — a drift reboot right after acme credentials or a cache store were added to the run config — died with: docker: open .kamal/proxy/secrets.env: no such file or directory after the old proxy was already stopped and pruned, leaving the host with no proxy at all. The sync (upload when the run config carries secrets, remove when it no longer does) now happens in replace_container, before any path launches a generation. Regression test drives a port-holder reboot with acme credentials and asserts the upload. --- lib/kamal/cli/proxy/reboot.rb | 21 +++++++++++++----- test/cli/proxy_test.rb | 17 ++++++++++++++ .../deploy_with_proxy_acme_port_holder.yml | 22 +++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/deploy_with_proxy_acme_port_holder.yml 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