dash 4.0.3 → 4.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 563633a7568cdc7c4567bbe4957af6e5212e37b3ae99ddda0e990552f949762f
4
- data.tar.gz: dda127134f2d0ef7fc4e7636ce7132106842855f9084d082a5a3b65c41313b56
3
+ metadata.gz: ee8ed58f4d81341ced43dc7d970c081a4c92becc3492860cf3ec8fd1a80687ec
4
+ data.tar.gz: 588e1b33ca3f041a54beb773472cab9b8144892a32972b71ae9c58306cbb8612
5
5
  SHA512:
6
- metadata.gz: 8ebab61a7f7f4df70ecd48e5e95415198a01990e6ae6ce93dca062775f2d11b91d2a971bcf9b0b13b4cd3aa6940700c45f8fc73bc1c50be64fe9478e2e1d7ca8
7
- data.tar.gz: 5cda006f0c95a79eea3198c9caa089d5e3b3d064860b6d031efe12b65513b2d4219ce04b62b5a9c66532bbda38681ba0ec8dd6cd51143b270588a804be647488
6
+ metadata.gz: b24d5758340f3e3b7b7b6ecf1df765ffabab7360ddd5bc0df99dad3b90ba3662fc65dcbbe6bfa99af29dea90efb977f4b3d5a2d68454c1e652d57602857f9166
7
+ data.tar.gz: bbc5bfea941e10310fd01776548b462e9e0e82d4228ed25bb66a0c16b35083979473cb2232ccb7bab3820ccaa45ae32303f424ffebfe76ac321b84354c54cf2e
data/bin/dash CHANGED
@@ -3,6 +3,11 @@
3
3
  # Prevent failures from being reported twice.
4
4
  Thread.report_on_exception = false
5
5
 
6
+ # Ruby block-buffers stdout when it is not a TTY, which is every CI runner. Line-by-line
7
+ # writes keep the runner's timestamps attributable to the command that printed them.
8
+ $stdout.sync = true
9
+ $stderr.sync = true
10
+
6
11
  require "dash"
7
12
 
8
13
  begin
@@ -14,22 +14,26 @@ class Dash::Cli::App::Boot
14
14
  end
15
15
 
16
16
  def run
17
- old_version = old_version_renamed_if_clashing
17
+ DASH.timings.phase("#{role} #{host}", depth: 1) do |timing|
18
+ @timing = timing
18
19
 
19
- wait_at_barrier if queuer?
20
+ old_version = old_version_renamed_if_clashing
20
21
 
21
- begin
22
- start_new_version
23
- rescue => e
24
- close_barrier if gatekeeper?
25
- stop_new_version
26
- raise
27
- end
22
+ wait_at_barrier if queuer?
23
+
24
+ begin
25
+ start_new_version
26
+ rescue => e
27
+ close_barrier if gatekeeper?
28
+ stop_new_version
29
+ raise
30
+ end
28
31
 
29
- release_barrier if gatekeeper?
32
+ release_barrier if gatekeeper?
30
33
 
31
- if old_version
32
- stop_old_version(old_version)
34
+ if old_version
35
+ stop_old_version(old_version)
36
+ end
33
37
  end
34
38
  end
35
39
 
@@ -59,10 +63,10 @@ class Dash::Cli::App::Boot
59
63
 
60
64
  run_hook "pre-proxy-deploy", hosts: host.to_s, role: role.name
61
65
  info "Deploying #{role} on #{host} via dash-proxy (waiting up to #{DASH.config.deploy_timeout}s for it to become healthy)..."
62
- execute *app.deploy(target: endpoint)
66
+ timing_healthy { execute *app.deploy(target: endpoint) }
63
67
  run_hook "post-proxy-deploy", hosts: host.to_s, role: role.name
64
68
  else
65
- Dash::Cli::Healthcheck::Poller.wait_for_healthy(role: role) { health_status }
69
+ timing_healthy { Dash::Cli::Healthcheck::Poller.wait_for_healthy(role: role) { health_status } }
66
70
  end
67
71
  rescue => e
68
72
  error "Failed to boot #{role} on #{host}"
@@ -136,6 +140,15 @@ class Dash::Cli::App::Boot
136
140
  end
