kamal 1.8.3 → 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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/kamal/cli/accessory.rb +92 -38
  4. data/lib/kamal/cli/alias/command.rb +10 -0
  5. data/lib/kamal/cli/app/{prepare_assets.rb → assets.rb} +1 -1
  6. data/lib/kamal/cli/app/boot.rb +23 -16
  7. data/lib/kamal/cli/app/error_pages.rb +33 -0
  8. data/lib/kamal/cli/app/ssl_certificates.rb +28 -0
  9. data/lib/kamal/cli/app.rb +132 -30
  10. data/lib/kamal/cli/base.rb +57 -53
  11. data/lib/kamal/cli/build.rb +81 -38
  12. data/lib/kamal/cli/healthcheck/barrier.rb +2 -0
  13. data/lib/kamal/cli/healthcheck/poller.rb +18 -39
  14. data/lib/kamal/cli/lock.rb +2 -3
  15. data/lib/kamal/cli/main.rb +60 -59
  16. data/lib/kamal/cli/proxy.rb +290 -0
  17. data/lib/kamal/cli/prune.rb +0 -1
  18. data/lib/kamal/cli/registry.rb +2 -0
  19. data/lib/kamal/cli/secrets.rb +49 -0
  20. data/lib/kamal/cli/server.rb +6 -5
  21. data/lib/kamal/cli/templates/deploy.yml +53 -53
  22. data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +2 -12
  23. data/lib/kamal/cli/templates/sample_hooks/post-app-boot.sample +3 -0
  24. data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +1 -1
  25. data/lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample +3 -0
  26. data/lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample +3 -0
  27. data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +1 -1
  28. data/lib/kamal/cli/templates/sample_hooks/pre-connect.sample +1 -1
  29. data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +19 -6
  30. data/lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample +3 -0
  31. data/lib/kamal/cli/templates/secrets +17 -0
  32. data/lib/kamal/cli.rb +2 -0
  33. data/lib/kamal/commander/specifics.rb +19 -6
  34. data/lib/kamal/commander.rb +39 -32
  35. data/lib/kamal/commands/accessory/proxy.rb +16 -0
  36. data/lib/kamal/commands/accessory.rb +19 -19
  37. data/lib/kamal/commands/app/assets.rb +10 -10
  38. data/lib/kamal/commands/app/containers.rb +2 -2
  39. data/lib/kamal/commands/app/error_pages.rb +9 -0
  40. data/lib/kamal/commands/app/execution.rb +7 -4
  41. data/lib/kamal/commands/app/images.rb +1 -1
  42. data/lib/kamal/commands/app/logging.rb +16 -6
  43. data/lib/kamal/commands/app/proxy.rb +32 -0
  44. data/lib/kamal/commands/app.rb +25 -24
  45. data/lib/kamal/commands/auditor.rb +12 -3
  46. data/lib/kamal/commands/base.rb +54 -8
  47. data/lib/kamal/commands/builder/base.rb +46 -16
  48. data/lib/kamal/commands/builder/clone.rb +16 -14
  49. data/lib/kamal/commands/builder/cloud.rb +22 -0
  50. data/lib/kamal/commands/builder/hybrid.rb +21 -0
  51. data/lib/kamal/commands/builder/local.rb +14 -0
  52. data/lib/kamal/commands/builder/pack.rb +46 -0
  53. data/lib/kamal/commands/builder/remote.rb +63 -0
  54. data/lib/kamal/commands/builder.rb +21 -45
  55. data/lib/kamal/commands/docker.rb +4 -0
  56. data/lib/kamal/commands/hook.rb +8 -2
  57. data/lib/kamal/commands/lock.rb +2 -6
  58. data/lib/kamal/commands/proxy.rb +127 -0
  59. data/lib/kamal/commands/prune.rb +1 -9
  60. data/lib/kamal/commands/registry.rb +9 -7
  61. data/lib/kamal/commands/server.rb +11 -1
  62. data/lib/kamal/configuration/accessory.rb +89 -12
  63. data/lib/kamal/configuration/alias.rb +15 -0
  64. data/lib/kamal/configuration/builder.rb +73 -15
  65. data/lib/kamal/configuration/docs/accessory.yml +53 -15
  66. data/lib/kamal/configuration/docs/alias.yml +26 -0
  67. data/lib/kamal/configuration/docs/boot.yml +3 -3
  68. data/lib/kamal/configuration/docs/builder.yml +63 -38
  69. data/lib/kamal/configuration/docs/configuration.yml +62 -46
  70. data/lib/kamal/configuration/docs/env.yml +61 -17
  71. data/lib/kamal/configuration/docs/logging.yml +3 -3
  72. data/lib/kamal/configuration/docs/proxy.yml +168 -0
  73. data/lib/kamal/configuration/docs/registry.yml +20 -13
  74. data/lib/kamal/configuration/docs/role.yml +14 -13
  75. data/lib/kamal/configuration/docs/servers.yml +2 -2
  76. data/lib/kamal/configuration/docs/ssh.yml +23 -19
  77. data/lib/kamal/configuration/docs/sshkit.yml +4 -4
  78. data/lib/kamal/configuration/env/tag.rb +4 -3
  79. data/lib/kamal/configuration/env.rb +19 -17
  80. data/lib/kamal/configuration/proxy/boot.rb +129 -0
  81. data/lib/kamal/configuration/proxy.rb +124 -0
  82. data/lib/kamal/configuration/registry.rb +7 -6
  83. data/lib/kamal/configuration/role.rb +69 -98
  84. data/lib/kamal/configuration/servers.rb +8 -1
  85. data/lib/kamal/configuration/validator/accessory.rb +6 -2
  86. data/lib/kamal/configuration/validator/alias.rb +15 -0
  87. data/lib/kamal/configuration/validator/builder.rb +6 -0
  88. data/lib/kamal/configuration/validator/proxy.rb +25 -0
  89. data/lib/kamal/configuration/validator/role.rb +3 -1
  90. data/lib/kamal/configuration/validator/servers.rb +1 -1
  91. data/lib/kamal/configuration/validator.rb +62 -24
  92. data/lib/kamal/configuration.rb +96 -50
  93. data/lib/kamal/docker.rb +30 -0
  94. data/lib/kamal/env_file.rb +7 -1
  95. data/lib/kamal/git.rb +10 -0
  96. data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +51 -0
  97. data/lib/kamal/secrets/adapters/base.rb +33 -0
  98. data/lib/kamal/secrets/adapters/bitwarden.rb +81 -0
  99. data/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb +66 -0
  100. data/lib/kamal/secrets/adapters/doppler.rb +57 -0
  101. data/lib/kamal/secrets/adapters/enpass.rb +71 -0
  102. data/lib/kamal/secrets/adapters/gcp_secret_manager.rb +112 -0
  103. data/lib/kamal/secrets/adapters/last_pass.rb +40 -0
  104. data/lib/kamal/secrets/adapters/one_password.rb +104 -0
  105. data/lib/kamal/secrets/adapters/passbolt.rb +130 -0
  106. data/lib/kamal/secrets/adapters/test.rb +14 -0
  107. data/lib/kamal/secrets/adapters.rb +16 -0
  108. data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +33 -0
  109. data/lib/kamal/secrets.rb +42 -0
  110. data/lib/kamal/sshkit_with_ext.rb +1 -0
  111. data/lib/kamal/utils.rb +30 -0
  112. data/lib/kamal/version.rb +1 -1
  113. data/lib/kamal.rb +3 -1
  114. metadata +63 -36
  115. data/lib/kamal/cli/env.rb +0 -54
  116. data/lib/kamal/cli/templates/sample_hooks/post-traefik-reboot.sample +0 -3
  117. data/lib/kamal/cli/templates/sample_hooks/pre-traefik-reboot.sample +0 -3
  118. data/lib/kamal/cli/templates/template.env +0 -2
  119. data/lib/kamal/cli/traefik.rb +0 -122
  120. data/lib/kamal/commands/app/cord.rb +0 -22
  121. data/lib/kamal/commands/builder/multiarch/remote.rb +0 -65
  122. data/lib/kamal/commands/builder/multiarch.rb +0 -41
  123. data/lib/kamal/commands/builder/native/cached.rb +0 -25
  124. data/lib/kamal/commands/builder/native/remote.rb +0 -67
  125. data/lib/kamal/commands/builder/native.rb +0 -20
  126. data/lib/kamal/commands/traefik.rb +0 -85
  127. data/lib/kamal/configuration/docs/healthcheck.yml +0 -59
  128. data/lib/kamal/configuration/docs/traefik.yml +0 -62
  129. data/lib/kamal/configuration/healthcheck.rb +0 -63
  130. data/lib/kamal/configuration/traefik.rb +0 -60
