nocoffee-kamal 2.3.0.1

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.
Files changed (114) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +13 -0
  4. data/bin/kamal +18 -0
  5. data/lib/kamal/cli/accessory.rb +287 -0
  6. data/lib/kamal/cli/alias/command.rb +9 -0
  7. data/lib/kamal/cli/app/boot.rb +125 -0
  8. data/lib/kamal/cli/app/prepare_assets.rb +24 -0
  9. data/lib/kamal/cli/app.rb +335 -0
  10. data/lib/kamal/cli/base.rb +198 -0
  11. data/lib/kamal/cli/build/clone.rb +61 -0
  12. data/lib/kamal/cli/build.rb +162 -0
  13. data/lib/kamal/cli/healthcheck/barrier.rb +33 -0
  14. data/lib/kamal/cli/healthcheck/error.rb +2 -0
  15. data/lib/kamal/cli/healthcheck/poller.rb +42 -0
  16. data/lib/kamal/cli/lock.rb +45 -0
  17. data/lib/kamal/cli/main.rb +279 -0
  18. data/lib/kamal/cli/proxy.rb +257 -0
  19. data/lib/kamal/cli/prune.rb +34 -0
  20. data/lib/kamal/cli/registry.rb +17 -0
  21. data/lib/kamal/cli/secrets.rb +43 -0
  22. data/lib/kamal/cli/server.rb +48 -0
  23. data/lib/kamal/cli/templates/deploy.yml +98 -0
  24. data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +3 -0
  25. data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +14 -0
  26. data/lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample +3 -0
  27. data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +51 -0
  28. data/lib/kamal/cli/templates/sample_hooks/pre-connect.sample +47 -0
  29. data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +109 -0
  30. data/lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample +3 -0
  31. data/lib/kamal/cli/templates/secrets +17 -0
  32. data/lib/kamal/cli.rb +8 -0
  33. data/lib/kamal/commander/specifics.rb +54 -0
  34. data/lib/kamal/commander.rb +176 -0
  35. data/lib/kamal/commands/accessory.rb +113 -0
  36. data/lib/kamal/commands/app/assets.rb +51 -0
  37. data/lib/kamal/commands/app/containers.rb +31 -0
  38. data/lib/kamal/commands/app/execution.rb +30 -0
  39. data/lib/kamal/commands/app/images.rb +13 -0
  40. data/lib/kamal/commands/app/logging.rb +18 -0
  41. data/lib/kamal/commands/app/proxy.rb +16 -0
  42. data/lib/kamal/commands/app.rb +115 -0
  43. data/lib/kamal/commands/auditor.rb +33 -0
  44. data/lib/kamal/commands/base.rb +98 -0
  45. data/lib/kamal/commands/builder/base.rb +111 -0
  46. data/lib/kamal/commands/builder/clone.rb +31 -0
  47. data/lib/kamal/commands/builder/hybrid.rb +21 -0
  48. data/lib/kamal/commands/builder/local.rb +14 -0
  49. data/lib/kamal/commands/builder/remote.rb +63 -0
  50. data/lib/kamal/commands/builder.rb +56 -0
  51. data/lib/kamal/commands/docker.rb +34 -0
  52. data/lib/kamal/commands/hook.rb +20 -0
  53. data/lib/kamal/commands/lock.rb +70 -0
  54. data/lib/kamal/commands/proxy.rb +87 -0
  55. data/lib/kamal/commands/prune.rb +38 -0
  56. data/lib/kamal/commands/registry.rb +14 -0
  57. data/lib/kamal/commands/server.rb +15 -0
  58. data/lib/kamal/commands.rb +2 -0
  59. data/lib/kamal/configuration/accessory.rb +186 -0
  60. data/lib/kamal/configuration/alias.rb +15 -0
  61. data/lib/kamal/configuration/boot.rb +25 -0
  62. data/lib/kamal/configuration/builder.rb +191 -0
  63. data/lib/kamal/configuration/docs/accessory.yml +100 -0
  64. data/lib/kamal/configuration/docs/alias.yml +26 -0
  65. data/lib/kamal/configuration/docs/boot.yml +19 -0
  66. data/lib/kamal/configuration/docs/builder.yml +110 -0
  67. data/lib/kamal/configuration/docs/configuration.yml +178 -0
  68. data/lib/kamal/configuration/docs/env.yml +85 -0
  69. data/lib/kamal/configuration/docs/logging.yml +21 -0
  70. data/lib/kamal/configuration/docs/proxy.yml +110 -0
  71. data/lib/kamal/configuration/docs/registry.yml +52 -0
  72. data/lib/kamal/configuration/docs/role.yml +53 -0
  73. data/lib/kamal/configuration/docs/servers.yml +27 -0
  74. data/lib/kamal/configuration/docs/ssh.yml +70 -0
  75. data/lib/kamal/configuration/docs/sshkit.yml +23 -0
  76. data/lib/kamal/configuration/env/tag.rb +13 -0
  77. data/lib/kamal/configuration/env.rb +29 -0
  78. data/lib/kamal/configuration/logging.rb +33 -0
  79. data/lib/kamal/configuration/proxy.rb +63 -0
  80. data/lib/kamal/configuration/registry.rb +32 -0
  81. data/lib/kamal/configuration/role.rb +220 -0
  82. data/lib/kamal/configuration/servers.rb +18 -0
  83. data/lib/kamal/configuration/ssh.rb +57 -0
  84. data/lib/kamal/configuration/sshkit.rb +22 -0
  85. data/lib/kamal/configuration/validation.rb +27 -0
  86. data/lib/kamal/configuration/validator/accessory.rb +9 -0
  87. data/lib/kamal/configuration/validator/alias.rb +15 -0
  88. data/lib/kamal/configuration/validator/builder.rb +13 -0
  89. data/lib/kamal/configuration/validator/configuration.rb +6 -0
  90. data/lib/kamal/configuration/validator/env.rb +54 -0
  91. data/lib/kamal/configuration/validator/proxy.rb +15 -0
  92. data/lib/kamal/configuration/validator/registry.rb +25 -0
  93. data/lib/kamal/configuration/validator/role.rb +11 -0
  94. data/lib/kamal/configuration/validator/servers.rb +7 -0
  95. data/lib/kamal/configuration/validator.rb +171 -0
  96. data/lib/kamal/configuration/volume.rb +22 -0
  97. data/lib/kamal/configuration.rb +393 -0
  98. data/lib/kamal/env_file.rb +44 -0
  99. data/lib/kamal/git.rb +27 -0
  100. data/lib/kamal/secrets/adapters/base.rb +23 -0
  101. data/lib/kamal/secrets/adapters/bitwarden.rb +81 -0
  102. data/lib/kamal/secrets/adapters/last_pass.rb +39 -0
  103. data/lib/kamal/secrets/adapters/one_password.rb +70 -0
  104. data/lib/kamal/secrets/adapters/test.rb +14 -0
  105. data/lib/kamal/secrets/adapters.rb +14 -0
  106. data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +32 -0
  107. data/lib/kamal/secrets.rb +42 -0
  108. data/lib/kamal/sshkit_with_ext.rb +142 -0
  109. data/lib/kamal/tags.rb +40 -0
  110. data/lib/kamal/utils/sensitive.rb +20 -0
  111. data/lib/kamal/utils.rb +110 -0
  112. data/lib/kamal/version.rb +3 -0
  113. data/lib/kamal.rb +14 -0
  114. metadata +349 -0
