Use local docker registry to push and pull app images

This commit is contained in:
T. R. Bernstein
2025-08-29 16:55:50 +02:00
parent 18f1bbbeac
commit 95cbc62ef1
24 changed files with 352 additions and 64 deletions

View File

@@ -153,6 +153,48 @@ class CliBuildTest < CliTestCase
end
end
test "push without builder for local registry" do
with_build_directory do |build_directory|
stub_setup
SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, "--version", "&&", :docker, :buildx, "version")
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args[0..1] == [ :docker, :login ] }
SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :start, "kamal-docker-registry", "||", :docker, :run, "--detach", "-p", "127.0.0.1:5000:5000", "--name", "kamal-docker-registry", "registry:3")
SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :rm, "kamal-local-registry-docker-container")
SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :create, "--name", "kamal-local-registry-docker-container", "--driver=docker-container --driver-opt network=host")
SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :inspect, "kamal-local-registry-docker-container")
.raises(SSHKit::Command::Failed.new("no builder"))
SSHKit::Backend::Abstract.any_instance.expects(:execute).with { |*args| args.first.to_s.start_with?("git") }
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD)
.returns(Kamal::Git.revision)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :status, "--porcelain")
.returns("")
SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :build, "--output=type=registry", "--platform", "linux/amd64", "--builder", "kamal-local-registry-docker-container", "-t", "localhost:5000/dhh/app:999", "-t", "localhost:5000/dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".", "2>&1")
run_command("push", fixture: :with_local_registry_and_accessories).tap do |output|
assert_match /WARN Missing compatible builder, so creating a new one first/, output
end
end
end
test "push without builder" do
with_build_directory do |build_directory|
stub_setup

View File

@@ -39,6 +39,30 @@ class CliMainTest < CliTestCase
end
end
test "deploy with local registry" do
with_test_secrets("secrets" => "DB_PASSWORD=secret") do
invoke_options = { "config_file" => "test/fixtures/deploy_with_local_registry.yml", "version" => "999", "skip_hooks" => false, "verbose" => true }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options)
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
run_command("deploy", "--verbose", config_file: "deploy_with_local_registry").tap do |output|
assert_hook_ran "pre-connect", output
assert_match /Build and push app image/, output
assert_hook_ran "pre-deploy", output
assert_match /Ensure kamal-proxy is running/, output
assert_match /Detect stale containers/, output
assert_match /Prune old containers and images/, output
assert_hook_ran "post-deploy", output
end
end
end
test "deploy" do
with_test_secrets("secrets" => "DB_PASSWORD=secret") do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, "verbose" => true }
@@ -302,6 +326,16 @@ class CliMainTest < CliTestCase
end
end
test "remove" do
options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_hooks" => false, "confirmed" => true }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:remove", [], options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:remove", [], options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:remove", [ "all" ], options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:remove", [], options.merge(skip_local: true))
run_command("remove", "-y")
end
test "details" do
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:details")
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:details")

View File

