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

@@ -67,16 +67,18 @@ class Kamal::Cli::Build < Kamal::Cli::Base
desc "pull", "Pull app image from registry onto servers"
def pull
login_to_registry_remotely
login_to_registry_remotely unless KAMAL.registry.local?
if (first_hosts = mirror_hosts).any?
#  Pull on a single host per mirror first to seed them
say "Pulling image on #{first_hosts.join(", ")} to seed the #{"mirror".pluralize(first_hosts.count)}...", :magenta
pull_on_hosts(first_hosts)
say "Pulling image on remaining hosts...", :magenta
pull_on_hosts(KAMAL.app_hosts - first_hosts)
else
pull_on_hosts(KAMAL.app_hosts)
forward_local_registry_port do
if (first_hosts = mirror_hosts).any?
# Pull on a single host per mirror first to seed them
say "Pulling image on #{first_hosts.join(", ")} to seed the #{"mirror".pluralize(first_hosts.count)}...", :magenta
pull_on_hosts(first_hosts)
say "Pulling image on remaining hosts...", :magenta
pull_on_hosts(KAMAL.app_hosts - first_hosts)
else
pull_on_hosts(KAMAL.app_hosts)
end
end
end
@@ -192,7 +194,11 @@ class Kamal::Cli::Build < Kamal::Cli::Base
def login_to_registry_locally
run_locally do
execute *KAMAL.registry.login
if KAMAL.registry.local?
execute *KAMAL.registry.setup
else
execute *KAMAL.registry.login
end
end
end
@@ -201,4 +207,14 @@ class Kamal::Cli::Build < Kamal::Cli::Base
execute *KAMAL.registry.login
end
end
def forward_local_registry_port(&block)
if KAMAL.config.registry.local?
Kamal::Cli::PortForwarding.
new(KAMAL.hosts, KAMAL.config.registry.local_port).
forward(&block)
else
yield
end
end
end

View File

@@ -182,7 +182,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
invoke "kamal:cli:app:remove", [], options.without(:confirmed)
invoke "kamal:cli:proxy:remove", [], options.without(:confirmed)
invoke "kamal:cli:accessory:remove", [ "all" ], options
invoke "kamal:cli:registry:logout", [], options.without(:confirmed).merge(skip_local: true)
invoke "kamal:cli:registry:remove", [], options.without(:confirmed).merge(skip_local: true)
end
end
end

View File

@@ -0,0 +1,42 @@
class Kamal::Cli::PortForwarding
attr_reader :hosts, :port
def initialize(hosts, port)
@hosts = hosts
@port = port
end
def forward
@done = false
forward_ports
yield
ensure
stop
end
private
def stop
@done = true
@threads.to_a.each(&:join)
end
def forward_ports
@threads = hosts.map do |host|
Thread.new do
Net::SSH.start(host, KAMAL.config.ssh.user) do |ssh|
ssh.forward.remote(port, "localhost", port, "localhost")
ssh.loop(0.1) do
if @done
ssh.forward.cancel_remote(port, "localhost")
break
else
true
end
end
end
end
end
end
end

View File

@@ -1,19 +1,27 @@
class Kamal::Cli::Registry < Kamal::Cli::Base
desc "login", "Log in to registry locally and remotely"
desc "setup", "Setup local registry or log in to remote registry locally and remotely"
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 login
def setup
ensure_docker_installed unless options[:skip_local]
run_locally { execute *KAMAL.registry.login } unless options[:skip_local]
on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote]
if KAMAL.registry.local?
run_locally { execute *KAMAL.registry.setup } unless options[:skip_local]
else
run_locally { execute *KAMAL.registry.login } unless options[:skip_local]
on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote]
end
end
desc "logout", "Log out of registry locally and remotely"
desc "remove", "Remove local registry or log out of remote registry locally and remotely"
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 logout
run_locally { execute *KAMAL.registry.logout } unless options[:skip_local]
on(KAMAL.hosts) { execute *KAMAL.registry.logout } unless options[:skip_remote]
def remove
if KAMAL.registry.local?
run_locally { execute *KAMAL.registry.remove, raise_on_non_zero_exit: false } unless options[:skip_local]
else
run_locally { execute *KAMAL.registry.logout } unless options[:skip_local]
on(KAMAL.hosts) { execute *KAMAL.registry.logout } unless options[:skip_remote]
end
end
end

View File

