kamal 2.4.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 +1 -1
- data/lib/kamal/cli/alias/command.rb +1 -0
- data/lib/kamal/cli/app.rb +15 -3
- 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/templates/deploy.yml +2 -2
- 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.rb +1 -5
- data/lib/kamal/commands/app/assets.rb +4 -4
- data/lib/kamal/commands/base.rb +14 -0
- data/lib/kamal/commands/builder/base.rb +12 -5
- 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 +36 -19
- data/lib/kamal/configuration/builder.rb +4 -0
- data/lib/kamal/configuration/docs/accessory.yml +20 -1
- data/lib/kamal/configuration/docs/builder.yml +3 -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 +29 -12
- data/lib/kamal/docker.rb +30 -0
- data/lib/kamal/git.rb +10 -0
- data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +12 -4
- data/lib/kamal/secrets/adapters/base.rb +5 -2
- 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 +15 -11
- 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/version.rb +1 -1
- metadata +10 -4
- data/lib/kamal/secrets/adapters/test_optional_account.rb +0 -5
@@ -5,7 +5,7 @@ class Kamal::Configuration::Accessory
|
|
5
5
|
|
6
6
|
delegate :argumentize, :optionize, to: Kamal::Utils
|
7
7
|
|
8
|
-
attr_reader :name, :
|
8
|
+
attr_reader :name, :env, :proxy, :registry
|
9
9
|
|
10
10
|
def initialize(name, config:)
|
11
11
|
@name, @config, @accessory_config = name.inquiry, config, config.raw_config["accessories"][name]
|
@@ -16,12 +16,11 @@ class Kamal::Configuration::Accessory
|
|
16
16
|
context: "accessories/#{name}",
|
17
17
|
with: Kamal::Configuration::Validator::Accessory
|
18
18
|
|
19
|
-
|
20
|
-
config: accessory_config.fetch("env", {}),
|
21
|
-
secrets: config.secrets,
|
22
|
-
context: "accessories/#{name}/env"
|
19
|
+
ensure_valid_roles
|
23
20
|
|
24
|
-
|
21
|
+
@env = initialize_env
|
22
|
+
@proxy = initialize_proxy if running_proxy?
|
23
|
+
@registry = initialize_registry if accessory_config["registry"].present?
|
25
24
|
end
|
26
25
|
|
27
26
|
def service_name
|
@@ -29,7 +28,7 @@ class Kamal::Configuration::Accessory
|
|
29
28
|
end
|
30
29
|
|
31
30
|
def image
|
32
|
-
accessory_config["image"]
|
31
|
+
[ registry&.server, accessory_config["image"] ].compact.join("/")
|
33
32
|
end
|
34
33
|
|
35
34
|
def hosts
|
@@ -109,18 +108,32 @@ class Kamal::Configuration::Accessory
|
|
109
108
|
end
|
110
109
|
|
111
110
|
def running_proxy?
|
112
|
-
|
113
|
-
end
|
114
|
-
|
115
|
-
def initialize_proxy
|
116
|
-
@proxy = Kamal::Configuration::Proxy.new \
|
117
|
-
config: config,
|
118
|
-
proxy_config: accessory_config["proxy"],
|
119
|
-
context: "accessories/#{name}/proxy"
|
111
|
+
accessory_config["proxy"].present?
|
120
112
|
end
|
121
113
|
|
122
114
|
private
|
123
|
-
|
115
|
+
attr_reader :config, :accessory_config
|
116
|
+
|
117
|
+
def initialize_env
|
118
|
+
Kamal::Configuration::Env.new \
|
119
|
+
config: accessory_config.fetch("env", {}),
|
120
|
+
secrets: config.secrets,
|
121
|
+
context: "accessories/#{name}/env"
|
122
|
+
end
|
123
|
+
|
124
|
+
def initialize_proxy
|
125
|
+
Kamal::Configuration::Proxy.new \
|
126
|
+
config: config,
|
127
|
+
proxy_config: accessory_config["proxy"],
|
128
|
+
context: "accessories/#{name}/proxy"
|
129
|
+
end
|
130
|
+
|
131
|
+
def initialize_registry
|
132
|
+
Kamal::Configuration::Registry.new \
|
133
|
+
config: accessory_config,
|
134
|
+
secrets: config.secrets,
|
135
|
+
context: "accessories/#{name}/registry"
|
136
|
+
end
|
124
137
|
|
125
138
|
def default_labels
|
126
139
|
{ "service" => service_name }
|
@@ -189,13 +202,17 @@ class Kamal::Configuration::Accessory
|
|
189
202
|
|
190
203
|
def hosts_from_roles
|
191
204
|
if accessory_config.key?("roles")
|
192
|
-
accessory_config["roles"].flat_map
|
193
|
-
config.role(role)&.hosts || raise(Kamal::ConfigurationError, "Unknown role in accessories config: '#{role}'")
|
194
|
-
end
|
205
|
+
accessory_config["roles"].flat_map { |role| config.role(role)&.hosts }
|
195
206
|
end
|
196
207
|
end
|
197
208
|
|
198
209
|
def network
|
199
210
|
accessory_config["network"] || DEFAULT_NETWORK
|
200
211
|
end
|
212
|
+
|
213
|
+
def ensure_valid_roles
|
214
|
+
if accessory_config["roles"] && (missing_roles = accessory_config["roles"] - config.roles.map(&:name)).any?
|
215
|
+
raise Kamal::ConfigurationError, "accessories/#{name}: unknown roles #{missing_roles.join(", ")}"
|
216
|
+
end
|
217
|
+
end
|
201
218
|
end
|
@@ -23,9 +23,27 @@ accessories:
|
|
23
23
|
|
24
24
|
# Image
|
25
25
|
#
|
26
|
-
# The Docker image to use
|
26
|
+
# The Docker image to use.
|
27
|
+
# Prefix it with its server when using root level registry different from Docker Hub.
|
28
|
+
# Define registry directly or via anchors when it differs from root level registry.
|
27
29
|
image: mysql:8.0
|
28
30
|
|
31
|
+
# Registry
|
32
|
+
#
|
33
|
+
# By default accessories use Docker Hub registry.
|
34
|
+
# You can specify different registry per accessory with this option.
|
35
|
+
# Don't prefix image with this registry server.
|
36
|
+
# Use anchors if you need to set the same specific registry for several accessories.
|
37
|
+
#
|
38
|
+
# ```yml
|
39
|
+
# registry:
|
40
|
+
# <<: *specific-registry
|
41
|
+
# ```
|
42
|
+
#
|
43
|
+
# See kamal docs registry for more information:
|
44
|
+
registry:
|
45
|
+
...
|
46
|
+
|
29
47
|
# Accessory hosts
|
30
48
|
#
|
31
49
|
# Specify one of `host`, `hosts`, or `roles`:
|
@@ -100,5 +118,6 @@ accessories:
|
|
100
118
|
|
101
119
|
# Proxy
|
102
120
|
#
|
121
|
+
# You can run your accessory behind the Kamal proxy. See kamal docs proxy for more information
|
103
122
|
proxy:
|
104
123
|
...
|
@@ -102,6 +102,9 @@ builder:
|
|
102
102
|
#
|
103
103
|
# The build driver to use, defaults to `docker-container`:
|
104
104
|
driver: docker
|
105
|
+
#
|
106
|
+
# If you want to use Docker Build Cloud (https://www.docker.com/products/build-cloud/), you can set the driver to:
|
107
|
+
driver: cloud org-name/builder-name
|
105
108
|
|
106
109
|
# Provenance
|
107
110
|
#
|
@@ -1,12 +1,10 @@
|
|
1
1
|
class Kamal::Configuration::Registry
|
2
2
|
include Kamal::Configuration::Validation
|
3
3
|
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
@secrets = config.secrets
|
9
|
-
validate! registry_config, with: Kamal::Configuration::Validator::Registry
|
4
|
+
def initialize(config:, secrets:, context: "registry")
|
5
|
+
@registry_config = config["registry"] || {}
|
6
|
+
@secrets = secrets
|
7
|
+
validate! registry_config, context: context, with: Kamal::Configuration::Validator::Registry
|
10
8
|
end
|
11
9
|
|
12
10
|
def server
|
@@ -22,6 +20,8 @@ class Kamal::Configuration::Registry
|
|
22
20
|
end
|
23
21
|
|
24
22
|
private
|
23
|
+
attr_reader :registry_config, :secrets
|
24
|
+
|
25
25
|
def lookup(key)
|
26
26
|
if registry_config[key].is_a?(Array)
|
27
27
|
secrets[registry_config[key].first]
|
@@ -10,7 +10,7 @@ class Kamal::Configuration::Role
|
|
10
10
|
def initialize(name, config:)
|
11
11
|
@name, @config = name.inquiry, config
|
12
12
|
validate! \
|
13
|
-
|
13
|
+
role_config,
|
14
14
|
example: validation_yml["servers"]["workers"],
|
15
15
|
context: "servers/#{name}",
|
16
16
|
with: Kamal::Configuration::Validator::Role
|
@@ -204,11 +204,11 @@ class Kamal::Configuration::Role
|
|
204
204
|
end
|
205
205
|
|
206
206
|
def specializations
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
207
|
+
@specializations ||= role_config.is_a?(Array) ? {} : role_config
|
208
|
+
end
|
209
|
+
|
210
|
+
def role_config
|
211
|
+
@role_config ||= config.raw_config.servers.is_a?(Array) ? {} : config.raw_config.servers[name]
|
212
212
|
end
|
213
213
|
|
214
214
|
def custom_labels
|
data/lib/kamal/configuration.rb
CHANGED
@@ -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
|
@@ -1,12 +1,16 @@
|
|
1
1
|
class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Base
|
2
|
+
def requires_account?
|
3
|
+
false
|
4
|
+
end
|
5
|
+
|
2
6
|
private
|
3
7
|
def login(_account)
|
4
8
|
nil
|
5
9
|
end
|
6
10
|
|
7
|
-
def fetch_secrets(secrets,
|
11
|
+
def fetch_secrets(secrets, from:, account: nil, session:)
|
8
12
|
{}.tap do |results|
|
9
|
-
get_from_secrets_manager(secrets, account: account).each do |secret|
|
13
|
+
get_from_secrets_manager(prefixed_secrets(secrets, from: from), account: account).each do |secret|
|
10
14
|
secret_name = secret["Name"]
|
11
15
|
secret_string = JSON.parse(secret["SecretString"])
|
12
16
|
|
@@ -19,8 +23,12 @@ class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Ba
|
|
19
23
|
end
|
20
24
|
end
|
21
25
|
|
22
|
-
def get_from_secrets_manager(secrets, account:)
|
23
|
-
|
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|
|
24
32
|
raise RuntimeError, "Could not read #{secrets} from AWS Secrets Manager" unless $?.success?
|
25
33
|
|
26
34
|
secrets = JSON.parse(secrets)
|
@@ -7,8 +7,7 @@ class Kamal::Secrets::Adapters::Base
|
|
7
7
|
check_dependencies!
|
8
8
|
|
9
9
|
session = login(account)
|
10
|
-
|
11
|
-
fetch_secrets(full_secrets, account: account, session: session)
|
10
|
+
fetch_secrets(secrets, from: from, account: account, session: session)
|
12
11
|
end
|
13
12
|
|
14
13
|
def requires_account?
|
@@ -27,4 +26,8 @@ class Kamal::Secrets::Adapters::Base
|
|
27
26
|
def check_dependencies!
|
28
27
|
raise NotImplementedError
|
29
28
|
end
|
29
|
+
|
30
|
+
def prefixed_secrets(secrets, from:)
|
31
|
+
secrets.map { |secret| [ from, secret ].compact.join("/") }
|
32
|
+
end
|
30
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
|
@@ -16,8 +16,21 @@ class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base
|
|
16
16
|
$?.success?
|
17
17
|
end
|
18
18
|
|
19
|
-
def fetch_secrets(secrets, **)
|
20
|
-
|
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)
|
21
34
|
unless service_token_set?
|
22
35
|
project, config, _ = secrets.first.split("/")
|
23
36
|
|
@@ -27,15 +40,6 @@ class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base
|
|
27
40
|
|
28
41
|
project_and_config_flags = "-p #{project.shellescape} -c #{config.shellescape}"
|
29
42
|
end
|
30
|
-
|
31
|
-
secret_names = secrets.collect { |s| s.split("/").last }
|
32
|
-
|
33
|
-
items = `doppler secrets get #{secret_names.map(&:shellescape).join(" ")} --json #{project_and_config_flags}`
|
34
|
-
raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success?
|
35
|
-
|
36
|
-
items = JSON.parse(items)
|
37
|
-
|
38
|
-
items.transform_values { |value| value["computed"] }
|
39
43
|
end
|
40
44
|
|
41
45
|
def service_token_set?
|
@@ -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
|