kamal 2.3.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/lib/kamal/cli/accessory.rb +42 -16
  3. data/lib/kamal/cli/alias/command.rb +1 -0
  4. data/lib/kamal/cli/app/{prepare_assets.rb → assets.rb} +1 -1
  5. data/lib/kamal/cli/app/boot.rb +3 -2
  6. data/lib/kamal/cli/app/error_pages.rb +33 -0
  7. data/lib/kamal/cli/app/ssl_certificates.rb +28 -0
  8. data/lib/kamal/cli/app.rb +94 -29
  9. data/lib/kamal/cli/base.rb +29 -4
  10. data/lib/kamal/cli/build.rb +60 -18
  11. data/lib/kamal/cli/main.rb +8 -10
  12. data/lib/kamal/cli/proxy.rb +58 -25
  13. data/lib/kamal/cli/registry.rb +2 -0
  14. data/lib/kamal/cli/secrets.rb +9 -3
  15. data/lib/kamal/cli/server.rb +4 -2
  16. data/lib/kamal/cli/templates/deploy.yml +6 -3
  17. data/lib/kamal/cli/templates/sample_hooks/post-app-boot.sample +3 -0
  18. data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +1 -1
  19. data/lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample +3 -0
  20. data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +1 -1
  21. data/lib/kamal/cli/templates/sample_hooks/pre-connect.sample +1 -1
  22. data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +19 -6
  23. data/lib/kamal/cli.rb +1 -0
  24. data/lib/kamal/commander/specifics.rb +9 -1
  25. data/lib/kamal/commander.rb +18 -27
  26. data/lib/kamal/commands/accessory/proxy.rb +16 -0
  27. data/lib/kamal/commands/accessory.rb +9 -9
  28. data/lib/kamal/commands/app/assets.rb +4 -4
  29. data/lib/kamal/commands/app/containers.rb +2 -2
  30. data/lib/kamal/commands/app/error_pages.rb +9 -0
  31. data/lib/kamal/commands/app/execution.rb +6 -4
  32. data/lib/kamal/commands/app/images.rb +1 -1
  33. data/lib/kamal/commands/app/logging.rb +14 -4
  34. data/lib/kamal/commands/app/proxy.rb +17 -1
  35. data/lib/kamal/commands/app.rb +19 -10
  36. data/lib/kamal/commands/auditor.rb +11 -5
  37. data/lib/kamal/commands/base.rb +37 -1
  38. data/lib/kamal/commands/builder/base.rb +20 -7
  39. data/lib/kamal/commands/builder/cloud.rb +22 -0
  40. data/lib/kamal/commands/builder/pack.rb +46 -0
  41. data/lib/kamal/commands/builder.rb +11 -19
  42. data/lib/kamal/commands/proxy.rb +55 -15
  43. data/lib/kamal/commands/registry.rb +9 -7
  44. data/lib/kamal/configuration/accessory.rb +66 -11
  45. data/lib/kamal/configuration/builder.rb +20 -0
  46. data/lib/kamal/configuration/docs/accessory.yml +32 -4
  47. data/lib/kamal/configuration/docs/alias.yml +2 -2
  48. data/lib/kamal/configuration/docs/builder.yml +22 -0
  49. data/lib/kamal/configuration/docs/configuration.yml +6 -0
  50. data/lib/kamal/configuration/docs/env.yml +31 -0
  51. data/lib/kamal/configuration/docs/proxy.yml +78 -15
  52. data/lib/kamal/configuration/docs/registry.yml +4 -0
  53. data/lib/kamal/configuration/env.rb +13 -4
  54. data/lib/kamal/configuration/proxy/boot.rb +129 -0
  55. data/lib/kamal/configuration/proxy.rb +67 -5
  56. data/lib/kamal/configuration/registry.rb +6 -6
  57. data/lib/kamal/configuration/role.rb +11 -9
  58. data/lib/kamal/configuration/servers.rb +8 -1
  59. data/lib/kamal/configuration/validator/accessory.rb +6 -2
  60. data/lib/kamal/configuration/validator/builder.rb +2 -0
  61. data/lib/kamal/configuration/validator/proxy.rb +10 -0
  62. data/lib/kamal/configuration/validator/role.rb +3 -1
  63. data/lib/kamal/configuration/validator/servers.rb +1 -1
  64. data/lib/kamal/configuration/validator.rb +21 -1
  65. data/lib/kamal/configuration.rb +36 -57
  66. data/lib/kamal/docker.rb +30 -0
  67. data/lib/kamal/git.rb +10 -0
  68. data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +51 -0
  69. data/lib/kamal/secrets/adapters/base.rb +13 -3
  70. data/lib/kamal/secrets/adapters/bitwarden.rb +2 -2
  71. data/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb +66 -0
  72. data/lib/kamal/secrets/adapters/doppler.rb +57 -0
  73. data/lib/kamal/secrets/adapters/enpass.rb +71 -0
  74. data/lib/kamal/secrets/adapters/gcp_secret_manager.rb +112 -0
  75. data/lib/kamal/secrets/adapters/last_pass.rb +3 -2
  76. data/lib/kamal/secrets/adapters/one_password.rb +47 -13
  77. data/lib/kamal/secrets/adapters/passbolt.rb +130 -0
  78. data/lib/kamal/secrets/adapters/test.rb +2 -2
  79. data/lib/kamal/secrets/adapters.rb +2 -0
  80. data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +2 -1
  81. data/lib/kamal/secrets.rb +1 -1
  82. data/lib/kamal/version.rb +1 -1
  83. metadata +22 -10
