From e8e0743425237c73cdae8e54b635d287c54b0089 Mon Sep 17 00:00:00 2001 From: m21ecom Date: Fri, 21 Aug 2026 21:39:41 +0200 Subject: [PATCH 1/3] Add apple-container builder engine Building with Kamal requires Docker on the developer's machine, which on macOS means Docker Desktop. Apple's `container` CLI builds and pushes OCI images natively on Apple silicon, but Kamal hardcodes `docker` for every local build, push and registry command, so there is no way to use it. Add `builder/engine`, defaulting to `docker`. Setting it to `apple-container` routes the commands that run on this machine through `container` instead. The deployment servers are untouched and still use Docker. The engine is chosen by picking an object rather than by branching inside the command classes. Kamal::Commands::Builder#local is the only place the engine name is read, and it returns Builder::AppleContainer in place of Builder::Local. Everything downstream is polymorphic on that object: the builder answers #local_registry with Registry::AppleContainer in place of Registry, and #install_error with the missing-dependency message for its engine. Anything that runs on the hosts goes through KAMAL.registry, which stays Docker-only, so Builder#target, Registry#login and Registry#logout are unchanged. - `container build` has no registry exporter, so a push is a build followed by one `container image push` per tag. - `registry/scheme` (auto, http, https) is read only by the apple paths, and falls back to http for a localhost registry. `--scheme auto` cannot reach one: it attempts TLS, hangs, and fails with "bad protocol version". - `builder/ssh` is resolved and exported as SSH_AUTH_SOCK, because `container build` accepts only the literal `--ssh default` and reads the socket from its own environment. - `kamal build remove` stops the builder rather than deleting it. `container` has a single machine-wide builder, so deleting it would discard every project's build cache. - Kamal::Docker.included_files takes the builder and asks it for the check image's build and run argv, and checks `status.success?` rather than the always-truthy Process::Status it was testing before. - Unsupported configuration is rejected up front: remote builders, disabled local builds, buildpacks, cache exports, provenance and SBOM attestations, custom builder drivers, and non-default SSH agents. `kamal build dev` now passes push_env to the build command, so with a remote builder it exports BUILDKIT_NO_CLIENT_TOKEN the way `kamal build push` already did. This affects the Docker path too. Verified by hand against `container` 1.2.2 on macOS 26: building and pushing both tags to an authenticated local registry, a multi-arch manifest list from repeated --platform, and SSH agent forwarding. The test suite was compared against main by failing-test set rather than by count, and the sets are identical both on Linux with Ruby 4.0 and on macOS with Docker running. Co-Authored-By: Claude Opus 5 --- lib/kamal/cli/base.rb | 10 +- lib/kamal/cli/build.rb | 12 +- lib/kamal/cli/registry.rb | 10 +- lib/kamal/commander.rb | 5 + lib/kamal/commands/base.rb | 4 + lib/kamal/commands/builder.rb | 7 +- lib/kamal/commands/builder/apple_container.rb | 100 ++++++++++++++++ lib/kamal/commands/builder/base.rb | 21 ++++ lib/kamal/commands/registry.rb | 10 +- .../commands/registry/apple_container.rb | 48 ++++++++ lib/kamal/configuration/builder.rb | 8 ++ lib/kamal/configuration/docs/builder.yml | 15 +++ lib/kamal/configuration/docs/registry.yml | 4 + lib/kamal/configuration/registry.rb | 9 ++ lib/kamal/configuration/validator/builder.rb | 13 +++ lib/kamal/configuration/validator/registry.rb | 1 + lib/kamal/docker.rb | 14 ++- test/cli/build_test.rb | 26 +++++ test/cli/registry_test.rb | 9 ++ test/commands/builder_test.rb | 108 ++++++++++++++++++ test/commands/registry_test.rb | 46 ++++++++ test/configuration/builder_test.rb | 12 ++ test/configuration/validation_test.rb | 9 ++ test/docker_test.rb | 35 ++++++ test/fixtures/deploy_with_apple_container.yml | 12 ++ 25 files changed, 519 insertions(+), 29 deletions(-) create mode 100644 lib/kamal/commands/builder/apple_container.rb create mode 100644 lib/kamal/commands/registry/apple_container.rb create mode 100644 test/docker_test.rb create mode 100644 test/fixtures/deploy_with_apple_container.yml diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index 9d99f759e..55a87694f 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -307,16 +307,12 @@ def with_env(env) ENV.update(current_env) end - def ensure_docker_installed + def ensure_builder_installed run_locally do begin - execute *KAMAL.builder.ensure_docker_installed + execute *KAMAL.builder.ensure_installed rescue SSHKit::Command::Failed => e - error = e.message =~ /command not found/ ? - "Docker is not installed locally" : - "Docker buildx plugin is not installed locally" - - raise DependencyError, error + raise DependencyError, KAMAL.builder.install_error(e.message) end end end diff --git a/lib/kamal/cli/build.rb b/lib/kamal/cli/build.rb index 68b417fe1..2a63eae55 100644 --- a/lib/kamal/cli/build.rb +++ b/lib/kamal/cli/build.rb @@ -17,7 +17,7 @@ def push # or the pre-build hooks. pre_connect_if_required - ensure_docker_installed + ensure_builder_installed setup_local_registry if KAMAL.registry.local? login_to_registry_locally if !KAMAL.registry.local? && KAMAL.builder.login_to_registry_locally? @@ -127,9 +127,9 @@ def details def dev cli = self - ensure_docker_installed + ensure_builder_installed - docker_included_files = Set.new(Kamal::Docker.included_files) + docker_included_files = Set.new(Kamal::Docker.included_files(builder: KAMAL.builder)) git_uncommitted_files = Set.new(Kamal::Git.uncommitted_files) git_untracked_files = Set.new(Kamal::Git.untracked_files) @@ -151,7 +151,7 @@ def dev run_locally do build = KAMAL.builder.push(cli.options[:output], tag_as_dirty: true, no_cache: cli.options[:no_cache]) KAMAL.with_verbosity(:debug) do - execute(*build) + execute(*build, env: KAMAL.builder.push_env) end end end @@ -197,13 +197,13 @@ def pull_on_hosts(hosts) def setup_local_registry run_locally do - execute *KAMAL.registry.setup + execute *KAMAL.local_registry.setup end end def login_to_registry_locally run_locally do - execute *KAMAL.registry.login + execute *KAMAL.local_registry.login end end diff --git a/lib/kamal/cli/registry.rb b/lib/kamal/cli/registry.rb index cdceccaef..4a051f8a7 100644 --- a/lib/kamal/cli/registry.rb +++ b/lib/kamal/cli/registry.rb @@ -3,12 +3,12 @@ class Kamal::Cli::Registry < Kamal::Cli::Base option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login" option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login" def setup - ensure_docker_installed unless options[:skip_local] + ensure_builder_installed unless options[:skip_local] if KAMAL.registry.local? - run_locally { execute *KAMAL.registry.setup } unless options[:skip_local] + run_locally { execute *KAMAL.local_registry.setup } unless options[:skip_local] else - run_locally { execute *KAMAL.registry.login } unless options[:skip_local] + run_locally { execute *KAMAL.local_registry.login } unless options[:skip_local] on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote] end end @@ -18,9 +18,9 @@ def setup option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login" def remove if KAMAL.registry.local? - run_locally { execute *KAMAL.registry.remove, raise_on_non_zero_exit: false } unless options[:skip_local] + run_locally { execute *KAMAL.local_registry.remove, raise_on_non_zero_exit: false } unless options[:skip_local] else - run_locally { execute *KAMAL.registry.logout } unless options[:skip_local] + run_locally { execute *KAMAL.local_registry.logout } unless options[:skip_local] on(KAMAL.hosts) { execute *KAMAL.registry.logout } unless options[:skip_remote] end end diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index 3879268ae..6f47118a3 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -121,6 +121,10 @@ def registry @commands[:registry] ||= Kamal::Commands::Registry.new(config) end + def local_registry + @commands[:local_registry] ||= builder.local_registry + end + def server @commands[:server] ||= Kamal::Commands::Server.new(config) end @@ -198,6 +202,7 @@ def configure_sshkit_with(config) sshkit.ssh_options = config.ssh.options end SSHKit.config.command_map[:docker] = "docker" # No need to use /usr/bin/env, just clogs up the logs + SSHKit.config.command_map[:container] = "container" SSHKit.config.output_verbosity = verbosity configure_output_with(config) diff --git a/lib/kamal/commands/base.rb b/lib/kamal/commands/base.rb index 1dfcfe314..c1647ce3c 100644 --- a/lib/kamal/commands/base.rb +++ b/lib/kamal/commands/base.rb @@ -84,6 +84,10 @@ def docker(*args) args.compact.unshift :docker end + def apple_container(*args) + args.compact.unshift :container + end + def pack(*args) args.compact.unshift :pack end diff --git a/lib/kamal/commands/builder.rb b/lib/kamal/commands/builder.rb index 0b6cdda4b..7e73cd6d0 100644 --- a/lib/kamal/commands/builder.rb +++ b/lib/kamal/commands/builder.rb @@ -3,6 +3,7 @@ class Kamal::Commands::Builder < Kamal::Commands::Base delegate \ :create, :remove, :dev, :push, :clean, :pull, :info, :inspect_builder, + :ensure_installed, :install_error, :local_registry, :build_check_commands, :validate_image, :first_mirror, :login_to_registry_locally?, :push_env, to: :target @@ -37,7 +38,11 @@ def remote end def local - @local ||= Kamal::Commands::Builder::Local.new(config) + @local ||= if config.builder.apple_container? + Kamal::Commands::Builder::AppleContainer.new(config) + else + Kamal::Commands::Builder::Local.new(config) + end end def hybrid diff --git a/lib/kamal/commands/builder/apple_container.rb b/lib/kamal/commands/builder/apple_container.rb new file mode 100644 index 000000000..50ab8ea27 --- /dev/null +++ b/lib/kamal/commands/builder/apple_container.rb @@ -0,0 +1,100 @@ +class Kamal::Commands::Builder::AppleContainer < Kamal::Commands::Builder::Base + def create + apple_container :builder, :start + end + + def remove + apple_container :builder, :stop + end + + def ensure_installed + combine \ + apple_container("--version"), + apple_container(:system, :status) + end + + def install_error(output) + output.match?(/command not found/) ? + "Apple container is not installed locally" : + "Apple container system service is not running locally" + end + + def local_registry + Kamal::Commands::Registry::AppleContainer.new(config) + end + + def info + apple_container :builder, :status + end + alias_method :inspect_builder, :info + + def build_check_commands(dockerfile:, tag:) + { build: [ "container", "build", "--tag", tag, "--file", dockerfile, "." ], + run: [ "container", "run", "--rm", tag ] } + end + + def push(export_action = "registry", tag_as_dirty: false, no_cache: false) + build = apple_container :build, + *platform_options(arches), + *build_tag_options(tag_as_dirty: tag_as_dirty), + *build_options, + *([ "--no-cache" ] if no_cache), + build_context, + "2>&1" + + case export_action + when "registry" + combine build, *build_tag_names(tag_as_dirty: tag_as_dirty).map { |tag| + apple_container(:image, :push, *registry_scheme_options, tag) + } + when "docker" + build + else + raise BuilderError, "The apple-container engine only supports registry and local image-store output" + end + end + + def build_options + [ *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh ] + end + + def push_env + if (socket = ssh_socket) + { "SSH_AUTH_SOCK" => socket } + else + {} + end + end + + private + def build_ssh + [ "--ssh", "default" ] if ssh.present? + end + + def build_secrets + secrets.keys.flat_map do |secret| + [ "--secret", "id=#{Kamal::Utils.escape_shell_value(secret)},env=#{Kamal::Utils.escape_shell_value(secret)}" ] + end + end + + def registry_scheme_options + [ "--scheme", registry_config.scheme ] if registry_config.scheme.present? + end + + def ssh_socket + source = ssh&.split("=", 2)&.[](1) + + case source + when /\A\$(\w+)\z/ + ENV[Regexp.last_match(1)] + when /\A\$\{(\w+)\}\z/ + ENV[Regexp.last_match(1)] + else + source + end + end + + def platform_options(arches) + arches.flat_map { |arch| [ "--platform", "linux/#{arch}" ] } + end +end diff --git a/lib/kamal/commands/builder/base.rb b/lib/kamal/commands/builder/base.rb index 53fd144cf..4f614127d 100644 --- a/lib/kamal/commands/builder/base.rb +++ b/lib/kamal/commands/builder/base.rb @@ -14,6 +14,27 @@ def clean docker :image, :rm, "--force", config.absolute_image end + def ensure_installed + ensure_docker_installed + end + + def install_error(output) + output.match?(/command not found/) ? + "Docker is not installed locally" : + "Docker buildx plugin is not installed locally" + end + + # The local registry runs alongside the builder, so it speaks the builder's engine. + def local_registry + Kamal::Commands::Registry.new(config) + end + + # Plain argv, not SSHKit commands: these run through Kernel#system and Open3. + def build_check_commands(dockerfile:, tag:) + { build: [ "docker", "buildx", "build", "--tag", tag, "--file", dockerfile, "." ], + run: [ "docker", "run", "--rm", tag ] } + end + def push(export_action = "registry", tag_as_dirty: false, no_cache: false) docker :buildx, :build, "--output=type=#{export_action}", diff --git a/lib/kamal/commands/registry.rb b/lib/kamal/commands/registry.rb index a8b534f20..53aca04a1 100644 --- a/lib/kamal/commands/registry.rb +++ b/lib/kamal/commands/registry.rb @@ -1,4 +1,6 @@ class Kamal::Commands::Registry < Kamal::Commands::Base + LOCAL_REGISTRY_CONTAINER = "kamal-docker-registry" + def login(registry_config: nil) registry_config ||= config.registry @@ -20,15 +22,15 @@ def setup(registry_config: nil) registry_config ||= config.registry combine \ - docker(:start, "kamal-docker-registry"), - docker(:run, "--detach", "-p", "127.0.0.1:#{registry_config.local_port}:5000", "--name", "kamal-docker-registry", "registry:3"), + docker(:start, LOCAL_REGISTRY_CONTAINER), + docker(:run, "--detach", "-p", "127.0.0.1:#{registry_config.local_port}:5000", "--name", LOCAL_REGISTRY_CONTAINER, "registry:3"), by: "||" end def remove combine \ - docker(:stop, "kamal-docker-registry"), - docker(:rm, "kamal-docker-registry"), + docker(:stop, LOCAL_REGISTRY_CONTAINER), + docker(:rm, LOCAL_REGISTRY_CONTAINER), by: "&&" end diff --git a/lib/kamal/commands/registry/apple_container.rb b/lib/kamal/commands/registry/apple_container.rb new file mode 100644 index 000000000..fb0b6643f --- /dev/null +++ b/lib/kamal/commands/registry/apple_container.rb @@ -0,0 +1,48 @@ +class Kamal::Commands::Registry::AppleContainer < Kamal::Commands::Registry + def login(registry_config: nil) + registry_config ||= config.registry + + return if registry_config.local? + + pipe \ + [ :echo, sensitive(Kamal::Utils.escape_shell_value(registry_config.password)) ], + apple_container( + :registry, :login, + *registry_scheme_options(registry_config), + "--username", sensitive(Kamal::Utils.escape_shell_value(registry_config.username)), + "--password-stdin", + server_for(registry_config)) + end + + def logout(registry_config: nil) + registry_config ||= config.registry + + apple_container :registry, :logout, server_for(registry_config) + end + + def setup(registry_config: nil) + registry_config ||= config.registry + + combine \ + apple_container(:start, LOCAL_REGISTRY_CONTAINER), + apple_container(:run, "--detach", "-p", "127.0.0.1:#{registry_config.local_port}:5000", "--name", LOCAL_REGISTRY_CONTAINER, "registry:3"), + by: "||" + end + + def remove + combine \ + apple_container(:stop, LOCAL_REGISTRY_CONTAINER), + apple_container(:delete, LOCAL_REGISTRY_CONTAINER), + by: "&&" + end + + private + def registry_scheme_options(registry_config) + [ "--scheme", registry_config.scheme ] if registry_config.scheme.present? + end + + # `container` has no implicit Docker Hub default. + def server_for(registry_config) + registry_config.server.presence || "docker.io" + end +end diff --git a/lib/kamal/configuration/builder.rb b/lib/kamal/configuration/builder.rb index 0e2c4c618..87aeb3a32 100644 --- a/lib/kamal/configuration/builder.rb +++ b/lib/kamal/configuration/builder.rb @@ -89,6 +89,14 @@ def driver builder_config.fetch("driver", "docker-container") end + def engine + builder_config.fetch("engine", "docker") + end + + def apple_container? + engine == "apple-container" + end + def pack_builder builder_config["pack"]["builder"] if pack? end diff --git a/lib/kamal/configuration/docs/builder.yml b/lib/kamal/configuration/docs/builder.yml index 190fd240a..eda2b0913 100644 --- a/lib/kamal/configuration/docs/builder.yml +++ b/lib/kamal/configuration/docs/builder.yml @@ -9,6 +9,21 @@ # Options go under the builder key in the root configuration. builder: + # Engine + # + # The local container engine used to build and push images. Set this to + # `apple-container` to build with Apple's `container` CLI instead of Docker; + # the deployment servers still use Docker. Needs Apple silicon and macOS 26, + # and is tested against `container` 1.2.2. + # + # It supports no remote or non-local builds, buildpacks, cache exports, + # attestations, custom drivers, or custom SSH agents. `build remove` stops its + # shared builder rather than deleting it, and a localhost registry has to be + # removed before switching engines. + # + # Defaults to docker: + engine: docker + # Arch # # The architectures to build for — you can set an array or just a single value. diff --git a/lib/kamal/configuration/docs/registry.yml b/lib/kamal/configuration/docs/registry.yml index 74a7a084f..5b3ac9e98 100644 --- a/lib/kamal/configuration/docs/registry.yml +++ b/lib/kamal/configuration/docs/registry.yml @@ -51,6 +51,10 @@ registry: # Here’s the final configuration: registry: server: -docker.pkg.dev + # Only Apple's `container` CLI reads this; Docker ignores it. A localhost + # registry already uses plain HTTP, so set this to `http` only for a + # plain-HTTP registry on another host. One of `auto`, `http`, or `https`: + scheme: auto username: _json_key_base64 password: - KAMAL_REGISTRY_PASSWORD diff --git a/lib/kamal/configuration/registry.rb b/lib/kamal/configuration/registry.rb index 1212bf211..5a6f4184d 100644 --- a/lib/kamal/configuration/registry.rb +++ b/lib/kamal/configuration/registry.rb @@ -19,6 +19,15 @@ def password lookup("password") end + # `auto` is the container CLI's own default, and it cannot reach a local + # registry: it attempts TLS and fails. Treat it as "let Kamal decide". + def scheme + configured = registry_config["scheme"] + configured = nil if configured == "auto" + + configured || ("http" if local?) + end + def local? server.to_s.match?("^localhost[:$]") end diff --git a/lib/kamal/configuration/validator/builder.rb b/lib/kamal/configuration/validator/builder.rb index 0115d437c..a8f5d7784 100644 --- a/lib/kamal/configuration/validator/builder.rb +++ b/lib/kamal/configuration/validator/builder.rb @@ -8,6 +8,19 @@ def validate! error "Builder arch not set" unless config["arch"].present? + error "Invalid builder engine: #{config["engine"]}" if config["engine"] && !%w[ docker apple-container ].include?(config["engine"]) + + if config["engine"] == "apple-container" + error "The apple-container engine does not support remote builders" if config["remote"] + error "The apple-container engine does not support disabling local builds" if config["local"] == false + error "The apple-container engine does not support buildpacks" if config["pack"] + error "The apple-container engine does not support cache exports" if config["cache"] + error "The apple-container engine does not support provenance attestations" if config.key?("provenance") + error "The apple-container engine does not support SBOM attestations" if config.key?("sbom") + error "The apple-container engine does not support custom builder drivers" if config["driver"] + error "The apple-container engine only supports the default SSH agent" if config["ssh"] && !config["ssh"].match?(/\Adefault(?:=.+)?\z/) + end + error "buildpacks only support building for one arch" if config["pack"] && config["arch"].is_a?(Array) && config["arch"].size > 1 error "Cannot disable local builds, no remote is set" if config["local"] == false && config["remote"].blank? diff --git a/lib/kamal/configuration/validator/registry.rb b/lib/kamal/configuration/validator/registry.rb index 55f4d2f67..c77159071 100644 --- a/lib/kamal/configuration/validator/registry.rb +++ b/lib/kamal/configuration/validator/registry.rb @@ -8,6 +8,7 @@ def validate! validate_string_or_one_item_array! "username" validate_string_or_one_item_array! "password" + error "Invalid registry scheme: #{config["scheme"]}" if config["scheme"] && !%w[ auto http https ].include?(config["scheme"]) end private diff --git a/lib/kamal/docker.rb b/lib/kamal/docker.rb index 6ae7b7f3e..cd64d4f65 100644 --- a/lib/kamal/docker.rb +++ b/lib/kamal/docker.rb @@ -5,7 +5,9 @@ module Kamal::Docker extend self BUILD_CHECK_TAG = "kamal-local-build-check" - def included_files + def included_files(builder:) + commands = nil + Tempfile.create do |dockerfile| dockerfile.write(<<~DOCKERFILE) FROM busybox @@ -15,13 +17,13 @@ def included_files DOCKERFILE dockerfile.close - cmd = "docker buildx build -t=#{BUILD_CHECK_TAG} -f=#{dockerfile.path} ." - system(cmd) || raise("failed to build check image") + commands = builder.build_check_commands(dockerfile: dockerfile.path, tag: BUILD_CHECK_TAG) + + system(*commands[:build]) || raise("failed to build check image") end - cmd = "docker run --rm #{BUILD_CHECK_TAG}" - out, err, status = Open3.capture3(cmd) - unless status + out, err, status = Open3.capture3(*commands[:run]) + unless status.success? raise "failed to run check image:\n#{err}" end diff --git a/test/cli/build_test.rb b/test/cli/build_test.rb index 8874cdd78..cdff77ea7 100644 --- a/test/cli/build_test.rb +++ b/test/cli/build_test.rb @@ -143,6 +143,19 @@ class CliBuildTest < CliTestCase end end + test "push with apple container does not invoke local docker" do + Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) + + run_command("push", "--verbose", fixture: :with_apple_container).tap do |output| + assert_match /container --version && container system status/, output + assert_match /echo \[REDACTED\] \| container registry login --username \[REDACTED\] --password-stdin docker.io/, output + assert_match %r{container builder status}, output + assert_match %r{container build --platform linux/amd64 -t dhh/app:999 -t dhh/app:latest --label service="app" --file Dockerfile \. 2>&1 && container image push dhh/app:999 && container image push dhh/app:latest}, output + assert_no_match /docker buildx/, output + assert_no_match /docker login.*localhost/, output + end + end + test "push with no-cache" do Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) @@ -408,6 +421,19 @@ class CliBuildTest < CliTestCase end end + test "dev with apple container does not invoke local docker" do + Kamal::Docker.expects(:included_files).with { |**kwargs| kwargs[:builder].name == "apple_container" }.returns([]) + Kamal::Git.stubs(:uncommitted_files).returns([]) + Kamal::Git.stubs(:untracked_files).returns([]) + + run_command("dev", "--verbose", fixture: :with_apple_container).tap do |output| + assert_match /container --version && container system status/, output + assert_match %r{container build --platform linux/amd64 -t dhh/app:999-dirty -t dhh/app:latest-dirty --label service="app" --file Dockerfile \. 2>&1}, output + assert_no_match /container image push/, output + assert_no_match /docker buildx/, output + end + end + test "dev --output=local" do with_build_directory do |build_directory| Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) diff --git a/test/cli/registry_test.rb b/test/cli/registry_test.rb index 2a2e7f9ce..25e8b6f57 100644 --- a/test/cli/registry_test.rb +++ b/test/cli/registry_test.rb @@ -22,6 +22,15 @@ class CliRegistryTest < CliTestCase end end + test "setup with apple container uses apple locally and docker remotely" do + run_command("setup", fixture: :with_apple_container).tap do |output| + assert_match /container --version && container system status/, output + assert_match /echo \[REDACTED\] \| container registry login --username \[REDACTED\] --password-stdin docker.io as .*@localhost/, output + assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output + assert_no_match /docker --version.*localhost/, output + end + end + test "remove" do run_command("remove").tap do |output| assert_match /docker logout as .*@localhost/, output diff --git a/test/commands/builder_test.rb b/test/commands/builder_test.rb index 65517c554..d8644a008 100644 --- a/test/commands/builder_test.rb +++ b/test/commands/builder_test.rb @@ -21,6 +21,114 @@ class CommandsBuilderTest < ActiveSupport::TestCase builder.push.join(" ") end + test "target apple container engine locally" do + builder = new_builder_command(builder: { "engine" => "apple-container", "arch" => "amd64" }) + + assert_equal "apple_container", builder.name + assert_equal \ + "container build --platform linux/amd64 -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile . 2>&1 && container image push dhh/app:123 && container image push dhh/app:latest", + builder.push.join(" ") + end + + test "apple container engine uses repeated platform arguments" do + builder = new_builder_command(builder: { "engine" => "apple-container", "arch" => [ "amd64", "arm64" ] }) + + assert_equal \ + "container build --platform linux/amd64 --platform linux/arm64 -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile . 2>&1 && container image push dhh/app:123 && container image push dhh/app:latest", + builder.push.join(" ") + end + + test "apple container engine supports local image-store output" do + builder = new_builder_command(builder: { "engine" => "apple-container", "arch" => "arm64" }) + + assert_equal \ + "container build --platform linux/arm64 -t dhh/app:123-dirty -t dhh/app:latest-dirty --label service=\"app\" --file Dockerfile . 2>&1", + builder.push("docker", tag_as_dirty: true).join(" ") + end + + test "apple container engine pushes to a local registry over http" do + @config[:registry] = { "server" => "localhost:5000" } + builder = new_builder_command(builder: { "engine" => "apple-container", "arch" => "amd64" }) + + assert_equal \ + "container build --platform linux/amd64 -t localhost:5000/dhh/app:123 -t localhost:5000/dhh/app:latest --label service=\"app\" --file Dockerfile . 2>&1 && container image push --scheme http localhost:5000/dhh/app:123 && container image push --scheme http localhost:5000/dhh/app:latest", + builder.push.join(" ") + end + + test "apple container engine pushes to a registry with its configured scheme" do + @config[:registry] = { "server" => "127.0.0.1:5000", "username" => "dhh", "password" => "secret", "scheme" => "http" } + builder = new_builder_command(builder: { "engine" => "apple-container", "arch" => "amd64" }) + + assert_equal \ + "container build --platform linux/amd64 -t 127.0.0.1:5000/dhh/app:123 -t 127.0.0.1:5000/dhh/app:latest --label service=\"app\" --file Dockerfile . 2>&1 && container image push --scheme http 127.0.0.1:5000/dhh/app:123 && container image push --scheme http 127.0.0.1:5000/dhh/app:latest", + builder.push.join(" ") + end + + test "apple container engine keeps http for a local registry asking for auto" do + @config[:registry] = { "server" => "localhost:5000", "scheme" => "auto" } + builder = new_builder_command(builder: { "engine" => "apple-container", "arch" => "amd64" }) + + assert_match "container image push --scheme http localhost:5000/dhh/app:123", builder.push.join(" ") + end + + test "apple container engine build secrets name their environment source" do + with_test_secrets("secrets" => "token_a=foo\ntoken_b=bar") do + FileUtils.touch("Dockerfile") + builder = new_builder_command(builder: { "engine" => "apple-container", "arch" => "arm64", "secrets" => [ "token_a", "token_b" ] }) + + assert_equal \ + "--label service=\"app\" --secret id=\"token_a\",env=\"token_a\" --secret id=\"token_b\",env=\"token_b\" --file Dockerfile", + builder.target.build_options.join(" ") + end + end + + test "apple container engine uses its default SSH agent syntax" do + original_ssh_auth_sock = ENV["SSH_AUTH_SOCK"] + ENV["SSH_AUTH_SOCK"] = "/tmp/custom-agent.sock" + + builder = new_builder_command(builder: { "engine" => "apple-container", "arch" => "arm64", "ssh" => "default=$SSH_AUTH_SOCK" }) + + assert_equal \ + "--label service=\"app\" --file Dockerfile --ssh default", + builder.target.build_options.join(" ") + assert_equal({ "SSH_AUTH_SOCK" => "/tmp/custom-agent.sock" }, builder.push_env) + + literal_builder = new_builder_command(builder: { "engine" => "apple-container", "arch" => "arm64", "ssh" => "default=/tmp/literal-agent.sock" }) + assert_equal({ "SSH_AUTH_SOCK" => "/tmp/literal-agent.sock" }, literal_builder.push_env) + + default_builder = new_builder_command(builder: { "engine" => "apple-container", "arch" => "arm64", "ssh" => "default" }) + assert_equal({}, default_builder.push_env) + ensure + ENV["SSH_AUTH_SOCK"] = original_ssh_auth_sock + end + + test "apple container engine lifecycle" do + builder = new_builder_command(builder: { "engine" => "apple-container", "arch" => "arm64" }) + + assert_equal "container --version && container system status", builder.ensure_installed.join(" ") + assert_equal "container builder start", builder.create.join(" ") + assert_equal "container builder status", builder.inspect_builder.join(" ") + assert_equal "container builder stop", builder.remove.join(" ") + end + + test "missing dependencies are reported for the engine in use" do + builder = new_builder_command + + assert_equal "Docker is not installed locally", builder.install_error("bash: docker: command not found") + assert_equal "Docker buildx plugin is not installed locally", builder.install_error("no buildx") + + apple_builder = new_builder_command(builder: { "engine" => "apple-container" }) + + assert_equal "Apple container is not installed locally", apple_builder.install_error("bash: container: command not found") + assert_equal "Apple container system service is not running locally", apple_builder.install_error("apiserver is not running") + end + + test "the local registry speaks the builder's engine" do + assert_instance_of Kamal::Commands::Registry, new_builder_command.local_registry + assert_instance_of Kamal::Commands::Registry::AppleContainer, + new_builder_command(builder: { "engine" => "apple-container" }).local_registry + end + test "build with caching" do builder = new_builder_command(builder: { "cache" => { "type" => "gha" } }) assert_equal "local", builder.name diff --git a/test/commands/registry_test.rb b/test/commands/registry_test.rb index 997590dbc..d5a4c08ce 100755 --- a/test/commands/registry_test.rb +++ b/test/commands/registry_test.rb @@ -79,6 +79,30 @@ class CommandsRegistryTest < ActiveSupport::TestCase registry.logout.join(" ") end + test "apple container local registry login and logout" do + assert_equal \ + "echo \"secret\" | container registry login --username \"dhh\" --password-stdin hub.docker.com", + apple_registry.login.join(" ") + assert_equal "container registry logout hub.docker.com", apple_registry.logout.join(" ") + assert_equal "docker login hub.docker.com -u \"dhh\" -p \"secret\"", registry.login.join(" ") + end + + test "apple container registry login uses its configured scheme" do + @config[:registry]["scheme"] = "http" + + assert_equal \ + "echo \"secret\" | container registry login --scheme http --username \"dhh\" --password-stdin hub.docker.com", + apple_registry.login.join(" ") + end + + test "apple container registry login leaves the scheme to container when set to auto" do + @config[:registry]["scheme"] = "auto" + + assert_equal \ + "echo \"secret\" | container registry login --username \"dhh\" --password-stdin hub.docker.com", + apple_registry.login.join(" ") + end + test "given registry logout" do assert_equal \ "docker logout other.hub.docker.com", @@ -94,11 +118,33 @@ class CommandsRegistryTest < ActiveSupport::TestCase assert_equal "docker stop kamal-docker-registry && docker rm kamal-docker-registry", registry.remove.join(" ") end + test "apple container local registry setup and remove" do + @config[:registry] = { "server" => "localhost:5000" } + + assert_equal \ + "container start kamal-docker-registry || container run --detach -p 127.0.0.1:5000:5000 --name kamal-docker-registry registry:3", + apple_registry.setup.join(" ") + assert_equal \ + "container stop kamal-docker-registry && container delete kamal-docker-registry", + apple_registry.remove.join(" ") + end + + test "both engines manage the same local registry container" do + @config[:registry] = { "server" => "localhost:5000" } + + assert_equal registry.setup.join(" ").gsub("docker ", ""), + apple_registry.setup.join(" ").gsub("container ", "") + end + private def registry Kamal::Commands::Registry.new main_config end + def apple_registry + Kamal::Commands::Registry::AppleContainer.new main_config + end + def main_config Kamal::Configuration.new(@config) end diff --git a/test/configuration/builder_test.rb b/test/configuration/builder_test.rb index 7925d4caf..9b15cab61 100644 --- a/test/configuration/builder_test.rb +++ b/test/configuration/builder_test.rb @@ -12,6 +12,18 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase assert_equal true, config.builder.local? end + test "docker engine by default" do + assert_equal "docker", config.builder.engine + assert_not config.builder.apple_container? + end + + test "apple container engine" do + @deploy[:builder] = { "engine" => "apple-container", "arch" => "arm64" } + + assert_equal "apple-container", config.builder.engine + assert config.builder.apple_container? + end + test "remote?" do assert_equal false, config.builder.remote? end diff --git a/test/configuration/validation_test.rb b/test/configuration/validation_test.rb index 18424756a..189989c20 100644 --- a/test/configuration/validation_test.rb +++ b/test/configuration/validation_test.rb @@ -53,6 +53,7 @@ class ConfigurationValidationTest < ActiveSupport::TestCase assert_error "registry/password: is required", registry: { "username" => "foo" } assert_error "registry/password: should be a string or an array with one string (for secret lookup)", registry: { "username" => "foo", "password" => [ "SECRET1", "SECRET2" ] } assert_error "registry/server: should be a string", registry: { "username" => "foo", "password" => "bar", "server" => [] } + assert_error "registry: Invalid registry scheme: ftp", registry: { "username" => "foo", "password" => "bar", "scheme" => "ftp" } end test "accessories" do @@ -99,6 +100,14 @@ class ConfigurationValidationTest < ActiveSupport::TestCase assert_error "builder/args: should be a hash", builder: { "args" => [ "foo" ] } assert_error "builder/cache/options: should be a string", builder: { "cache" => { "options" => [] } } assert_error "builder: buildpacks only support building for one arch", builder: { "arch" => [ "amd64", "arm64" ], "pack" => { "builder" => "heroku/builder:24" } } + assert_error "builder: Invalid builder engine: podman", builder: { "engine" => "podman", "arch" => "amd64" } + assert_error "builder: The apple-container engine does not support remote builders", builder: { "engine" => "apple-container", "arch" => "amd64", "remote" => "ssh://builder" } + assert_error "builder: The apple-container engine does not support cache exports", builder: { "engine" => "apple-container", "arch" => "amd64", "cache" => { "type" => "registry" } } + assert_error "builder: The apple-container engine does not support provenance attestations", builder: { "engine" => "apple-container", "arch" => "amd64", "provenance" => false } + assert_error "builder: The apple-container engine does not support SBOM attestations", builder: { "engine" => "apple-container", "arch" => "amd64", "sbom" => false } + assert_error "builder: The apple-container engine does not support custom builder drivers", builder: { "engine" => "apple-container", "arch" => "amd64", "driver" => "cloud example/builder" } + assert_error "builder: The apple-container engine only supports the default SSH agent", builder: { "engine" => "apple-container", "arch" => "amd64", "ssh" => "other=/tmp/agent.sock" } + assert_error "builder: The apple-container engine only supports the default SSH agent", builder: { "engine" => "apple-container", "arch" => "amd64", "ssh" => "default-other" } end test "local registry with remote builder requires ssh url" do diff --git a/test/docker_test.rb b/test/docker_test.rb new file mode 100644 index 000000000..4bab858c8 --- /dev/null +++ b/test/docker_test.rb @@ -0,0 +1,35 @@ +require "test_helper" + +class DockerTest < ActiveSupport::TestCase + test "included files runs the builder's check commands" do + Kamal::Docker.expects(:system).with do |*command| + command[0..4] == [ "container", "build", "--tag", "kamal-local-build-check", "--file" ] && + command[5].is_a?(String) && command[6] == "." + end.returns(true) + Open3.expects(:capture3) + .with("container", "run", "--rm", "kamal-local-build-check") + .returns([ "app.rb\nDockerfile\n", "", stub(success?: true) ]) + + assert_equal [ "app.rb", "Dockerfile" ], Kamal::Docker.included_files(builder: apple_container_builder) + end + + test "included files raises when the check image fails" do + Kamal::Docker.expects(:system).returns(true) + Open3.expects(:capture3) + .returns([ "", "check failed", stub(success?: false) ]) + + error = assert_raises(RuntimeError) do + Kamal::Docker.included_files(builder: apple_container_builder) + end + + assert_equal "failed to run check image:\ncheck failed", error.message + end + + private + def apple_container_builder + config = Kamal::Configuration.create_from \ + config_file: Pathname.new(File.expand_path("fixtures/deploy_with_apple_container.yml", __dir__)) + + Kamal::Commands::Builder.new(config) + end +end diff --git a/test/fixtures/deploy_with_apple_container.yml b/test/fixtures/deploy_with_apple_container.yml new file mode 100644 index 000000000..f2193e093 --- /dev/null +++ b/test/fixtures/deploy_with_apple_container.yml @@ -0,0 +1,12 @@ +service: app +image: dhh/app +servers: + - "1.1.1.1" + - "1.1.1.2" +registry: + username: user + password: pw +builder: + engine: apple-container + arch: amd64 + context: "." From f33e33589dd74800b271eb0f162f8a0bd4648fa8 Mon Sep 17 00:00:00 2001 From: m21ecom Date: Mon, 24 Aug 2026 10:04:11 +0200 Subject: [PATCH 2/3] Clear CONTAINER_DEFAULT_PLATFORM for apple-container pushes The push commands carry no --platform: they push whatever tags the build produced, so a multi-arch build sends a manifest list. `container` fills a missing --platform from CONTAINER_DEFAULT_PLATFORM, so a developer with that variable exported would push one architecture under a tag Kamal built for two, and the servers on the other architecture would fail to pull it. Export the variable empty for the build-and-push command, which 1.2.2 reads as unset. The build itself is unaffected either way, because the --platform flags it passes take precedence over the variable. Co-Authored-By: Claude Opus 5 --- lib/kamal/commands/builder/apple_container.rb | 10 ++++++---- test/commands/builder_test.rb | 12 +++++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/kamal/commands/builder/apple_container.rb b/lib/kamal/commands/builder/apple_container.rb index 50ab8ea27..9d81a6e61 100644 --- a/lib/kamal/commands/builder/apple_container.rb +++ b/lib/kamal/commands/builder/apple_container.rb @@ -58,11 +58,13 @@ def build_options [ *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh ] end + # The push commands carry no --platform, so `container` would take one from + # CONTAINER_DEFAULT_PLATFORM and narrow the push to it. Empty reads as unset. def push_env - if (socket = ssh_socket) - { "SSH_AUTH_SOCK" => socket } - else - {} + socket = ssh_socket + + { "CONTAINER_DEFAULT_PLATFORM" => "" }.tap do |env| + env["SSH_AUTH_SOCK"] = socket if socket end end diff --git a/test/commands/builder_test.rb b/test/commands/builder_test.rb index d8644a008..e4d3cf9c8 100644 --- a/test/commands/builder_test.rb +++ b/test/commands/builder_test.rb @@ -91,17 +91,23 @@ class CommandsBuilderTest < ActiveSupport::TestCase assert_equal \ "--label service=\"app\" --file Dockerfile --ssh default", builder.target.build_options.join(" ") - assert_equal({ "SSH_AUTH_SOCK" => "/tmp/custom-agent.sock" }, builder.push_env) + assert_equal({ "CONTAINER_DEFAULT_PLATFORM" => "", "SSH_AUTH_SOCK" => "/tmp/custom-agent.sock" }, builder.push_env) literal_builder = new_builder_command(builder: { "engine" => "apple-container", "arch" => "arm64", "ssh" => "default=/tmp/literal-agent.sock" }) - assert_equal({ "SSH_AUTH_SOCK" => "/tmp/literal-agent.sock" }, literal_builder.push_env) + assert_equal({ "CONTAINER_DEFAULT_PLATFORM" => "", "SSH_AUTH_SOCK" => "/tmp/literal-agent.sock" }, literal_builder.push_env) default_builder = new_builder_command(builder: { "engine" => "apple-container", "arch" => "arm64", "ssh" => "default" }) - assert_equal({}, default_builder.push_env) + assert_equal({ "CONTAINER_DEFAULT_PLATFORM" => "" }, default_builder.push_env) ensure ENV["SSH_AUTH_SOCK"] = original_ssh_auth_sock end + test "apple container engine keeps the configured arches authoritative on push" do + builder = new_builder_command(builder: { "engine" => "apple-container", "arch" => [ "amd64", "arm64" ] }) + + assert_equal({ "CONTAINER_DEFAULT_PLATFORM" => "" }, builder.push_env) + end + test "apple container engine lifecycle" do builder = new_builder_command(builder: { "engine" => "apple-container", "arch" => "arm64" }) From a1e5c309b716ba170348e9ec905e292c4a2492af Mon Sep 17 00:00:00 2001 From: m21ecom Date: Mon, 24 Aug 2026 10:04:12 +0200 Subject: [PATCH 3/3] Say what `--scheme auto` does to a plain-HTTP registry Naming the failure makes it clear why Kamal picks http for a localhost registry rather than leaving the scheme to `container`: 1.2.2 attempts TLS for localhost and 127.0.0.1 alike, and fails with "bad protocol version". Co-Authored-By: Claude Opus 5 --- lib/kamal/configuration/registry.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/kamal/configuration/registry.rb b/lib/kamal/configuration/registry.rb index 5a6f4184d..4c7047c44 100644 --- a/lib/kamal/configuration/registry.rb +++ b/lib/kamal/configuration/registry.rb @@ -19,8 +19,9 @@ def password lookup("password") end - # `auto` is the container CLI's own default, and it cannot reach a local - # registry: it attempts TLS and fails. Treat it as "let Kamal decide". + # `auto` is the container CLI's own default, and it cannot reach a plain-HTTP + # registry: 1.2.2 attempts TLS for localhost and 127.0.0.1 alike and fails with + # "bad protocol version". Treat it as "let Kamal decide". def scheme configured = registry_config["scheme"] configured = nil if configured == "auto"