kamal 2.3.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/lib/kamal/cli/accessory.rb +24 -9
  3. data/lib/kamal/cli/alias/command.rb +1 -0
  4. data/lib/kamal/cli/app/boot.rb +2 -2
  5. data/lib/kamal/cli/app.rb +28 -8
  6. data/lib/kamal/cli/base.rb +16 -1
  7. data/lib/kamal/cli/build.rb +36 -14
  8. data/lib/kamal/cli/main.rb +4 -3
  9. data/lib/kamal/cli/proxy.rb +2 -4
  10. data/lib/kamal/cli/registry.rb +2 -0
  11. data/lib/kamal/cli/secrets.rb +9 -3
  12. data/lib/kamal/cli/templates/deploy.yml +6 -3
  13. data/lib/kamal/cli/templates/sample_hooks/post-app-boot.sample +3 -0
  14. data/lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample +3 -0
  15. data/lib/kamal/cli.rb +1 -0
  16. data/lib/kamal/commander.rb +16 -25
  17. data/lib/kamal/commands/accessory/proxy.rb +16 -0
  18. data/lib/kamal/commands/accessory.rb +4 -4
  19. data/lib/kamal/commands/app/assets.rb +4 -4
  20. data/lib/kamal/commands/app/containers.rb +2 -2
  21. data/lib/kamal/commands/app/execution.rb +4 -2
  22. data/lib/kamal/commands/app/images.rb +1 -1
  23. data/lib/kamal/commands/app/logging.rb +14 -4
  24. data/lib/kamal/commands/app.rb +15 -7
  25. data/lib/kamal/commands/base.rb +25 -1
  26. data/lib/kamal/commands/builder/base.rb +17 -6
  27. data/lib/kamal/commands/builder/cloud.rb +22 -0
  28. data/lib/kamal/commands/builder.rb +6 -20
  29. data/lib/kamal/commands/registry.rb +9 -7
  30. data/lib/kamal/configuration/accessory.rb +41 -9
  31. data/lib/kamal/configuration/builder.rb +8 -0
  32. data/lib/kamal/configuration/docs/accessory.yml +26 -3
  33. data/lib/kamal/configuration/docs/alias.yml +2 -2
  34. data/lib/kamal/configuration/docs/builder.yml +9 -0
  35. data/lib/kamal/configuration/docs/proxy.yml +13 -10
  36. data/lib/kamal/configuration/docs/registry.yml +4 -0
  37. data/lib/kamal/configuration/registry.rb +6 -6
  38. data/lib/kamal/configuration/role.rb +6 -6
  39. data/lib/kamal/configuration/validator/role.rb +1 -1
  40. data/lib/kamal/configuration.rb +31 -14
  41. data/lib/kamal/docker.rb +30 -0
  42. data/lib/kamal/git.rb +10 -0
  43. data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +50 -0
  44. data/lib/kamal/secrets/adapters/base.rb +13 -3
  45. data/lib/kamal/secrets/adapters/bitwarden.rb +2 -2
  46. data/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb +72 -0
  47. data/lib/kamal/secrets/adapters/doppler.rb +57 -0
  48. data/lib/kamal/secrets/adapters/enpass.rb +71 -0
  49. data/lib/kamal/secrets/adapters/gcp_secret_manager.rb +112 -0
  50. data/lib/kamal/secrets/adapters/last_pass.rb +3 -2
  51. data/lib/kamal/secrets/adapters/one_password.rb +2 -2
  52. data/lib/kamal/secrets/adapters/test.rb +2 -2
  53. data/lib/kamal/secrets/adapters.rb +2 -0
  54. data/lib/kamal/secrets.rb +1 -1
  55. data/lib/kamal/version.rb +1 -1
  56. metadata +13 -3
@@ -14,7 +14,7 @@ class Kamal::Configuration
14
14
 
15
15
  include Validation
16
16
 
17
- PROXY_MINIMUM_VERSION = "v0.8.2"
17
+ PROXY_MINIMUM_VERSION = "v0.8.4"
18
18
  PROXY_HTTP_PORT = 80
19
19
  PROXY_HTTPS_PORT = 443
20
20
  PROXY_LOG_MAX_SIZE = "10m"
@@ -37,7 +37,7 @@ class Kamal::Configuration
37
37
  if file.exist?
38
38
  # Newer Psych doesn't load aliases by default
39
39
  load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load
40
- YAML.send(load_method, ERB.new(IO.read(file)).result).symbolize_keys
40
+ YAML.send(load_method, ERB.new(File.read(file)).result).symbolize_keys
41
41
  else
42
42
  raise "Configuration file not found in #{file}"
43
43
  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