@@ -0,0 +1,66 @@
1
+ class Kamal::Secrets::Adapters::BitwardenSecretsManager < Kamal::Secrets::Adapters::Base
2
+ def requires_account?
3
+ false
4
+ end
5
+
6
+ private
7
+ LIST_ALL_SELECTOR = "all"
8
+ LIST_ALL_FROM_PROJECT_SUFFIX = "/all"
9
+ LIST_COMMAND = "secret list"
10
+ GET_COMMAND = "secret get"
11
+
12
+ def fetch_secrets(secrets, from:, account:, session:)
13
+ raise RuntimeError, "You must specify what to retrieve from Bitwarden Secrets Manager" if secrets.length == 0
14
+
15
+ secrets = prefixed_secrets(secrets, from: from)
16
+ command, project = extract_command_and_project(secrets)
17
+
18
+ {}.tap do |results|
19
+ if command.nil?
20
+ secrets.each do |secret_uuid|
21
+ item_json = run_command("#{GET_COMMAND} #{secret_uuid.shellescape}")
22
+ raise RuntimeError, "Could not read #{secret_uuid} from Bitwarden Secrets Manager" unless $?.success?
23
+ item_json = JSON.parse(item_json)
24
+ results[item_json["key"]] = item_json["value"]
25
+ end
26
+ else
27
+ items_json = run_command(command)
28
+ raise RuntimeError, "Could not read secrets from Bitwarden Secrets Manager" unless $?.success?
29
+
30
+ JSON.parse(items_json).each do |item_json|
31
+ results[item_json["key"]] = item_json["value"]
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ def extract_command_and_project(secrets)
38
+ if secrets.length == 1
39
+ if secrets[0] == LIST_ALL_SELECTOR
40
+ [ LIST_COMMAND, nil ]
41
+ elsif secrets[0].end_with?(LIST_ALL_FROM_PROJECT_SUFFIX)
42
+ project = secrets[0].split(LIST_ALL_FROM_PROJECT_SUFFIX).first
43
+ [ "#{LIST_COMMAND} #{project.shellescape}", project ]
44
+ end
45
+ end
46
+ end
47
+
48
+ def run_command(command, session: nil)
49
+ full_command = [ "bws", command ].join(" ")
50
+ `#{full_command}`
51
+ end
52
+
53
+ def login(account)
54
+ run_command("project list")
55
+ raise RuntimeError, "Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?" unless $?.success?
56
+ end
57
+
58
+ def check_dependencies!
59
+ raise RuntimeError, "Bitwarden Secrets Manager CLI is not installed" unless cli_installed?
60
+ end
61
+
62
+ def cli_installed?
63
+ `bws --version 2> /dev/null`
64
+ $?.success?
65
+ end
66
+ end
@@ -0,0 +1,57 @@
1
+ class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base
2
+ def requires_account?
3
+ false
4
+ end
5
+
6
+ private
7
+ def login(*)
8
+ unless loggedin?
9
+ `doppler login -y`
10
+ raise RuntimeError, "Failed to login to Doppler" unless $?.success?
11
+ end
12
+ end
13
+
14
+ def loggedin?
15
+ `doppler me --json 2> /dev/null`
16
+ $?.success?
17
+ end
18
+
19
+ def fetch_secrets(secrets, from:, **)
20
+ secrets = prefixed_secrets(secrets, from: from)
21
+ flags = secrets_get_flags(secrets)
22
+
23
+ secret_names = secrets.collect { |s| s.split("/").last }
24
+
25
+ items = `doppler secrets get #{secret_names.map(&:shellescape).join(" ")} --json #{flags}`
26
+ raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success?
27
+
28
+ items = JSON.parse(items)
29
+
30
+ items.transform_values { |value| value["computed"] }
31
+ end
32
+
33
+ def secrets_get_flags(secrets)
34
+ unless service_token_set?
35
+ project, config, _ = secrets.first.split("/")
36
+
37
+ unless project && config
38
+ raise RuntimeError, "Missing project or config from '--from=project/config' option"
39
+ end
40
+
41
+ project_and_config_flags = "-p #{project.shellescape} -c #{config.shellescape}"
42
+ end
43
+ end
44
+
45
+ def service_token_set?
46
+ ENV["DOPPLER_TOKEN"] && ENV["DOPPLER_TOKEN"][0, 5] == "dp.st"
47
+ end
48
+
49
+ def check_dependencies!
50
+ raise RuntimeError, "Doppler CLI is not installed" unless cli_installed?
51
+ end
52
+
53
+ def cli_installed?
54
+ `doppler --version 2> /dev/null`
55
+ $?.success?
56
+ end
57
+ end
@@ -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,18 +15,34 @@ 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
+ if secrets.blank?
20
+ fetch_all_secrets(from: from, account: account, session: session) if secrets.blank?
21
+ else
22
+ fetch_specified_secrets(secrets, from: from, account: account, session: session)
23
+ end
24
+ end
25
+
26
+ def fetch_specified_secrets(secrets, from:, account:, session:)
19
27
  {}.tap do |results|
