dash 2.12.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 (142) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +13 -0
  4. data/bin/dash +18 -0
  5. data/bin/kamal +18 -0
  6. data/lib/kamal/cli/accessory.rb +342 -0
  7. data/lib/kamal/cli/alias/command.rb +10 -0
  8. data/lib/kamal/cli/app/assets.rb +24 -0
  9. data/lib/kamal/cli/app/boot.rb +126 -0
  10. data/lib/kamal/cli/app/error_pages.rb +33 -0
  11. data/lib/kamal/cli/app/ssl_certificates.rb +28 -0
  12. data/lib/kamal/cli/app.rb +368 -0
  13. data/lib/kamal/cli/base.rb +324 -0
  14. data/lib/kamal/cli/build/clone.rb +59 -0
  15. data/lib/kamal/cli/build/port_forwarding.rb +66 -0
  16. data/lib/kamal/cli/build.rb +242 -0
  17. data/lib/kamal/cli/healthcheck/barrier.rb +33 -0
  18. data/lib/kamal/cli/healthcheck/error.rb +2 -0
  19. data/lib/kamal/cli/healthcheck/poller.rb +42 -0
  20. data/lib/kamal/cli/lock.rb +34 -0
  21. data/lib/kamal/cli/main.rb +299 -0
  22. data/lib/kamal/cli/proxy.rb +419 -0
  23. data/lib/kamal/cli/prune.rb +34 -0
  24. data/lib/kamal/cli/registry.rb +49 -0
  25. data/lib/kamal/cli/secrets.rb +50 -0
  26. data/lib/kamal/cli/server.rb +70 -0
  27. data/lib/kamal/cli/templates/deploy.yml +102 -0
  28. data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +3 -0
  29. data/lib/kamal/cli/templates/sample_hooks/post-app-boot.sample +3 -0
  30. data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +14 -0
  31. data/lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample +3 -0
  32. data/lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample +3 -0
  33. data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +51 -0
  34. data/lib/kamal/cli/templates/sample_hooks/pre-connect.sample +47 -0
  35. data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +122 -0
  36. data/lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample +3 -0
  37. data/lib/kamal/cli/templates/secrets +22 -0
  38. data/lib/kamal/cli.rb +9 -0
  39. data/lib/kamal/commander/specifics.rb +62 -0
  40. data/lib/kamal/commander.rb +230 -0
  41. data/lib/kamal/commands/accessory/proxy.rb +16 -0
  42. data/lib/kamal/commands/accessory.rb +118 -0
  43. data/lib/kamal/commands/app/assets.rb +51 -0
  44. data/lib/kamal/commands/app/containers.rb +31 -0
  45. data/lib/kamal/commands/app/error_pages.rb +9 -0
  46. data/lib/kamal/commands/app/execution.rb +38 -0
  47. data/lib/kamal/commands/app/images.rb +13 -0
  48. data/lib/kamal/commands/app/logging.rb +28 -0
  49. data/lib/kamal/commands/app/proxy.rb +32 -0
  50. data/lib/kamal/commands/app.rb +125 -0
  51. data/lib/kamal/commands/auditor.rb +39 -0
  52. data/lib/kamal/commands/base.rb +147 -0
  53. data/lib/kamal/commands/builder/base.rb +143 -0
  54. data/lib/kamal/commands/builder/clone.rb +32 -0
  55. data/lib/kamal/commands/builder/cloud.rb +22 -0
  56. data/lib/kamal/commands/builder/hybrid.rb +21 -0
  57. data/lib/kamal/commands/builder/local.rb +20 -0
  58. data/lib/kamal/commands/builder/pack.rb +46 -0
  59. data/lib/kamal/commands/builder/remote.rb +75 -0
  60. data/lib/kamal/commands/builder.rb +54 -0
  61. data/lib/kamal/commands/docker.rb +50 -0
  62. data/lib/kamal/commands/hook.rb +20 -0
  63. data/lib/kamal/commands/loadbalancer.rb +130 -0
  64. data/lib/kamal/commands/lock.rb +70 -0
  65. data/lib/kamal/commands/proxy.rb +150 -0
  66. data/lib/kamal/commands/prune.rb +38 -0
  67. data/lib/kamal/commands/registry.rb +38 -0
  68. data/lib/kamal/commands/server.rb +15 -0
  69. data/lib/kamal/commands.rb +2 -0
  70. data/lib/kamal/configuration/accessory.rb +280 -0
  71. data/lib/kamal/configuration/alias.rb +15 -0
  72. data/lib/kamal/configuration/boot.rb +29 -0
  73. data/lib/kamal/configuration/builder.rb +218 -0
  74. data/lib/kamal/configuration/docs/accessory.yml +160 -0
  75. data/lib/kamal/configuration/docs/alias.yml +29 -0
  76. data/lib/kamal/configuration/docs/boot.yml +21 -0
  77. data/lib/kamal/configuration/docs/builder.yml +132 -0
  78. data/lib/kamal/configuration/docs/configuration.yml +228 -0
  79. data/lib/kamal/configuration/docs/env.yml +118 -0
  80. data/lib/kamal/configuration/docs/logging.yml +21 -0
  81. data/lib/kamal/configuration/docs/output.yml +25 -0
  82. data/lib/kamal/configuration/docs/proxy.yml +207 -0
  83. data/lib/kamal/configuration/docs/registry.yml +64 -0
  84. data/lib/kamal/configuration/docs/role.yml +54 -0
  85. data/lib/kamal/configuration/docs/servers.yml +27 -0
  86. data/lib/kamal/configuration/docs/ssh.yml +81 -0
  87. data/lib/kamal/configuration/docs/sshkit.yml +31 -0
  88. data/lib/kamal/configuration/env/tag.rb +13 -0
  89. data/lib/kamal/configuration/env.rb +42 -0
  90. data/lib/kamal/configuration/loadbalancer.rb +34 -0
  91. data/lib/kamal/configuration/logging.rb +33 -0
  92. data/lib/kamal/configuration/output.rb +34 -0
  93. data/lib/kamal/configuration/proxy/boot.rb +124 -0
  94. data/lib/kamal/configuration/proxy/run.rb +152 -0
  95. data/lib/kamal/configuration/proxy.rb +156 -0
  96. data/lib/kamal/configuration/registry.rb +40 -0
  97. data/lib/kamal/configuration/role.rb +247 -0
  98. data/lib/kamal/configuration/servers.rb +25 -0
  99. data/lib/kamal/configuration/ssh.rb +76 -0
  100. data/lib/kamal/configuration/sshkit.rb +26 -0
  101. data/lib/kamal/configuration/validation.rb +27 -0
  102. data/lib/kamal/configuration/validator/accessory.rb +13 -0
  103. data/lib/kamal/configuration/validator/alias.rb +15 -0
  104. data/lib/kamal/configuration/validator/builder.rb +15 -0
  105. data/lib/kamal/configuration/validator/configuration.rb +6 -0
  106. data/lib/kamal/configuration/validator/env.rb +54 -0
  107. data/lib/kamal/configuration/validator/proxy.rb +47 -0
  108. data/lib/kamal/configuration/validator/registry.rb +27 -0
  109. data/lib/kamal/configuration/validator/role.rb +13 -0
  110. data/lib/kamal/configuration/validator/servers.rb +7 -0
  111. data/lib/kamal/configuration/validator.rb +251 -0
  112. data/lib/kamal/configuration/volume.rb +29 -0
  113. data/lib/kamal/configuration.rb +465 -0
  114. data/lib/kamal/docker.rb +30 -0
  115. data/lib/kamal/env_file.rb +44 -0
  116. data/lib/kamal/git.rb +37 -0
  117. data/lib/kamal/otel_shipper.rb +176 -0
  118. data/lib/kamal/output/base_logger.rb +29 -0
  119. data/lib/kamal/output/file_logger.rb +51 -0
  120. data/lib/kamal/output/formatter.rb +36 -0
  121. data/lib/kamal/output/otel_logger.rb +70 -0
  122. data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +59 -0
  123. data/lib/kamal/secrets/adapters/base.rb +33 -0
  124. data/lib/kamal/secrets/adapters/bitwarden.rb +81 -0
  125. data/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb +66 -0
  126. data/lib/kamal/secrets/adapters/doppler.rb +57 -0
  127. data/lib/kamal/secrets/adapters/enpass.rb +71 -0
  128. data/lib/kamal/secrets/adapters/gcp_secret_manager.rb +112 -0
  129. data/lib/kamal/secrets/adapters/last_pass.rb +40 -0
  130. data/lib/kamal/secrets/adapters/one_password.rb +104 -0
  131. data/lib/kamal/secrets/adapters/passbolt.rb +129 -0
  132. data/lib/kamal/secrets/adapters/test.rb +16 -0
  133. data/lib/kamal/secrets/adapters.rb +16 -0
  134. data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +47 -0
  135. data/lib/kamal/secrets.rb +53 -0
  136. data/lib/kamal/sshkit_with_ext.rb +273 -0
  137. data/lib/kamal/tags.rb +40 -0
  138. data/lib/kamal/utils/sensitive.rb +20 -0
  139. data/lib/kamal/utils.rb +110 -0
  140. data/lib/kamal/version.rb +3 -0
  141. data/lib/kamal.rb +15 -0
  142. metadata +388 -0