@@ -0,0 +1,50 @@
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
+ cmd = args.join(" ")
30
+
31
+ `#{cmd}`.tap do |secrets|
32
+ raise RuntimeError, "Could not read #{secrets} from AWS Secrets Manager" unless $?.success?
33
+
34
+ secrets = JSON.parse(secrets)
35
+
36
+ return secrets["SecretValues"] unless secrets["Errors"].present?
37
+
38
+ raise RuntimeError, secrets["Errors"].map { |error| "#{error['SecretId']}: #{error['Message']}" }.join(" ")
39
+ end
40
+ end
41
+
42
+ def check_dependencies!
43
+ raise RuntimeError, "AWS CLI is not installed" unless cli_installed?
44
+ end
45
+
46
+ def cli_installed?
47
+ `aws --version 2> /dev/null`
48
+ $?.success?
49
+ end
50
+ end
@@ -1,11 +1,17 @@
1
1
  class Kamal::Secrets::Adapters::Base
2
2
  delegate :optionize, to: Kamal::Utils
3
3
 
4
- def fetch(secrets, account:, from: nil)
4
+ def fetch(secrets, account: nil, from: nil)
5
+ raise RuntimeError, "Missing required option '--account'" if requires_account? && account.blank?
6
+
5
7
  check_dependencies!
8
+
6
9
  session = login(account)
7
- full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") }
8
- fetch_secrets(full_secrets, account: account, session: session)
10
+ fetch_secrets(secrets, from: from, account: account, session: session)
11
+ end
12
+
13
+ def requires_account?
14
+ true
9
15
  end
10
16
 
11
17
  private
@@ -20,4 +26,8 @@ class Kamal::Secrets::Adapters::Base
20
26
  def check_dependencies!
21
27
  raise NotImplementedError
22
28
  end
29
+
30
+ def prefixed_secrets(secrets, from:)
31
+ secrets.map { |secret| [ from, secret ].compact.join("/") }
32
+ end
23
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
@@ -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
@@ -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
@@ -0,0 +1,112 @@
1
+ class Kamal::Secrets::Adapters::GcpSecretManager < Kamal::Secrets::Adapters::Base
2
+ private
3
+ def login(account)
4
+ # Since only the account option is passed from the cli, we'll use it for both account and service account
5
+ # impersonation.
6
+ #
7
+ # Syntax:
8
+ # ACCOUNT: USER | USER "|" DELEGATION_CHAIN
9
+ # USER: DEFAULT_USER | EMAIL
10
+ # DELEGATION_CHAIN: EMAIL | EMAIL "," DELEGATION_CHAIN
11
+ # EMAIL: <The email address of the user or service account, like "my-user@example.com" >
12
+ # DEFAULT_USER: "default"
13
+ #
14
+ # Some valid examples:
15
+ # - "my-user@example.com" sets the user
16
+ # - "my-user@example.com|my-service-user@example.com" will use my-user and enable service account impersonation as my-service-user
17
+ # - "default" will use the default user and no impersonation
18
+ # - "default|my-service-user@example.com" will use the default user, and enable service account impersonation as my-service-user
19
+ # - "default|my-service-user@example.com,another-service-user@example.com" same as above, but with an impersonation delegation chain
20
+
21
+ unless logged_in?
22
+ `gcloud auth login`
23
+ raise RuntimeError, "could not login to gcloud" unless logged_in?
24
+ end
25
+
26
+ nil
27
+ end
28
+
29
+ def fetch_secrets(secrets, from:, account:, session:)
30
+ user, service_account = parse_account(account)
31
+
32
+ {}.tap do |results|
33
+ secrets_with_metadata(prefixed_secrets(secrets, from: from)).each do |secret, (project, secret_name, secret_version)|
34
+ item_name = "#{project}/#{secret_name}"
35
+ results[item_name] = fetch_secret(project, secret_name, secret_version, user, service_account)
36
+ raise RuntimeError, "Could not read #{item_name} from Google Secret Manager" unless $?.success?
37
+ end
38
+ end
39
+ end
40
+
41
+ def fetch_secret(project, secret_name, secret_version, user, service_account)
42
+ secret = run_command(
43
+ "secrets versions access #{secret_version.shellescape} --secret=#{secret_name.shellescape}",
44
+ project: project,
45
+ user: user,
46
+ service_account: service_account
47
+ )
48
+ Base64.decode64(secret.dig("payload", "data"))
49
+ end
50
+
51
+ # The secret needs to at least contain a secret name, but project name, and secret version can also be specified.
52
+ #
53
+ # The string "default" can be used to refer to the default project configured for gcloud.
54
+ #
55
+ # The version can be either the string "latest", or a version number.
56
+ #
57
+ # The following formats are valid:
58
+ #
59
+ # - The following are all equivalent, and sets project: default, secret name: my-secret, version: latest
60
+ # - "my-secret"
61
+ # - "default/my-secret"
62
+ # - "default/my-secret/latest"
63
+ # - "my-secret/latest" in combination with --from=default
64
+ # - "my-secret/123" (only in combination with --from=some-project) -> project: some-project, secret name: my-secret, version: 123
65
+ # - "some-project/my-secret/123" -> project: some-project, secret name: my-secret, version: 123
66
+ def secrets_with_metadata(secrets)
67
+ {}.tap do |items|
68
+ secrets.each do |secret|
69
+ parts = secret.split("/")
70
+ parts.unshift("default") if parts.length == 1
71
+ project = parts.shift
72
+ secret_name = parts.shift
73
+ secret_version = parts.shift || "latest"
74
+
75
+ items[secret] = [ project, secret_name, secret_version ]
76
+ end
77
+ end
78
+ end
79
+
80
+ def run_command(command, project: "default", user: "default", service_account: nil)
81
+ full_command = [ "gcloud", command ]
82
+ full_command << "--project=#{project.shellescape}" unless project == "default"
83
+ full_command << "--account=#{user.shellescape}" unless user == "default"
84
+ full_command << "--impersonate-service-account=#{service_account.shellescape}" if service_account
85
+ full_command << "--format=json"
86
+ full_command = full_command.join(" ")
87
+
88
+ result = `#{full_command}`.strip
89
+ JSON.parse(result)
90
+ end
91
+
92
+ def check_dependencies!
93
+ raise RuntimeError, "gcloud CLI is not installed" unless cli_installed?
94
+ end
95
+
96
+ def cli_installed?
97
+ `gcloud --version 2> /dev/null`
98
+ $?.success?
99
+ end
100
+
101
+ def logged_in?
102
+ JSON.parse(`gcloud auth list --format=json`).any?
103
+ end
104
+
105
+ def parse_account(account)
106
+ account.split("|", 2)
107
+ end
108
+
109
+ def is_user?(candidate)
110
+ candidate.include?("@")
111
+ end
112
+ end
@@ -11,7 +11,8 @@ class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base
11
11
  `lpass status --color never`.strip == "Logged in as #{account}."