@@ -1,55 +1,55 @@
require_relative "cli_test_case"
class CliRegistryTest < CliTestCase
test "login" do
run_command("login").tap do |output|
test "setup" do
run_command("setup").tap do |output|
assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output
assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output
end
end
test "login skip local" do
run_command("login", "-L").tap do |output|
test "setup skip local" do
run_command("setup", "-L").tap do |output|
assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output
assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output
end
end
test "login skip remote" do
run_command("login", "-R").tap do |output|
test "setup skip remote" do
run_command("setup", "-R").tap do |output|
assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output
assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output
end
end
test "logout" do
run_command("logout").tap do |output|
test "remove" do
run_command("remove").tap do |output|
assert_match /docker logout as .*@localhost/, output
assert_match /docker logout on 1.1.1.\d/, output
end
end
test "logout skip local" do
run_command("logout", "-L").tap do |output|
test "remove skip local" do
run_command("remove", "-L").tap do |output|
assert_no_match /docker logout as .*@localhost/, output
assert_match /docker logout on 1.1.1.\d/, output
end
end
test "logout skip remote" do
run_command("logout", "-R").tap do |output|
test "remove skip remote" do
run_command("remove", "-R").tap do |output|
assert_match /docker logout as .*@localhost/, output
assert_no_match /docker logout on 1.1.1.\d/, output
end
end
test "login with no docker" do
test "setup with no docker" do
stub_setup
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, "--version", "&&", :docker, :buildx, "version")
.raises(SSHKit::Command::Failed.new("command not found"))
assert_raises(Kamal::Cli::DependencyError) { run_command("login") }
assert_raises(Kamal::Cli::DependencyError) { run_command("setup") }
end
test "allow remote login with no docker" do
@@ -61,12 +61,23 @@ class CliRegistryTest < CliTestCase
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args[0..1] == [ :docker, :login ] }
assert_nothing_raised { run_command("login", "--skip-local") }
assert_nothing_raised { run_command("setup", "--skip-local") }
end
test "setup local registry" do
run_command("setup", fixture: :with_local_registry).tap do |output|
assert_match /docker start kamal-docker-registry || docker run --detach -p 127.0.0.1:5000:5000 --name kamal-docker-registry registry:2 as .*@localhost/, output
end
end
test "remove local registry" do
run_command("remove", fixture: :with_local_registry).tap do |output|
assert_match /docker stop kamal-docker-registry && docker rm kamal-docker-registry as .*@localhost/, output
end
end
private
def run_command(*command)
stdouted { Kamal::Cli::Registry.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) }
def run_command(*command, fixture: :with_accessories)
stdouted { Kamal::Cli::Registry.start([ *command, "-c", "test/fixtures/deploy_#{fixture}.yml" ]) }
end
end

View File

@@ -228,7 +228,11 @@ class CommandsBuilderTest < ActiveSupport::TestCase
private
def new_builder_command(additional_config = {})
Kamal::Commands::Builder.new(Kamal::Configuration.new(@config.deep_merge(additional_config), version: "123"))
Kamal::Configuration.new(@config.deep_merge(additional_config), version: "123").then do |config|
KAMAL.reset
KAMAL.stubs(:config).returns(config)
Kamal::Commands::Builder.new(config)
end
end
def local_arch

View File

@@ -85,6 +85,15 @@ class CommandsRegistryTest < ActiveSupport::TestCase
registry.logout(registry_config: accessory_registry_config).join(" ")
end
test "registry setup" do
@config[:registry] = { "server" => "localhost:5000" }
assert_equal "docker start kamal-docker-registry || docker run --detach -p 127.0.0.1:5000:5000 --name kamal-docker-registry registry:3", registry.setup.join(" ")
end
test "registry remove" do
assert_equal "docker stop kamal-docker-registry && docker rm kamal-docker-registry", registry.remove.join(" ")
end
private
def registry
Kamal::Commands::Registry.new main_config

View File

@@ -43,6 +43,19 @@ class ConfigurationTest < ActiveSupport::TestCase
end
end
test "image uses service name if registry is local" do
assert_equal "app", Kamal::Configuration.new(@deploy.tap {
_1[:registry] = { "server" => "localhost:5000" }
_1.delete(:image)
}).image
end
test "image uses image if registry is local" do
assert_equal "dhh/app", Kamal::Configuration.new(@deploy.tap {
_1[:registry] = { "server" => "localhost:5000" }
}).image
end
test "service name valid" do
assert_nothing_raised do
Kamal::Configuration.new(@deploy.tap { |config| config[:service] = "hey-app1_primary" })

View File

@@ -0,0 +1,10 @@
service: app
image: dhh/app
servers:
web:
- '1.1.1.1'
- '1.1.1.2'
registry:
server: localhost:5000
builder:
arch: amd64

View File

@@ -0,0 +1,45 @@
service: app
image: dhh/app
servers:
web:
- '1.1.1.1'
- '1.1.1.2'
workers:
- '1.1.1.3'
- '1.1.1.4'
registry:
server: localhost:5000
builder:
arch: amd64
accessories:
mysql:
image: mysql:5.7
host: 1.1.1.3
port: 3306
env:
clear:
MYSQL_ROOT_HOST: '%'
secret:
- MYSQL_ROOT_PASSWORD
files:
- test/fixtures/files/my.cnf:/etc/mysql/my.cnf
directories:
- data:/var/lib/mysql
redis:
image: redis:latest
roles:
- web
port: 6379
directories:
- data:/data
busybox:
service: custom-box
image: busybox:latest
host: 1.1.1.3
registry:
server: other.registry
username: other_user
password: other_pw
readiness_delay: 0