20
- vaults_items_fields(secrets).map do |vault, items|
28
+ vaults_items_fields(prefixed_secrets(secrets, from: from)).map do |vault, items|
21
29
  items.each do |item, fields|
22
- fields_json = JSON.parse(op_item_get(vault, item, fields, account: account, session: session))
30
+ fields_json = JSON.parse(op_item_get(vault, item, fields: fields, account: account, session: session))
23
31
  fields_json = [ fields_json ] if fields.one?
24
32
 
25
- fields_json.each do |field_json|
26
- # The reference is in the form `op://vault/item/field[/field]`
27
- field = field_json["reference"].delete_prefix("op://").delete_suffix("/password")
28
- results[field] = field_json["value"]
29
- end
33
+ results.merge!(fields_map(fields_json))
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ def fetch_all_secrets(from:, account:, session:)
40
+ {}.tap do |results|
41
+ vault_items(from).each do |vault, items|
42
+ items.each do |item|
43
+ fields_json = JSON.parse(op_item_get(vault, item, account: account, session: session)).fetch("fields")
44
+
45
+ results.merge!(fields_map(fields_json))
30
46
  end
31
47
  end
32
48
  end
@@ -50,12 +66,30 @@ class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base
50
66
  end
51
67
  end
52
68
 
53
- def op_item_get(vault, item, fields, account:, session:)
54
- labels = fields.map { |field| "label=#{field}" }.join(",")
55
- options = to_options(vault: vault, fields: labels, format: "json", account: account, session: session.presence)
69
+ def vault_items(from)
70
+ from = from.delete_prefix("op://")
71
+ vault, item = from.split("/")
72
+ { vault => [ item ] }
73
+ end
74
+
75
+ def fields_map(fields_json)
76
+ fields_json.to_h do |field_json|
77
+ # The reference is in the form `op://vault/item/field[/field]`
78
+ field = field_json["reference"].delete_prefix("op://").delete_suffix("/password")
79
+ [ field, field_json["value"] ]
80
+ end
81
+ end
82
+
83
+ def op_item_get(vault, item, fields: nil, account:, session:)
84
+ options = { vault: vault, format: "json", account: account, session: session.presence }
85
+
86
+ if fields.present?
87
+ labels = fields.map { |field| "label=#{field}" }.join(",")
88
+ options.merge!(fields: labels)
89
+ end
56
90
 
