Use local registry for app images

Allow applications to be deployed without needing to set up a repository
in a remote Docker registry.

If the registry server starts with `localhost`, Kamal will start a local
docker registry on that port and push the app image to it.

Then when pulling the image onto the servers, we use net-ssh to forward
the that port from the app server to the deployment server.

This will allow the deployment server to pull the image from the
registry as if it were local, meaning we don't need to set up a cert.
This commit is contained in:
Donal McBreen
2024-09-16 20:41:54 +01:00
parent e71bfcbadd
commit 6aa707e233
17 changed files with 192 additions and 48 deletions

View File

@@ -275,7 +275,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
def prepare(name)
with_accessory(name) do |accessory, hosts|
on(hosts) do
execute *KAMAL.registry.login
execute *KAMAL.registry.login unless KAMAL.config.registry.local?
execute *KAMAL.docker.create_network
rescue SSHKit::Command::Failed => e
raise unless e.message.include?("already exists")

View File

@@ -1,4 +1,5 @@
require "uri"
require "net/ssh"
class Kamal::Cli::Build < Kamal::Cli::Base
class BuildError < StandardError; end
@@ -60,6 +61,8 @@ class Kamal::Cli::Build < Kamal::Cli::Base
desc "pull", "Pull app image from registry onto servers"
def pull
tunnels = Kamal::Cli::Tunnel::RemotePorts.new(KAMAL.hosts, KAMAL.config.registry.local_port).tap(&:open) if KAMAL.config.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
@@ -69,6 +72,8 @@ class Kamal::Cli::Build < Kamal::Cli::Base
else
pull_on_hosts(KAMAL.hosts)
end
ensure
tunnels&.close
end
desc "create", "Create a build setup"
@@ -152,7 +157,7 @@ class Kamal::Cli::Build < Kamal::Cli::Base
end
def pull_on_hosts(hosts)
on(hosts) do
on(hosts) do |host|
execute *KAMAL.auditor.record("Pulled image with version #{KAMAL.config.version}"), verbosity: :debug
execute *KAMAL.builder.clean, raise_on_non_zero_exit: false
execute *KAMAL.builder.pull

View File

@@ -22,7 +22,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
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
@@ -184,7 +184,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

@@ -9,7 +9,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
end
on(KAMAL.proxy_hosts) do |host|
execute *KAMAL.registry.login
execute *KAMAL.registry.login unless KAMAL.config.registry.local?
version = capture_with_info(*KAMAL.proxy.version).strip.presence
@@ -33,7 +33,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
run_hook "pre-proxy-reboot", hosts: host_list
on(hosts) do |host|
execute *KAMAL.auditor.record("Rebooted proxy"), verbosity: :debug
execute *KAMAL.registry.login
execute *KAMAL.registry.login unless KAMAL.config.registry.local?
"Stopping and removing Traefik on #{host}, if running..."
execute *KAMAL.proxy.cleanup_traefik
@@ -76,7 +76,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
run_hook "pre-proxy-reboot", hosts: host_list
on(hosts) do |host|
execute *KAMAL.auditor.record("Rebooted proxy"), verbosity: :debug
execute *KAMAL.registry.login
execute *KAMAL.registry.login unless KAMAL.config.registry.local?
"Stopping and removing Traefik on #{host}, if running..."
execute *KAMAL.proxy.cleanup_traefik

View File

@@ -1,17 +1,25 @@
class Kamal::Cli::Registry < Kamal::Cli::Base
desc "login", "Log in to registry locally and remotely"
desc "login", "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
run_locally { execute *KAMAL.registry.login } unless options[:skip_local]
on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote]
def setup
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

@@ -0,0 +1,66 @@
Signal.trap "SIGPROF" do
Thread.list.each do |thread|
puts thread.name
puts thread.backtrace.map { |bt| " #{bt}" }
puts
end
end
require "concurrent/map"
class Kamal::Cli::Tunnel::RemotePorts
attr_reader :hosts, :port
def initialize(hosts, port)
@hosts = hosts
@port = port
@open = false
end
def open
@open = true
@opened = Concurrent::Map.new
@threads = hosts.map do |host|
Thread.new do
Net::SSH.start(host, KAMAL.config.ssh.user) do |ssh|
forwarding = nil
ssh.forward.remote(port, "localhost", port, "localhost") do |actual_remote_port|
forwarding = !!actual_remote_port
:no_exception # will yield the exception on my own thread
end
ssh.loop { forwarding.nil? }
if forwarding
@opened[host] = true
ssh.loop(0.1) { @open }
ssh.forward.cancel_remote(port, "localhost")
ssh.loop(0.1) { ssh.forward.active_remotes.include?([ port, "localhost" ]) }
else
@opened[host] = false
end
end
rescue => e
@opened[host] = false
puts e.message
puts e.backtrace
end
end
loop do
break if @opened.size == hosts.size
sleep 0.1
end
raise "Could not open tunnels on #{opened.reject { |k, v| v }.join(", ")}" unless @opened.values.all?
end
def close
p "Closing"
@open = false
p "Joining"
@threads.each(&:join)
p "Joined"
end
end

View File

@@ -1,5 +1,6 @@
class Kamal::Commands::Registry < Kamal::Commands::Base
delegate :registry, to: :config
delegate :local?, :local_port, to: :registry
def login
docker :login,
@@ -11,4 +12,26 @@ class Kamal::Commands::Registry < Kamal::Commands::Base
def logout
docker :logout, registry.server
end
def setup
combine \
docker(:start, "kamal-docker-registry"),
docker(:run, "--detach", "-p", "#{local_port}:5000", "--name", "kamal-docker-registry", "registry:2"),
by: "||"
end
def remove
combine \
docker(:stop, "kamal-docker-registry"),
docker(:rm, "kamal-docker-registry"),
by: "&&"
end
def logout
docker :logout, registry.server
end
def tunnel(host)
run_over_ssh "-R", "#{local_port}:localhost:#{local_port}", host: host
end
end

View File

@@ -21,6 +21,14 @@ class Kamal::Configuration::Registry
lookup("password")
end
def local?
server&.match?("^localhost[:$]")
end
def local_port
local? ? (server.split(":").last.to_i || 80) : nil
end
private
def lookup(key)
if registry_config[key].is_a?(Array)

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