@@ -0,0 +1,44 @@
1
+ # Encode an env hash as a string where secret values have been looked up and all values escaped for Docker.
2
+ class Kamal::EnvFile
3
+ def initialize(env)
4
+ @env = env
5
+ end
6
+
7
+ def to_s
8
+ env_file = StringIO.new.tap do |contents|
9
+ @env.each do |key, value|
10
+ contents << docker_env_file_line(key, value)
11
+ end
12
+ end.string
13
+
14
+ # Ensure the file has some contents to avoid the SSHKIT empty file warning
15
+ env_file.presence || "\n"
16
+ end
17
+
18
+ def to_io
19
+ StringIO.new(to_s)
20
+ end
21
+
22
+ alias to_str to_s
23
+
24
+ private
25
+ def docker_env_file_line(key, value)
26
+ "#{key}=#{escape_docker_env_file_value(value)}\n"
27
+ end
28
+
29
+ # Escape a value to make it safe to dump in a docker file.
30
+ def escape_docker_env_file_value(value)
31
+ # keep non-ascii(UTF-8) characters as it is
32
+ value.to_s.scan(/[\x00-\x7F]+|[^\x00-\x7F]+/).map do |part|
33
+ part.ascii_only? ? escape_docker_env_file_ascii_value(part) : part
34
+ end.join
35
+ end
36
+
37
+ def escape_docker_env_file_ascii_value(value)
38
+ # Doublequotes are treated literally in docker env files
39
+ # so remove leading and trailing ones and unescape any others
40
+ value.to_s.dump[1..-2]
41
+ .gsub(/\\"/, "\"")
42
+ .gsub(/\\#/, "#")
43
+ end
44
+ end
data/lib/kamal/git.rb ADDED
@@ -0,0 +1,27 @@
1
+ module Kamal::Git
2
+ extend self
3
+
4
+ def used?
5
+ system("git rev-parse")
6
+ end
7
+
8
+ def user_name
9
+ `git config user.name`.strip
10
+ end
11
+
12
+ def email
13
+ `git config user.email`.strip
14
+ end
15
+
16
+ def revision
17
+ `git rev-parse HEAD`.strip
18
+ end
19
+
20
+ def uncommitted_changes
21
+ `git status --porcelain`.strip
22
+ end
23
+
24
+ def root
25
+ `git rev-parse --show-toplevel`.strip
26
+ end
27
+ end
@@ -0,0 +1,23 @@
1
+ class Kamal::Secrets::Adapters::Base
2
+ delegate :optionize, to: Kamal::Utils
3
+
4
+ def fetch(secrets, account:, from: nil)
5
+ check_dependencies!
6
+ session = login(account)
7
+ full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") }
8
+ fetch_secrets(full_secrets, account: account, session: session)
9
+ end
10
+
11
+ private
12
+ def login(...)
13
+ raise NotImplementedError
14
+ end
15
+
16
+ def fetch_secrets(...)
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def check_dependencies!
21
+ raise NotImplementedError
22
+ end
23
+ end
@@ -0,0 +1,81 @@
1
+ class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base
2
+ private
3
+ def login(account)
4
+ status = run_command("status")
5
+
6
+ if status["status"] == "unauthenticated"
7
+ run_command("login #{account.shellescape}", raw: true)
8
+ status = run_command("status")
9
+ end
10
+
11
+ if status["status"] == "locked"
12
+ session = run_command("unlock --raw", raw: true).presence
13
+ status = run_command("status", session: session)
14
+ end
15
+
16
+ raise RuntimeError, "Failed to login to and unlock Bitwarden" unless status["status"] == "unlocked"
17
+
18
+ run_command("sync", session: session, raw: true)
19
+ raise RuntimeError, "Failed to sync Bitwarden" unless $?.success?
20
+
21
+ session
22
+ end
23
+
24
+ def fetch_secrets(secrets, account:, session:)
25
+ {}.tap do |results|
26
+ items_fields(secrets).each do |item, fields|
27
+ item_json = run_command("get item #{item.shellescape}", session: session, raw: true)
28
+ raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success?
29
+ item_json = JSON.parse(item_json)
30
+ if fields.any?
31
+ results.merge! fetch_secrets_from_fields(fields, item, item_json)
32
+ elsif item_json.dig("login", "password")
33
+ results[item] = item_json.dig("login", "password")
34
+ elsif item_json["fields"]&.any?
35
+ fields = item_json["fields"].pluck("name")
36
+ results.merge! fetch_secrets_from_fields(fields, item, item_json)
37
+ else
38
+ raise RuntimeError, "Item #{item} is not a login type item and no fields were specified"
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ def fetch_secrets_from_fields(fields, item, item_json)
45
+ fields.to_h do |field|
46
+ item_field = item_json["fields"].find { |f| f["name"] == field }
47
+ raise RuntimeError, "Could not find field #{field} in item #{item} in Bitwarden" unless item_field
48
+ value = item_field["value"]
49
+ [ "#{item}/#{field}", value ]
50
+ end
51
+ end
52
+
53
+ def items_fields(secrets)
54
+ {}.tap do |items|
55
+ secrets.each do |secret|
56
+ item, field = secret.split("/")
57
+ items[item] ||= []
58
+ items[item] << field
59
+ end
60
+ end
61
+ end
62
+
63
+ def signedin?(account)
64
+ run_command("status")["status"] != "unauthenticated"
65
+ end
66
+
67
+ def run_command(command, session: nil, raw: false)
68
+ full_command = [ *("BW_SESSION=#{session.shellescape}" if session), "bw", command ].join(" ")
69
+ result = `#{full_command}`.strip
70
+ raw ? result : JSON.parse(result)
71
+ end
72
+
73
+ def check_dependencies!
74
+ raise RuntimeError, "Bitwarden CLI is not installed" unless cli_installed?
75
+ end
76
+
77
+ def cli_installed?
78
+ `bw --version 2> /dev/null`
79
+ $?.success?
80
+ end
81
+ end
@@ -0,0 +1,39 @@
1
+ class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base
2
+ private
3
+ def login(account)
4
+ unless loggedin?(account)
5
+ `lpass login #{account.shellescape}`
6
+ raise RuntimeError, "Failed to login to LastPass" unless $?.success?
7
+ end
8
+ end
9
+
10
+ def loggedin?(account)
11
+ `lpass status --color never`.strip == "Logged in as #{account}."
12
+ end
13
+
14
+ def fetch_secrets(secrets, account:, session:)
15
+ items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json`
16
+ raise RuntimeError, "Could not read #{secrets} from LastPass" unless $?.success?
17
+
18
+ items = JSON.parse(items)
19
+
20
+ {}.tap do |results|
21
+ items.each do |item|
22
+ results[item["fullname"]] = item["password"]
23
+ end
24
+
25
+ if (missing_items = secrets - results.keys).any?
26
+ raise RuntimeError, "Could not find #{missing_items.join(", ")} in LassPass"
27
+ end
28
+ end
29
+ end
30
+
31
+ def check_dependencies!
32
+ raise RuntimeError, "LastPass CLI is not installed" unless cli_installed?
33
+ end
34
+
35
+ def cli_installed?
36
+ `lpass --version 2> /dev/null`
37
+ $?.success?
38
+ end
39
+ end
@@ -0,0 +1,70 @@
1
+ class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base
2
+ delegate :optionize, to: Kamal::Utils
3
+
4
+ private
5
+ def login(account)
6
+ unless loggedin?(account)
7
+ `op signin #{to_options(account: account, force: true, raw: true)}`.tap do
8
+ raise RuntimeError, "Failed to login to 1Password" unless $?.success?
9
+ end
10
+ end
11
+ end
12
+
13
+ def loggedin?(account)
14
+ `op account get --account #{account.shellescape} 2> /dev/null`
15
+ $?.success?
16
+ end
17
+
18
+ def fetch_secrets(secrets, account:, session:)
19
+ {}.tap do |results|
20
+ vaults_items_fields(secrets).map do |vault, items|
21
+ items.each do |item, fields|
22
+ fields_json = JSON.parse(op_item_get(vault, item, fields, account: account, session: session))
23
+ fields_json = [ fields_json ] if fields.one?
24
+
25
+ fields_json.each do |field_json|
26
+ # The reference is in the form `op://vault/item/field[/field]`
27
+ field = field_json["reference"].delete_prefix("op://").delete_suffix("/password")
28
+ results[field] = field_json["value"]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ def to_options(**options)
36
+ optionize(options.compact).join(" ")
37
+ end
38
+
39
+ def vaults_items_fields(secrets)
40
+ {}.tap do |vaults|
41
+ secrets.each do |secret|
42
+ secret = secret.delete_prefix("op://")
43
+ vault, item, *fields = secret.split("/")
44
+ fields << "password" if fields.empty?
45
+
46
+ vaults[vault] ||= {}
47
+ vaults[vault][item] ||= []
48
+ vaults[vault][item] << fields.join(".")
49
+ end
50
+ end
51
+ end
52
+
53
+ def op_item_get(vault, item, fields, account:, session:)
54
+ labels = fields.map { |field| "label=#{field}" }.join(",")
55
+ options = to_options(vault: vault, fields: labels, format: "json", account: account, session: session.presence)
56
+
57
+ `op item get #{item.shellescape} #{options}`.tap do
58
+ raise RuntimeError, "Could not read #{fields.join(", ")} from #{item} in the #{vault} 1Password vault" unless $?.success?
59
+ end
60
+ end
61
+
62
+ def check_dependencies!
63
+ raise RuntimeError, "1Password CLI is not installed" unless cli_installed?
64
+ end
65
+
66
+ def cli_installed?
67
+ `op --version 2> /dev/null`
68
+ $?.success?
69
+ end
70
+ end
@@ -0,0 +1,14 @@
1
+ class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base
2
+ private
3
+ def login(account)
4
+ true
5
+ end
6
+
7
+ def fetch_secrets(secrets, account:, session:)
8
+ secrets.to_h { |secret| [ secret, secret.reverse ] }
9
+ end
10
+
11
+ def check_dependencies!
12
+ # no op
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ require "active_support/core_ext/string/inflections"
2
+ module Kamal::Secrets::Adapters
3
+ def self.lookup(name)
4
+ name = "one_password" if name.downcase == "1password"
5
+ name = "last_pass" if name.downcase == "lastpass"
6
+ adapter_class(name)
7
+ end
8
+
9
+ def self.adapter_class(name)
10
+ Object.const_get("Kamal::Secrets::Adapters::#{name.camelize}").new
11
+ rescue NameError => e
12
+ raise RuntimeError, "Unknown secrets adapter: #{name}"
13
+ end
14
+ end
@@ -0,0 +1,32 @@
1
+ class Kamal::Secrets::Dotenv::InlineCommandSubstitution
2
+ class << self
3
+ def install!
4
+ ::Dotenv::Parser.substitutions.map! { |sub| sub == ::Dotenv::Substitutions::Command ? self : sub }
5
+ end
6
+
7
+ def call(value, _env, overwrite: false)
8
+ # Process interpolated shell commands
9
+ value.gsub(Dotenv::Substitutions::Command.singleton_class::INTERPOLATED_SHELL_COMMAND) do |*|
10
+ # Eliminate opening and closing parentheses
11
+ command = $LAST_MATCH_INFO[:cmd][1..-2]
12
+
13
+ if $LAST_MATCH_INFO[:backslash]
14
+ # Command is escaped, don't replace it.
15
+ $LAST_MATCH_INFO[0][1..]
16
+ else
17
+ if command =~ /\A\s*kamal\s*secrets\s+/
18
+ # Inline the command
19
+ inline_secrets_command(command)
20
+ else
21
+ # Execute the command and return the value
22
+ `#{command}`.chomp
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ def inline_secrets_command(command)
29
+ Kamal::Cli::Main.start(command.shellsplit[1..] + [ "--inline" ]).chomp
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,42 @@
1
+ require "dotenv"
2
+
3
+ class Kamal::Secrets
4
+ Kamal::Secrets::Dotenv::InlineCommandSubstitution.install!
5
+
6
+ def initialize(destination: nil)
7
+ @destination = destination
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def [](key)
12
+ # Fetching secrets may ask the user for input, so ensure only one thread does that
13
+ @mutex.synchronize do
14
+ secrets.fetch(key)
15
+ end
16
+ rescue KeyError
17
+ if secrets_files.present?
18
+ raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secrets_files.join(", ")}"
19
+ else
20
+ raise Kamal::ConfigurationError, "Secret '#{key}' not found, no secret files (#{secrets_filenames.join(", ")}) provided"
21
+ end
22
+ end
23
+
24
+ def to_h
25
+ secrets
26
+ end
27
+
28
+ def secrets_files
29
+ @secrets_files ||= secrets_filenames.select { |f| File.exist?(f) }
30
+ end
31
+
32
+ private
33
+ def secrets
34
+ @secrets ||= secrets_files.inject({}) do |secrets, secrets_file|
35
+ secrets.merge!(::Dotenv.parse(secrets_file))
36
+ end
37
+ end
38
+
39
+ def secrets_filenames
40
+ [ ".kamal/secrets-common", ".kamal/secrets#{(".#{@destination}" if @destination)}" ]
41
+ end
42
+ end
@@ -0,0 +1,142 @@
1
+ require "sshkit"
2
+ require "sshkit/dsl"
3
+ require "net/scp"
4
+ require "active_support/core_ext/hash/deep_merge"
5
+ require "json"
6
+ require "concurrent/atomic/semaphore"
7
+
8
+ class SSHKit::Backend::Abstract
9
+ def capture_with_info(*args, **kwargs)
10
+ capture(*args, **kwargs, verbosity: Logger::INFO)
11
+ end
12
+
13
+ def capture_with_debug(*args, **kwargs)
14
+ capture(*args, **kwargs, verbosity: Logger::DEBUG)
15
+ end
16
+
17
+ def capture_with_pretty_json(*args, **kwargs)
18
+ JSON.pretty_generate(JSON.parse(capture(*args, **kwargs)))
19
+ end
20
+
21
+ def puts_by_host(host, output, type: "App")
22
+ puts "#{type} Host: #{host}\n#{output}\n\n"
23
+ end
24
+
25
+ # Our execution pattern is for the CLI execute args lists returned
26
+ # from commands, but this doesn't support returning execution options
27
+ # from the command.
28
+ #
29
+ # Support this by using kwargs for CLI options and merging with the
30
+ # args-extracted options.
31
+ module CommandEnvMerge
32
+ private
33
+
34
+ # Override to merge options returned by commands in the args list with
35
+ # options passed by the CLI and pass them along as kwargs.
36
+ def command(args, options)
37
+ more_options, args = args.partition { |a| a.is_a? Hash }
38
+ more_options << options
39
+
40
+ build_command(args, **more_options.reduce(:deep_merge))
41
+ end
42
+
43
+ # Destructure options to pluck out env for merge
44
+ def build_command(args, env: nil, **options)
45
+ # Rely on native Ruby kwargs precedence rather than explicit Hash merges
46
+ SSHKit::Command.new(*args, **default_command_options, **options, env: env_for(env))
47
+ end
48
+
49
+ def default_command_options
50
+ { in: pwd_path, host: @host, user: @user, group: @group }
51
+ end
52
+
53
+ def env_for(env)
54
+ @env.to_h.merge(env.to_h)
55
+ end
56
+ end
57
+ prepend CommandEnvMerge
58
+ end
59
+
60
+ class SSHKit::Backend::Netssh::Configuration
61
+ attr_accessor :max_concurrent_starts
62
+ end
63
+
64
+ class SSHKit::Backend::Netssh
65
+ module LimitConcurrentStartsClass
66
+ attr_reader :start_semaphore
67
+
68
+ def configure(&block)
69
+ super &block
70
+ # Create this here to avoid lazy creation by multiple threads
71
+ if config.max_concurrent_starts
72
+ @start_semaphore = Concurrent::Semaphore.new(config.max_concurrent_starts)
73
+ end
74
+ end
75
+ end
76
+
77
+ class << self
78
+ prepend LimitConcurrentStartsClass
79
+ end
80
+
81
+ module LimitConcurrentStartsInstance
82
+ private
83
+ def with_ssh(&block)
84
+ host.ssh_options = self.class.config.ssh_options.merge(host.ssh_options || {})
85
+ self.class.pool.with(
86
+ method(:start_with_concurrency_limit),
87
+ String(host.hostname),
88
+ host.username,
89
+ host.netssh_options,
90
+ &block
91
+ )
92
+ end
93
+
94
+ def start_with_concurrency_limit(*args)
95
+ if self.class.start_semaphore
96
+ self.class.start_semaphore.acquire do
97
+ Net::SSH.start(*args)
98
+ end
99
+ else
100
+ Net::SSH.start(*args)
101
+ end
102
+ end
103
+ end
104
+
105
+ prepend LimitConcurrentStartsInstance
106
+ end
107
+
108
+ class SSHKit::Runner::Parallel
109
+ # SSHKit joins the threads in sequence and fails on the first error it encounters, which means that we wait threads
110
+ # before the first failure to complete but not for ones after.
111
+ #
112
+ # We'll patch it to wait for them all to complete, and to record all the threads that errored so we can see when a
113
+ # problem occurs on multiple hosts.
114
+ module CompleteAll
115
+ def execute
116
+ threads = hosts.map do |host|
117
+ Thread.new(host) do |h|
118
+ backend(h, &block).run
119
+ rescue ::StandardError => e
120
+ e2 = SSHKit::Runner::ExecuteError.new e
121
+ raise e2, "Exception while executing #{host.user ? "as #{host.user}@" : "on host "}#{host}: #{e.message}"
122
+ end
123
+ end
124
+
125
+ exceptions = []
126
+ threads.each do |t|
127
+ begin
128
+ t.join
129
+ rescue SSHKit::Runner::ExecuteError => e
130
+ exceptions << e
131
+ end
132
+ end
133
+ if exceptions.one?
134
+ raise exceptions.first
135
+ elsif exceptions.many?
136
+ raise exceptions.first, [ "Exceptions on #{exceptions.count} hosts:", exceptions.map(&:message) ].join("\n")
137
+ end
138
+ end
139
+ end
140
+
141
+ prepend CompleteAll
142
+ end
data/lib/kamal/tags.rb ADDED
@@ -0,0 +1,40 @@
1
+ require "time"
2
+
3
+ class Kamal::Tags
4
+ attr_reader :config, :tags
5
+
6
+ class << self
7
+ def from_config(config, **extra)
8
+ new(**default_tags(config), **extra)
9
+ end
10
+
11
+ def default_tags(config)
12
+ { recorded_at: Time.now.utc.iso8601,
13
+ performer: Kamal::Git.email.presence || `whoami`.chomp,
14
+ destination: config.destination,
15
+ version: config.version,
16
+ service_version: service_version(config),
17
+ service: config.service }
18
+ end
19
+
20
+ def service_version(config)
21
+ [ config.service, config.abbreviated_version ].compact.join("@")
22
+ end
23
+ end
24
+
25
+ def initialize(**tags)
26
+ @tags = tags.compact
27
+ end
28
+
29
+ def env
30
+ tags.transform_keys { |detail| "KAMAL_#{detail.upcase}" }
31
+ end
32
+
33
+ def to_s
34
+ tags.values.map { |value| "[#{value}]" }.join(" ")
35
+ end
36
+
37
+ def except(*tags)
38
+ self.class.new(**self.tags.except(*tags))
39
+ end
40
+ end
@@ -0,0 +1,20 @@
1
+ require "active_support/core_ext/module/delegation"
2
+ require "sshkit"
3
+
4
+ class Kamal::Utils::Sensitive
5
+ # So SSHKit knows to redact these values.
6
+ include SSHKit::Redaction
7
+
8
+ attr_reader :unredacted, :redaction
9
+ delegate :to_s, to: :unredacted
10
+ delegate :inspect, to: :redaction
11
+
12
+ def initialize(value, redaction: "[REDACTED]")
13
+ @unredacted, @redaction = value, redaction
14
+ end
15
+
16
+ # Sensitive values won't leak into YAML output.
17
+ def encode_with(coder)
18
+ coder.represent_scalar nil, redaction
19
+ end
20
+ end