kamal 1.8.3 → 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 +4 -4
  2. data/README.md +1 -1
  3. data/lib/kamal/cli/accessory.rb +92 -38
  4. data/lib/kamal/cli/alias/command.rb +10 -0
  5. data/lib/kamal/cli/app/{prepare_assets.rb → assets.rb} +1 -1
  6. data/lib/kamal/cli/app/boot.rb +23 -16
  7. data/lib/kamal/cli/app/error_pages.rb +33 -0
  8. data/lib/kamal/cli/app/ssl_certificates.rb +28 -0
  9. data/lib/kamal/cli/app.rb +132 -30
  10. data/lib/kamal/cli/base.rb +57 -53
  11. data/lib/kamal/cli/build.rb +81 -38
  12. data/lib/kamal/cli/healthcheck/barrier.rb +2 -0
  13. data/lib/kamal/cli/healthcheck/poller.rb +18 -39
  14. data/lib/kamal/cli/lock.rb +2 -3
  15. data/lib/kamal/cli/main.rb +60 -59
  16. data/lib/kamal/cli/proxy.rb +290 -0
  17. data/lib/kamal/cli/prune.rb +0 -1
  18. data/lib/kamal/cli/registry.rb +2 -0
  19. data/lib/kamal/cli/secrets.rb +49 -0
  20. data/lib/kamal/cli/server.rb +6 -5
  21. data/lib/kamal/cli/templates/deploy.yml +53 -53
  22. data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +2 -12
  23. data/lib/kamal/cli/templates/sample_hooks/post-app-boot.sample +3 -0
  24. data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +1 -1
  25. data/lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample +3 -0
  26. data/lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample +3 -0
  27. data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +1 -1
  28. data/lib/kamal/cli/templates/sample_hooks/pre-connect.sample +1 -1
  29. data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +19 -6
  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 +2 -0
  33. data/lib/kamal/commander/specifics.rb +19 -6
  34. data/lib/kamal/commander.rb +39 -32
  35. data/lib/kamal/commands/accessory/proxy.rb +16 -0
  36. data/lib/kamal/commands/accessory.rb +19 -19
  37. data/lib/kamal/commands/app/assets.rb +10 -10
  38. data/lib/kamal/commands/app/containers.rb +2 -2
  39. data/lib/kamal/commands/app/error_pages.rb +9 -0
  40. data/lib/kamal/commands/app/execution.rb +7 -4
  41. data/lib/kamal/commands/app/images.rb +1 -1
  42. data/lib/kamal/commands/app/logging.rb +16 -6
  43. data/lib/kamal/commands/app/proxy.rb +32 -0
  44. data/lib/kamal/commands/app.rb +25 -24
  45. data/lib/kamal/commands/auditor.rb +12 -3
  46. data/lib/kamal/commands/base.rb +54 -8
  47. data/lib/kamal/commands/builder/base.rb +46 -16
  48. data/lib/kamal/commands/builder/clone.rb +16 -14
  49. data/lib/kamal/commands/builder/cloud.rb +22 -0
  50. data/lib/kamal/commands/builder/hybrid.rb +21 -0
  51. data/lib/kamal/commands/builder/local.rb +14 -0
  52. data/lib/kamal/commands/builder/pack.rb +46 -0
  53. data/lib/kamal/commands/builder/remote.rb +63 -0
  54. data/lib/kamal/commands/builder.rb +21 -45
  55. data/lib/kamal/commands/docker.rb +4 -0
  56. data/lib/kamal/commands/hook.rb +8 -2
  57. data/lib/kamal/commands/lock.rb +2 -6
  58. data/lib/kamal/commands/proxy.rb +127 -0
  59. data/lib/kamal/commands/prune.rb +1 -9
  60. data/lib/kamal/commands/registry.rb +9 -7
  61. data/lib/kamal/commands/server.rb +11 -1
  62. data/lib/kamal/configuration/accessory.rb +89 -12
  63. data/lib/kamal/configuration/alias.rb +15 -0
  64. data/lib/kamal/configuration/builder.rb +73 -15
  65. data/lib/kamal/configuration/docs/accessory.yml +53 -15
  66. data/lib/kamal/configuration/docs/alias.yml +26 -0
  67. data/lib/kamal/configuration/docs/boot.yml +3 -3
  68. data/lib/kamal/configuration/docs/builder.yml +63 -38
  69. data/lib/kamal/configuration/docs/configuration.yml +62 -46
  70. data/lib/kamal/configuration/docs/env.yml +61 -17
  71. data/lib/kamal/configuration/docs/logging.yml +3 -3
  72. data/lib/kamal/configuration/docs/proxy.yml +168 -0
  73. data/lib/kamal/configuration/docs/registry.yml +20 -13
  74. data/lib/kamal/configuration/docs/role.yml +14 -13
  75. data/lib/kamal/configuration/docs/servers.yml +2 -2
  76. data/lib/kamal/configuration/docs/ssh.yml +23 -19
  77. data/lib/kamal/configuration/docs/sshkit.yml +4 -4
  78. data/lib/kamal/configuration/env/tag.rb +4 -3
  79. data/lib/kamal/configuration/env.rb +19 -17
  80. data/lib/kamal/configuration/proxy/boot.rb +129 -0
  81. data/lib/kamal/configuration/proxy.rb +124 -0
  82. data/lib/kamal/configuration/registry.rb +7 -6
  83. data/lib/kamal/configuration/role.rb +69 -98
  84. data/lib/kamal/configuration/servers.rb +8 -1
  85. data/lib/kamal/configuration/validator/accessory.rb +6 -2
  86. data/lib/kamal/configuration/validator/alias.rb +15 -0
  87. data/lib/kamal/configuration/validator/builder.rb +6 -0
  88. data/lib/kamal/configuration/validator/proxy.rb +25 -0
  89. data/lib/kamal/configuration/validator/role.rb +3 -1
  90. data/lib/kamal/configuration/validator/servers.rb +1 -1
  91. data/lib/kamal/configuration/validator.rb +62 -24
  92. data/lib/kamal/configuration.rb +96 -50
  93. data/lib/kamal/docker.rb +30 -0
  94. data/lib/kamal/env_file.rb +7 -1
  95. data/lib/kamal/git.rb +10 -0
  96. data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +51 -0
  97. data/lib/kamal/secrets/adapters/base.rb +33 -0
  98. data/lib/kamal/secrets/adapters/bitwarden.rb +81 -0
  99. data/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb +66 -0
  100. data/lib/kamal/secrets/adapters/doppler.rb +57 -0
  101. data/lib/kamal/secrets/adapters/enpass.rb +71 -0
  102. data/lib/kamal/secrets/adapters/gcp_secret_manager.rb +112 -0
  103. data/lib/kamal/secrets/adapters/last_pass.rb +40 -0
  104. data/lib/kamal/secrets/adapters/one_password.rb +104 -0
  105. data/lib/kamal/secrets/adapters/passbolt.rb +130 -0
  106. data/lib/kamal/secrets/adapters/test.rb +14 -0
  107. data/lib/kamal/secrets/adapters.rb +16 -0
  108. data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +33 -0
  109. data/lib/kamal/secrets.rb +42 -0
  110. data/lib/kamal/sshkit_with_ext.rb +1 -0
  111. data/lib/kamal/utils.rb +30 -0
  112. data/lib/kamal/version.rb +1 -1
  113. data/lib/kamal.rb +3 -1
  114. metadata +63 -36
  115. data/lib/kamal/cli/env.rb +0 -54
  116. data/lib/kamal/cli/templates/sample_hooks/post-traefik-reboot.sample +0 -3
  117. data/lib/kamal/cli/templates/sample_hooks/pre-traefik-reboot.sample +0 -3
  118. data/lib/kamal/cli/templates/template.env +0 -2
  119. data/lib/kamal/cli/traefik.rb +0 -122
  120. data/lib/kamal/commands/app/cord.rb +0 -22
  121. data/lib/kamal/commands/builder/multiarch/remote.rb +0 -65
  122. data/lib/kamal/commands/builder/multiarch.rb +0 -41
  123. data/lib/kamal/commands/builder/native/cached.rb +0 -25
  124. data/lib/kamal/commands/builder/native/remote.rb +0 -67
  125. data/lib/kamal/commands/builder/native.rb +0 -20
  126. data/lib/kamal/commands/traefik.rb +0 -85
  127. data/lib/kamal/configuration/docs/healthcheck.yml +0 -59
  128. data/lib/kamal/configuration/docs/traefik.yml +0 -62
  129. data/lib/kamal/configuration/healthcheck.rb +0 -63
  130. data/lib/kamal/configuration/traefik.rb +0 -60
