Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/dash/cli/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ def boot

# Tag once the app booted on all hosts
on(DASH.app_hosts) do |host|
execute *DASH.auditor.record("Tagging #{DASH.config.absolute_image} as the latest image"), verbosity: :debug
execute *DASH.app.tag_latest_image
execute *DASH.auditor.record_then("Tagging #{DASH.config.absolute_image} as the latest image",
DASH.app.tag_latest_image)
end
end
end
Expand Down
30 changes: 20 additions & 10 deletions lib/dash/cli/app/boot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,22 +38,36 @@ def run
end

private
# Both answers come back from one round trip, which means the running version is read
# before any rename happens. When the clashing container IS the running one, the
# version to stop later is the name it was renamed to - the name that was read now
# belongs to the container this boot is about to start.
def old_version_renamed_if_clashing
if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present?
clashing_container_id, old_version = capture_boot_state

if clashing_container_id.present?
renamed_version = "#{version}_replaced_#{SecureRandom.hex(8)}"
info "Renaming container #{version} to #{renamed_version} as already deployed on #{host}"
audit("Renaming container #{version} to #{renamed_version}")
execute *app.rename_container(version: version, new_version: renamed_version)
execute *auditor.record_then("Renaming container #{version} to #{renamed_version}",
app.rename_container(version: version, new_version: renamed_version))

old_version = renamed_version if old_version == version
end

capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip.presence
old_version
end