137
141
  end
138
142
 
143
+ # The readiness wait is the part of a host's boot an operator can actually tune
144
+ # (health check interval, app boot time), so it gets called out on the host's entry.
145
+ def timing_healthy
146
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
147
+ yield
148
+ ensure
149
+ @timing.detail = format("healthy after %.1fs", Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
150
+ end
151
+
139
152
  def barrier_role?
140
153
  role == DASH.primary_role
141
154
  end
data/lib/dash/cli/app.rb CHANGED
@@ -426,10 +426,24 @@ class Dash::Cli::App < Dash::Cli::Base
426
426
  end
427
427
  end
428
428
 
429
+ # Canary hosts each form a group of one and go first; whatever is left is sliced by
430
+ # `limit` (a percentage counts those remaining hosts, the set it actually slices) or
431
+ # boots as a single group. No canary and no limit is the one-group default.
429
432
  def host_boot_groups
430
433
  hosts = DASH.app_hosts
431
- limit = DASH.config.boot.limit_for(hosts)
434
+ canary_hosts = canary_boot_hosts(hosts)
435
+ rest = hosts - canary_hosts
436
+ limit = DASH.config.boot.limit_for(rest)
432
437
 
433
- limit ? hosts.each_slice(limit).to_a : [ hosts ]
438
+ rest_groups = limit ? rest.each_slice(limit).to_a : [ rest ]
439
+
440
+ canary_hosts.map { |host| [ host ] } + rest_groups.reject(&:empty?)
441
+ end
442
+
443
+ # The first N primary-role hosts of this run. `--roles`/`--hosts` may leave none, in
444
+ # which case there is nothing to prove ahead of the others and no canary group.
445
+ def canary_boot_hosts(hosts)
446
+ count = DASH.config.boot.canary
447
+ count ? (DASH.primary_role.hosts & hosts).first(count) : []
434
448
  end
435
449
  end
data/lib/dash/cli/base.rb CHANGED
@@ -140,13 +140,22 @@ module Dash::Cli
140
140
  end
141
141
  end
142
142
 
143
+ # `setup` wraps `deploy`, so this nests. Each level reports its own total; the phase
144
+ # table belongs to the outermost, which is the one that saw every phase.
143
145
  def print_runtime
144
146
  started_at = Time.now
147
+ @print_runtime_depth = @print_runtime_depth.to_i + 1
145
148
  yield
146
149
  Time.now - started_at
147
150
  ensure
151
+ @print_runtime_depth -= 1
148
152
  runtime = Time.now - started_at
149
153
  puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
154
+ puts DASH.timings.lines if @print_runtime_depth.zero? && DASH.timings.any?
155
+ end
156
+
157
+ def timed(name, depth: 0, &block)
158
+ DASH.timings.phase(name, depth: depth, &block)
150
159
  end
151
160
 
152
161
  # Deploy lock outside, server lock inside. Every caller acquires in that
data/lib/dash/cli/main.rb CHANGED
@@ -8,7 +8,7 @@ class Dash::Cli::Main < Dash::Cli::Base
8
8
  invoke_options = deploy_options
9
9
 
10
10
  say "Ensure Docker is installed...", :magenta
11
- invoke "dash:cli:server:bootstrap", [], invoke_options
11
+ timed("Ensure Docker is installed") { invoke "dash:cli:server:bootstrap", [], invoke_options }
12
12
 
13
13
  deploy(boot_accessories: true)
14
14
  end
@@ -30,32 +30,32 @@ class Dash::Cli::Main < Dash::Cli::Base
30
30
 
31
31
  if options[:skip_push]
32
32
  say "Pull app image...", :magenta
33
- invoke "dash:cli:build:pull", [], invoke_options
33
+ timed("Pull app image") { invoke "dash:cli:build:pull", [], invoke_options }
34
34
  else
35
35
  say "Build and push app image...", :magenta
36
- invoke "dash:cli:build:deliver", [], invoke_options
36
+ timed("Build and push app image") { invoke "dash:cli:build:deliver", [], invoke_options }
37
37
  end
38
38
 
39
39
  modify(lock: true) do
40
40
  run_hook "pre-deploy", secrets: true
41
41
 
42
42
  say "Ensure dash-proxy is running...", :magenta
43
- invoke "dash:cli:proxy:boot", [], invoke_options
43
+ timed("Ensure dash-proxy") { invoke "dash:cli:proxy:boot", [], invoke_options }
44
44
 
45
- invoke "dash:cli:accessory:boot", [ "all" ], invoke_options if boot_accessories
45
+ timed("Boot accessories") { invoke "dash:cli:accessory:boot", [ "all" ], invoke_options } if boot_accessories
46
46
 
47
47
  say "Detect stale containers...", :magenta
48
- invoke "dash:cli:app:stale_containers", [], invoke_options.merge(stop: true)
48
+ timed("Detect stale containers") { invoke "dash:cli:app:stale_containers", [], invoke_options.merge(stop: true) }
49
49
 
50
- invoke "dash:cli:app:boot", [], invoke_options
50
+ timed("Boot") { invoke "dash:cli:app:boot", [], invoke_options }
51
51
 
52
52
  if DASH.config.proxy.load_balancing?
53
53
  say "Updating loadbalancer configuration...", :magenta
54
- invoke "dash:cli:proxy:loadbalancer", [ "deploy" ], invoke_options
54
+ timed("Loadbalancer") { invoke "dash:cli:proxy:loadbalancer", [ "deploy" ], invoke_options }
55
55
  end
56
56
 
57
57
  say "Prune old containers and images...", :magenta
58
- invoke "dash:cli:prune:all", [], invoke_options
58
+ timed("Prune") { invoke "dash:cli:prune:all", [], invoke_options }
59
59
  end
60
60
  end
61
61
 
@@ -78,23 +78,23 @@ class Dash::Cli::Main < Dash::Cli::Base
78
78
 
79
79
  if options[:skip_push]
80
80
  say "Pull app image...", :magenta
81
- invoke "dash:cli:build:pull", [], invoke_options
81
+ timed("Pull app image") { invoke "dash:cli:build:pull", [], invoke_options }
82
82
  else
83
83
  say "Build and push app image...", :magenta
84
- invoke "dash:cli:build:deliver", [], invoke_options
84
+ timed("Build and push app image") { invoke "dash:cli:build:deliver", [], invoke_options }
85
85
  end
86
86
 
87
87
  modify(lock: true) do
88
88
  run_hook "pre-deploy", secrets: true
89
89
 
90
90
  say "Detect stale containers...", :magenta
91
- invoke "dash:cli:app:stale_containers", [], invoke_options.merge(stop: true)
91
+ timed("Detect stale containers") { invoke "dash:cli:app:stale_containers", [], invoke_options.merge(stop: true) }
92
92
 
93
- invoke "dash:cli:app:boot", [], invoke_options
93
+ timed("Boot") { invoke "dash:cli:app:boot", [], invoke_options }
94
94
 
95
95
  if DASH.config.proxy.load_balancing?
96
96
  say "Updating loadbalancer configuration...", :magenta
97
- invoke "dash:cli:proxy:loadbalancer", [ "deploy" ], invoke_options
97
+ timed("Loadbalancer") { invoke "dash:cli:proxy:loadbalancer", [ "deploy" ], invoke_options }
98
98
  end
99
99
  end
100
100
  end
@@ -117,7 +117,7 @@ class Dash::Cli::Main < Dash::Cli::Base
117
117
  if container_available?(version)
118
118
  run_hook "pre-deploy", secrets: true
119
119
 
120
- invoke "dash:cli:app:boot", [], invoke_options.merge(version: version)
120
+ timed("Boot") { invoke "dash:cli:app:boot", [], invoke_options.merge(version: version) }
121
121
  rolled_back = true
122
122
  else
123
123
  say "The app version '#{version}' is not available as a container (use 'dash app containers' for available versions)", :red
@@ -19,6 +19,7 @@ class Dash::Cli::Proxy::LoadbalancerReboot
19
19
  def run
20
20
  execute *DASH.auditor.record("Rebooted loadbalancer"), verbosity: :debug
21
21
  execute *DASH.registry.login
22
+ ensure_network
22
23
 
23
24
  info "Stopping and removing #{DASH.loadbalancer.container_name} on #{host}, if running..."
24
25
  execute *DASH.loadbalancer.stop, raise_on_non_zero_exit: false
@@ -45,6 +46,16 @@ class Dash::Cli::Proxy::LoadbalancerReboot
45
46
  end
46
47
 
47
48
  private
49
+ # Before the old container is stopped: `docker run` against a missing
50
+ # network would otherwise fail with the edge already down. A dedicated
51
+ # loadbalancer host has no other path that creates the network
52
+ # (zoolutions/dash#140).
53
+ def ensure_network
54
+ execute *DASH.docker.create_network
55
+ rescue SSHKit::Command::Failed => e
56
+ raise unless e.message.include?("already exists")
57
+ end
58
+
48
59
  def wait_until_ready
49
60
  deadline = Time.now + READY_TIMEOUT
50
61
 
@@ -2,7 +2,7 @@ class Dash::Cli::Proxy < Dash::Cli::Base
2
2
  desc "boot", "Boot proxy on servers"
3
3
  def boot
4
4
  modify(lock: true, server_lock: true) do
5
- on(DASH.hosts) do |host|
5
+ on(network_hosts) do |host|
6
6
  execute *DASH.docker.create_network
7
7
  rescue SSHKit::Command::Failed => e
8
8
  raise unless e.message.include?("already exists")
@@ -76,8 +76,10 @@ class Dash::Cli::Proxy < Dash::Cli::Base
76
76
  info "Starting loadbalancer on #{host}..."
77
77
  execute *DASH.registry.login
78
78
 
79
- # Bring a pre-rename volume across before the container can be created
80
- # against an empty one. A no-op once it has been.
79
+ # Bring a pre-rename host across before the container can be created
80
+ # against an empty volume or a network nothing else joined. A no-op
81
+ # once it has been.
82
+ execute *DASH.docker.connect_legacy_network_containers
81
83
  execute *DASH.loadbalancer.copy_legacy_config_volume
82
84
 
83
85
  # The load balancer terminates TLS and owns the cache, so its host
@@ -653,6 +655,15 @@ class Dash::Cli::Proxy < Dash::Cli::Base
653
655
  end
654
656
 
655
657
  private
658
+ # Every host that runs a container on the proxy network. A dedicated
659
+ # loadbalancer host is not in DASH.hosts, and nothing else creates the
660
+ # network there (zoolutions/dash#140).
661
+ def network_hosts
662
+ hosts = DASH.hosts
663
+ hosts |= [ DASH.config.proxy.effective_loadbalancer ] if DASH.config.proxy.load_balancing?
664
+ hosts
665
+ end
666
+
656
667
  # The host that owns TLS, and so the certificate store: the loadbalancer
657
668
  # host when load balancing (TLS terminates at the edge), else the primary
658
669
  # host - the same host `loadbalancer: true` would resolve to.
@@ -6,7 +6,7 @@ require "active_support/notifications"
6
6
 
7
7
  class Dash::Commander
8
8
  attr_accessor :verbosity, :holding_lock, :holding_server_lock, :connected, :logging, :lock_wait, :lock_wait_timeout, :lock_wait_interval
9
- attr_reader :specific_roles, :specific_hosts
9
+ attr_reader :specific_roles, :specific_hosts, :timings
10
10
  delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :app_hosts, :proxy_hosts, :accessory_hosts, to: :specifics
11
11
 
12
12
  def initialize
@@ -23,6 +23,7 @@ class Dash::Commander
23
23
  self.lock_wait_timeout = 900
24
24
  self.lock_wait_interval = 15
25
25
  @modify_depth = 0
26
+ @timings = Dash::Timings.new
26
27
  @specifics = @specific_roles = @specific_hosts = nil
27
28
  @config = @config_kwargs = nil
28
29
  @output_logger = nil
@@ -8,6 +8,7 @@ class Dash::Configuration::Boot
8
8
  def initialize(config:, boot_config: nil, context: nil)
9
9
  @boot_config = boot_config || config.raw_config.boot || {}
10
10
  validate! @boot_config, context: context
11
+ validate_canary!(context || self.class.validation_config_key)
11
12
  end
12
13
 
13
14
  # The group size to slice `hosts` by. A percentage only means something against the set
@@ -35,6 +36,22 @@ class Dash::Configuration::Boot
35
36
  boot_config["wait"]
36
37
  end
37
38
 
39
+ # How many primary-role hosts boot alone, one at a time, before the rest boot together.
40
+ # Whole-deploy only — Cli::App#host_boot_groups is the sole consumer.
41
+ def canary
42
+ boot_config["canary"]
43
+ end
44
+
45
+ def canary?
46
+ canary.present?
47
+ end
48
+
49
+ # Whether this config splits hosts into more than one group at all — what `wait` needs
50
+ # in order to mean anything.
51
+ def groups?
52
+ limit? || canary?
53
+ end
54
+
38
55
  def parallel_roles
39
56
  boot_config["parallel_roles"]
40
57
  end
@@ -57,4 +74,20 @@ class Dash::Configuration::Boot
57
74
  { in: :groups, limit: limit, wait: wait.to_i }
58
75
  end
59
76
  end
77
+
78
+ private
79
+ # The example-driven validator has already checked the type; the range and the
80
+ # top-level-only rule are ours. A role-scoped Boot always carries a context.
81
+ def validate_canary!(context)
82
+ return unless canary?
83
+
84
+ if context != self.class.validation_config_key
85
+ raise Dash::ConfigurationError, "#{context}/canary is only supported at the top-level boot: " \
86
+ "it slices the primary role's hosts ahead of every other role"
87
+ end
88
+
89
+ if canary < 1
90
+ raise Dash::ConfigurationError, "#{context}/canary must be at least 1, got #{canary}"
91
+ end
92
+ end
60
93
  end
@@ -26,6 +26,20 @@ boot:
26
26
  # moves on as soon as the last host is up.
27
27
  wait: 10
28
28
 
29
+ # The number of primary-role hosts to boot one at a time before the rest of the hosts boot.
30
+ #
31
+ # A canary is the gate people reach for `limit: 1` to get — prove the new version on one host
32
+ # before touching the fleet — without serialising every host after it. The first N hosts of the
33
+ # primary role each boot alone; a boot failure there stops the deploy before any other host is
34
+ # touched. The remaining hosts of every role then boot together, or in groups when `limit` is
35
+ # also set (a percentage limit counts those remaining hosts). `wait` paces canary and remaining
36
+ # groups alike. Behind dash-proxy this never reduces capacity: each host keeps serving the old
37
+ # container until the new one passes its health check.
38
+ #
39
+ # Top-level only. A role-scoped `boot` refuses it: the canary slices the primary role's hosts
40
+ # ahead of every other role, which a single role cannot express.
41
+ canary: 1
42
+
29
43
  # Whether to boot roles in parallel on a host.
30
44
  #
31
45
  # If a host has multiple roles, control whether they are booted in parallel or sequentially on that host.
@@ -82,7 +82,7 @@ asset_path: /path/to/assets
82
82
  # Hooks path
83
83
  #
84
84
  # Path to hooks, defaults to `.dash/hooks` (`.kamal/hooks` is still read when only that exists).
85
- # See https://kamal-deploy.org/docs/hooks for more information:
85
+ # See https://dash.zoolutions.llc/docs/hooks for every hook, when it fires, and its environment:
86
86
  hooks_path: /user_home/kamal/hooks
87
87
 
88
88
  # Hook output
@@ -31,7 +31,7 @@ env:
31
31
  # RAILS_MASTER_KEY=$(cat config/master.key)
32
32
  # ```
33
33
  #
34
- # You can also use [secret helpers](../../commands/secrets) for some common password managers.
34
+ # You can also use [secret helpers](https://dash.zoolutions.llc/docs/secrets) for common password managers and secret stores.
35
35
  #
36
36
  # ```shell
37
37
  # SECRETS=$(dash secrets fetch ...)
@@ -834,7 +834,7 @@ proxy:
834
834
  # below already embeds its ghcr.io host
835
835
  repository: ghcr.io/zoolutions/dash-proxy # Container repository for the
836
836
  # dash-proxy image (this is the default)
837
- version: v1.1.0.0 # Version tag of the dash-proxy image to use.
837
+ version: v1.1.0.1 # Version tag of the dash-proxy image to use.
838
838
  # Defaults to the minimum version this gem
839
839
  # requires - only pin it to roll forward early,
840
840
  # never below the default
@@ -1,5 +1,5 @@
1
1
  class Dash::Configuration::Proxy::Run
2
- MINIMUM_VERSION = "v1.1.0.0"
2
+ MINIMUM_VERSION = "v1.1.0.1"
3
3
  DEFAULT_HTTP_PORT = 80
4
4
  DEFAULT_HTTPS_PORT = 443
5
5
  DEFAULT_LOG_MAX_SIZE = "10m"
@@ -592,17 +592,17 @@ class Dash::Configuration
592
592
  end
593
593
 
594
594
  # `wait` only ever fills the gap between one group of hosts and the next, and without a
595
- # `limit` there are no gaps — every host boots in one group. Before #47 that silently
595
+ # `limit` or a `canary` there are no gaps — every host boots in one group. Before #47 that silently
596
596
  # cost a deploy one `wait` interval; now it silently does nothing at all. Either way the
597
597
  # operator asked for staggering and is not getting it, so say so.
598
598
  def ensure_boot_wait_paces_something
599
599
  offenders = [ [ "boot", boot ], *roles.filter_map { |role| [ "servers/#{role.name}/boot", role.boot ] if role.boot } ]
600
- .select { |_context, boot_config| boot_config.wait.present? && !boot_config.limit? }
600
+ .select { |_context, boot_config| boot_config.wait.present? && !boot_config.groups? }
601
601
 
602
602
  offenders.each do |context, boot_config|
603
603
  warn "#{context}/wait is set to #{boot_config.wait} but #{context}/limit is not, so it does nothing. " \
604
604
  "`wait` paces one group of hosts against the next, and without a limit every host boots in a single group. " \
605
- "Set `#{context}/limit` to stagger the boot, or remove `#{context}/wait`."
605
+ "Set `#{context}/limit` or `#{context}/canary` to stagger the boot, or remove `#{context}/wait`."
606
606
  end
607
607
 
608
608
  true
@@ -0,0 +1,45 @@
1
+ # Wall-clock accounting for a deploy, printed under "Finished all in". The total on its
2
+ # own says nothing about where the time went — a serialised boot, a slow pull, or a proxy
3
+ # waiting on a health check all look the same from the outside.
4
+ #
5
+ # Entries land in start order and carry a depth, so a parent phase (Boot) prints above
6
+ # the host entries it wraps even though the hosts finish first. Boot runs one thread per
7
+ # host, so every mutation is behind the mutex.
8
+ class Dash::Timings
9
+ Entry = Struct.new(:name, :seconds, :detail, :depth)
10
+
11
+ def initialize
12
+ @entries = []
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ # Times the block. The entry is yielded so the block can annotate it — a boot can say
17
+ # how long of its total was spent waiting for the container to become healthy.
18
+ def phase(name, depth: 0)
19
+ entry = Entry.new(name, nil, nil, depth)
20
+ @mutex.synchronize { @entries << entry }
21
+ started = clock
22
+
23
+ yield entry
24
+ ensure
25
+ entry.seconds = clock - started
26
+ end
27
+
28
+ def any?
29
+ @mutex.synchronize { @entries.any? }
30
+ end
31
+
32
+ def lines
33
+ @mutex.synchronize do
34
+ @entries.map do |entry|
35
+ line = format(" %s%-36s %6.1fs", " " * entry.depth, entry.name, entry.seconds.to_f)
36
+ entry.detail ? "#{line} (#{entry.detail})" : line
37
+ end
38
+ end
39
+ end
40
+
41
+ private
42
+ def clock
43
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
44
+ end
45
+ end
data/lib/dash/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Dash
2
- VERSION = "4.0.3"
2
+ VERSION = "4.0.5"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dash
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.3
4
+ version: 4.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -329,6 +329,7 @@ files:
329
329
  - lib/dash/secrets/dotenv/inline_command_substitution.rb
330
330
  - lib/dash/sshkit_with_ext.rb
331
331
  - lib/dash/tags.rb
332
+ - lib/dash/timings.rb
332
333
  - lib/dash/utils.rb
333
334
  - lib/dash/utils/sensitive.rb
334
335
  - lib/dash/version.rb