@@ -2,21 +2,22 @@ require "active_support/ordered_options"
2
2
  require "active_support/core_ext/string/inquiry"
3
3
  require "active_support/core_ext/module/delegation"
4
4
  require "active_support/core_ext/hash/keys"
5
- require "pathname"
6
5
  require "erb"
7
6
  require "net/ssh/proxy/jump"
8
7
 
9
8
  class Kamal::Configuration
10
- delegate :service, :image, :labels, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true
9
+ delegate :service, :image, :labels, :hooks_path, to: :raw_config, allow_nil: true
11
10
  delegate :argumentize, :optionize, to: Kamal::Utils
12
11
 
13
- attr_reader :destination, :raw_config
14
- attr_reader :accessories, :boot, :builder, :env, :healthcheck, :logging, :traefik, :servers, :ssh, :sshkit, :registry
12
+ attr_reader :destination, :raw_config, :secrets
13
+ attr_reader :accessories, :aliases, :boot, :builder, :env, :logging, :proxy, :proxy_boot, :servers, :ssh, :sshkit, :registry
15
14
 
16
15
  include Validation
17
16
 
18
17
  class << self
19
18
  def create_from(config_file:, destination: nil, version: nil)
19
+ ENV["KAMAL_DESTINATION"] = destination
20
+
20
21
  raw_config = load_config_files(config_file, *destination_config_file(config_file, destination))
