kamal 2.3.0 → 2.5.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 +24 -9
- data/lib/kamal/cli/alias/command.rb +1 -0
- data/lib/kamal/cli/app/boot.rb +2 -2
- data/lib/kamal/cli/app.rb +28 -8
- data/lib/kamal/cli/base.rb +16 -1
- data/lib/kamal/cli/build.rb +36 -14
- data/lib/kamal/cli/main.rb +4 -3
- data/lib/kamal/cli/proxy.rb +2 -4
- data/lib/kamal/cli/registry.rb +2 -0
- data/lib/kamal/cli/secrets.rb +9 -3
- 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/pre-app-boot.sample +3 -0
- data/lib/kamal/cli.rb +1 -0
- data/lib/kamal/commander.rb +16 -25
- data/lib/kamal/commands/accessory/proxy.rb +16 -0
- data/lib/kamal/commands/accessory.rb +4 -4
- data/lib/kamal/commands/app/assets.rb +4 -4
- data/lib/kamal/commands/app/containers.rb +2 -2
- data/lib/kamal/commands/app/execution.rb +4 -2
- data/lib/kamal/commands/app/images.rb +1 -1
- data/lib/kamal/commands/app/logging.rb +14 -4
- data/lib/kamal/commands/app.rb +15 -7
- data/lib/kamal/commands/base.rb +25 -1
- data/lib/kamal/commands/builder/base.rb +17 -6
- data/lib/kamal/commands/builder/cloud.rb +22 -0
- data/lib/kamal/commands/builder.rb +6 -20
- data/lib/kamal/commands/registry.rb +9 -7
- data/lib/kamal/configuration/accessory.rb +41 -9
- data/lib/kamal/configuration/builder.rb +8 -0
- data/lib/kamal/configuration/docs/accessory.yml +26 -3
- data/lib/kamal/configuration/docs/alias.yml +2 -2
- data/lib/kamal/configuration/docs/builder.yml +9 -0
- data/lib/kamal/configuration/docs/proxy.yml +13 -10
- data/lib/kamal/configuration/docs/registry.yml +4 -0
- data/lib/kamal/configuration/registry.rb +6 -6
- data/lib/kamal/configuration/role.rb +6 -6
- data/lib/kamal/configuration/validator/role.rb +1 -1
- data/lib/kamal/configuration.rb +31 -14
- data/lib/kamal/docker.rb +30 -0
- data/lib/kamal/git.rb +10 -0
- data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +50 -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 +72 -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 +2 -2
- data/lib/kamal/secrets/adapters/test.rb +2 -2
- data/lib/kamal/secrets/adapters.rb +2 -0
- data/lib/kamal/secrets.rb +1 -1
- data/lib/kamal/version.rb +1 -1
- metadata +13 -3
data/lib/kamal/configuration.rb
CHANGED
@@ -14,7 +14,7 @@ class Kamal::Configuration
|
|
14
14
|
|
15
15
|
include Validation
|
16
16
|
|
17
|
-
PROXY_MINIMUM_VERSION = "v0.8.
|
17
|
+
PROXY_MINIMUM_VERSION = "v0.8.4"
|
18
18
|
PROXY_HTTP_PORT = 80
|
19
19
|
PROXY_HTTPS_PORT = 443
|
20
20
|
PROXY_LOG_MAX_SIZE = "10m"
|
@@ -37,7 +37,7 @@ class Kamal::Configuration
|
|
37
37
|
if file.exist?
|
38
38
|
# Newer Psych doesn't load aliases by default
|
39
39
|
load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load
|
40
|
-
YAML.send(load_method, ERB.new(
|
40
|
+
YAML.send(load_method, ERB.new(File.read(file)).result).symbolize_keys
|
41
41
|
else
|
42
42
|
raise "Configuration file not found in #{file}"
|
43
43
|
end
|
@@ -59,7 +59,7 @@ class Kamal::Configuration
|
|
59
59
|
|
60
60
|
# Eager load config to validate it, these are first as they have dependencies later on
|
61
61
|
@servers = Servers.new(config: self)
|
62
|
-
@registry = Registry.new(config:
|
62
|
+
@registry = Registry.new(config: @raw_config, secrets: secrets)
|
63
63
|
|
64
64
|
@accessories = @raw_config.accessories&.keys&.collect { |name| Accessory.new(name, config: self) } || []
|
65
65
|
@aliases = @raw_config.aliases&.keys&.to_h { |name| [ name, Alias.new(name, config: self) ] } || {}
|
@@ -82,7 +82,6 @@ class Kamal::Configuration
|
|
82
82
|
ensure_unique_hosts_for_ssl_roles
|
83
83
|
end
|
84
84
|
|
85
|
-
|
86
85
|
def version=(version)
|
87
86
|
@declared_version = version
|
88
87
|
end
|
@@ -106,7 +105,6 @@ class Kamal::Configuration
|
|
106
105
|
raw_config.minimum_version
|
107
106
|
end
|
108
107
|
|
109
|
-
|
110
108
|
def roles
|
111
109
|
servers.roles
|
112
110
|
end
|
@@ -119,7 +117,6 @@ class Kamal::Configuration
|
|
119
117
|
accessories.detect { |a| a.name == name.to_s }
|
120
118
|
end
|
121
119
|
|
122
|
-
|
123
120
|
def all_hosts
|
124
121
|
(roles + accessories).flat_map(&:hosts).uniq
|
125
122
|
end
|
@@ -180,7 +177,6 @@ class Kamal::Configuration
|
|
180
177
|
raw_config.retain_containers || 5
|
181
178
|
end
|
182
179
|
|
183
|
-
|
184
180
|
def volume_args
|
185
181
|
if raw_config.volumes.present?
|
186
182
|
argumentize "--volume", raw_config.volumes
|
@@ -193,7 +189,6 @@ class Kamal::Configuration
|
|
193
189
|
logging.args
|
194
190
|
end
|
195
191
|
|
196
|
-
|
197
192
|
def readiness_delay
|
198
193
|
raw_config.readiness_delay || 7
|
199
194
|
end
|
@@ -206,7 +201,6 @@ class Kamal::Configuration
|
|
206
201
|
raw_config.drain_timeout || 30
|
207
202
|
end
|
208
203
|
|
209
|
-
|
210
204
|
def run_directory
|
211
205
|
".kamal"
|
212
206
|
end
|
@@ -227,7 +221,6 @@ class Kamal::Configuration
|
|
227
221
|
File.join app_directory, "assets"
|
228
222
|
end
|
229
223
|
|
230
|
-
|
231
224
|
def hooks_path
|
232
225
|
raw_config.hooks_path || ".kamal/hooks"
|
233
226
|
end
|
@@ -236,7 +229,6 @@ class Kamal::Configuration
|
|
236
229
|
raw_config.asset_path
|
237
230
|
end
|
238
231
|
|
239
|
-
|
240
232
|
def env_tags
|
241
233
|
@env_tags ||= if (tags = raw_config.env["tags"])
|
242
234
|
tags.collect { |name, config| Env::Tag.new(name, config: config, secrets: secrets) }
|
@@ -249,8 +241,16 @@ class Kamal::Configuration
|
|
249
241
|
env_tags.detect { |t| t.name == name.to_s }
|
250
242
|
end
|
251
243
|
|
252
|
-
def proxy_publish_args(http_port, https_port)
|
253
|
-
|
244
|
+
def proxy_publish_args(http_port, https_port, bind_ips = nil)
|
245
|
+
ensure_valid_bind_ips(bind_ips)
|
246
|
+
|
247
|
+
(bind_ips || [ nil ]).map do |bind_ip|
|
248
|
+
bind_ip = format_bind_ip(bind_ip)
|
249
|
+
publish_http = [ bind_ip, http_port, PROXY_HTTP_PORT ].compact.join(":")
|
250
|
+
publish_https = [ bind_ip, https_port, PROXY_HTTPS_PORT ].compact.join(":")
|
251
|
+
|
252
|
+
argumentize "--publish", [ publish_http, publish_https ]
|
253
|
+
end.join(" ")
|
254
254
|
end
|
255
255
|
|
256
256
|
def proxy_logging_args(max_size)
|
@@ -277,7 +277,6 @@ class Kamal::Configuration
|
|
277
277
|
File.join proxy_directory, "options"
|
278
278
|
end
|
279
279
|
|
280
|
-
|
281
280
|
def to_h
|
282
281
|
{
|
283
282
|
roles: role_names,
|
@@ -344,6 +343,15 @@ class Kamal::Configuration
|
|
344
343
|
true
|
345
344
|
end
|
346
345
|
|
346
|
+
def ensure_valid_bind_ips(bind_ips)
|
347
|
+
bind_ips.present? && bind_ips.each do |ip|
|
348
|
+
next if ip =~ Resolv::IPv4::Regex || ip =~ Resolv::IPv6::Regex
|
349
|
+
raise ArgumentError, "Invalid publish IP address: #{ip}"
|
350
|
+
end
|
351
|
+
|
352
|
+
true
|
353
|
+
end
|
354
|
+
|
347
355
|
def ensure_retain_containers_valid
|
348
356
|
raise Kamal::ConfigurationError, "Must retain at least 1 container" if retain_containers < 1
|
349
357
|
|
@@ -375,6 +383,15 @@ class Kamal::Configuration
|
|
375
383
|
true
|
376
384
|
end
|
377
385
|
|
386
|
+
def format_bind_ip(ip)
|
387
|
+
# Ensure IPv6 address inside square brackets - e.g. [::1]
|
388
|
+
if ip =~ Resolv::IPv6::Regex && ip !~ /\[.*\]/
|
389
|
+
"[#{ip}]"
|
390
|
+
else
|
391
|
+
ip
|
392
|
+
end
|
393
|
+
end
|
394
|
+
|
378
395
|
def role_names
|
379
396
|
raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
|
380
397
|
end
|
data/lib/kamal/docker.rb
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
require "tempfile"
|
2
|
+
require "open3"
|
3
|
+
|
4
|
+
module Kamal::Docker
|
5
|
+
extend self
|
6
|
+
BUILD_CHECK_TAG = "kamal-local-build-check"
|
7
|
+
|
8
|
+
def included_files
|
9
|
+
Tempfile.create do |dockerfile|
|
10
|
+
dockerfile.write(<<~DOCKERFILE)
|
11
|
+
FROM busybox
|
12
|
+
COPY . app
|
13
|
+
WORKDIR app
|
14
|
+
CMD find . -type f | sed "s|^\./||"
|
15
|
+
DOCKERFILE
|
16
|
+
dockerfile.close
|
17
|
+
|
18
|
+
cmd = "docker buildx build -t=#{BUILD_CHECK_TAG} -f=#{dockerfile.path} ."
|
19
|
+
system(cmd) || raise("failed to build check image")
|
20
|
+
end
|
21
|
+
|
22
|
+
cmd = "docker run --rm #{BUILD_CHECK_TAG}"
|
23
|
+
out, err, status = Open3.capture3(cmd)
|
24
|
+
unless status
|
25
|
+
raise "failed to run check image:\n#{err}"
|
26
|
+
end
|
27
|
+
|
28
|
+
out.lines.map(&:strip)
|
29
|
+
end
|
30
|
+
end
|
data/lib/kamal/git.rb
CHANGED
@@ -24,4 +24,14 @@ module Kamal::Git
|
|
24
24
|
def root
|
25
25
|
`git rev-parse --show-toplevel`.strip
|
26
26
|
end
|
27
|
+
|
28
|
+
# returns an array of relative path names of files with uncommitted changes
|
29
|
+
def uncommitted_files
|
30
|
+
`git ls-files --modified`.lines.map(&:strip)
|
31
|
+
end
|
32
|
+
|
33
|
+
# returns an array of relative path names of untracked files, including gitignored files
|
34
|
+
def untracked_files
|
35
|
+
`git ls-files --others`.lines.map(&:strip)
|
36
|
+
end
|
27
37
|
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Base
|
2
|
+
def requires_account?
|
3
|
+
false
|
4
|
+
end
|
5
|
+
|
6
|
+
private
|
7
|
+
def login(_account)
|
8
|
+
nil
|
9
|
+
end
|
10
|
+
|
11
|
+
def fetch_secrets(secrets, from:, account: nil, session:)
|
12
|
+
{}.tap do |results|
|
13
|
+
get_from_secrets_manager(prefixed_secrets(secrets, from: from), account: account).each do |secret|
|
14
|
+
secret_name = secret["Name"]
|
15
|
+
secret_string = JSON.parse(secret["SecretString"])
|
16
|
+
|
17
|
+
secret_string.each do |key, value|
|
18
|
+
results["#{secret_name}/#{key}"] = value
|
19
|
+
end
|
20
|
+
rescue JSON::ParserError
|
21
|
+
results["#{secret_name}"] = secret["SecretString"]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def get_from_secrets_manager(secrets, account: nil)
|
27
|
+
args = [ "aws", "secretsmanager", "batch-get-secret-value", "--secret-id-list" ] + secrets.map(&:shellescape)
|
28
|
+
args += [ "--profile", account.shellescape ] if account
|
29
|
+
cmd = args.join(" ")
|
30
|
+
|
31
|
+
`#{cmd}`.tap do |secrets|
|
32
|
+
raise RuntimeError, "Could not read #{secrets} from AWS Secrets Manager" unless $?.success?
|
33
|
+
|
34
|
+
secrets = JSON.parse(secrets)
|
35
|
+
|
36
|
+
return secrets["SecretValues"] unless secrets["Errors"].present?
|
37
|
+
|
38
|
+
raise RuntimeError, secrets["Errors"].map { |error| "#{error['SecretId']}: #{error['Message']}" }.join(" ")
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def check_dependencies!
|
43
|
+
raise RuntimeError, "AWS CLI is not installed" unless cli_installed?
|
44
|
+
end
|
45
|
+
|
46
|
+
def cli_installed?
|
47
|
+
`aws --version 2> /dev/null`
|
48
|
+
$?.success?
|
49
|
+
end
|
50
|
+
end
|
@@ -1,11 +1,17 @@
|
|
1
1
|
class Kamal::Secrets::Adapters::Base
|
2
2
|
delegate :optionize, to: Kamal::Utils
|
3
3
|
|
4
|
-
def fetch(secrets, account
|
4
|
+
def fetch(secrets, account: nil, from: nil)
|
5
|
+
raise RuntimeError, "Missing required option '--account'" if requires_account? && account.blank?
|
6
|
+
|
5
7
|
check_dependencies!
|
8
|
+
|
6
9
|
session = login(account)
|
7
|
-
|
8
|
-
|
10
|
+
fetch_secrets(secrets, from: from, account: account, session: session)
|
11
|
+
end
|
12
|
+
|
13
|
+
def requires_account?
|
14
|
+
true
|
9
15
|
end
|
10
16
|
|
11
17
|
private
|
@@ -20,4 +26,8 @@ class Kamal::Secrets::Adapters::Base
|
|
20
26
|
def check_dependencies!
|
21
27
|
raise NotImplementedError
|
22
28
|
end
|
29
|
+
|
30
|
+
def prefixed_secrets(secrets, from:)
|
31
|
+
secrets.map { |secret| [ from, secret ].compact.join("/") }
|
32
|
+
end
|
23
33
|
end
|
@@ -21,9 +21,9 @@ class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base
|
|
21
21
|
session
|
22
22
|
end
|
23
23
|
|
24
|
-
def fetch_secrets(secrets, account:, session:)
|
24
|
+
def fetch_secrets(secrets, from:, account:, session:)
|
25
25
|
{}.tap do |results|
|
26
|
-
items_fields(secrets).each do |item, fields|
|
26
|
+
items_fields(prefixed_secrets(secrets, from: from)).each do |item, fields|
|
27
27
|
item_json = run_command("get item #{item.shellescape}", session: session, raw: true)
|
28
28
|
raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success?
|
29
29
|
item_json = JSON.parse(item_json)
|
@@ -0,0 +1,72 @@
|
|
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 -o env"
|
10
|
+
GET_COMMAND = "secret get -o env"
|
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
|
+
secret = run_command("#{GET_COMMAND} #{secret_uuid.shellescape}")
|
22
|
+
raise RuntimeError, "Could not read #{secret_uuid} from Bitwarden Secrets Manager" unless $?.success?
|
23
|
+
key, value = parse_secret(secret)
|
24
|
+
results[key] = value
|
25
|
+
end
|
26
|
+
else
|
27
|
+
secrets = run_command(command)
|
28
|
+
raise RuntimeError, "Could not read secrets from Bitwarden Secrets Manager" unless $?.success?
|
29
|
+
secrets.split("\n").each do |secret|
|
30
|
+
key, value = parse_secret(secret)
|
31
|
+
results[key] = 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 parse_secret(secret)
|
49
|
+
key, value = secret.split("=", 2)
|
50
|
+
value = value.gsub(/^"|"$/, "")
|
51
|
+
[ key, value ]
|
52
|
+
end
|
53
|
+
|
54
|
+
def run_command(command, session: nil)
|
55
|
+
full_command = [ "bws", command ].join(" ")
|
56
|
+
`#{full_command}`
|
57
|
+
end
|
58
|
+
|
59
|
+
def login(account)
|
60
|
+
run_command("run 'echo OK'")
|
61
|
+
raise RuntimeError, "Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?" unless $?.success?
|
62
|
+
end
|
63
|
+
|
64
|
+
def check_dependencies!
|
65
|
+
raise RuntimeError, "Bitwarden Secrets Manager CLI is not installed" unless cli_installed?
|
66
|
+
end
|
67
|
+
|
68
|
+
def cli_installed?
|
69
|
+
`bws --version 2> /dev/null`
|
70
|
+
$?.success?
|
71
|
+
end
|
72
|
+
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,9 +15,9 @@ 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
19
|
{}.tap do |results|
|
20
|
-
vaults_items_fields(secrets).map do |vault, items|
|
20
|
+
vaults_items_fields(prefixed_secrets(secrets, from: from)).map do |vault, items|
|
21
21
|
items.each do |item, fields|
|
22
22
|
fields_json = JSON.parse(op_item_get(vault, item, fields, account: account, session: session))
|
23
23
|
fields_json = [ fields_json ] if fields.one?
|
@@ -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
|
|
data/lib/kamal/secrets.rb
CHANGED
data/lib/kamal/version.rb
CHANGED