View File

@@ -29,14 +29,14 @@ class AppTest < IntegrationTest
images = kamal :app, :images, capture: true
assert_match "App Host: vm1", images
assert_match "App Host: vm2", images
assert_match /registry:4443\/app\s+#{latest_app_version}/, images
assert_match /registry:4443\/app\s+latest/, images
assert_match /localhost:5000\/app\s+#{latest_app_version}/, images
assert_match /localhost:5000\/app\s+latest/, images
containers = kamal :app, :containers, capture: true
assert_match "App Host: vm1", containers
assert_match "App Host: vm2", containers
assert_match "registry:4443/app:#{latest_app_version}", containers
assert_match "registry:4443/app:latest", containers
assert_match "localhost:5000/app:#{latest_app_version}", containers
assert_match "localhost:5000/app:latest", containers
exec_output = kamal :app, :exec, :ps, capture: true
assert_match "App Host: vm1", exec_output

View File

@@ -2,12 +2,12 @@ service: app
image: app
servers:
- vm1
- vm2: [ tag1, tag2 ]
- vm2: [tag1, tag2]
env:
clear:
CLEAR_TOKEN: 4321
CLEAR_TAG: ""
HOST_TOKEN: "${HOST_TOKEN}"
CLEAR_TAG: ''
HOST_TOKEN: '${HOST_TOKEN}'
secret:
- SECRET_TOKEN
- INTERPOLATED_SECRET1
@@ -26,9 +26,7 @@ readiness_delay: 0
proxy:
host: 127.0.0.1
registry:
server: registry:4443
username: root
password: root
server: localhost:5000
builder:
driver: docker
arch: <%= Kamal::Utils.docker_arch %>

View File

@@ -131,7 +131,9 @@ class IntegrationTest < ActiveSupport::TestCase
def wait_for_healthy(timeout: 30)
timeout_at = Time.now + timeout
while docker_compose("ps -a | tail -n +2 | grep -v '(healthy)' | wc -l", capture: true) != "0"
loop do
result = docker_compose("ps -a | tail -n +2 | grep -v '(healthy)' | wc -l", capture: true)
break if result.split.last == "0" || result == "0"
if timeout_at < Time.now
docker_compose("ps -a | tail -n +2 | grep -v '(healthy)'")
raise "Container not healthy after #{timeout} seconds" if timeout_at < Time.now

View File

@@ -33,7 +33,7 @@ class MainTest < IntegrationTest
assert_match /App Host: vm1/, details
assert_match /App Host: vm2/, details
assert_match /basecamp\/kamal-proxy:#{Kamal::Configuration::Proxy::Boot::MINIMUM_VERSION}/, details
assert_match /registry:4443\/app:#{first_version}/, details
assert_match /localhost:5000\/app:#{first_version}/, details
audit = kamal :audit, capture: true
assert_match /Booted app version #{first_version}.*Booted app version #{second_version}.*Booted app version #{first_version}.*/m, audit
@@ -67,8 +67,8 @@ class MainTest < IntegrationTest
assert_equal [ "vm1", "vm2", "vm3" ], config[:hosts]
assert_equal "vm1", config[:primary_host]
assert_equal version, config[:version]
assert_equal "registry:4443/app", config[:repository]
assert_equal "registry:4443/app:#{version}", config[:absolute_image]
assert_equal "localhost:5000/app", config[:repository]
assert_equal "localhost:5000/app:#{version}", config[:absolute_image]
assert_equal "app-#{version}", config[:service_with_version]
assert_equal [], config[:volume_args]
assert_equal({ user: "root", port: 22, keepalive: true, keepalive_interval: 30, log_level: :fatal }, config[:ssh_options])