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.
- checksums.yaml +4 -4
- data/lib/kamal/cli/accessory.rb +42 -16
- data/lib/kamal/cli/alias/command.rb +1 -0
- data/lib/kamal/cli/app/{prepare_assets.rb → assets.rb} +1 -1
- data/lib/kamal/cli/app/boot.rb +3 -2
- data/lib/kamal/cli/app/error_pages.rb +33 -0
- data/lib/kamal/cli/app/ssl_certificates.rb +28 -0
- data/lib/kamal/cli/app.rb +94 -29
- data/lib/kamal/cli/base.rb +29 -4
- data/lib/kamal/cli/build.rb +60 -18
- data/lib/kamal/cli/main.rb +8 -10
- data/lib/kamal/cli/proxy.rb +58 -25
- data/lib/kamal/cli/registry.rb +2 -0
- data/lib/kamal/cli/secrets.rb +9 -3
- data/lib/kamal/cli/server.rb +4 -2
- data/lib/kamal/cli/templates/deploy.yml +6 -3
- data/lib/kamal/cli/templates/sample_hooks/post-app-boot.sample +3 -0
- data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +1 -1
- data/lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample +3 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +1 -1
- data/lib/kamal/cli/templates/sample_hooks/pre-connect.sample +1 -1
- data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +19 -6
- data/lib/kamal/cli.rb +1 -0
- data/lib/kamal/commander/specifics.rb +9 -1
- data/lib/kamal/commander.rb +18 -27
- data/lib/kamal/commands/accessory/proxy.rb +16 -0
- data/lib/kamal/commands/accessory.rb +9 -9
- data/lib/kamal/commands/app/assets.rb +4 -4
- data/lib/kamal/commands/app/containers.rb +2 -2
- data/lib/kamal/commands/app/error_pages.rb +9 -0
- data/lib/kamal/commands/app/execution.rb +6 -4
- data/lib/kamal/commands/app/images.rb +1 -1
- data/lib/kamal/commands/app/logging.rb +14 -4
- data/lib/kamal/commands/app/proxy.rb +17 -1
- data/lib/kamal/commands/app.rb +19 -10
- data/lib/kamal/commands/auditor.rb +11 -5
- data/lib/kamal/commands/base.rb +37 -1
- data/lib/kamal/commands/builder/base.rb +20 -7
- data/lib/kamal/commands/builder/cloud.rb +22 -0
- data/lib/kamal/commands/builder/pack.rb +46 -0
- data/lib/kamal/commands/builder.rb +11 -19
- data/lib/kamal/commands/proxy.rb +55 -15
- data/lib/kamal/commands/registry.rb +9 -7
- data/lib/kamal/configuration/accessory.rb +66 -11
- data/lib/kamal/configuration/builder.rb +20 -0
- data/lib/kamal/configuration/docs/accessory.yml +32 -4
- data/lib/kamal/configuration/docs/alias.yml +2 -2
- data/lib/kamal/configuration/docs/builder.yml +22 -0
- data/lib/kamal/configuration/docs/configuration.yml +6 -0
- data/lib/kamal/configuration/docs/env.yml +31 -0
- data/lib/kamal/configuration/docs/proxy.yml +78 -15
- data/lib/kamal/configuration/docs/registry.yml +4 -0
- data/lib/kamal/configuration/env.rb +13 -4
- data/lib/kamal/configuration/proxy/boot.rb +129 -0
- data/lib/kamal/configuration/proxy.rb +67 -5
- data/lib/kamal/configuration/registry.rb +6 -6
- data/lib/kamal/configuration/role.rb +11 -9
- data/lib/kamal/configuration/servers.rb +8 -1
- data/lib/kamal/configuration/validator/accessory.rb +6 -2
- data/lib/kamal/configuration/validator/builder.rb +2 -0
- data/lib/kamal/configuration/validator/proxy.rb +10 -0
- data/lib/kamal/configuration/validator/role.rb +3 -1
- data/lib/kamal/configuration/validator/servers.rb +1 -1
- data/lib/kamal/configuration/validator.rb +21 -1
- data/lib/kamal/configuration.rb +36 -57
- data/lib/kamal/docker.rb +30 -0
- data/lib/kamal/git.rb +10 -0
- data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +51 -0
- data/lib/kamal/secrets/adapters/base.rb +13 -3
- data/lib/kamal/secrets/adapters/bitwarden.rb +2 -2
- data/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb +66 -0
- data/lib/kamal/secrets/adapters/doppler.rb +57 -0
- data/lib/kamal/secrets/adapters/enpass.rb +71 -0
- data/lib/kamal/secrets/adapters/gcp_secret_manager.rb +112 -0
- data/lib/kamal/secrets/adapters/last_pass.rb +3 -2
- data/lib/kamal/secrets/adapters/one_password.rb +47 -13
- data/lib/kamal/secrets/adapters/passbolt.rb +130 -0
- data/lib/kamal/secrets/adapters/test.rb +2 -2
- data/lib/kamal/secrets/adapters.rb +2 -0
- data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +2 -1
- data/lib/kamal/secrets.rb +1 -1
- data/lib/kamal/version.rb +1 -1
- 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
|
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
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
54
|
-
|
55
|
-
|
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,
|
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
data/lib/kamal/version.rb
CHANGED