kamal-insecure 2.7.0

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 (130) 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 +313 -0
  6. data/lib/kamal/cli/alias/command.rb +10 -0
  7. data/lib/kamal/cli/app/assets.rb +24 -0
  8. data/lib/kamal/cli/app/boot.rb +126 -0
  9. data/lib/kamal/cli/app/error_pages.rb +33 -0
  10. data/lib/kamal/cli/app/ssl_certificates.rb +28 -0
  11. data/lib/kamal/cli/app.rb +400 -0
  12. data/lib/kamal/cli/base.rb +223 -0
  13. data/lib/kamal/cli/build/clone.rb +61 -0
  14. data/lib/kamal/cli/build.rb +204 -0
  15. data/lib/kamal/cli/healthcheck/barrier.rb +33 -0
  16. data/lib/kamal/cli/healthcheck/error.rb +2 -0
  17. data/lib/kamal/cli/healthcheck/poller.rb +42 -0
  18. data/lib/kamal/cli/lock.rb +45 -0
  19. data/lib/kamal/cli/main.rb +277 -0
  20. data/lib/kamal/cli/proxy.rb +290 -0
  21. data/lib/kamal/cli/prune.rb +34 -0
  22. data/lib/kamal/cli/registry.rb +19 -0
  23. data/lib/kamal/cli/secrets.rb +49 -0
  24. data/lib/kamal/cli/server.rb +50 -0
  25. data/lib/kamal/cli/templates/deploy.yml +101 -0
  26. data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +3 -0
  27. data/lib/kamal/cli/templates/sample_hooks/post-app-boot.sample +3 -0
  28. data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +14 -0
  29. data/lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample +3 -0
  30. data/lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample +3 -0
  31. data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +51 -0
  32. data/lib/kamal/cli/templates/sample_hooks/pre-connect.sample +47 -0
  33. data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +122 -0
  34. data/lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample +3 -0
  35. data/lib/kamal/cli/templates/secrets +17 -0
  36. data/lib/kamal/cli.rb +9 -0
  37. data/lib/kamal/commander/specifics.rb +62 -0
  38. data/lib/kamal/commander.rb +167 -0
  39. data/lib/kamal/commands/accessory/proxy.rb +16 -0
  40. data/lib/kamal/commands/accessory.rb +113 -0
  41. data/lib/kamal/commands/app/assets.rb +51 -0
  42. data/lib/kamal/commands/app/containers.rb +31 -0
  43. data/lib/kamal/commands/app/error_pages.rb +9 -0
  44. data/lib/kamal/commands/app/execution.rb +32 -0
  45. data/lib/kamal/commands/app/images.rb +13 -0
  46. data/lib/kamal/commands/app/logging.rb +28 -0
  47. data/lib/kamal/commands/app/proxy.rb +32 -0
  48. data/lib/kamal/commands/app.rb +124 -0
  49. data/lib/kamal/commands/auditor.rb +39 -0
  50. data/lib/kamal/commands/base.rb +134 -0
  51. data/lib/kamal/commands/builder/base.rb +124 -0
  52. data/lib/kamal/commands/builder/clone.rb +31 -0
  53. data/lib/kamal/commands/builder/cloud.rb +22 -0
  54. data/lib/kamal/commands/builder/hybrid.rb +21 -0
  55. data/lib/kamal/commands/builder/local.rb +14 -0
  56. data/lib/kamal/commands/builder/pack.rb +46 -0
  57. data/lib/kamal/commands/builder/remote.rb +63 -0
  58. data/lib/kamal/commands/builder.rb +48 -0
  59. data/lib/kamal/commands/docker.rb +34 -0
  60. data/lib/kamal/commands/hook.rb +20 -0
  61. data/lib/kamal/commands/lock.rb +70 -0
  62. data/lib/kamal/commands/proxy.rb +127 -0
  63. data/lib/kamal/commands/prune.rb +38 -0
  64. data/lib/kamal/commands/registry.rb +16 -0
  65. data/lib/kamal/commands/server.rb +15 -0
  66. data/lib/kamal/commands.rb +2 -0
  67. data/lib/kamal/configuration/accessory.rb +241 -0
  68. data/lib/kamal/configuration/alias.rb +15 -0
  69. data/lib/kamal/configuration/boot.rb +25 -0
  70. data/lib/kamal/configuration/builder.rb +211 -0
  71. data/lib/kamal/configuration/docs/accessory.yml +128 -0
  72. data/lib/kamal/configuration/docs/alias.yml +26 -0
  73. data/lib/kamal/configuration/docs/boot.yml +19 -0
  74. data/lib/kamal/configuration/docs/builder.yml +132 -0
  75. data/lib/kamal/configuration/docs/configuration.yml +184 -0
  76. data/lib/kamal/configuration/docs/env.yml +116 -0
  77. data/lib/kamal/configuration/docs/logging.yml +21 -0
  78. data/lib/kamal/configuration/docs/proxy.yml +164 -0
  79. data/lib/kamal/configuration/docs/registry.yml +56 -0
  80. data/lib/kamal/configuration/docs/role.yml +53 -0
  81. data/lib/kamal/configuration/docs/servers.yml +27 -0
  82. data/lib/kamal/configuration/docs/ssh.yml +70 -0
  83. data/lib/kamal/configuration/docs/sshkit.yml +23 -0
  84. data/lib/kamal/configuration/env/tag.rb +13 -0
  85. data/lib/kamal/configuration/env.rb +38 -0
  86. data/lib/kamal/configuration/logging.rb +33 -0
  87. data/lib/kamal/configuration/proxy/boot.rb +129 -0
  88. data/lib/kamal/configuration/proxy.rb +124 -0
  89. data/lib/kamal/configuration/registry.rb +32 -0
  90. data/lib/kamal/configuration/role.rb +222 -0
  91. data/lib/kamal/configuration/servers.rb +25 -0
  92. data/lib/kamal/configuration/ssh.rb +57 -0
  93. data/lib/kamal/configuration/sshkit.rb +22 -0
  94. data/lib/kamal/configuration/validation.rb +27 -0
  95. data/lib/kamal/configuration/validator/accessory.rb +13 -0
  96. data/lib/kamal/configuration/validator/alias.rb +15 -0
  97. data/lib/kamal/configuration/validator/builder.rb +15 -0
  98. data/lib/kamal/configuration/validator/configuration.rb +6 -0
  99. data/lib/kamal/configuration/validator/env.rb +54 -0
  100. data/lib/kamal/configuration/validator/proxy.rb +25 -0
  101. data/lib/kamal/configuration/validator/registry.rb +25 -0
  102. data/lib/kamal/configuration/validator/role.rb +13 -0
  103. data/lib/kamal/configuration/validator/servers.rb +7 -0
  104. data/lib/kamal/configuration/validator.rb +191 -0
  105. data/lib/kamal/configuration/volume.rb +22 -0
  106. data/lib/kamal/configuration.rb +372 -0
  107. data/lib/kamal/docker.rb +30 -0
  108. data/lib/kamal/env_file.rb +44 -0
  109. data/lib/kamal/git.rb +37 -0
  110. data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +51 -0
  111. data/lib/kamal/secrets/adapters/base.rb +33 -0
  112. data/lib/kamal/secrets/adapters/bitwarden.rb +81 -0
  113. data/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb +66 -0
  114. data/lib/kamal/secrets/adapters/doppler.rb +57 -0
  115. data/lib/kamal/secrets/adapters/enpass.rb +71 -0
  116. data/lib/kamal/secrets/adapters/gcp_secret_manager.rb +112 -0
  117. data/lib/kamal/secrets/adapters/last_pass.rb +40 -0
  118. data/lib/kamal/secrets/adapters/one_password.rb +104 -0
  119. data/lib/kamal/secrets/adapters/passbolt.rb +130 -0
  120. data/lib/kamal/secrets/adapters/test.rb +14 -0
  121. data/lib/kamal/secrets/adapters.rb +16 -0
  122. data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +33 -0
  123. data/lib/kamal/secrets.rb +42 -0
  124. data/lib/kamal/sshkit_with_ext.rb +142 -0
  125. data/lib/kamal/tags.rb +40 -0
  126. data/lib/kamal/utils/sensitive.rb +20 -0
  127. data/lib/kamal/utils.rb +110 -0
  128. data/lib/kamal/version.rb +3 -0
  129. data/lib/kamal.rb +14 -0
  130. metadata +365 -0