@@ -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) 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:)
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,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
@@ -0,0 +1,14 @@
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 { |secret| [ secret, secret.reverse ] }
9
+ end
10
+
11
+ def check_dependencies!
12
+ # no op
13
+ end
14
+ 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,33 @@
1
+ class Kamal::Secrets::Dotenv::InlineCommandSubstitution
2
+ class << self
3
+ def install!
4
+ ::Dotenv::Parser.substitutions.map! { |sub| sub == ::Dotenv::Substitutions::Command ? self : sub }
5
+ end
6
+
7
+ def call(value, env, overwrite: false)
8
+ # Process interpolated shell commands
9
+ value.gsub(Dotenv::Substitutions::Command.singleton_class::INTERPOLATED_SHELL_COMMAND) do |*|
10
+ # Eliminate opening and closing parentheses
11
+ command = $LAST_MATCH_INFO[:cmd][1..-2]
12
+
13
+ if $LAST_MATCH_INFO[:backslash]
14
+ # Command is escaped, don't replace it.
15
+ $LAST_MATCH_INFO[0][1..]
16
+ else
17
+ command = ::Dotenv::Substitutions::Variable.call(command, env)
18
+ if command =~ /\A\s*kamal\s*secrets\s+/
19
+ # Inline the command
20
+ inline_secrets_command(command)
21
+ else
22
+ # Execute the command and return the value
23
+ `#{command}`.chomp
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ def inline_secrets_command(command)
30
+ Kamal::Cli::Main.start(command.shellsplit[1..] + [ "--inline" ]).chomp
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,42 @@
1
+ require "dotenv"
2
+
3
+ class Kamal::Secrets
4
+ Kamal::Secrets::Dotenv::InlineCommandSubstitution.install!
5
+
6
+ def initialize(destination: nil)
7
+ @destination = destination
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def [](key)
12
+ # Fetching secrets may ask the user for input, so ensure only one thread does that
13
+ @mutex.synchronize do
14
+ secrets.fetch(key)
15
+ end
16
+ rescue KeyError
17
+ if secrets_files.present?
18
+ raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secrets_files.join(", ")}"
19
+ else
20
+ raise Kamal::ConfigurationError, "Secret '#{key}' not found, no secret files (#{secrets_filenames.join(", ")}) provided"
21
+ end
22
+ end
23
+
24
+ def to_h
25
+ secrets
26
+ end
27
+
28
+ def secrets_files
29
+ @secrets_files ||= secrets_filenames.select { |f| File.exist?(f) }
30
+ end
31
+
32
+ private
33
+ def secrets
34
+ @secrets ||= secrets_files.inject({}) do |secrets, secrets_file|
35
+ secrets.merge!(::Dotenv.parse(secrets_file, overwrite: true))
36
+ end
37
+ end
38
+
39
+ def secrets_filenames
40
+ [ ".kamal/secrets-common", ".kamal/secrets#{(".#{@destination}" if @destination)}" ]
41
+ end
42
+ end
@@ -3,6 +3,7 @@ require "sshkit/dsl"
3
3
  require "net/scp"
4
4
  require "active_support/core_ext/hash/deep_merge"
5
5
  require "json"
6
+ require "concurrent/atomic/semaphore"
6
7
 
7
8
  class SSHKit::Backend::Abstract
8
9
  def capture_with_info(*args, **kwargs)
data/lib/kamal/utils.rb CHANGED
@@ -1,3 +1,5 @@
1
+ require "active_support/core_ext/object/try"
2
+
1
3
  module Kamal::Utils
2
4
  extend self
3
5
 
@@ -10,6 +12,8 @@ module Kamal::Utils
10
12
  attr = "#{key}=#{escape_shell_value(value)}"
11
13
  attr = self.sensitive(attr, redaction: "#{key}=[REDACTED]") if sensitive
12
14
  [ argument, attr ]
15
+ elsif value == false
16
+ [ argument, "#{key}=false" ]
13
17
  else
14
18
  [ argument, key ]
15
19
  end
@@ -54,6 +58,12 @@ module Kamal::Utils
54
58
 
55
59
  # Escape a value to make it safe for shell use.
56
60
  def escape_shell_value(value)
61
+ value.to_s.scan(/[\x00-\x7F]+|[^\x00-\x7F]+/) \
62
+ .map { |part| part.ascii_only? ? escape_ascii_shell_value(part) : part }
63
+ .join
64
+ end
65
+
66
+ def escape_ascii_shell_value(value)
57
67
  value.to_s.dump
58
68
  .gsub(/`/, '\\\\`')
59
69
  .gsub(DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX, '\$')
@@ -77,4 +87,24 @@ module Kamal::Utils
77
87
  def stable_sort!(elements, &block)
78
88
  elements.sort_by!.with_index { |element, index| [ block.call(element), index ] }
79
89
  end
90
+
91
+ def join_commands(commands)
92
+ commands.map(&:strip).join(" ")
93
+ end
94
+
95
+ def docker_arch
96
+ arch = `docker info --format '{{.Architecture}}'`.strip
97
+ case arch
98
+ when /aarch64/
99
+ "arm64"
100
+ when /x86_64/
101
+ "amd64"
102
+ else
103
+ arch
104
+ end
105
+ end
106
+
107
+ def older_version?(version, other_version)
108
+ Gem::Version.new(version.delete_prefix("v")) < Gem::Version.new(other_version.delete_prefix("v"))
109
+ end
80
110
  end
data/lib/kamal/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kamal
2
- VERSION = "1.8.3"
2
+ VERSION = "2.7.0"
3
3
  end
data/lib/kamal.rb CHANGED
@@ -5,8 +5,10 @@ end
5
5
  require "active_support"
6
6
  require "zeitwerk"
7
7
  require "yaml"
8
+ require "tmpdir"
9
+ require "pathname"
8
10
 
9
11
  loader = Zeitwerk::Loader.for_gem
10
12
  loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb"))
11
13
  loader.setup
12
- loader.eager_load # We need all commands loaded.
14
+ loader.eager_load_namespace(Kamal::Cli) # We need all commands loaded.