57
- `op item get #{item.shellescape} #{options}`.tap do
58
- raise RuntimeError, "Could not read #{fields.join(", ")} from #{item} in the #{vault} 1Password vault" unless $?.success?
91
+ `op item get #{item.shellescape} #{to_options(**options)}`.tap do
92
+ raise RuntimeError, "Could not read #{"#{fields.join(", ")} " if fields.present?}from #{item} in the #{vault} 1Password vault" unless $?.success?
59
93
  end
60
94
  end
61
95
 
@@ -0,0 +1,130 @@
1
+ class Kamal::Secrets::Adapters::Passbolt < Kamal::Secrets::Adapters::Base
2
+ def requires_account?
3
+ false
4
+ end
5
+
6
+ private
7
+
8
+ def login(*)
9
+ `passbolt verify`
10
+ raise RuntimeError, "Failed to login to Passbolt" unless $?.success?
11
+ end
12
+
13
+ def fetch_secrets(secrets, from:, **)
14
+ secrets = prefixed_secrets(secrets, from: from)
15
+ raise ArgumentError, "No secrets given to fetch" if secrets.empty?
16
+
17
+ secret_names = secrets.collect { |s| s.split("/").last }
18
+ folders = secrets_get_folders(secrets)
19
+
20
+ # build filter conditions for each secret with its corresponding folder
21
+ filter_conditions = []
22
+ secrets.each do |secret|
23
+ parts = secret.split("/")
24
+ secret_name = parts.last
25
+
26
+ if parts.size > 1
27
+ # get the folder path without the secret name
28
+ folder_path = parts[0..-2]
29
+
30
+ # find the most nested folder for this path
31
+ current_folder = nil
32
+ current_path = []
33
+
34
+ folder_path.each do |folder_name|
35
+ current_path << folder_name
36
+ matching_folders = folders.select { |f| get_folder_path(f, folders) == current_path.join("/") }
37
+ current_folder = matching_folders.first if matching_folders.any?
38
+ end
39
+
40
+ if current_folder
41
+ filter_conditions << "(Name == #{secret_name.shellescape.inspect} && FolderParentID == #{current_folder["id"].shellescape.inspect})"
42
+ end
43
+ else
44
+ # for root level secrets (no folders)
45
+ filter_conditions << "Name == #{secret_name.shellescape.inspect}"
46
+ end
47
+ end
48
+
49
+ filter_condition = filter_conditions.any? ? "--filter '#{filter_conditions.join(" || ")}'" : ""
50
+ items = `passbolt list resources #{filter_condition} #{folders.map { |item| "--folder #{item["id"]}" }.join(" ")} --json`
51
+ raise RuntimeError, "Could not read #{secrets} from Passbolt" unless $?.success?
52
+
53
+ items = JSON.parse(items)
54
+ found_names = items.map { |item| item["name"] }
55
+ missing_secrets = secret_names - found_names
56
+ raise RuntimeError, "Could not find the following secrets in Passbolt: #{missing_secrets.join(", ")}" if missing_secrets.any?
57
+
58
+ items.to_h { |item| [ item["name"], item["password"] ] }
59
+ end
60
+
61
+ def secrets_get_folders(secrets)
62
+ # extract all folder paths (both parent and nested)
63
+ folder_paths = secrets
64
+ .select { |s| s.include?("/") }
65
+ .map { |s| s.split("/")[0..-2] } # get all parts except the secret name
66
+ .uniq
67
+
68
+ return [] if folder_paths.empty?
69
+
70
+ all_folders = []
71
+
72
+ # first get all top-level folders
73
+ parent_folders = folder_paths.map(&:first).uniq
74
+ filter_condition = "--filter '#{parent_folders.map { |name| "Name == #{name.shellescape.inspect}" }.join(" || ")}'"
75
+ fetch_folders = `passbolt list folders #{filter_condition} --json`
76
+ raise RuntimeError, "Could not read folders from Passbolt" unless $?.success?
77
+
78
+ parent_folder_items = JSON.parse(fetch_folders)
79
+ all_folders.concat(parent_folder_items)
80
+
81
+ # get nested folders for each parent
82
+ folder_paths.each do |path|
83
+ next if path.size <= 1 # skip non-nested folders
84
+
85
+ parent = path[0]
86
+ parent_folder = parent_folder_items.find { |f| f["name"] == parent }
87
+ next unless parent_folder
88
+
89
+ # for each nested level, get the folders using the parent's ID
90
+ current_parent = parent_folder
91
+ path[1..-1].each do |folder_name|
92
+ filter_condition = "--filter 'Name == #{folder_name.shellescape.inspect} && FolderParentID == #{current_parent["id"].shellescape.inspect}'"
93
+ fetch_nested = `passbolt list folders #{filter_condition} --json`
94
+ next unless $?.success?
95
+
96
+ nested_folders = JSON.parse(fetch_nested)
97
+ break if nested_folders.empty?
98
+
99
+ all_folders.concat(nested_folders)
100
+ current_parent = nested_folders.first
101
+ end
102
+ end
103
+
104
+ # check if we found all required folders
105
+ found_paths = all_folders.map { |f| get_folder_path(f, all_folders) }
106
+ missing_paths = folder_paths.map { |path| path.join("/") } - found_paths
107
+ raise RuntimeError, "Could not find the following folders in Passbolt: #{missing_paths.join(", ")}" if missing_paths.any?
108
+
109
+ all_folders
110
+ end
111
+
112
+ def get_folder_path(folder, all_folders, path = [])
113
+ path.unshift(folder["name"])
114
+ return path.join("/") if folder["folder_parent_id"].to_s.empty?
115
+
116
+ parent = all_folders.find { |f| f["id"] == folder["folder_parent_id"] }
117
+ return path.join("/") unless parent
118
+
119
+ get_folder_path(parent, all_folders, path)
120
+ end
121
+
122
+ def check_dependencies!
123
+ raise RuntimeError, "Passbolt CLI is not installed" unless cli_installed?
124
+ end
125
+
126
+ def cli_installed?
127
+ `passbolt --version 2> /dev/null`
128
+ $?.success?
129
+ end
130
+ end
@@ -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
 
@@ -4,7 +4,7 @@ class Kamal::Secrets::Dotenv::InlineCommandSubstitution
4
4
  ::Dotenv::Parser.substitutions.map! { |sub| sub == ::Dotenv::Substitutions::Command ? self : sub }
5
5
  end
6
6
 
7
- def call(value, _env, overwrite: false)
7
+ def call(value, env, overwrite: false)
8
8
  # Process interpolated shell commands
9
9
  value.gsub(Dotenv::Substitutions::Command.singleton_class::INTERPOLATED_SHELL_COMMAND) do |*|
10
10
  # Eliminate opening and closing parentheses
@@ -14,6 +14,7 @@ class Kamal::Secrets::Dotenv::InlineCommandSubstitution
14
14
  # Command is escaped, don't replace it.
15
15
  $LAST_MATCH_INFO[0][1..]
16
16
  else
17
+ command = ::Dotenv::Substitutions::Variable.call(command, env)
17
18
  if command =~ /\A\s*kamal\s*secrets\s+/
18
19
  # Inline the command
19
20
  inline_secrets_command(command)
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.7.0"
3
3
  end