diff --git a/lib/kamal/cli/app.rb b/lib/kamal/cli/app.rb index 9c34e8dd..4cf16de5 100644 --- a/lib/kamal/cli/app.rb +++ b/lib/kamal/cli/app.rb @@ -20,6 +20,9 @@ class Kamal::Cli::App < Kamal::Cli::Base auditor = KAMAL.auditor(role: role) role_config = KAMAL.config.role(role) + execute *app.extract_assets if role_config.assets? + + if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present? tmp_version = "#{version}_replaced_#{SecureRandom.hex(8)}" info "Renaming container #{version} to #{tmp_version} as already deployed on #{host}" @@ -27,13 +30,14 @@ class Kamal::Cli::App < Kamal::Cli::Base execute *app.rename_container(version: version, new_version: tmp_version) end - execute *auditor.record("Booted app version #{version}"), verbosity: :debug - old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip + original_old_version = old_version.gsub(/_replaced_[a-f0-9]{16}$/, "") - if role_config.uses_cord? - execute *app.tie_cord(role_config.cord_host_file) - end + execute *app.sync_asset_volumes(old_version: original_old_version) if role_config.assets? + + execute *app.tie_cord(role_config.cord_host_file) if role_config.uses_cord? + + execute *auditor.record("Booted app version #{version}"), verbosity: :debug execute *app.run(hostname: "#{host}-#{SecureRandom.hex(6)}") @@ -47,7 +51,10 @@ class Kamal::Cli::App < Kamal::Cli::Base Kamal::Utils::HealthcheckPoller.wait_for_unhealthy(pause_after_ready: true) { capture_with_info(*app.status(version: old_version)) } end end + execute *app.stop(version: old_version), raise_on_non_zero_exit: false + + execute *app.cleanup_assets if role_config.assets? end end end diff --git a/lib/kamal/commands/app.rb b/lib/kamal/commands/app.rb index 7fac2e17..5ce31ed1 100644 --- a/lib/kamal/commands/app.rb +++ b/lib/kamal/commands/app.rb @@ -20,6 +20,7 @@ class Kamal::Commands::App < Kamal::Commands::Base *role_config.health_check_args, *config.logging_args, *config.volume_args, + *role_config.asset_volume_args, *role_config.label_args, *role_config.option_args, config.absolute_image, @@ -105,7 +106,7 @@ class Kamal::Commands::App < Kamal::Commands::Base def list_versions(*docker_args, statuses: nil) pipe \ docker(:ps, *filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'), - %(while read line; do echo ${line##{role_config.full_name}-}; done) # Extract SHA from "service-role-dest-SHA" + %(while read line; do echo ${line##{role_config.container_prefix}-}; done) # Extract SHA from "service-role-dest-SHA" end def list_containers @@ -153,7 +154,7 @@ class Kamal::Commands::App < Kamal::Commands::Base def cord(version:) pipe \ docker(:inspect, "-f '{{ range .Mounts }}{{ .Source }} {{ .Destination }} {{ end }}'", container_name(version)), - [:awk, "'$2 == \"#{role_config.cord_container_directory}\" {print $1}'"] + [:awk, "'$2 == \"#{role_config.cord_volume.container_path}\" {print $1}'"] end def tie_cord(cord) @@ -164,9 +165,43 @@ class Kamal::Commands::App < Kamal::Commands::Base remove_directory(cord) end + def extract_assets + asset_container = "#{role_config.container_prefix}-assets" + + combine \ + make_directory(role_config.asset_extracted_path), + [*docker(:stop, "-t 1", asset_container, "2> /dev/null"), "|| true"], + docker(:run, "--name", asset_container, "--detach", "--rm", config.latest_image, "sleep infinity"), + docker(:cp, "-L", "#{asset_container}:#{role_config.asset_path}/.", role_config.asset_extracted_path), + docker(:stop, "-t 1", asset_container), + by: "&&" + end + + def sync_asset_volumes(old_version: nil) + new_extracted_path, new_volume_path = role_config.asset_extracted_path(config.version), role_config.asset_volume.host_path + if old_version.present? + old_extracted_path, old_volume_path = role_config.asset_extracted_path(old_version), role_config.asset_volume(old_version).host_path + end + + commands = [make_directory(new_volume_path), copy_contents(new_extracted_path, new_volume_path)] + + if old_version.present? + commands << copy_contents(new_extracted_path, old_volume_path) + commands << copy_contents(old_extracted_path, new_volume_path) + end + + chain *commands + end + + def cleanup_assets + chain \ + find_and_remove_older_siblings(role_config.asset_extracted_path), + find_and_remove_older_siblings(role_config.asset_volume_path) + end + private def container_name(version = nil) - [ role_config.full_name, version || config.version ].compact.join("-") + [ role_config.container_prefix, version || config.version ].compact.join("-") end def filter_args(statuses: nil) @@ -186,4 +221,19 @@ class Kamal::Commands::App < Kamal::Commands::Base end end end + + def find_and_remove_older_siblings(path) + [ + :find, + Pathname.new(path).dirname, + "-maxdepth 1", + "-name", "'#{role_config.container_prefix}-*'", + "!", "-name", Pathname.new(path).basename, + "-exec rm -rf \"{}\" +" + ] + end + + def copy_contents(source, destination) + [ :cp, "-rn", "#{source}/*", destination ] + end end diff --git a/lib/kamal/configuration.rb b/lib/kamal/configuration.rb index af6e46c5..9c8cc4af 100644 --- a/lib/kamal/configuration.rb +++ b/lib/kamal/configuration.rb @@ -211,6 +211,10 @@ class Kamal::Configuration @run_id ||= SecureRandom.hex(16) end + def asset_path + raw_config.asset_path + end + private # Will raise ArgumentError if any required config keys are missing def ensure_required_keys_present diff --git a/lib/kamal/configuration/role.rb b/lib/kamal/configuration/role.rb index 6c57bc1d..d135acec 100644 --- a/lib/kamal/configuration/role.rb +++ b/lib/kamal/configuration/role.rb @@ -48,11 +48,15 @@ class Kamal::Configuration::Role argumentize "--env-file", host_env_file_path end + def asset_volume_args + asset_volume&.docker_args + end + def health_check_args(cord: true) if health_check_cmd.present? if cord && uses_cord? optionize({ "health-cmd" => health_check_cmd_with_cord, "health-interval" => health_check_interval }) - .concat(["--volume", "#{cord_host_directory}:#{cord_container_directory}"]) + .concat(cord_volume.docker_args) else optionize({ "health-cmd" => health_check_cmd, "health-interval" => health_check_interval }) end @@ -74,15 +78,23 @@ class Kamal::Configuration::Role end def uses_cord? - running_traefik? && cord_container_directory.present? && health_check_cmd.present? + running_traefik? && cord_volume && health_check_cmd.present? end def cord_host_directory - File.join config.run_directory_as_docker_volume, "cords", [full_name, config.run_id].join("-") + File.join config.run_directory_as_docker_volume, "cords", [container_prefix, config.run_id].join("-") + end + + def cord_volume + if (cord = health_check_options["cord"]) + @cord_volume ||= Kamal::Configuration::Volume.new \ + host_path: File.join(config.run_directory, "cords", [container_prefix, config.run_id].join("-")), + container_path: cord + end end def cord_host_file - File.join cord_host_directory, CORD_FILE + File.join cord_volume.host_path, CORD_FILE end def cord_container_directory @@ -90,7 +102,7 @@ class Kamal::Configuration::Role end def cord_container_file - File.join cord_container_directory, CORD_FILE + File.join cord_volume.container_path, CORD_FILE end @@ -110,10 +122,37 @@ class Kamal::Configuration::Role name.web? || specializations["traefik"] end - def full_name + def container_name(version = nil) + [ container_prefix, version || config.version ].compact.join("-") + end + + def container_prefix [ config.service, name, config.destination ].compact.join("-") end + def asset_path + specializations["asset_path"] || config.asset_path + end + + def assets? + asset_path.present? && running_traefik? + end + + def asset_volume(version = nil) + if assets? + Kamal::Configuration::Volume.new \ + host_path: asset_volume_path(version), container_path: asset_path + end + end + + def asset_extracted_path(version = nil) + File.join config.run_directory, "assets", "extracted", container_name(version) + end + + def asset_volume_path(version = nil) + File.join config.run_directory, "assets", "volumes", container_name(version) + end + private attr_accessor :config diff --git a/lib/kamal/configuration/volume.rb b/lib/kamal/configuration/volume.rb new file mode 100644 index 00000000..98d9398d --- /dev/null +++ b/lib/kamal/configuration/volume.rb @@ -0,0 +1,22 @@ +class Kamal::Configuration::Volume + attr_reader :host_path, :container_path + delegate :argumentize, to: Kamal::Utils + + def initialize(host_path:, container_path:) + @host_path = host_path + @container_path = container_path + end + + def docker_args + argumentize "--volume", "#{host_path_for_docker_volume}:#{container_path}" + end + + private + def host_path_for_docker_volume + if Pathname.new(host_path).absolute? + host_path + else + File.join "$(pwd)", host_path + end + end +end diff --git a/test/configuration/role_test.rb b/test/configuration/role_test.rb index 65003979..e1e824e0 100644 --- a/test/configuration/role_test.rb +++ b/test/configuration/role_test.rb @@ -180,16 +180,15 @@ class ConfigurationRoleTest < ActiveSupport::TestCase assert !@config_with_roles.role(:workers).uses_cord? end - test "cord host directory" do - assert_match %r{\$\(pwd\)/.kamal/cords/app-web-[0-9a-f]{32}}, @config_with_roles.role(:web).cord_host_directory - end - test "cord host file" do - assert_match %r{\$\(pwd\)/.kamal/cords/app-web-[0-9a-f]{32}/cord}, @config_with_roles.role(:web).cord_host_file + assert_match %r{.kamal/cords/app-web-[0-9a-f]{32}/cord}, @config_with_roles.role(:web).cord_host_file end - test "cord container directory" do - assert_equal "/tmp/kamal-cord", @config_with_roles.role(:web).cord_container_directory + test "cord volume" do + assert_equal "/tmp/kamal-cord", @config_with_roles.role(:web).cord_volume.container_path + assert_match %r{.kamal/cords/app-web-[0-9a-f]{32}}, @config_with_roles.role(:web).cord_volume.host_path + assert_equal "--volume", @config_with_roles.role(:web).cord_volume.docker_args[0] + assert_match %r{\$\(pwd\)/.kamal/cords/app-web-[0-9a-f]{32}:/tmp/kamal-cord}, @config_with_roles.role(:web).cord_volume.docker_args[1] end test "cord container file" do diff --git a/test/integration/docker/deployer/app/Dockerfile b/test/integration/docker/deployer/app/Dockerfile index cbe60ae0..2310ee96 100644 --- a/test/integration/docker/deployer/app/Dockerfile +++ b/test/integration/docker/deployer/app/Dockerfile @@ -4,4 +4,5 @@ COPY default.conf /etc/nginx/conf.d/default.conf ARG COMMIT_SHA RUN echo $COMMIT_SHA > /usr/share/nginx/html/version +RUN mkdir -p /usr/share/nginx/html/versions && echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA diff --git a/test/integration/docker/deployer/app/config/deploy.yml b/test/integration/docker/deployer/app/config/deploy.yml index fe4a26ff..ec070f6c 100644 --- a/test/integration/docker/deployer/app/config/deploy.yml +++ b/test/integration/docker/deployer/app/config/deploy.yml @@ -8,6 +8,7 @@ env: CLEAR_TOKEN: '4321' secret: - SECRET_TOKEN +asset_path: /usr/share/nginx/html/versions registry: server: registry:4443 diff --git a/test/integration/main_test.rb b/test/integration/main_test.rb index e10d9e52..3e0f39e5 100644 --- a/test/integration/main_test.rb +++ b/test/integration/main_test.rb @@ -20,6 +20,8 @@ class MainTest < IntegrationTest assert_app_is_up version: second_version assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy" + assert_accumulated_assets first_version, second_version + kamal :rollback, first_version assert_hooks_ran "pre-connect", "pre-deploy", "post-deploy" assert_app_is_up version: first_version @@ -69,4 +71,10 @@ class MainTest < IntegrationTest def assert_no_remote_env_file assert_equal "nofile", docker_compose("exec vm1 stat /root/.kamal/env/roles/app-web.env 2> /dev/null || echo nofile", capture: true) end + + def assert_accumulated_assets(*versions) + versions.each do |version| + assert_equal "200", Net::HTTP.get_response(URI.parse("http://localhost:12345/versions/#{version}")).code + end + end end