@@ -25,13 +25,14 @@ proxy:
# Credentials for your image host.
registry:
server: localhost:5555
# Specify the registry server, if you're not using Docker Hub
# server: registry.digitalocean.com / ghcr.io / ...
username: my-user
# username: my-user
# Always use an access token rather than real password (pulled from .kamal/secrets).
password:
- KAMAL_REGISTRY_PASSWORD
# password:
# - KAMAL_REGISTRY_PASSWORD
# Configure builder setup.
builder:
@@ -39,7 +40,6 @@ builder:
# Pass in additional build args needed for your Dockerfile.
# args:
# RUBY_VERSION: <%= ENV["RBENV_VERSION"] || ENV["rvm_ruby_string"] || "#{RUBY_ENGINE}-#{RUBY_ENGINE_VERSION}" %>
# Inject ENV variables into containers (secrets come from .kamal/secrets).
#
# env:

View File

@@ -3,7 +3,7 @@
# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git.
# Option 1: Read secrets from the environment
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
# KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
# Option 2: Read secrets via a command
# RAILS_MASTER_KEY=$(cat config/master.key)

View File

@@ -21,7 +21,7 @@ class Kamal::Commander
end
def config
@config ||= Kamal::Configuration.create_from(**@config_kwargs).tap do |config|
@config ||= Kamal::Configuration.create_from(**@config_kwargs.to_h).tap do |config|
@config_kwargs = nil
configure_sshkit_with(config)
end

View File

@@ -1,6 +1,15 @@
class Kamal::Commands::Builder::Local < Kamal::Commands::Builder::Base
def create
docker :buildx, :create, "--name", builder_name, "--driver=#{driver}" unless docker_driver?
return if docker_driver?
options =
if KAMAL.registry.local?
"--driver=#{driver} --driver-opt network=host"
else
"--driver=#{driver}"
end
docker :buildx, :create, "--name", builder_name, options
end
def remove
@@ -9,6 +18,10 @@ class Kamal::Commands::Builder::Local < Kamal::Commands::Builder::Base
private
def builder_name
"kamal-local-#{driver}"
if KAMAL.registry.local?
"kamal-local-registry-#{driver}"
else
"kamal-local-#{driver}"
end
end
end

View File

@@ -2,6 +2,8 @@ class Kamal::Commands::Registry < Kamal::Commands::Base
def login(registry_config: nil)
registry_config ||= config.registry
return if registry_config.local?
docker :login,
registry_config.server,
"-u", sensitive(Kamal::Utils.escape_shell_value(registry_config.username)),
@@ -13,4 +15,24 @@ class Kamal::Commands::Registry < Kamal::Commands::Base
docker :logout, registry_config.server
end
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"),
by: "||"
end
def remove
combine \
docker(:stop, "kamal-docker-registry"),
docker(:rm, "kamal-docker-registry"),
by: "&&"
end
def local?
config.registry.local?
end
end

View File

@@ -6,7 +6,7 @@ require "erb"
require "net/ssh/proxy/jump"
class Kamal::Configuration
delegate :service, :image, :labels, :hooks_path, to: :raw_config, allow_nil: true
delegate :service, :labels, :hooks_path, to: :raw_config, allow_nil: true
delegate :argumentize, :optionize, to: Kamal::Utils
attr_reader :destination, :raw_config, :secrets
@@ -157,6 +157,13 @@ class Kamal::Configuration
(proxy_roles.flat_map(&:hosts) + proxy_accessories.flat_map(&:hosts)).uniq
end
def image
name = raw_config&.image.presence
name ||= raw_config&.service if registry.local?
name
end
def repository
[ registry.server, image ].compact.join("/")
end
@@ -282,10 +289,12 @@ class Kamal::Configuration
end
def ensure_required_keys_present
%i[ service image registry ].each do |key|
%i[ service registry ].each do |key|
raise Kamal::ConfigurationError, "Missing required configuration for #{key}" unless raw_config[key].present?
end
raise Kamal::ConfigurationError, "Missing required configuration for image" if image.blank?
if raw_config.servers.nil?
raise Kamal::ConfigurationError, "No servers or accessories specified" unless raw_config.accessories.present?
else

View File

@@ -19,6 +19,14 @@ class Kamal::Configuration::Registry
lookup("password")
end
def local?
server.to_s.match?("^localhost[:$]")
end
def local_port
local? ? (server.split(":").last.to_i || 80) : nil
end
private
attr_reader :registry_config, :secrets

View File

@@ -15,10 +15,12 @@ class Kamal::Configuration::Validator::Registry < Kamal::Configuration::Validato
with_context(key) do
value = config[key]
error "is required" unless value.present?
unless config["server"]&.match?("^localhost[:$]")
error "is required" unless value.present?
unless value.is_a?(String) || (value.is_a?(Array) && value.size == 1 && value.first.is_a?(String))
error "should be a string or an array with one string (for secret lookup)"
unless value.is_a?(String) || (value.is_a?(Array) && value.size == 1 && value.first.is_a?(String))
error "should be a string or an array with one string (for secret lookup)"
end
end
end
end

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