def capture_boot_state
output = capture_with_info(*app.boot_state(version), raise_on_non_zero_exit: false).to_s
clashing, _, running = output.partition(/^#{Regexp.escape(Dash::Commands::App::BOOT_STATE_SEPARATOR)}$/)

[ clashing.strip.presence, running.strip.presence ]
end

def start_new_version
audit "Booted app version #{version}"
hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"

execute *app.ensure_env_directory
execute *auditor.record_then("Booted app version #{version}", app.ensure_env_directory)
upload! role.secrets_io(host), role.secrets_path, mode: "0600"

execute *app.run(hostname: hostname)
Expand Down Expand Up @@ -161,10 +175,6 @@ def auditor
@auditor = DASH.auditor(role: role)
end

def audit(message)
execute *auditor.record(message), verbosity: :debug
end

def gatekeeper?
barrier && barrier_role?
end
Expand Down
10 changes: 9 additions & 1 deletion lib/dash/cli/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -577,10 +577,18 @@ def reset_invocation(cli_class)
instance_variable_get("@_invocations")[cli_class].pop
end

# Every lock acquire wants the run directory to exist, but the sweep is idempotent
# and a process only needs it once per host - the deploy lock and the server lock
# were paying for it twice.
def ensure_run_directory
on(DASH.hosts) do
pending = DASH.hosts.map(&:to_s) - DASH.run_directory_ensured_on
return if pending.empty?

on(pending) do
execute(*DASH.server.ensure_run_directory)
end

DASH.run_directory_ensured_on.concat(pending)
end

def with_env(env)
Expand Down
8 changes: 5 additions & 3 deletions lib/dash/cli/build.rb
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,13 @@ def mirror_hosts
end
end

# Audit, clean and pull share one round trip. validate_image keeps its own: folding it
# in would put the pull under validate_image's trailing `|| (echo ... && exit 1)`, and
# a failed pull would then report a missing service label.
def pull_on_hosts(hosts)
on(hosts) do
execute *DASH.auditor.record("Pulled image with version #{DASH.config.version}"), verbosity: :debug
execute *DASH.builder.clean, raise_on_non_zero_exit: false
execute *DASH.builder.pull
execute *DASH.auditor.record_then("Pulled image with version #{DASH.config.version}",
DASH.builder.clean_then_pull)
execute *DASH.builder.validate_image
end
end
Expand Down
13 changes: 5 additions & 8 deletions lib/dash/cli/prune.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ def all
def images
modify(lock: true, server_lock: true) do
on(DASH.hosts) do
execute *DASH.auditor.record("Pruned images"), verbosity: :debug
execute *DASH.prune.dangling_images
execute *DASH.prune.tagged_images
execute *DASH.auditor.record_then("Pruned images", DASH.prune.dangling_images, DASH.prune.tagged_images)
end
end
end
Expand All @@ -26,11 +24,10 @@ def containers

modify(lock: true, server_lock: true) do
on(DASH.hosts) do |host|
execute *DASH.auditor.record("Pruned containers"), verbosity: :debug

DASH.roles_on(host).each do |role|
execute *DASH.prune.app_containers(retain: retain, role: role)
end
# One round trip per host, whatever it runs: a host with no app roles still
# records that the sweep reached it.
execute *DASH.auditor.record_then("Pruned containers",
*DASH.roles_on(host).map { |role| DASH.prune.app_containers(retain: retain, role: role) })
end
end
end
Expand Down
7 changes: 7 additions & 0 deletions lib/dash/commander.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
class Dash::Commander
attr_accessor :verbosity, :holding_lock, :holding_server_lock, :connected, :logging, :lock_wait, :lock_wait_timeout, :lock_wait_interval
attr_reader :specific_roles, :specific_hosts, :timings, :report

# Hosts whose run directory this process has already swept, so the second lock acquire
# of a command does not re-run the migration everywhere. Per host rather than a flag:
# `dash upgrade` narrows the host set between acquires, and a host that was never in
# scope has never been swept.
attr_reader :run_directory_ensured_on
delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :app_hosts, :proxy_hosts, :accessory_hosts, to: :specifics

def initialize
Expand All @@ -29,6 +35,7 @@ def reset
@config = @config_kwargs = nil
@output_logger = nil
@commands = {}
@run_directory_ensured_on = []
end

def config
Expand Down
18 changes: 18 additions & 0 deletions lib/dash/commands/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ class Dash::Commands::App < Dash::Commands::Base

ACTIVE_DOCKER_STATUSES = [ :running, :restarting ]

# Separates the two answers #boot_state returns. A container id is hex and a version is
# a name suffix, so neither can produce this line on its own.
BOOT_STATE_SEPARATOR = "--%--"

attr_reader :role, :host

delegate :container_name, to: :role
Expand Down Expand Up @@ -75,6 +79,20 @@ def current_running_version
extract_version_from_name
end

# Everything a boot needs to know about a host before it starts anything: whether a
# container for the version being deployed already exists (so it can be renamed out of
# the way) and which version is running now (so it can be stopped once the new one is
# live). Two questions, one round trip, answers split on BOOT_STATE_SEPARATOR.
#
# Chained with `;` rather than `&&`: an empty answer to either is a normal result, not
# a failure, and the second question must be asked whatever the first one said.
def boot_state(version)
chain \
container_id_for_version(version),
[ :echo, BOOT_STATE_SEPARATOR ],
current_running_version
end

def list_versions(*docker_args, statuses: nil)
pipe \
docker(:ps, *container_filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'),
Expand Down
10 changes: 10 additions & 0 deletions lib/dash/commands/auditor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ def record(line, **details)
append([ :echo, escape_shell_value(audit_line(line, **details)) ], audit_log_file)
end

# The audit line and the action it describes in one round trip, still in that order:
# the log is written first, and `&&` means a failed write aborts the action exactly as
# a failed standalone audit would have.
#
# Only ever fold in commands the caller would `execute`. A `capture` folded in here
# would come back with nothing to distinguish the audit's own output from the answer.
def record_then(line, *commands, **details)
combine record(line, **details), *commands
end

def reveal
[ :tail, "-n", 50, audit_log_file ]
end
Expand Down
2 changes: 1 addition & 1 deletion lib/dash/commands/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

class Dash::Commands::Builder < Dash::Commands::Base
delegate \
:create, :remove, :dev, :push, :clean, :pull, :info, :inspect_builder,
:create, :remove, :dev, :push, :clean, :pull, :clean_then_pull, :info, :inspect_builder,
:validate_image, :first_mirror, :login_to_registry_locally?, :push_env,
to: :target

Expand Down
13 changes: 13 additions & 0 deletions lib/dash/commands/builder/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ def clean
docker :image, :rm, "--force", config.absolute_image
end

# Dropping the old image is housekeeping - a host that never had it is not an error -
# so it must not short-circuit whatever it shares a round trip with.
#
# The `|| true` is parenthesised because `&&` and `||` bind equally and associate left:
# ungrouped, an `audit && clean || true && pull` chain lets a FAILED audit fall into the
# same `|| true` and pull anyway, exit status 0. The group confines it to the clean.
#
# Composed only, never executed on its own: SSHKit's command map prefixes an unknown
# first word with /usr/bin/env, and the first word here is `(`.
def clean_then_pull
combine [ "(", *any(clean, [ :true ]), ")" ], pull
end

def push(export_action = "registry", tag_as_dirty: false, no_cache: false)
docker :buildx, :build,
"--output=type=#{export_action}",
Expand Down
Loading