@@ -0,0 +1,104 @@
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, from:, account:, session:)
19
+ if secrets.blank?
20
+ fetch_all_secrets(from: from, account: account, session: session)
21
+ else
22
+ fetch_specified_secrets(secrets, from: from, account: account, session: session)
23
+ end
24
+ end
25
+
26
+ def fetch_specified_secrets(secrets, from:, account:, session:)
27
+ {}.tap do |results|
28
+ vaults_items_fields(prefixed_secrets(secrets, from: from)).map do |vault, items|
29
+ items.each do |item, fields|
30
+ fields_json = JSON.parse(op_item_get(vault, item, fields: fields, account: account, session: session))
31
+ fields_json = [ fields_json ] if fields.one?
32
+
33
+ results.merge!(fields_map(fields_json))
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ def fetch_all_secrets(from:, account:, session:)
40
+ {}.tap do |results|
41
+ vault_items(from).each do |vault, items|
42
+ items.each do |item|
43
+ fields_json = JSON.parse(op_item_get(vault, item, account: account, session: session)).fetch("fields")
44
+
45
+ results.merge!(fields_map(fields_json))
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ def to_options(**options)
52
+ optionize(options.compact).join(" ")
53
+ end
54
+
55
+ def vaults_items_fields(secrets)
56
+ {}.tap do |vaults|
57
+ secrets.each do |secret|
58
+ secret = secret.delete_prefix("op://")
59
+ vault, item, *fields = secret.split("/")
60
+ fields << "password" if fields.empty?
61
+
62
+ vaults[vault] ||= {}
63
+ vaults[vault][item] ||= []
64
+ vaults[vault][item] << fields.join(".")
65
+ end
66
+ end
67
+ end
68
+
69
+ def vault_items(from)
70
+ from = from.delete_prefix("op://")
71
+ vault, item = from.split("/")
72
+ { vault => [ item ] }
73
+ end
74
+
75
+ def fields_map(fields_json)
76
+ fields_json.to_h do |field_json|
77
+ # The reference is in the form `op://vault/item/field[/field]`
78
+ field = field_json["reference"].delete_prefix("op://").delete_suffix("/password")
79
+ [ field, field_json["value"] ]
80
+ end
81
+ end
82
+
83
+ def op_item_get(vault, item, fields: nil, account:, session:)
84
+ options = { vault: vault, format: "json", account: account, session: session.presence }
85
+
86
+ if fields.present?
87
+ labels = fields.map { |field| "label=#{field}" }.join(",")
88
+ options.merge!(fields: labels)
89
+ end
90
+
91
+ `op item get #{item.shellescape} #{to_options(**options)}`.tap do
92
+ raise RuntimeError, "Could not read #{"#{fields.join(", ")} " if fields.present?}from #{item} in the #{vault} 1Password vault" unless $?.success?
93
+ end
94
+ end
95
+
96
+ def check_dependencies!
97
+ raise RuntimeError, "1Password CLI is not installed" unless cli_installed?
98
+ end
99
+
100
+ def cli_installed?
101
+ `op --version 2> /dev/null`
102
+ $?.success?
103
+ end
104
+ end
@@ -0,0 +1,130 @@
1
+ class Kamal::Secrets::Adapters::Passbolt < Kamal::Secrets::Adapters::Base
2
+ def requires_account?
3
+ false
4
+ end
5
+
6
+ private
7
+
8
+ def login(*)
9
+ `passbolt verify`
10
+ raise RuntimeError, "Failed to login to Passbolt" unless $?.success?
11
+ end
12
+
13
+ def fetch_secrets(secrets, from:, **)
14
+ secrets = prefixed_secrets(secrets, from: from)
15
+ raise ArgumentError, "No secrets given to fetch" if secrets.empty?
16
+
17
+ secret_names = secrets.collect { |s| s.split("/").last }
18
+ folders = secrets_get_folders(secrets)
19
+
20
+ # build filter conditions for each secret with its corresponding folder
21
+ filter_conditions = []
22
+ secrets.each do |secret|
23
+ parts = secret.split("/")
24
+ secret_name = parts.last
25
+
26
+ if parts.size > 1
27
+ # get the folder path without the secret name
28
+ folder_path = parts[0..-2]
29
+
30
+ # find the most nested folder for this path
31
+ current_folder = nil
32
+ current_path = []
33
+
34
+ folder_path.each do |folder_name|
35
+ current_path << folder_name
36
+ matching_folders = folders.select { |f| get_folder_path(f, folders) == current_path.join("/") }
37
+ current_folder = matching_folders.first if matching_folders.any?
38
+ end
39
+
40
+ if current_folder
41
+ filter_conditions << "(Name == #{secret_name.shellescape.inspect} && FolderParentID == #{current_folder["id"].shellescape.inspect})"
42
+ end
43
+ else
44
+ # for root level secrets (no folders)
45
+ filter_conditions << "Name == #{secret_name.shellescape.inspect}"
46
+ end
47
+ end
48
+
49
+ filter_condition = filter_conditions.any? ? "--filter '#{filter_conditions.join(" || ")}'" : ""
50
+ items = `passbolt list resources #{filter_condition} #{folders.map { |item| "--folder #{item["id"]}" }.join(" ")} --json`
51
+ raise RuntimeError, "Could not read #{secrets} from Passbolt" unless $?.success?
52
+
53
+ items = JSON.parse(items)
54
+ found_names = items.map { |item| item["name"] }
55
+ missing_secrets = secret_names - found_names
56
+ raise RuntimeError, "Could not find the following secrets in Passbolt: #{missing_secrets.join(", ")}" if missing_secrets.any?
57
+
58
+ items.to_h { |item| [ item["name"], item["password"] ] }
59
+ end
60
+
61
+ def secrets_get_folders(secrets)
62
+ # extract all folder paths (both parent and nested)
63
+ folder_paths = secrets
64
+ .select { |s| s.include?("/") }
65
+ .map { |s| s.split("/")[0..-2] } # get all parts except the secret name
66
+ .uniq
67
+
68
+ return [] if folder_paths.empty?
69
+
70
+ all_folders = []
71
+
72
+ # first get all top-level folders
73
+ parent_folders = folder_paths.map(&:first).uniq
74
+ filter_condition = "--filter '#{parent_folders.map { |name| "Name == #{name.shellescape.inspect}" }.join(" || ")}'"
75
+ fetch_folders = `passbolt list folders #{filter_condition} --json`
76
+ raise RuntimeError, "Could not read folders from Passbolt" unless $?.success?
77
+
78
+ parent_folder_items = JSON.parse(fetch_folders)
79
+ all_folders.concat(parent_folder_items)
80
+
81
+ # get nested folders for each parent
82
+ folder_paths.each do |path|
83
+ next if path.size <= 1 # skip non-nested folders
84
+
85
+ parent = path[0]
86
+ parent_folder = parent_folder_items.find { |f| f["name"] == parent }
87
+ next unless parent_folder
88
+
89
+ # for each nested level, get the folders using the parent's ID
90
+ current_parent = parent_folder
91
+ path[1..-1].each do |folder_name|
92
+ filter_condition = "--filter 'Name == #{folder_name.shellescape.inspect} && FolderParentID == #{current_parent["id"].shellescape.inspect}'"
93
+ fetch_nested = `passbolt list folders #{filter_condition} --json`
94
+ next unless $?.success?
95
+
96
+ nested_folders = JSON.parse(fetch_nested)
97
+ break if nested_folders.empty?
98
+
99
+ all_folders.concat(nested_folders)
100
+ current_parent = nested_folders.first
101
+ end
102
+ end
103
+
104
+ # check if we found all required folders
105
+ found_paths = all_folders.map { |f| get_folder_path(f, all_folders) }
106
+ missing_paths = folder_paths.map { |path| path.join("/") } - found_paths
107
+ raise RuntimeError, "Could not find the following folders in Passbolt: #{missing_paths.join(", ")}" if missing_paths.any?
108
+
109
+ all_folders
110
+ end
111
+
112
+ def get_folder_path(folder, all_folders, path = [])
113
+ path.unshift(folder["name"])
114
+ return path.join("/") if folder["folder_parent_id"].to_s.empty?
115
+
116
+ parent = all_folders.find { |f| f["id"] == folder["folder_parent_id"] }
117
+ return path.join("/") unless parent
118
+
119
+ get_folder_path(parent, all_folders, path)
120
+ end
121
+
122
+ def check_dependencies!
123
+ raise RuntimeError, "Passbolt CLI is not installed" unless cli_installed?
124
+ end
125
+
126
+ def cli_installed?
127
+ `passbolt --version 2> /dev/null`
128
+ $?.success?
129
+ end
130
+ 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, from:, account:, session:)
8
+ prefixed_secrets(secrets, from: from).to_h { |secret| [ secret, secret.reverse ] }
9
+ end
10
+
11
+ def check_dependencies!
12
+ # no op
13
+ end
14
+ end
@@ -0,0 +1,16 @@
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
+ name = "gcp_secret_manager" if name.downcase == "gcp"
7
+ name = "bitwarden_secrets_manager" if name.downcase == "bitwarden-sm"
8
+ adapter_class(name)
9
+ end
10
+
11
+ def self.adapter_class(name)
12
+ Object.const_get("Kamal::Secrets::Adapters::#{name.camelize}").new
13
+ rescue NameError => e
14
+ raise RuntimeError, "Unknown secrets adapter: #{name}"
15
+ end
16
+ end
@@ -0,0 +1,33 @@
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
+ command = ::Dotenv::Substitutions::Variable.call(command, env)
18
+ if command =~ /\A\s*kamal\s*secrets\s+/
19
+ # Inline the command
20
+ inline_secrets_command(command)
21
+ else
22
+ # Execute the command and return the value
23
+ `#{command}`.chomp
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ def inline_secrets_command(command)
30
+ Kamal::Cli::Main.start(command.shellsplit[1..] + [ "--inline" ]).chomp
31
+ end
32
+ end
33
+ 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, overwrite: true))
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
@@ -0,0 +1,110 @@
1
+ require "active_support/core_ext/object/try"
2
+
3
+ module Kamal::Utils
4
+ extend self
5
+
6
+ DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX = /\$(?!{[^\}]*\})/
7
+
8
+ # Return a list of escaped shell arguments using the same named argument against the passed attributes (hash or array).
9
+ def argumentize(argument, attributes, sensitive: false)
10
+ Array(attributes).flat_map do |key, value|
11
+ if value.present?
12
+ attr = "#{key}=#{escape_shell_value(value)}"
13
+ attr = self.sensitive(attr, redaction: "#{key}=[REDACTED]") if sensitive
14
+ [ argument, attr ]
15
+ elsif value == false
16
+ [ argument, "#{key}=false" ]
17
+ else
18
+ [ argument, key ]
19
+ end
20
+ end
21
+ end
22
+
23
+ # Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option.
24
+ def optionize(args, with: nil)
25
+ options = if with
26
+ flatten_args(args).collect { |(key, value)| value == true ? "--#{key}" : "--#{key}#{with}#{escape_shell_value(value)}" }
27
+ else
28
+ flatten_args(args).collect { |(key, value)| [ "--#{key}", value == true ? nil : escape_shell_value(value) ] }
29
+ end
30
+
31
+ options.flatten.compact
32
+ end
33
+
34
+ # Flattens a one-to-many structure into an array of two-element arrays each containing a key-value pair
35
+ def flatten_args(args)
36
+ args.flat_map { |key, value| value.try(:map) { |entry| [ key, entry ] } || [ [ key, value ] ] }
37
+ end
38
+
39
+ # Marks sensitive values for redaction in logs and human-visible output.
40
+ # Pass `redaction:` to change the default `"[REDACTED]"` redaction, e.g.
41
+ # `sensitive "#{arg}=#{secret}", redaction: "#{arg}=xxxx"
42
+ def sensitive(...)
43
+ Kamal::Utils::Sensitive.new(...)
44
+ end
45
+
46
+ def redacted(value)
47
+ case
48
+ when value.respond_to?(:redaction)
49
+ value.redaction
50
+ when value.respond_to?(:transform_values)
51
+ value.transform_values { |value| redacted value }
52
+ when value.respond_to?(:map)
53
+ value.map { |element| redacted element }
54
+ else
55
+ value
56
+ end
57
+ end
58
+
59
+ # Escape a value to make it safe for shell use.
60
+ def escape_shell_value(value)
61
+ value.to_s.scan(/[\x00-\x7F]+|[^\x00-\x7F]+/) \
62
+ .map { |part| part.ascii_only? ? escape_ascii_shell_value(part) : part }
63
+ .join
64
+ end
65
+
66
+ def escape_ascii_shell_value(value)
67
+ value.to_s.dump
68
+ .gsub(/`/, '\\\\`')
69
+ .gsub(DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX, '\$')
70
+ end
71
+
72
+ # Apply a list of host or role filters, including wildcard matches
73
+ def filter_specific_items(filters, items)
74
+ matches = []
75
+
76
+ Array(filters).select do |filter|
77
+ matches += Array(items).select do |item|
78
+ # Only allow * for a wildcard
79
+ # items are roles or hosts
80
+ File.fnmatch(filter, item.to_s, File::FNM_EXTGLOB)
81
+ end
82
+ end
83
+
84
+ matches.uniq
85
+ end
86
+
87
+ def stable_sort!(elements, &block)
88
+ elements.sort_by!.with_index { |element, index| [ block.call(element), index ] }
89
+ end
90
+
91
+ def join_commands(commands)
92
+ commands.map(&:strip).join(" ")
93
+ end
94
+
95
+ def docker_arch
96
+ arch = `docker info --format '{{.Architecture}}'`.strip
97
+ case arch
98
+ when /aarch64/
99
+ "arm64"
100
+ when /x86_64/
101
+ "amd64"
102
+ else
103
+ arch
104
+ end
105
+ end
106
+
107
+ def older_version?(version, other_version)
108
+ Gem::Version.new(version.delete_prefix("v")) < Gem::Version.new(other_version.delete_prefix("v"))
109
+ end
110
+ end
@@ -0,0 +1,3 @@
1
+ module Kamal
2
+ VERSION = "2.7.0"
3
+ end
data/lib/kamal.rb ADDED
@@ -0,0 +1,14 @@
1
+ module Kamal
2
+ class ConfigurationError < StandardError; end
3
+ end
4
+
5
+ require "active_support"
6
+ require "zeitwerk"
7
+ require "yaml"
8
+ require "tmpdir"
9
+ require "pathname"
10
+
11
+ loader = Zeitwerk::Loader.for_gem
12
+ loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb"))
13
+ loader.setup
14
+ loader.eager_load_namespace(Kamal::Cli) # We need all commands loaded.