Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use local docker registry to push and pull app images #1355

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
18 changes: 10 additions & 8 deletions lib/kamal/cli/build.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,16 @@ def push

desc "pull", "Pull app image from registry onto servers"
def pull
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.hosts - first_hosts)
else
pull_on_hosts(KAMAL.hosts)
Kamal::Cli::PortForwarding.new(KAMAL.hosts, KAMAL.config.registry.local_port).forward do
Copy link
Contributor

Choose a reason for hiding this comment

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

Thank you guys for working on this feature! 🙏🏼 I was thinking to try it myself 😄

Minor 2 cents suggestion:

  • This line read like forward ports always. While there is condition inside of class implementation. Even with local registry by default it kind of confusing in case of remote registry usage.
  • And the condition in the class if KAMAL.config.registry.local? is the only part that coupled to registry port. Extracting that condition will make class more general purpose PortForwarding to use for any other service port forwarding.

So how about, instead of this line here (and condition in the class), to do something like following with direct condition?

    def pull_on_hosts(hosts, forward_registry_port: KAMAL.config.registry.local?)
      if forward_registry_port
        return Kamal::Cli::PortForwarding.new(hosts, KAMAL.config.registry.local_port).forward do
          pull_on_hosts(hosts, forward_registry_port: false)
        end
      end

      ...
    end

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hey @ShPakvel, I like the suggestion around making the PortForwarding class usable elsewhere and not tying it to if the registry is local. Didnt really follow the code suggestion but made some edits to make the class usable elsewhere. Let me know what you think

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.hosts - first_hosts)
else
pull_on_hosts(KAMAL.hosts)
end
end
end

Expand Down
4 changes: 2 additions & 2 deletions lib/kamal/cli/main.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def deploy(boot_accessories: false)
invoke_options = deploy_options

say "Log into image registry...", :magenta
invoke "kamal:cli:registry:login", [], invoke_options.merge(skip_local: options[:skip_push])
invoke "kamal:cli:registry:setup", [], invoke_options.merge(skip_local: options[:skip_push])

if options[:skip_push]
say "Pull app image...", :magenta
Expand Down Expand Up @@ -185,7 +185,7 @@ def remove
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
Expand Down
44 changes: 44 additions & 0 deletions lib/kamal/cli/port_forwarding.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
class Kamal::Cli::PortForwarding
attr_reader :hosts, :port

def initialize(hosts, port)
@hosts = hosts
@port = port
end

def forward
if KAMAL.config.registry.local?
@done = false
forward_ports
end

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
24 changes: 16 additions & 8 deletions lib/kamal/cli/registry.rb
Original file line number Diff line number Diff line change
@@ -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

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
7 changes: 4 additions & 3 deletions lib/kamal/cli/templates/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion lib/kamal/cli/templates/secrets
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion lib/kamal/commander.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def initialize
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
Expand Down
17 changes: 15 additions & 2 deletions lib/kamal/commands/builder/local.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,6 +18,10 @@ def remove

private
def builder_name
"kamal-local-#{driver}"
if KAMAL.registry.local?
"kamal-local-registry-#{driver}"
else
"kamal-local-#{driver}"
end
end
end
22 changes: 22 additions & 0 deletions lib/kamal/commands/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -13,4 +15,24 @@ def logout(registry_config: nil)

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.0.0-rc.2"),
by: "||"
end

def remove
combine \
docker(:stop, "kamal-docker-registry"),
docker(:rm, "kamal-docker-registry"),
by: "&&"
end

def local?
config.registry.local?
end
end
8 changes: 8 additions & 0 deletions lib/kamal/configuration/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ def password
lookup("password")
end

def local?
server&.match?("^localhost[:$]")
end

def local_port
local? ? (server.split(":").last.to_i || 80) : nil
end

private
attr_reader :registry_config, :secrets

Expand Down
8 changes: 5 additions & 3 deletions lib/kamal/configuration/validator/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ def validate_string_or_one_item_array!(key)
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
Expand Down
36 changes: 36 additions & 0 deletions test/cli/build_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,42 @@ 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.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.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, "--push", "--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", ".")

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
Expand Down
49 changes: 43 additions & 6 deletions test/cli/main_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class CliMainTest < CliTestCase
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options)
# deploy
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: true))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:setup", [], invoke_options.merge(skip_local: true))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:pull", [], 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))
Expand All @@ -41,11 +41,38 @@ 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:registry:setup", [], invoke_options.merge(skip_local: false))
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)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "deploy" }

run_command("deploy", "--verbose", config_file: "deploy_with_local_registry").tap do |output|
assert_hook_ran "pre-connect", output, **hook_variables
assert_match /Log into image registry/, output
assert_match /Build and push app image/, output
assert_hook_ran "pre-deploy", output, **hook_variables, secrets: true
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, **hook_variables, runtime: true, secrets: true
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 }

Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: false))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:setup", [], invoke_options.merge(skip_local: false))
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))
Expand All @@ -71,7 +98,7 @@ class CliMainTest < CliTestCase
test "deploy with skip_push" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }

Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: true))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:setup", [], invoke_options.merge(skip_local: true))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:pull", [], 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))
Expand Down Expand Up @@ -158,7 +185,7 @@ class CliMainTest < CliTestCase
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, :skip_local => false }

Kamal::Cli::Main.any_instance.expects(:invoke)
.with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: false))
.with("kamal:cli:registry:setup", [], invoke_options.merge(skip_local: false))
.raises(RuntimeError)

assert_not KAMAL.holding_lock?
Expand All @@ -171,7 +198,7 @@ class CliMainTest < CliTestCase
test "deploy with skipped hooks" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => true }

Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: false))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:setup", [], invoke_options.merge(skip_local: false))
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))
Expand All @@ -186,7 +213,7 @@ class CliMainTest < CliTestCase
test "deploy with missing secrets" do
invoke_options = { "config_file" => "test/fixtures/deploy_with_secrets.yml", "version" => "999", "skip_hooks" => false }

Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:login", [], invoke_options.merge(skip_local: false))
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:setup", [], invoke_options.merge(skip_local: false))
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))
Expand Down Expand Up @@ -288,6 +315,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")
Expand Down
Loading
Loading