12
12
  end
13
13
 
14
- def fetch_secrets(secrets, account:, session:)
14
+ def fetch_secrets(secrets, from:, account:, session:)
15
+ secrets = prefixed_secrets(secrets, from: from)
15
16
  items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json`
16
17
  raise RuntimeError, "Could not read #{secrets} from LastPass" unless $?.success?
17
18
 
@@ -23,7 +24,7 @@ class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base
23
24
  end
24
25
 
25
26
  if (missing_items = secrets - results.keys).any?
26
- raise RuntimeError, "Could not find #{missing_items.join(", ")} in LassPass"
27
+ raise RuntimeError, "Could not find #{missing_items.join(", ")} in LastPass"
27
28
  end
28
29
  end
29
30
  end
@@ -15,9 +15,9 @@ class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base
15
15
  $?.success?
16
16
  end
17
17
 
18
- def fetch_secrets(secrets, account:, session:)
18
+ def fetch_secrets(secrets, from:, account:, session:)
19
19
  {}.tap do |results|
20
- vaults_items_fields(secrets).map do |vault, items|
20
+ vaults_items_fields(prefixed_secrets(secrets, from: from)).map do |vault, items|
21
21
  items.each do |item, fields|
22
22
  fields_json = JSON.parse(op_item_get(vault, item, fields, account: account, session: session))
23
23
  fields_json = [ fields_json ] if fields.one?
@@ -4,8 +4,8 @@ class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base
4
4
  true
5
5
  end
6
6
 
7
- def fetch_secrets(secrets, account:, session:)
8
- secrets.to_h { |secret| [ secret, secret.reverse ] }
7
+ def fetch_secrets(secrets, from:, account:, session:)
8
+ prefixed_secrets(secrets, from: from).to_h { |secret| [ secret, secret.reverse ] }
9
9
  end
10
10
 
11
11
  def check_dependencies!
@@ -3,6 +3,8 @@ module Kamal::Secrets::Adapters
3
3
  def self.lookup(name)
4
4
  name = "one_password" if name.downcase == "1password"
5
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"
6
8
  adapter_class(name)
7
9
  end
8
10
 
data/lib/kamal/secrets.rb CHANGED
@@ -32,7 +32,7 @@ class Kamal::Secrets
32
32
  private
33
33
  def secrets
34
34
  @secrets ||= secrets_files.inject({}) do |secrets, secrets_file|
35
- secrets.merge!(::Dotenv.parse(secrets_file))
35
+ secrets.merge!(::Dotenv.parse(secrets_file, overwrite: true))
36
36
  end
37
37
  end
38
38
 
data/lib/kamal/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kamal
2
- VERSION = "2.3.0"
2
+ VERSION = "2.5.0"
3
3
  end