21
22
 
22
23
  new raw_config, destination: destination, version: version
@@ -31,7 +32,7 @@ class Kamal::Configuration
31
32
  if file.exist?
32
33
  # Newer Psych doesn't load aliases by default
33
34
  load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load
34
- YAML.send(load_method, ERB.new(IO.read(file)).result).symbolize_keys
35
+ YAML.send(load_method, ERB.new(File.read(file)).result).symbolize_keys
35
36
  else
36
37
  raise "Configuration file not found in #{file}"
37
38
  end
@@ -49,18 +50,21 @@ class Kamal::Configuration
49
50
 
50
51
  validate! raw_config, example: validation_yml.symbolize_keys, context: "", with: Kamal::Configuration::Validator::Configuration
51
52
 
53
+ @secrets = Kamal::Secrets.new(destination: destination)
54
+
52
55
  # Eager load config to validate it, these are first as they have dependencies later on
53
56
  @servers = Servers.new(config: self)
54
- @registry = Registry.new(config: self)
57
+ @registry = Registry.new(config: @raw_config, secrets: secrets)
55
58
 
56
59
  @accessories = @raw_config.accessories&.keys&.collect { |name| Accessory.new(name, config: self) } || []
60
+ @aliases = @raw_config.aliases&.keys&.to_h { |name| [ name, Alias.new(name, config: self) ] } || {}
57
61
  @boot = Boot.new(config: self)
58
62
  @builder = Builder.new(config: self)
59
- @env = Env.new(config: @raw_config.env || {})
63
+ @env = Env.new(config: @raw_config.env || {}, secrets: secrets)
60
64
 
61
- @healthcheck = Healthcheck.new(healthcheck_config: @raw_config.healthcheck)
62
65
  @logging = Logging.new(logging_config: @raw_config.logging)
63
- @traefik = Traefik.new(config: self)
66
+ @proxy = Proxy.new(config: self, proxy_config: @raw_config.proxy, secrets: secrets)
67
+ @proxy_boot = Proxy::Boot.new(config: self)
64
68
  @ssh = Ssh.new(config: self)
65
69
  @sshkit = Sshkit.new(config: self)
66
70
 
@@ -69,9 +73,11 @@ class Kamal::Configuration
69
73
  ensure_valid_kamal_version
70
74
  ensure_retain_containers_valid
71
75
  ensure_valid_service_name
76
+ ensure_no_traefik_reboot_hooks
77
+ ensure_one_host_for_ssl_roles
78
+ ensure_unique_hosts_for_ssl_roles
72
79
  end
73
80
 
74
-
75
81
  def version=(version)
76
82
  @declared_version = version
77
83
  end
@@ -95,6 +101,9 @@ class Kamal::Configuration
95
101
  raw_config.minimum_version
96
102
  end
97
103
 
104
+ def service_and_destination
105
+ [ service, destination ].compact.join("-")
106
+ end
98
107
 
99
108
  def roles
100
109
  servers.roles
@@ -108,11 +117,14 @@ class Kamal::Configuration
108
117
  accessories.detect { |a| a.name == name.to_s }
109
118
  end
110
119
 
111
-
112
120
  def all_hosts
113
121
  (roles + accessories).flat_map(&:hosts).uniq
114
122
  end
115
123
 
124
+ def app_hosts
125
+ roles.flat_map(&:hosts).uniq
126
+ end
127
+
116
128
  def primary_host
117
129
  primary_role&.primary_host
118
130
  end
@@ -129,16 +141,20 @@ class Kamal::Configuration
129
141
  raw_config.allow_empty_roles
130
142
  end
131
143
 
132
- def traefik_roles
133
- roles.select(&:running_traefik?)
144
+ def proxy_roles
145
+ roles.select(&:running_proxy?)
146
+ end
147
+
148
+ def proxy_role_names
149
+ proxy_roles.flat_map(&:name)
134
150
  end
135
151
 
136
- def traefik_role_names
137
- traefik_roles.flat_map(&:name)
152
+ def proxy_accessories
153
+ accessories.select(&:running_proxy?)
138
154
  end
139
155
 
140
- def traefik_hosts
141
- traefik_roles.flat_map(&:hosts).uniq
156
+ def proxy_hosts
157
+ (proxy_roles.flat_map(&:hosts) + proxy_accessories.flat_map(&:hosts)).uniq
142
158
  end
143
159
 
144
160
  def repository
@@ -169,7 +185,6 @@ class Kamal::Configuration
169
185
  raw_config.retain_containers || 5
170
186
  end
171
187
 
172
-
173
188
  def volume_args
174
189
  if raw_config.volumes.present?
175
190
  argumentize "--volume", raw_config.volumes
@@ -182,30 +197,36 @@ class Kamal::Configuration
182
197
  logging.args
183
198
  end
184
199
 
185
-
186
- def healthcheck_service
187
- [ "healthcheck", service, destination ].compact.join("-")
188
- end
189
-
190
200
  def readiness_delay
191
201
  raw_config.readiness_delay || 7
192
202
  end
193
203
 
194
- def run_id
195
- @run_id ||= SecureRandom.hex(16)
204
+ def deploy_timeout
205
+ raw_config.deploy_timeout || 30
196
206
  end
197
207
 
208
+ def drain_timeout
209
+ raw_config.drain_timeout || 30
210
+ end
198
211
 
199
212
  def run_directory
200
- raw_config.run_directory || ".kamal"
213
+ ".kamal"
201
214
  end
202
215
 
203
- def run_directory_as_docker_volume
204
- if Pathname.new(run_directory).absolute?
205
- run_directory
206
- else
207
- File.join "$(pwd)", run_directory
208
- end
216
+ def apps_directory
217
+ File.join run_directory, "apps"
218
+ end
219
+
220
+ def app_directory
221
+ File.join apps_directory, service_and_destination
222
+ end
223
+
224
+ def env_directory
225
+ File.join app_directory, "env"
226
+ end
227
+
228
+ def assets_directory
229
+ File.join app_directory, "assets"
209
230
  end
210
231
 
211
232
  def hooks_path
@@ -216,14 +237,13 @@ class Kamal::Configuration
216
237
  raw_config.asset_path
217
238
  end
218
239
 
219
-
220
- def host_env_directory
221
- File.join(run_directory, "env")
240
+ def error_pages_path
241
+ raw_config.error_pages_path
222
242
  end
223
243
 
224
244
  def env_tags
225
245
  @env_tags ||= if (tags = raw_config.env["tags"])
226
- tags.collect { |name, config| Env::Tag.new(name, config: config) }
246
+ tags.collect { |name, config| Env::Tag.new(name, config: config, secrets: secrets) }
227
247
  else
228
248
  []
229
249
  end
@@ -233,7 +253,6 @@ class Kamal::Configuration
233
253
  env_tags.detect { |t| t.name == name.to_s }
234
254
  end
235
255
 
236
-
237
256
  def to_h
238
257
  {
239
258
  roles: role_names,
@@ -248,8 +267,7 @@ class Kamal::Configuration
248
267
  sshkit: sshkit.to_h,
249
268
  builder: builder.to_h,
250
269
  accessories: raw_config.accessories,
251
- logging: logging_args,
252
- healthcheck: healthcheck.to_h
270
+ logging: logging_args
253
271
  }.compact
254
272
  end
255
273
 
@@ -264,22 +282,26 @@ class Kamal::Configuration
264
282
  end
265
283
 
266
284
  def ensure_required_keys_present
267
- %i[ service image registry servers ].each do |key|
285
+ %i[ service image registry ].each do |key|
268
286
  raise Kamal::ConfigurationError, "Missing required configuration for #{key}" unless raw_config[key].present?
269
287
  end
270
288
 
271
- unless role(primary_role_name).present?
272
- raise Kamal::ConfigurationError, "The primary_role #{primary_role_name} isn't defined"
273
- end
289
+ if raw_config.servers.nil?
290
+ raise Kamal::ConfigurationError, "No servers or accessories specified" unless raw_config.accessories.present?
291
+ else
292
+ unless role(primary_role_name).present?
293
+ raise Kamal::ConfigurationError, "The primary_role #{primary_role_name} isn't defined"
294
+ end
274
295
 
275
- if primary_role.hosts.empty?
276
- raise Kamal::ConfigurationError, "No servers specified for the #{primary_role.name} primary_role"
277
- end
296
+ if primary_role.hosts.empty?
297
+ raise Kamal::ConfigurationError, "No servers specified for the #{primary_role.name} primary_role"
298
+ end
278
299
 
279
- unless allow_empty_roles?
280
- roles.each do |role|
281
- if role.hosts.empty?
282
- raise Kamal::ConfigurationError, "No servers specified for the #{role.name} role. You can ignore this with allow_empty_roles: true"
300
+ unless allow_empty_roles?
301
+ roles.each do |role|
302
+ if role.hosts.empty?
303
+ raise Kamal::ConfigurationError, "No servers specified for the #{role.name} role. You can ignore this with allow_empty_roles: true"
304
+ end
283
305
  end
284
306
  end
285
307
  end
@@ -307,6 +329,30 @@ class Kamal::Configuration
307
329
  true
308
330
  end
309
331
 
332
+ def ensure_no_traefik_reboot_hooks
333
+ hooks = %w[ pre-traefik-reboot post-traefik-reboot ].select { |hook_file| File.exist?(File.join(hooks_path, hook_file)) }
334
+
335
+ if hooks.any?
336
+ raise Kamal::ConfigurationError, "Found #{hooks.join(", ")}, these should be renamed to (pre|post)-proxy-reboot"
337
+ end
338
+
339
+ true
340
+ end
341
+
342
+ def ensure_one_host_for_ssl_roles
343
+ roles.each(&:ensure_one_host_for_ssl)
344
+
345
+ true
346
+ end
347
+
348
+ def ensure_unique_hosts_for_ssl_roles
349
+ hosts = roles.select(&:ssl?).flat_map { |role| role.proxy.hosts }
350
+ duplicates = hosts.tally.filter_map { |host, count| host if count > 1 }
351
+
352
+ raise Kamal::ConfigurationError, "Different roles can't share the same host for SSL: #{duplicates.join(", ")}" if duplicates.any?
353
+
354
+ true
355
+ end
310
356
 
311
357
  def role_names
312
358
  raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
@@ -0,0 +1,30 @@
1
+ require "tempfile"
2
+ require "open3"
3
+
4
+ module Kamal::Docker
5
+ extend self
6
+ BUILD_CHECK_TAG = "kamal-local-build-check"
7
+
8
+ def included_files
9
+ Tempfile.create do |dockerfile|
10
+ dockerfile.write(<<~DOCKERFILE)
11
+ FROM busybox
12
+ COPY . app
13
+ WORKDIR app
14
+ CMD find . -type f | sed "s|^\./||"
15
+ DOCKERFILE
16
+ dockerfile.close
17
+
18
+ cmd = "docker buildx build -t=#{BUILD_CHECK_TAG} -f=#{dockerfile.path} ."
19
+ system(cmd) || raise("failed to build check image")
20
+ end
21
+
22
+ cmd = "docker run --rm #{BUILD_CHECK_TAG}"
23
+ out, err, status = Open3.capture3(cmd)
24
+ unless status
25
+ raise "failed to run check image:\n#{err}"
26
+ end
27
+
28
+ out.lines.map(&:strip)
29
+ end
30
+ end
@@ -15,6 +15,10 @@ class Kamal::EnvFile
15
15
  env_file.presence || "\n"
16
16
  end
17
17
 
18
+ def to_io
19
+ StringIO.new(to_s)
20
+ end
21
+
18
22
  alias to_str to_s
19
23
 
20
24
  private
@@ -33,6 +37,8 @@ class Kamal::EnvFile
33
37
  def escape_docker_env_file_ascii_value(value)
34
38
  # Doublequotes are treated literally in docker env files
35
39
  # so remove leading and trailing ones and unescape any others
36
- value.to_s.dump[1..-2].gsub(/\\"/, "\"")
40
+ value.to_s.dump[1..-2]
41
+ .gsub(/\\"/, "\"")
42
+ .gsub(/\\#/, "#")
37
43
  end
38
44
  end
data/lib/kamal/git.rb CHANGED
@@ -24,4 +24,14 @@ module Kamal::Git
24
24
  def root
25
25
  `git rev-parse --show-toplevel`.strip
26
26
  end
27
+
28
+ # returns an array of relative path names of files with uncommitted changes
29
+ def uncommitted_files
30
+ `git ls-files --modified`.lines.map(&:strip)
31
+ end
32
+
33
+ # returns an array of relative path names of untracked files, including gitignored files
34
+ def untracked_files
35
+ `git ls-files --others`.lines.map(&:strip)
36
+ end
27
37
  end
@@ -0,0 +1,51 @@
1
+ class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Base
2
+ def requires_account?
3
+ false
4
+ end
5
+
6
+ private
7
+ def login(_account)
8
+ nil
9
+ end
10
+
11
+ def fetch_secrets(secrets, from:, account: nil, session:)
12
+ {}.tap do |results|
13
+ get_from_secrets_manager(prefixed_secrets(secrets, from: from), account: account).each do |secret|
14
+ secret_name = secret["Name"]
15
+ secret_string = JSON.parse(secret["SecretString"])
16
+
17
+ secret_string.each do |key, value|
18
+ results["#{secret_name}/#{key}"] = value
19
+ end
20
+ rescue JSON::ParserError
21
+ results["#{secret_name}"] = secret["SecretString"]
22
+ end
23
+ end
24
+ end
25
+
26
+ def get_from_secrets_manager(secrets, account: nil)
27
+ args = [ "aws", "secretsmanager", "batch-get-secret-value", "--secret-id-list" ] + secrets.map(&:shellescape)
28
+ args += [ "--profile", account.shellescape ] if account
29
+ args += [ "--output", "json" ]
30
+ cmd = args.join(" ")
31
+
32
+ `#{cmd}`.tap do |secrets|
33
+ raise RuntimeError, "Could not read #{secrets} from AWS Secrets Manager" unless $?.success?
34
+
35
+ secrets = JSON.parse(secrets)
36
+
37
+ return secrets["SecretValues"] unless secrets["Errors"].present?
38
+
39
+ raise RuntimeError, secrets["Errors"].map { |error| "#{error['SecretId']}: #{error['Message']}" }.join(" ")
40
+ end
41
+ end
42
+
43
+ def check_dependencies!
44
+ raise RuntimeError, "AWS CLI is not installed" unless cli_installed?
45
+ end
46
+
47
+ def cli_installed?
48
+ `aws --version 2> /dev/null`
49
+ $?.success?
50
+ end
51
+ end
@@ -0,0 +1,33 @@
1
+ class Kamal::Secrets::Adapters::Base
2
+ delegate :optionize, to: Kamal::Utils
3
+
4
+ def fetch(secrets, account: nil, from: nil)
5
+ raise RuntimeError, "Missing required option '--account'" if requires_account? && account.blank?
6
+
7
+ check_dependencies!
8
+
9
+ session = login(account)
10
+ fetch_secrets(secrets, from: from, account: account, session: session)
11
+ end
12
+
13
+ def requires_account?
14
+ true
15
+ end
16
+
17
+ private
18
+ def login(...)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def fetch_secrets(...)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ def check_dependencies!
27
+ raise NotImplementedError
28
+ end
29
+
30
+ def prefixed_secrets(secrets, from:)
31
+ secrets.map { |secret| [ from, secret ].compact.join("/") }
32
+ end
33
+ 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, from:, account:, session:)
25
+ {}.tap do |results|
26
+ items_fields(prefixed_secrets(secrets, from: from)).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,66 @@
1
+ class Kamal::Secrets::Adapters::BitwardenSecretsManager < Kamal::Secrets::Adapters::Base
2
+ def requires_account?
3
+ false
4
+ end
5
+
6
+ private
7
+ LIST_ALL_SELECTOR = "all"
8
+ LIST_ALL_FROM_PROJECT_SUFFIX = "/all"
9
+ LIST_COMMAND = "secret list"
10
+ GET_COMMAND = "secret get"
11
+
12
+ def fetch_secrets(secrets, from:, account:, session:)
13
+ raise RuntimeError, "You must specify what to retrieve from Bitwarden Secrets Manager" if secrets.length == 0
14
+
15
+ secrets = prefixed_secrets(secrets, from: from)
16
+ command, project = extract_command_and_project(secrets)
17
+
18
+ {}.tap do |results|
19
+ if command.nil?
20
+ secrets.each do |secret_uuid|
21
+ item_json = run_command("#{GET_COMMAND} #{secret_uuid.shellescape}")
22
+ raise RuntimeError, "Could not read #{secret_uuid} from Bitwarden Secrets Manager" unless $?.success?
23
+ item_json = JSON.parse(item_json)
24
+ results[item_json["key"]] = item_json["value"]
25
+ end
26
+ else
27
+ items_json = run_command(command)
28
+ raise RuntimeError, "Could not read secrets from Bitwarden Secrets Manager" unless $?.success?
29
+
30
+ JSON.parse(items_json).each do |item_json|
31
+ results[item_json["key"]] = item_json["value"]
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ def extract_command_and_project(secrets)
38
+ if secrets.length == 1
39
+ if secrets[0] == LIST_ALL_SELECTOR
40
+ [ LIST_COMMAND, nil ]
41
+ elsif secrets[0].end_with?(LIST_ALL_FROM_PROJECT_SUFFIX)
42
+ project = secrets[0].split(LIST_ALL_FROM_PROJECT_SUFFIX).first
43
+ [ "#{LIST_COMMAND} #{project.shellescape}", project ]
44
+ end
45
+ end
46
+ end
47
+
48
+ def run_command(command, session: nil)
49
+ full_command = [ "bws", command ].join(" ")
50
+ `#{full_command}`
51
+ end
52
+
53
+ def login(account)
54
+ run_command("project list")
55
+ raise RuntimeError, "Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?" unless $?.success?
56
+ end
57
+
58
+ def check_dependencies!
59
+ raise RuntimeError, "Bitwarden Secrets Manager CLI is not installed" unless cli_installed?
60
+ end
61
+
62
+ def cli_installed?
63
+ `bws --version 2> /dev/null`
64
+ $?.success?
65
+ end
66
+ end
@@ -0,0 +1,57 @@
1
+ class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base
2
+ def requires_account?
3
+ false
4
+ end
5
+
6
+ private
7
+ def login(*)
8
+ unless loggedin?
9
+ `doppler login -y`
10
+ raise RuntimeError, "Failed to login to Doppler" unless $?.success?
11
+ end
12
+ end
13
+
14
+ def loggedin?
15
+ `doppler me --json 2> /dev/null`
16
+ $?.success?
17
+ end
18
+
19
+ def fetch_secrets(secrets, from:, **)
20
+ secrets = prefixed_secrets(secrets, from: from)
21
+ flags = secrets_get_flags(secrets)
22
+
23
+ secret_names = secrets.collect { |s| s.split("/").last }
24
+
25
+ items = `doppler secrets get #{secret_names.map(&:shellescape).join(" ")} --json #{flags}`
26
+ raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success?
27
+
28
+ items = JSON.parse(items)
29
+
30
+ items.transform_values { |value| value["computed"] }
31
+ end
32
+
33
+ def secrets_get_flags(secrets)
34
+ unless service_token_set?
35
+ project, config, _ = secrets.first.split("/")
36
+
37
+ unless project && config
38
+ raise RuntimeError, "Missing project or config from '--from=project/config' option"
39
+ end
40
+
41
+ project_and_config_flags = "-p #{project.shellescape} -c #{config.shellescape}"
42
+ end
43
+ end
44
+
45
+ def service_token_set?
46
+ ENV["DOPPLER_TOKEN"] && ENV["DOPPLER_TOKEN"][0, 5] == "dp.st"
47
+ end
48
+
49
+ def check_dependencies!
50
+ raise RuntimeError, "Doppler CLI is not installed" unless cli_installed?
51
+ end
52
+
53
+ def cli_installed?
54
+ `doppler --version 2> /dev/null`
55
+ $?.success?
56
+ end
57
+ end