kamal 2.4.0 → 2.5.0

Sign up to get free protection for your applications and to get access to all the features.
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