@@ -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
@@ -0,0 +1,40 @@
1
+ class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base
2
+ private
3
+ def login(account)
4
+ unless loggedin?(account)
5
+ `lpass login #{account.shellescape}`
6
+ raise RuntimeError, "Failed to login to LastPass" unless $?.success?
7
+ end
8
+ end
9
+
10
+ def loggedin?(account)
11
+ `lpass status --color never`.strip == "Logged in as #{account}."
12
+ end
13
+
14
+ def fetch_secrets(secrets, from:, account:, session:)
15
+ secrets = prefixed_secrets(secrets, from: from)
16
+ items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json`
17
+ raise RuntimeError, "Could not read #{secrets} from LastPass" unless $?.success?
18
+
19
+ items = JSON.parse(items)
20
+
21
+ {}.tap do |results|
22
+ items.each do |item|
23
+ results[item["fullname"]] = item["password"]
24
+ end
25
+
26
+ if (missing_items = secrets - results.keys).any?
27
+ raise RuntimeError, "Could not find #{missing_items.join(", ")} in LastPass"
28
+ end
29
+ end
30
+ end
31
+
32
+ def check_dependencies!
33
+ raise RuntimeError, "LastPass CLI is not installed" unless cli_installed?
34
+ end
35
+
36
+ def cli_installed?
37
+ `lpass --version 2> /dev/null`
38
+ $?.success?
39
+ end
40
+ end
@@ -0,0 +1,104 @@
1
+ class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base
2
+ delegate :optionize, to: Kamal::Utils
3
+
4
+ private
5
+ def login(account)
6
+ unless loggedin?(account)
7
+ `op signin #{to_options(account: account, force: true, raw: true)}`.tap do
8
+ raise RuntimeError, "Failed to login to 1Password" unless $?.success?
9
+ end
10
+ end
11
+ end
12
+
13
+ def loggedin?(account)
14
+ `op account get --account #{account.shellescape} 2> /dev/null`
15
+ $?.success?
16
+ end
17
+
18
+ def fetch_secrets(secrets, from:, account:, session:)
19
+ if secrets.blank?
20
+ fetch_all_secrets(from: from, account: account, session: session)
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:)
27
+ {}.tap do |results|
28
+ vaults_items_fields(prefixed_secrets(secrets, from: from)).map do |vault, items|
29
+ items.each do |item, fields|
30
+ fields_json = JSON.parse(op_item_get(vault, item, fields: fields, account: account, session: session))
31
+ fields_json = [ fields_json ] if fields.one?
32
+
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))
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ def to_options(**options)
52
+ optionize(options.compact).join(" ")
53
+ end
54
+
55
+ def vaults_items_fields(secrets)
56
+ {}.tap do |vaults|
57
+ secrets.each do |secret|
58
+ secret = secret.delete_prefix("op://")
59
+ vault, item, *fields = secret.split("/")
60
+ fields << "password" if fields.empty?
61
+
62
+ vaults[vault] ||= {}
63
+ vaults[vault][item] ||= []
64
+ vaults[vault][item] << fields.join(".")
65
+ end
66
+ end
67
+ end
68
+
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
90
+
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?
93
+ end
94
+ end
95
+
96
+ def check_dependencies!
97
+ raise RuntimeError, "1Password CLI is not installed" unless cli_installed?
98
+ end
99
+
100
+ def cli_installed?
101
+ `op --version 2> /dev/null`
102
+ $?.success?
103
+ end
104
+ end
@@ -0,0 +1,129 @@
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"].to_s.shellescape}" }.join(" ")} --column name --column password --json`
51
+ raise RuntimeError, "Could not read #{secrets} from Passbolt" unless $?.success?
52
+ items = JSON.parse(items)
53
+ found_names = items.map { |item| item["name"] }
54
+ missing_secrets = secret_names - found_names
55
+ raise RuntimeError, "Could not find the following secrets in Passbolt: #{missing_secrets.join(", ")}" if missing_secrets.any?
56
+
57
+ items.to_h { |item| [ item["name"], item["password"] ] }
58
+ end
59
+
60
+ def secrets_get_folders(secrets)
61
+ # extract all folder paths (both parent and nested)
62
+ folder_paths = secrets
63
+ .select { |s| s.include?("/") }
64
+ .map { |s| s.split("/")[0..-2] } # get all parts except the secret name
65
+ .uniq
66
+
67
+ return [] if folder_paths.empty?
68
+
69
+ all_folders = []
70
+
71
+ # first get all top-level folders
72
+ parent_folders = folder_paths.map(&:first).uniq
73
+ filter_condition = "--filter '#{parent_folders.map { |name| "Name == #{name.shellescape.inspect}" }.join(" || ")}'"
74
+ fetch_folders = `passbolt list folders #{filter_condition} --json`
75
+ raise RuntimeError, "Could not read folders from Passbolt" unless $?.success?
76
+
77
+ parent_folder_items = JSON.parse(fetch_folders)
78
+ all_folders.concat(parent_folder_items)
79
+
80
+ # get nested folders for each parent
81
+ folder_paths.each do |path|
82
+ next if path.size <= 1 # skip non-nested folders
83
+
84
+ parent = path[0]
85
+ parent_folder = parent_folder_items.find { |f| f["name"] == parent }
86
+ next unless parent_folder
87
+
88
+ # for each nested level, get the folders using the parent's ID
89
+ current_parent = parent_folder
90
+ path[1..-1].each do |folder_name|
91
+ filter_condition = "--filter 'Name == #{folder_name.shellescape.inspect} && FolderParentID == #{current_parent["id"].shellescape.inspect}'"
92
+ fetch_nested = `passbolt list folders #{filter_condition} --json`
93
+ next unless $?.success?
94
+
95
+ nested_folders = JSON.parse(fetch_nested)
96
+ break if nested_folders.empty?
97
+
98
+ all_folders.concat(nested_folders)
99
+ current_parent = nested_folders.first
100
+ end
101
+ end
102
+
103
+ # check if we found all required folders
104
+ found_paths = all_folders.map { |f| get_folder_path(f, all_folders) }
105
+ missing_paths = folder_paths.map { |path| path.join("/") } - found_paths
106
+ raise RuntimeError, "Could not find the following folders in Passbolt: #{missing_paths.join(", ")}" if missing_paths.any?
107
+
108
+ all_folders
109
+ end
110
+
111
+ def get_folder_path(folder, all_folders, path = [])
112
+ path.unshift(folder["name"])
113
+ return path.join("/") if folder["folder_parent_id"].to_s.empty?
114
+
115
+ parent = all_folders.find { |f| f["id"] == folder["folder_parent_id"] }
116
+ return path.join("/") unless parent
117
+
118
+ get_folder_path(parent, all_folders, path)
119
+ end
120
+
121
+ def check_dependencies!
122
+ raise RuntimeError, "Passbolt CLI is not installed" unless cli_installed?
123
+ end
124
+
125
+ def cli_installed?
126
+ `passbolt --version 2> /dev/null`
127
+ $?.success?
128
+ end
129
+ end
@@ -0,0 +1,16 @@
1
+ class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base
2
+ private
3
+ def login(account)
4
+ true
5
+ end
6
+
7
+ def fetch_secrets(secrets, from:, account:, session:)
8
+ prefixed_secrets(secrets, from: from).to_h do |secret|
9
+ [ secret, secret.gsub("LPAREN", "(").gsub("RPAREN", ")").reverse ]
10
+ end
11
+ end
12
+
13
+ def check_dependencies!
14
+ # no op
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ require "active_support/core_ext/string/inflections"
2
+ module Kamal::Secrets::Adapters
3
+ def self.lookup(name)
4
+ name = "one_password" if name.downcase == "1password"
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"
8
+ adapter_class(name)
9
+ end
10
+
11
+ def self.adapter_class(name)
12
+ Object.const_get("Kamal::Secrets::Adapters::#{name.camelize}").new
13
+ rescue NameError => e
14
+ raise RuntimeError, "Unknown secrets adapter: #{name}"
15
+ end
16
+ end
@@ -0,0 +1,47 @@
1
+ class Kamal::Secrets::Dotenv::InlineCommandSubstitution
2
+ # Unlike dotenv, this regex does not match escaped
3
+ # parentheses when looking for command substitutions.
4
+ INTERPOLATED_SHELL_COMMAND = /
5
+ (?<backslash>\\)? # is it escaped with a backslash?
6
+ \$ # literal $
7
+ (?<cmd> # collect command content for eval
8
+ \( # require opening paren
9
+ (?:\\.|[^()\\]|\g<cmd>)+ # allow any number of non-parens or escaped
10
+ # parens (by nesting the <cmd> expression
11
+ # recursively)
12
+ \) # require closing paren
13
+ )
14
+ /x
15
+
16
+ class << self
17
+ def install!
18
+ ::Dotenv::Parser.substitutions.map! { |sub| sub == ::Dotenv::Substitutions::Command ? self : sub }
19
+ end
20
+
21
+ def call(value, env, overwrite: false)
22
+ # Process interpolated shell commands
23
+ value.gsub(INTERPOLATED_SHELL_COMMAND) do |*|
24
+ # Eliminate opening and closing parentheses
25
+ command = $LAST_MATCH_INFO[:cmd][1..-2]
26
+
27
+ if $LAST_MATCH_INFO[:backslash]
28
+ # Command is escaped, don't replace it.
29
+ $LAST_MATCH_INFO[0][1..]
30
+ else
31
+ command = ::Dotenv::Substitutions::Variable.call(command, env)
32
+ if command =~ /\A\s*kamal\s*secrets\s+/
33
+ # Inline the command
34
+ inline_secrets_command(command)
35
+ else
36
+ # Execute the command and return the value
37
+ `#{command}`.chomp
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ def inline_secrets_command(command)
44
+ Kamal::Cli::Main.start(command.shellsplit[1..] + [ "--inline" ]).chomp
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,53 @@
1
+ require "dotenv"
2
+
3
+ class Kamal::Secrets
4
+ Kamal::Secrets::Dotenv::InlineCommandSubstitution.install!
5
+
6
+ def initialize(destination: nil, secrets_path: ".kamal/secrets")
7
+ @destination = destination
8
+ @secrets_path = secrets_path
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def [](key)
13
+ synchronized_fetch(key)
14
+ rescue KeyError
15
+ if secrets_files.present?
16
+ raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secrets_files.join(", ")}"
17
+ else
18
+ raise Kamal::ConfigurationError, "Secret '#{key}' not found, no secret files (#{secrets_filenames.join(", ")}) provided"
19
+ end
20
+ end
21
+
22
+ def to_h
23
+ secrets
24
+ end
25
+
26
+ def secrets_files
27
+ @secrets_files ||= secrets_filenames.select { |f| File.exist?(f) }
28
+ end
29
+
30
+ def key?(key)
31
+ synchronized_fetch(key).present?
32
+ rescue KeyError
33
+ false
34
+ end
35
+
36
+ private
37
+ def secrets
38
+ @secrets ||= secrets_files.inject({}) do |secrets, secrets_file|
39
+ secrets.merge!(::Dotenv.parse(secrets_file, overwrite: true))
40
+ end
41
+ end
42
+
43
+ def secrets_filenames
44
+ [ "#{@secrets_path}-common", "#{@secrets_path}#{(".#{@destination}" if @destination)}" ]
45
+ end
46
+
47
+ def synchronized_fetch(key)
48
+ # Fetching secrets may ask the user for input, so ensure only one thread does that
49
+ @mutex.synchronize do
50
+ secrets.fetch(key)
51
+ end
52
+ end
53
+ end