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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/kamal/cli/accessory.rb +92 -38
- data/lib/kamal/cli/alias/command.rb +10 -0
- data/lib/kamal/cli/app/{prepare_assets.rb → assets.rb} +1 -1
- data/lib/kamal/cli/app/boot.rb +23 -16
- 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 +132 -30
- data/lib/kamal/cli/base.rb +57 -53
- data/lib/kamal/cli/build.rb +81 -38
- data/lib/kamal/cli/healthcheck/barrier.rb +2 -0
- data/lib/kamal/cli/healthcheck/poller.rb +18 -39
- data/lib/kamal/cli/lock.rb +2 -3
- data/lib/kamal/cli/main.rb +60 -59
- data/lib/kamal/cli/proxy.rb +290 -0
- data/lib/kamal/cli/prune.rb +0 -1
- data/lib/kamal/cli/registry.rb +2 -0
- data/lib/kamal/cli/secrets.rb +49 -0
- data/lib/kamal/cli/server.rb +6 -5
- data/lib/kamal/cli/templates/deploy.yml +53 -53
- data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +2 -12
- 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/post-proxy-reboot.sample +3 -0
- 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/templates/sample_hooks/pre-proxy-reboot.sample +3 -0
- data/lib/kamal/cli/templates/secrets +17 -0
- data/lib/kamal/cli.rb +2 -0
- data/lib/kamal/commander/specifics.rb +19 -6
- data/lib/kamal/commander.rb +39 -32
- data/lib/kamal/commands/accessory/proxy.rb +16 -0
- data/lib/kamal/commands/accessory.rb +19 -19
- data/lib/kamal/commands/app/assets.rb +10 -10
- 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 +7 -4
- data/lib/kamal/commands/app/images.rb +1 -1
- data/lib/kamal/commands/app/logging.rb +16 -6
- data/lib/kamal/commands/app/proxy.rb +32 -0
- data/lib/kamal/commands/app.rb +25 -24
- data/lib/kamal/commands/auditor.rb +12 -3
- data/lib/kamal/commands/base.rb +54 -8
- data/lib/kamal/commands/builder/base.rb +46 -16
- data/lib/kamal/commands/builder/clone.rb +16 -14
- data/lib/kamal/commands/builder/cloud.rb +22 -0
- data/lib/kamal/commands/builder/hybrid.rb +21 -0
- data/lib/kamal/commands/builder/local.rb +14 -0
- data/lib/kamal/commands/builder/pack.rb +46 -0
- data/lib/kamal/commands/builder/remote.rb +63 -0
- data/lib/kamal/commands/builder.rb +21 -45
- data/lib/kamal/commands/docker.rb +4 -0
- data/lib/kamal/commands/hook.rb +8 -2
- data/lib/kamal/commands/lock.rb +2 -6
- data/lib/kamal/commands/proxy.rb +127 -0
- data/lib/kamal/commands/prune.rb +1 -9
- data/lib/kamal/commands/registry.rb +9 -7
- data/lib/kamal/commands/server.rb +11 -1
- data/lib/kamal/configuration/accessory.rb +89 -12
- data/lib/kamal/configuration/alias.rb +15 -0
- data/lib/kamal/configuration/builder.rb +73 -15
- data/lib/kamal/configuration/docs/accessory.yml +53 -15
- data/lib/kamal/configuration/docs/alias.yml +26 -0
- data/lib/kamal/configuration/docs/boot.yml +3 -3
- data/lib/kamal/configuration/docs/builder.yml +63 -38
- data/lib/kamal/configuration/docs/configuration.yml +62 -46
- data/lib/kamal/configuration/docs/env.yml +61 -17
- data/lib/kamal/configuration/docs/logging.yml +3 -3
- data/lib/kamal/configuration/docs/proxy.yml +168 -0
- data/lib/kamal/configuration/docs/registry.yml +20 -13
- data/lib/kamal/configuration/docs/role.yml +14 -13
- data/lib/kamal/configuration/docs/servers.yml +2 -2
- data/lib/kamal/configuration/docs/ssh.yml +23 -19
- data/lib/kamal/configuration/docs/sshkit.yml +4 -4
- data/lib/kamal/configuration/env/tag.rb +4 -3
- data/lib/kamal/configuration/env.rb +19 -17
- data/lib/kamal/configuration/proxy/boot.rb +129 -0
- data/lib/kamal/configuration/proxy.rb +124 -0
- data/lib/kamal/configuration/registry.rb +7 -6
- data/lib/kamal/configuration/role.rb +69 -98
- data/lib/kamal/configuration/servers.rb +8 -1
- data/lib/kamal/configuration/validator/accessory.rb +6 -2
- data/lib/kamal/configuration/validator/alias.rb +15 -0
- data/lib/kamal/configuration/validator/builder.rb +6 -0
- data/lib/kamal/configuration/validator/proxy.rb +25 -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 +62 -24
- data/lib/kamal/configuration.rb +96 -50
- data/lib/kamal/docker.rb +30 -0
- data/lib/kamal/env_file.rb +7 -1
- 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 +33 -0
- data/lib/kamal/secrets/adapters/bitwarden.rb +81 -0
- 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 +40 -0
- data/lib/kamal/secrets/adapters/one_password.rb +104 -0
- data/lib/kamal/secrets/adapters/passbolt.rb +130 -0
- data/lib/kamal/secrets/adapters/test.rb +14 -0
- data/lib/kamal/secrets/adapters.rb +16 -0
- data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +33 -0
- data/lib/kamal/secrets.rb +42 -0
- data/lib/kamal/sshkit_with_ext.rb +1 -0
- data/lib/kamal/utils.rb +30 -0
- data/lib/kamal/version.rb +1 -1
- data/lib/kamal.rb +3 -1
- metadata +63 -36
- data/lib/kamal/cli/env.rb +0 -54
- data/lib/kamal/cli/templates/sample_hooks/post-traefik-reboot.sample +0 -3
- data/lib/kamal/cli/templates/sample_hooks/pre-traefik-reboot.sample +0 -3
- data/lib/kamal/cli/templates/template.env +0 -2
- data/lib/kamal/cli/traefik.rb +0 -122
- data/lib/kamal/commands/app/cord.rb +0 -22
- data/lib/kamal/commands/builder/multiarch/remote.rb +0 -65
- data/lib/kamal/commands/builder/multiarch.rb +0 -41
- data/lib/kamal/commands/builder/native/cached.rb +0 -25
- data/lib/kamal/commands/builder/native/remote.rb +0 -67
- data/lib/kamal/commands/builder/native.rb +0 -20
- data/lib/kamal/commands/traefik.rb +0 -85
- data/lib/kamal/configuration/docs/healthcheck.yml +0 -59
- data/lib/kamal/configuration/docs/traefik.yml +0 -62
- data/lib/kamal/configuration/healthcheck.rb +0 -63
- 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
|
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
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.
|
14
|
+
loader.eager_load_namespace(Kamal::Cli) # We need all commands loaded.
|