Add kamal app create and kamal accessory create for standby container deployment#1810
Add kamal app create and kamal accessory create for standby container deployment#1810dutchess wants to merge 2 commits intobasecamp:mainfrom
kamal app create and kamal accessory create for standby container deployment#1810Conversation
There was a problem hiding this comment.
Pull request overview
Adds new kamal app create and kamal accessory create commands to provision “standby” containers in a Created (stopped) state so they can be activated later with start, enabling faster failover/cutover workflows.
Changes:
- Add
createcommand builders for app and accessories usingdocker create(instead ofdocker run). - Add CLI subcommands
kamal app createandkamal accessory create, intentionally skipping container start and proxy deployment. - Add test coverage to ensure the new commands do not invoke
docker startorkamal-proxy deploy.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
lib/kamal/commands/app.rb |
Adds App#create using docker create to create (not run) app containers. |
lib/kamal/commands/accessory.rb |
Adds Accessory#create using docker create for accessory containers. |
lib/kamal/cli/app.rb |
Adds kamal app create workflow (prep + create, no start/proxy deploy). |
lib/kamal/cli/accessory.rb |
Adds kamal accessory create workflow (dirs/upload/secrets + create). |
test/commands/accessory_test.rb |
Adds command-string expectations for accessory create. |
test/cli/app_test.rb |
Adds CLI behavior tests for kamal app create, including rename-on-conflict. |
test/cli/accessory_test.rb |
Adds CLI behavior tests for kamal accessory create and create all. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
test/commands/accessory_test.rb
Outdated
|
|
||
| test "create with host" do | ||
| assert_equal \ | ||
| "docker create --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.5\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", |
There was a problem hiding this comment.
In create with host, the expected command includes --detach, but docker create doesn’t support it and the implementation doesn’t add it. Update this expectation to omit --detach (and match the actual arg ordering).
| "docker create --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.5\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", | |
| "docker create --name app-mysql --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.5\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", |
| run_command("create").tap do |output| | ||
| assert_match /docker create --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12}/, output | ||
| # Should NOT contain start command | ||
| assert_no_match /docker start/, output | ||
| # Should NOT try to deploy to proxy (container not running) | ||
| assert_no_match /kamal-proxy deploy/, output |
There was a problem hiding this comment.
This assertion expects docker create --detach ..., but Kamal::Commands::App#create does not include --detach (and docker create doesn’t accept it). Update the regex to match the actual docker create invocation (no --detach, and note the current arg ordering places --restart before --name).
| run_command("create").tap do |output| | ||
| assert_match /Renaming container .* to .* as already exists on 1.1.1.1/, output | ||
| assert_match /docker rename app-web-latest app-web-latest_replaced_[0-9a-f]{16}/, output | ||
| assert_match /docker create --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12}/, output | ||
| # Should NOT contain start command or proxy deployment | ||
| assert_no_match /docker start/, output | ||
| assert_no_match /kamal-proxy deploy/, output |
There was a problem hiding this comment.
Same as the previous test: the expected docker create --detach ... pattern is incorrect for docker create and doesn’t match the implementation. Adjust the regex to remove --detach and align with the real argument order so this test validates the intended behavior.
test/cli/accessory_test.rb
Outdated
| assert_match "docker create --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.3\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output | ||
| assert_match "docker create --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output | ||
| assert_match "docker create --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.2\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output | ||
| assert_match "docker create --name custom-box --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env KAMAL_HOST=\"1.1.1.3\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-box\" other.registry/busybox:latest on 1.1.1.3", output |
There was a problem hiding this comment.
Same issue for the create all assertions: docker create does not accept --detach, so these expected command strings should be updated to remove it (and to reflect the actual ordering produced by Kamal::Commands::Accessory#create).
| assert_match "docker create --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.3\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output | |
| assert_match "docker create --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output | |
| assert_match "docker create --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.2\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output | |
| assert_match "docker create --name custom-box --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env KAMAL_HOST=\"1.1.1.3\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-box\" other.registry/busybox:latest on 1.1.1.3", output | |
| assert_match "docker create --name app-mysql --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.3\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output | |
| assert_match "docker create --name app-redis --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output | |
| assert_match "docker create --name app-redis --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.2\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output | |
| assert_match "docker create --name custom-box --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env KAMAL_HOST=\"1.1.1.3\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-box\" other.registry/busybox:latest on 1.1.1.3", output |
| def create(name) | ||
| with_lock do | ||
| if name == "all" | ||
| KAMAL.accessory_names.each { |accessory_name| create(accessory_name) } | ||
| else | ||
| directories(name) | ||
| upload(name) | ||
|
|
||
| with_accessory(name) do |accessory, hosts| | ||
| on(hosts) do |host| | ||
| execute *KAMAL.auditor.record("Created #{name} accessory"), verbosity: :debug | ||
| execute *accessory.ensure_env_directory | ||
| upload! accessory.secrets_io, accessory.secrets_path, mode: "0600" | ||
| execute *accessory.create(host: host) | ||
| end |
There was a problem hiding this comment.
kamal accessory create currently skips prepare(name), but prepare is what logs into the registry and ensures the kamal Docker network exists (as boot does). Since accessory.create still uses --network kamal, this can fail on fresh hosts or when a registry login is required. Consider calling prepare(name) (or the relevant subset) before creating the container.
| test "create" do | ||
| Kamal::Cli::Accessory.any_instance.expects(:directories).with("mysql") | ||
| Kamal::Cli::Accessory.any_instance.expects(:upload).with("mysql") | ||
|
|
||
| run_command("create", "mysql").tap do |output| | ||
| assert_match "docker create --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.3\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output | ||
| # Should NOT contain start command | ||
| assert_no_match /docker container start/, output | ||
| end |
There was a problem hiding this comment.
These expectations include --detach in the docker create command, but docker create doesn’t support --detach and the implementation doesn’t add it. Update the asserted string(s) to omit --detach and match the actual args output by the CLI.
| # Tag once the app booted on all hosts | ||
| on(KAMAL.app_hosts) do |host| | ||
| execute *KAMAL.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug | ||
| execute *KAMAL.app.tag_latest_image | ||
| end |
There was a problem hiding this comment.
The comment says “Tag once the app booted on all hosts”, but this is the create command (containers aren’t booted/started here). Update the comment to avoid confusion about what this step is doing.
test/commands/accessory_test.rb
Outdated
| "docker create --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", | ||
| new_command(:mysql).create.join(" ") | ||
|
|
||
| assert_equal \ | ||
| "docker create --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env SOMETHING=\"else\" --env-file .kamal/apps/app/env/accessories/redis.env --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest", | ||
| new_command(:redis).create.join(" ") | ||
|
|
||
| assert_equal \ | ||
| "docker create --name custom-busybox --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" other.registry/busybox:latest", | ||
| new_command(:busybox).create.join(" ") | ||
| end | ||
|
|
||
| test "create with host" do | ||
| assert_equal \ | ||
| "docker create --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.5\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", |
There was a problem hiding this comment.
docker create does not support --detach (and Kamal::Commands::Accessory#create doesn’t add it). These expectations will fail and the real command would error if --detach were included—update the expected strings in this create test to omit --detach (and match the actual argument order).
| "docker create --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", | |
| new_command(:mysql).create.join(" ") | |
| assert_equal \ | |
| "docker create --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env SOMETHING=\"else\" --env-file .kamal/apps/app/env/accessories/redis.env --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest", | |
| new_command(:redis).create.join(" ") | |
| assert_equal \ | |
| "docker create --name custom-busybox --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" other.registry/busybox:latest", | |
| new_command(:busybox).create.join(" ") | |
| end | |
| test "create with host" do | |
| assert_equal \ | |
| "docker create --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.5\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", | |
| "docker create --name app-mysql --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", | |
| new_command(:mysql).create.join(" ") | |
| assert_equal \ | |
| "docker create --name app-redis --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env SOMETHING=\"else\" --env-file .kamal/apps/app/env/accessories/redis.env --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest", | |
| new_command(:redis).create.join(" ") | |
| assert_equal \ | |
| "docker create --name custom-busybox --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" other.registry/busybox:latest", | |
| new_command(:busybox).create.join(" ") | |
| end | |
| test "create with host" do | |
| assert_equal \ | |
| "docker create --name app-mysql --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.5\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", |
test/commands/accessory_test.rb
Outdated
| "docker create --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", | ||
| new_command(:mysql).create.join(" ") | ||
|
|
||
| assert_equal \ | ||
| "docker create --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env SOMETHING=\"else\" --env-file .kamal/apps/app/env/accessories/redis.env --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest", | ||
| new_command(:redis).create.join(" ") | ||
|
|
||
| assert_equal \ | ||
| "docker create --name custom-busybox --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" other.registry/busybox:latest", | ||
| new_command(:busybox).create.join(" ") | ||
| end | ||
|
|
||
| test "create with host" do | ||
| assert_equal \ | ||
| "docker create --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.5\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", |
There was a problem hiding this comment.
Same issue here: docker create doesn’t accept --detach, so these expected command strings for create should not include it (and should reflect the args produced by Kamal::Commands::Accessory#create).
| "docker create --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", | |
| new_command(:mysql).create.join(" ") | |
| assert_equal \ | |
| "docker create --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env SOMETHING=\"else\" --env-file .kamal/apps/app/env/accessories/redis.env --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest", | |
| new_command(:redis).create.join(" ") | |
| assert_equal \ | |
| "docker create --name custom-busybox --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" other.registry/busybox:latest", | |
| new_command(:busybox).create.join(" ") | |
| end | |
| test "create with host" do | |
| assert_equal \ | |
| "docker create --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.5\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", | |
| "docker create --name app-mysql --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", | |
| new_command(:mysql).create.join(" ") | |
| assert_equal \ | |
| "docker create --name app-redis --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env SOMETHING=\"else\" --env-file .kamal/apps/app/env/accessories/redis.env --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest", | |
| new_command(:redis).create.join(" ") | |
| assert_equal \ | |
| "docker create --name custom-busybox --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" other.registry/busybox:latest", | |
| new_command(:busybox).create.join(" ") | |
| end | |
| test "create with host" do | |
| assert_equal \ | |
| "docker create --name app-mysql --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.5\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", |
Motivation
See issue #1809. We need to deploy containers to standby hosts in a Created (stopped) state, to be activated later with kamal app start / kamal accessory start. This enables near-instant failover and precise maintenance-window cutover without requiring operator knowledge of kamal's internal docker invocation.
Changes