kamal 2.3.0 → 2.5.0

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