Skip to content

Add kamal app create and kamal accessory create for standby container deployment#1810

Open
dutchess wants to merge 2 commits intobasecamp:mainfrom
powerhome:YNGJ-1591/standby-deploy
Open

Add kamal app create and kamal accessory create for standby container deployment#1810
dutchess wants to merge 2 commits intobasecamp:mainfrom
powerhome:YNGJ-1591/standby-deploy

Conversation

@dutchess
Copy link
Copy Markdown

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

  • app.rb — adds App#create, mirroring App#run but using docker create instead of docker run. The --detach flag is omitted (meaningless for an unstarted container).
  • accessory.rb — adds Accessory#create, same pattern.
  • app.rb — kamal app create: performs asset/SSL/error-page prep and the rename-on-conflict deduplication logic identical to boot, then calls app.create. Deliberately omits docker start and kamal-proxy deploy.
  • accessory.rb — kamal accessory create [NAME] / NAME=all: performs directory creation, file upload, secrets upload, then calls accessory.create. Skips proxy registration.
  • app_test.rb, accessory_test.rb, app_test.rb, accessory_test.rb — test coverage for all new commands, including verification that docker start and kamal-proxy deploy are not issued.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 create command builders for app and accessories using docker create (instead of docker run).
  • Add CLI subcommands kamal app create and kamal accessory create, intentionally skipping container start and proxy deployment.
  • Add test coverage to ensure the new commands do not invoke docker start or kamal-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 "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",
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
"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",

Copilot uses AI. Check for mistakes.
Comment on lines +268 to +273
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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +284 to +290
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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +68
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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +109 to +123
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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +53
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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +95 to +99
# 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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +108
"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",
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
"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",

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +108
"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",
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
"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",

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants