kamal 2.4.0 → 2.5.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/lib/kamal/cli/accessory.rb +1 -1
  3. data/lib/kamal/cli/alias/command.rb +1 -0
  4. data/lib/kamal/cli/app.rb +15 -3
  5. data/lib/kamal/cli/base.rb +16 -1
  6. data/lib/kamal/cli/build.rb +36 -14
  7. data/lib/kamal/cli/main.rb +4 -3
  8. data/lib/kamal/cli/proxy.rb +2 -4
  9. data/lib/kamal/cli/registry.rb +2 -0
  10. data/lib/kamal/cli/templates/deploy.yml +2 -2
  11. data/lib/kamal/cli/templates/sample_hooks/post-app-boot.sample +3 -0
  12. data/lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample +3 -0
  13. data/lib/kamal/cli.rb +1 -0
  14. data/lib/kamal/commander.rb +16 -25
  15. data/lib/kamal/commands/accessory.rb +1 -5
  16. data/lib/kamal/commands/app/assets.rb +4 -4
  17. data/lib/kamal/commands/base.rb +14 -0
  18. data/lib/kamal/commands/builder/base.rb +12 -5
  19. data/lib/kamal/commands/builder/cloud.rb +22 -0
  20. data/lib/kamal/commands/builder.rb +6 -20
  21. data/lib/kamal/commands/registry.rb +9 -7
  22. data/lib/kamal/configuration/accessory.rb +36 -19
  23. data/lib/kamal/configuration/builder.rb +4 -0
  24. data/lib/kamal/configuration/docs/accessory.yml +20 -1
  25. data/lib/kamal/configuration/docs/builder.yml +3 -0
  26. data/lib/kamal/configuration/registry.rb +6 -6
  27. data/lib/kamal/configuration/role.rb +6 -6
  28. data/lib/kamal/configuration/validator/role.rb +1 -1
  29. data/lib/kamal/configuration.rb +29 -12
  30. data/lib/kamal/docker.rb +30 -0
  31. data/lib/kamal/git.rb +10 -0
  32. data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +12 -4
  33. data/lib/kamal/secrets/adapters/base.rb +5 -2
  34. data/lib/kamal/secrets/adapters/bitwarden.rb +2 -2
  35. data/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb +72 -0
  36. data/lib/kamal/secrets/adapters/doppler.rb +15 -11
  37. data/lib/kamal/secrets/adapters/enpass.rb +71 -0
  38. data/lib/kamal/secrets/adapters/gcp_secret_manager.rb +112 -0
  39. data/lib/kamal/secrets/adapters/last_pass.rb +3 -2
  40. data/lib/kamal/secrets/adapters/one_password.rb +2 -2
  41. data/lib/kamal/secrets/adapters/test.rb +2 -2
  42. data/lib/kamal/secrets/adapters.rb +2 -0
  43. data/lib/kamal/version.rb +1 -1
  44. metadata +10 -4
  45. data/lib/kamal/secrets/adapters/test_optional_account.rb +0 -5
@@ -5,7 +5,7 @@ class Kamal::Configuration::Accessory
5
5
 
6
6
  delegate :argumentize, :optionize, to: Kamal::Utils
7
7
 
8
- attr_reader :name, :accessory_config, :env, :proxy
8
+ attr_reader :name, :env, :proxy, :registry
9
9
 
10
10
  def initialize(name, config:)
11
11
  @name, @config, @accessory_config = name.inquiry, config, config.raw_config["accessories"][name]
@@ -16,12 +16,11 @@ class Kamal::Configuration::Accessory
16
16
  context: "accessories/#{name}",
17
17
  with: Kamal::Configuration::Validator::Accessory
18
18
 
19
- @env = Kamal::Configuration::Env.new \
20
- config: accessory_config.fetch("env", {}),
21
- secrets: config.secrets,
22
- context: "accessories/#{name}/env"
19
+ ensure_valid_roles
23
20
 
24
- initialize_proxy if running_proxy?
21
+ @env = initialize_env
22
+ @proxy = initialize_proxy if running_proxy?
23
+ @registry = initialize_registry if accessory_config["registry"].present?
25
24
  end
26
25
 
27
26
  def service_name
@@ -29,7 +28,7 @@ class Kamal::Configuration::Accessory
29
28
  end
30
29
 
31
30
  def image
32
- accessory_config["image"]
31
+ [ registry&.server, accessory_config["image"] ].compact.join("/")
33
32
  end
34
33
 
35
34
  def hosts
@@ -109,18 +108,32 @@ class Kamal::Configuration::Accessory
109
108
  end
110
109
 
111
110
  def running_proxy?
112
- @accessory_config["proxy"].present?
113
- end
114
-
115
- def initialize_proxy
116
- @proxy = Kamal::Configuration::Proxy.new \
117
- config: config,
118
- proxy_config: accessory_config["proxy"],
119
- context: "accessories/#{name}/proxy"
111
+ accessory_config["proxy"].present?
120
112
  end
121
113
 
122
114
  private
123
- attr_accessor :config
115
+ attr_reader :config, :accessory_config
116
+
117
+ def initialize_env
118
+ Kamal::Configuration::Env.new \
119
+ config: accessory_config.fetch("env", {}),
120
+ secrets: config.secrets,
121
+ context: "accessories/#{name}/env"
122
+ end
123
+
124
+ def initialize_proxy
125
+ Kamal::Configuration::Proxy.new \
126
+ config: config,
127
+ proxy_config: accessory_config["proxy"],
128
+ context: "accessories/#{name}/proxy"
129
+ end
130
+
131
+ def initialize_registry
132
+ Kamal::Configuration::Registry.new \
133
+ config: accessory_config,
134
+ secrets: config.secrets,
135
+ context: "accessories/#{name}/registry"
136
+ end
124
137
 
125
138
  def default_labels
126
139
  { "service" => service_name }
@@ -189,13 +202,17 @@ class Kamal::Configuration::Accessory
189
202
 
190
203
  def hosts_from_roles
191
204
  if accessory_config.key?("roles")
192
- accessory_config["roles"].flat_map do |role|
193
- config.role(role)&.hosts || raise(Kamal::ConfigurationError, "Unknown role in accessories config: '#{role}'")
194
- end
205
+ accessory_config["roles"].flat_map { |role| config.role(role)&.hosts }
195
206
  end
196
207
  end
197
208
 
198
209
  def network
199
210
  accessory_config["network"] || DEFAULT_NETWORK
200
211
  end
212
+
213
+ def ensure_valid_roles
214
+ if accessory_config["roles"] && (missing_roles = accessory_config["roles"] - config.roles.map(&:name)).any?
215
+ raise Kamal::ConfigurationError, "accessories/#{name}: unknown roles #{missing_roles.join(", ")}"
216
+ end
217
+ end
201
218
  end
@@ -53,6 +53,10 @@ class Kamal::Configuration::Builder
53
53
  !local_disabled? && (arches.empty? || local_arches.any?)
54
54
  end
55
55
 
56
+ def cloud?
57
+ driver.start_with? "cloud"
58
+ end
59
+
56
60
  def cached?
57
61
  !!builder_config["cache"]
58
62
  end
@@ -23,9 +23,27 @@ accessories:
23
23
 
24
24
  # Image
25
25
  #
26
- # The Docker image to use, prefix it with a registry if not using Docker Hub:
26
+ # The Docker image to use.
27
+ # Prefix it with its server when using root level registry different from Docker Hub.
28
+ # Define registry directly or via anchors when it differs from root level registry.
27
29
  image: mysql:8.0
28
30
 
31
+ # Registry
32
+ #
33
+ # By default accessories use Docker Hub registry.
34
+ # You can specify different registry per accessory with this option.
35
+ # Don't prefix image with this registry server.
36
+ # Use anchors if you need to set the same specific registry for several accessories.
37
+ #
38
+ # ```yml
39
+ # registry:
40
+ # <<: *specific-registry
41
+ # ```
42
+ #
43
+ # See kamal docs registry for more information:
44
+ registry:
45
+ ...
46
+
29
47
  # Accessory hosts
30
48
  #
31
49
  # Specify one of `host`, `hosts`, or `roles`:
@@ -100,5 +118,6 @@ accessories:
100
118
 
101
119
  # Proxy
102
120
  #
121
+ # You can run your accessory behind the Kamal proxy. See kamal docs proxy for more information
103
122
  proxy:
104
123
  ...
@@ -102,6 +102,9 @@ builder:
102
102
  #
103
103
  # The build driver to use, defaults to `docker-container`:
104
104
  driver: docker
105
+ #
106
+ # If you want to use Docker Build Cloud (https://www.docker.com/products/build-cloud/), you can set the driver to:
107
+ driver: cloud org-name/builder-name
105
108
 
106
109
  # Provenance
107
110
  #
@@ -1,12 +1,10 @@
1
1
  class Kamal::Configuration::Registry
2
2
  include Kamal::Configuration::Validation
3
3
 
4
- attr_reader :registry_config, :secrets
5
-
6
- def initialize(config:)
7
- @registry_config = config.raw_config.registry || {}
8
- @secrets = config.secrets
9
- validate! registry_config, with: Kamal::Configuration::Validator::Registry
4
+ def initialize(config:, secrets:, context: "registry")
5
+ @registry_config = config["registry"] || {}
6
+ @secrets = secrets
7
+ validate! registry_config, context: context, with: Kamal::Configuration::Validator::Registry
10
8
  end
11
9
 
12
10
  def server
@@ -22,6 +20,8 @@ class Kamal::Configuration::Registry
22
20
  end
23
21
 
24
22
  private
23
+ attr_reader :registry_config, :secrets
24
+
25
25
  def lookup(key)
26
26
  if registry_config[key].is_a?(Array)
27
27
  secrets[registry_config[key].first]
@@ -10,7 +10,7 @@ class Kamal::Configuration::Role
10
10
  def initialize(name, config:)
11
11
  @name, @config = name.inquiry, config
12
12
  validate! \
13
- specializations,
13
+ role_config,
14
14
  example: validation_yml["servers"]["workers"],
15
15
  context: "servers/#{name}",
16
16
  with: Kamal::Configuration::Validator::Role
@@ -204,11 +204,11 @@ class Kamal::Configuration::Role
204
204
  end
205
205
 
206
206
  def specializations
207
- if config.raw_config.servers.is_a?(Array) || config.raw_config.servers[name].is_a?(Array)
208
- {}
209
- else
210
- config.raw_config.servers[name]
211
- end
207
+ @specializations ||= role_config.is_a?(Array) ? {} : role_config
208
+ end
209
+
210
+ def role_config
211
+ @role_config ||= config.raw_config.servers.is_a?(Array) ? {} : config.raw_config.servers[name]
212
212
  end
213
213
 
214
214
  def custom_labels
@@ -3,7 +3,7 @@ class Kamal::Configuration::Validator::Role < Kamal::Configuration::Validator
3
3
  validate_type! config, Array, Hash
4
4
 
5
5
  if config.is_a?(Array)
6
- validate_servers! "servers", config
6
+ validate_servers!(config)
7
7
  else
8
8
  super
9
9
  end
@@ -59,7 +59,7 @@ class Kamal::Configuration
59
59
 
60
60
  # Eager load config to validate it, these are first as they have dependencies later on
61
61
  @servers = Servers.new(config: self)
62
- @registry = Registry.new(config: self)
62
+ @registry = Registry.new(config: @raw_config, secrets: secrets)
63
63
 
64
64
  @accessories = @raw_config.accessories&.keys&.collect { |name| Accessory.new(name, config: self) } || []
65
65
  @aliases = @raw_config.aliases&.keys&.to_h { |name| [ name, Alias.new(name, config: self) ] } || {}
@@ -82,7 +82,6 @@ class Kamal::Configuration
82
82
  ensure_unique_hosts_for_ssl_roles
83
83
  end
84
84
 
85
-
86
85
  def version=(version)
87
86
  @declared_version = version
88
87
  end
@@ -106,7 +105,6 @@ class Kamal::Configuration
106
105
  raw_config.minimum_version
107
106
  end
108
107
 
109
-
110
108
  def roles
111
109
  servers.roles
112
110
  end
@@ -119,7 +117,6 @@ class Kamal::Configuration
119
117
  accessories.detect { |a| a.name == name.to_s }
120
118
  end
121
119
 
122
-
123
120
  def all_hosts
124
121
  (roles + accessories).flat_map(&:hosts).uniq
125
122
  end
@@ -180,7 +177,6 @@ class Kamal::Configuration
180
177
  raw_config.retain_containers || 5
181
178
  end
182
179
 
183
-
184
180
  def volume_args
185
181
  if raw_config.volumes.present?
186
182
  argumentize "--volume", raw_config.volumes
@@ -193,7 +189,6 @@ class Kamal::Configuration
193
189
  logging.args
194
190
  end
195
191
 
196
-
197
192
  def readiness_delay
198
193
  raw_config.readiness_delay || 7
199
194
  end
@@ -206,7 +201,6 @@ class Kamal::Configuration
206
201
  raw_config.drain_timeout || 30
207
202
  end
208
203
 
209
-
210
204
  def run_directory
211
205
  ".kamal"
212
206
  end
@@ -227,7 +221,6 @@ class Kamal::Configuration
227
221
  File.join app_directory, "assets"
228
222
  end
229
223
 
230
-
231
224
  def hooks_path
232
225
  raw_config.hooks_path || ".kamal/hooks"
233
226
  end
@@ -236,7 +229,6 @@ class Kamal::Configuration
236
229
  raw_config.asset_path
237
230
  end
238
231
 
239
-
240
232
  def env_tags
241
233
  @env_tags ||= if (tags = raw_config.env["tags"])
242
234
  tags.collect { |name, config| Env::Tag.new(name, config: config, secrets: secrets) }
@@ -249,8 +241,16 @@ class Kamal::Configuration
249
241
  env_tags.detect { |t| t.name == name.to_s }
250
242
  end
251
243
 
252
- def proxy_publish_args(http_port, https_port)
253
- argumentize "--publish", [ "#{http_port}:#{PROXY_HTTP_PORT}", "#{https_port}:#{PROXY_HTTPS_PORT}" ]
244
+ def proxy_publish_args(http_port, https_port, bind_ips = nil)
245
+ ensure_valid_bind_ips(bind_ips)
246
+
247
+ (bind_ips || [ nil ]).map do |bind_ip|
248
+ bind_ip = format_bind_ip(bind_ip)
249
+ publish_http = [ bind_ip, http_port, PROXY_HTTP_PORT ].compact.join(":")
250
+ publish_https = [ bind_ip, https_port, PROXY_HTTPS_PORT ].compact.join(":")
251
+
252
+ argumentize "--publish", [ publish_http, publish_https ]
253
+ end.join(" ")
254
254
  end
255
255
 
256
256
  def proxy_logging_args(max_size)
@@ -277,7 +277,6 @@ class Kamal::Configuration
277
277
  File.join proxy_directory, "options"
278
278
  end
279
279
 
280
-
281
280
  def to_h
282
281
  {
283
282
  roles: role_names,
@@ -344,6 +343,15 @@ class Kamal::Configuration
344
343
  true
345
344
  end
346
345
 
346
+ def ensure_valid_bind_ips(bind_ips)
347
+ bind_ips.present? && bind_ips.each do |ip|
348
+ next if ip =~ Resolv::IPv4::Regex || ip =~ Resolv::IPv6::Regex
349
+ raise ArgumentError, "Invalid publish IP address: #{ip}"
350
+ end
351
+
352
+ true
353
+ end
354
+
347
355
  def ensure_retain_containers_valid
348
356
  raise Kamal::ConfigurationError, "Must retain at least 1 container" if retain_containers < 1
349
357
 
@@ -375,6 +383,15 @@ class Kamal::Configuration
375
383
  true
376
384
  end
377
385
 
386
+ def format_bind_ip(ip)
387
+ # Ensure IPv6 address inside square brackets - e.g. [::1]
388
+ if ip =~ Resolv::IPv6::Regex && ip !~ /\[.*\]/
389
+ "[#{ip}]"
390
+ else
391
+ ip
392
+ end
393
+ end
394
+
378
395
  def role_names
379
396
  raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
380
397
  end
@@ -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
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
@@ -1,12 +1,16 @@
1
1
  class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Base
2
+ def requires_account?
3
+ false
4
+ end
5
+
2
6
  private
3
7
  def login(_account)
4
8
  nil
5
9
  end
6
10
 
7
- def fetch_secrets(secrets, account:, session:)
11
+ def fetch_secrets(secrets, from:, account: nil, session:)
8
12
  {}.tap do |results|
9
- get_from_secrets_manager(secrets, account: account).each do |secret|
13
+ get_from_secrets_manager(prefixed_secrets(secrets, from: from), account: account).each do |secret|
10
14
  secret_name = secret["Name"]
11
15
  secret_string = JSON.parse(secret["SecretString"])
12
16
 
@@ -19,8 +23,12 @@ class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Ba
19
23
  end
20
24
  end
21
25
 
22
- def get_from_secrets_manager(secrets, account:)
23
- `aws secretsmanager batch-get-secret-value --secret-id-list #{secrets.map(&:shellescape).join(" ")} --profile #{account.shellescape}`.tap do |secrets|
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
+ cmd = args.join(" ")
30
+
31
+ `#{cmd}`.tap do |secrets|
24
32
  raise RuntimeError, "Could not read #{secrets} from AWS Secrets Manager" unless $?.success?
25
33
 
26
34
  secrets = JSON.parse(secrets)
@@ -7,8 +7,7 @@ class Kamal::Secrets::Adapters::Base
7
7
  check_dependencies!
8
8
 
9
9
  session = login(account)
10
- full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") }
11
- fetch_secrets(full_secrets, account: account, session: session)
10
+ fetch_secrets(secrets, from: from, account: account, session: session)
12
11
  end
13
12
 
14
13
  def requires_account?
@@ -27,4 +26,8 @@ class Kamal::Secrets::Adapters::Base
27
26
  def check_dependencies!
28
27
  raise NotImplementedError
29
28
  end
29
+
30
+ def prefixed_secrets(secrets, from:)
31
+ secrets.map { |secret| [ from, secret ].compact.join("/") }
32
+ end
30
33
  end
@@ -21,9 +21,9 @@ class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base
21
21
  session
22
22
  end
23
23
 
24
- def fetch_secrets(secrets, account:, session:)
24
+ def fetch_secrets(secrets, from:, account:, session:)
25
25
  {}.tap do |results|
26
- items_fields(secrets).each do |item, fields|
26
+ items_fields(prefixed_secrets(secrets, from: from)).each do |item, fields|
27
27
  item_json = run_command("get item #{item.shellescape}", session: session, raw: true)
28
28
  raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success?
29
29
  item_json = JSON.parse(item_json)
@@ -0,0 +1,72 @@
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 -o env"
10
+ GET_COMMAND = "secret get -o env"
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
+ secret = run_command("#{GET_COMMAND} #{secret_uuid.shellescape}")
22
+ raise RuntimeError, "Could not read #{secret_uuid} from Bitwarden Secrets Manager" unless $?.success?
23
+ key, value = parse_secret(secret)
24
+ results[key] = value
25
+ end
26
+ else
27
+ secrets = run_command(command)
28
+ raise RuntimeError, "Could not read secrets from Bitwarden Secrets Manager" unless $?.success?
29
+ secrets.split("\n").each do |secret|
30
+ key, value = parse_secret(secret)
31
+ results[key] = 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 parse_secret(secret)
49
+ key, value = secret.split("=", 2)
50
+ value = value.gsub(/^"|"$/, "")
51
+ [ key, value ]
52
+ end
53
+
54
+ def run_command(command, session: nil)
55
+ full_command = [ "bws", command ].join(" ")
56
+ `#{full_command}`
57
+ end
58
+
59
+ def login(account)
60
+ run_command("run 'echo OK'")
61
+ raise RuntimeError, "Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?" unless $?.success?
62
+ end
63
+
64
+ def check_dependencies!
65
+ raise RuntimeError, "Bitwarden Secrets Manager CLI is not installed" unless cli_installed?
66
+ end
67
+
68
+ def cli_installed?
69
+ `bws --version 2> /dev/null`
70
+ $?.success?
71
+ end
72
+ end
@@ -16,8 +16,21 @@ class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base
16
16
  $?.success?
17
17
  end
18
18
 
19
- def fetch_secrets(secrets, **)
20
- project_and_config_flags = ""
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)
21
34
  unless service_token_set?
22
35
  project, config, _ = secrets.first.split("/")
23
36
 
@@ -27,15 +40,6 @@ class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base
27
40
 
28
41
  project_and_config_flags = "-p #{project.shellescape} -c #{config.shellescape}"
29
42
  end
30
-
31
- secret_names = secrets.collect { |s| s.split("/").last }
32
-
33
- items = `doppler secrets get #{secret_names.map(&:shellescape).join(" ")} --json #{project_and_config_flags}`
34
- raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success?
35
-
36
- items = JSON.parse(items)
37
-
38
- items.transform_values { |value| value["computed"] }
39
43
  end
40
44
 
41
45
  def service_token_set?
@@ -0,0 +1,71 @@
1
+ ##
2
+ # Enpass is different from most password managers, in a way that it's offline and doesn't need an account.
3
+ #
4
+ # Usage
5
+ #
6
+ # Fetch all password from FooBar item
7
+ # `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar`
8
+ #
9
+ # Fetch only DB_PASSWORD from FooBar item
10
+ # `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar/DB_PASSWORD`
11
+ class Kamal::Secrets::Adapters::Enpass < Kamal::Secrets::Adapters::Base
12
+ def requires_account?
13
+ false
14
+ end
15
+
16
+ private
17
+ def fetch_secrets(secrets, from:, account:, session:)
18
+ secrets_titles = fetch_secret_titles(secrets)
19
+
20
+ result = `enpass-cli -json -vault #{from.shellescape} show #{secrets_titles.map(&:shellescape).join(" ")}`.strip
21
+
22
+ parse_result_and_take_secrets(result, secrets)
23
+ end
24
+
25
+ def check_dependencies!
26
+ raise RuntimeError, "Enpass CLI is not installed" unless cli_installed?
27
+ end
28
+
29
+ def cli_installed?
30
+ `enpass-cli version 2> /dev/null`
31
+ $?.success?
32
+ end
33
+
34
+ def login(account)
35
+ nil
36
+ end
37
+
38
+ def fetch_secret_titles(secrets)
39
+ secrets.reduce(Set.new) do |secret_titles, secret|
40
+ # Sometimes secrets contain a '/', when the intent is to fetch a single password for an item. Example: FooBar/DB_PASSWORD
41
+ # Another case is, when the intent is to fetch all passwords for an item. Example: FooBar (and FooBar may have multiple different passwords)
42
+ key, separator, value = secret.rpartition("/")
43
+ if key.empty?
44
+ secret_titles << value
45
+ else
46
+ secret_titles << key
47
+ end
48
+ end.to_a
49
+ end
50
+
51
+ def parse_result_and_take_secrets(unparsed_result, secrets)
52
+ result = JSON.parse(unparsed_result)
53
+
54
+ result.reduce({}) do |secrets_with_passwords, item|
55
+ title = item["title"]
56
+ label = item["label"]
57
+ password = item["password"]
58
+
59
+ if title && password.present?
60
+ key = [ title, label ].compact.reject(&:empty?).join("/")
61
+
62
+ if secrets.include?(title) || secrets.include?(key)
63
+ raise RuntimeError, "#{key} is present more than once" if secrets_with_passwords[key]
64
+ secrets_with_passwords[key] = password
65
+ end
66
+ end
67
+
68
+ secrets_with_passwords
69
+ end
70
+ end
71
+ end