nocoffee-kamal 2.3.0.1
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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +13 -0
- data/bin/kamal +18 -0
- data/lib/kamal/cli/accessory.rb +287 -0
- data/lib/kamal/cli/alias/command.rb +9 -0
- data/lib/kamal/cli/app/boot.rb +125 -0
- data/lib/kamal/cli/app/prepare_assets.rb +24 -0
- data/lib/kamal/cli/app.rb +335 -0
- data/lib/kamal/cli/base.rb +198 -0
- data/lib/kamal/cli/build/clone.rb +61 -0
- data/lib/kamal/cli/build.rb +162 -0
- data/lib/kamal/cli/healthcheck/barrier.rb +33 -0
- data/lib/kamal/cli/healthcheck/error.rb +2 -0
- data/lib/kamal/cli/healthcheck/poller.rb +42 -0
- data/lib/kamal/cli/lock.rb +45 -0
- data/lib/kamal/cli/main.rb +279 -0
- data/lib/kamal/cli/proxy.rb +257 -0
- data/lib/kamal/cli/prune.rb +34 -0
- data/lib/kamal/cli/registry.rb +17 -0
- data/lib/kamal/cli/secrets.rb +43 -0
- data/lib/kamal/cli/server.rb +48 -0
- data/lib/kamal/cli/templates/deploy.yml +98 -0
- data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +3 -0
- data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +14 -0
- data/lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample +3 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +51 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-connect.sample +47 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +109 -0
- 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 +8 -0
- data/lib/kamal/commander/specifics.rb +54 -0
- data/lib/kamal/commander.rb +176 -0
- data/lib/kamal/commands/accessory.rb +113 -0
- data/lib/kamal/commands/app/assets.rb +51 -0
- data/lib/kamal/commands/app/containers.rb +31 -0
- data/lib/kamal/commands/app/execution.rb +30 -0
- data/lib/kamal/commands/app/images.rb +13 -0
- data/lib/kamal/commands/app/logging.rb +18 -0
- data/lib/kamal/commands/app/proxy.rb +16 -0
- data/lib/kamal/commands/app.rb +115 -0
- data/lib/kamal/commands/auditor.rb +33 -0
- data/lib/kamal/commands/base.rb +98 -0
- data/lib/kamal/commands/builder/base.rb +111 -0
- data/lib/kamal/commands/builder/clone.rb +31 -0
- data/lib/kamal/commands/builder/hybrid.rb +21 -0
- data/lib/kamal/commands/builder/local.rb +14 -0
- data/lib/kamal/commands/builder/remote.rb +63 -0
- data/lib/kamal/commands/builder.rb +56 -0
- data/lib/kamal/commands/docker.rb +34 -0
- data/lib/kamal/commands/hook.rb +20 -0
- data/lib/kamal/commands/lock.rb +70 -0
- data/lib/kamal/commands/proxy.rb +87 -0
- data/lib/kamal/commands/prune.rb +38 -0
- data/lib/kamal/commands/registry.rb +14 -0
- data/lib/kamal/commands/server.rb +15 -0
- data/lib/kamal/commands.rb +2 -0
- data/lib/kamal/configuration/accessory.rb +186 -0
- data/lib/kamal/configuration/alias.rb +15 -0
- data/lib/kamal/configuration/boot.rb +25 -0
- data/lib/kamal/configuration/builder.rb +191 -0
- data/lib/kamal/configuration/docs/accessory.yml +100 -0
- data/lib/kamal/configuration/docs/alias.yml +26 -0
- data/lib/kamal/configuration/docs/boot.yml +19 -0
- data/lib/kamal/configuration/docs/builder.yml +110 -0
- data/lib/kamal/configuration/docs/configuration.yml +178 -0
- data/lib/kamal/configuration/docs/env.yml +85 -0
- data/lib/kamal/configuration/docs/logging.yml +21 -0
- data/lib/kamal/configuration/docs/proxy.yml +110 -0
- data/lib/kamal/configuration/docs/registry.yml +52 -0
- data/lib/kamal/configuration/docs/role.yml +53 -0
- data/lib/kamal/configuration/docs/servers.yml +27 -0
- data/lib/kamal/configuration/docs/ssh.yml +70 -0
- data/lib/kamal/configuration/docs/sshkit.yml +23 -0
- data/lib/kamal/configuration/env/tag.rb +13 -0
- data/lib/kamal/configuration/env.rb +29 -0
- data/lib/kamal/configuration/logging.rb +33 -0
- data/lib/kamal/configuration/proxy.rb +63 -0
- data/lib/kamal/configuration/registry.rb +32 -0
- data/lib/kamal/configuration/role.rb +220 -0
- data/lib/kamal/configuration/servers.rb +18 -0
- data/lib/kamal/configuration/ssh.rb +57 -0
- data/lib/kamal/configuration/sshkit.rb +22 -0
- data/lib/kamal/configuration/validation.rb +27 -0
- data/lib/kamal/configuration/validator/accessory.rb +9 -0
- data/lib/kamal/configuration/validator/alias.rb +15 -0
- data/lib/kamal/configuration/validator/builder.rb +13 -0
- data/lib/kamal/configuration/validator/configuration.rb +6 -0
- data/lib/kamal/configuration/validator/env.rb +54 -0
- data/lib/kamal/configuration/validator/proxy.rb +15 -0
- data/lib/kamal/configuration/validator/registry.rb +25 -0
- data/lib/kamal/configuration/validator/role.rb +11 -0
- data/lib/kamal/configuration/validator/servers.rb +7 -0
- data/lib/kamal/configuration/validator.rb +171 -0
- data/lib/kamal/configuration/volume.rb +22 -0
- data/lib/kamal/configuration.rb +393 -0
- data/lib/kamal/env_file.rb +44 -0
- data/lib/kamal/git.rb +27 -0
- data/lib/kamal/secrets/adapters/base.rb +23 -0
- data/lib/kamal/secrets/adapters/bitwarden.rb +81 -0
- data/lib/kamal/secrets/adapters/last_pass.rb +39 -0
- data/lib/kamal/secrets/adapters/one_password.rb +70 -0
- data/lib/kamal/secrets/adapters/test.rb +14 -0
- data/lib/kamal/secrets/adapters.rb +14 -0
- data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +32 -0
- data/lib/kamal/secrets.rb +42 -0
- data/lib/kamal/sshkit_with_ext.rb +142 -0
- data/lib/kamal/tags.rb +40 -0
- data/lib/kamal/utils/sensitive.rb +20 -0
- data/lib/kamal/utils.rb +110 -0
- data/lib/kamal/version.rb +3 -0
- data/lib/kamal.rb +14 -0
- metadata +349 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Encode an env hash as a string where secret values have been looked up and all values escaped for Docker.
|
|
2
|
+
class Kamal::EnvFile
|
|
3
|
+
def initialize(env)
|
|
4
|
+
@env = env
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def to_s
|
|
8
|
+
env_file = StringIO.new.tap do |contents|
|
|
9
|
+
@env.each do |key, value|
|
|
10
|
+
contents << docker_env_file_line(key, value)
|
|
11
|
+
end
|
|
12
|
+
end.string
|
|
13
|
+
|
|
14
|
+
# Ensure the file has some contents to avoid the SSHKIT empty file warning
|
|
15
|
+
env_file.presence || "\n"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_io
|
|
19
|
+
StringIO.new(to_s)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
alias to_str to_s
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
def docker_env_file_line(key, value)
|
|
26
|
+
"#{key}=#{escape_docker_env_file_value(value)}\n"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Escape a value to make it safe to dump in a docker file.
|
|
30
|
+
def escape_docker_env_file_value(value)
|
|
31
|
+
# keep non-ascii(UTF-8) characters as it is
|
|
32
|
+
value.to_s.scan(/[\x00-\x7F]+|[^\x00-\x7F]+/).map do |part|
|
|
33
|
+
part.ascii_only? ? escape_docker_env_file_ascii_value(part) : part
|
|
34
|
+
end.join
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def escape_docker_env_file_ascii_value(value)
|
|
38
|
+
# Doublequotes are treated literally in docker env files
|
|
39
|
+
# so remove leading and trailing ones and unescape any others
|
|
40
|
+
value.to_s.dump[1..-2]
|
|
41
|
+
.gsub(/\\"/, "\"")
|
|
42
|
+
.gsub(/\\#/, "#")
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/kamal/git.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Kamal::Git
|
|
2
|
+
extend self
|
|
3
|
+
|
|
4
|
+
def used?
|
|
5
|
+
system("git rev-parse")
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def user_name
|
|
9
|
+
`git config user.name`.strip
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def email
|
|
13
|
+
`git config user.email`.strip
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def revision
|
|
17
|
+
`git rev-parse HEAD`.strip
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def uncommitted_changes
|
|
21
|
+
`git status --porcelain`.strip
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def root
|
|
25
|
+
`git rev-parse --show-toplevel`.strip
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class Kamal::Secrets::Adapters::Base
|
|
2
|
+
delegate :optionize, to: Kamal::Utils
|
|
3
|
+
|
|
4
|
+
def fetch(secrets, account:, from: nil)
|
|
5
|
+
check_dependencies!
|
|
6
|
+
session = login(account)
|
|
7
|
+
full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") }
|
|
8
|
+
fetch_secrets(full_secrets, account: account, session: session)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
def login(...)
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def fetch_secrets(...)
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def check_dependencies!
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base
|
|
2
|
+
private
|
|
3
|
+
def login(account)
|
|
4
|
+
status = run_command("status")
|
|
5
|
+
|
|
6
|
+
if status["status"] == "unauthenticated"
|
|
7
|
+
run_command("login #{account.shellescape}", raw: true)
|
|
8
|
+
status = run_command("status")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
if status["status"] == "locked"
|
|
12
|
+
session = run_command("unlock --raw", raw: true).presence
|
|
13
|
+
status = run_command("status", session: session)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
raise RuntimeError, "Failed to login to and unlock Bitwarden" unless status["status"] == "unlocked"
|
|
17
|
+
|
|
18
|
+
run_command("sync", session: session, raw: true)
|
|
19
|
+
raise RuntimeError, "Failed to sync Bitwarden" unless $?.success?
|
|
20
|
+
|
|
21
|
+
session
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fetch_secrets(secrets, account:, session:)
|
|
25
|
+
{}.tap do |results|
|
|
26
|
+
items_fields(secrets).each do |item, fields|
|
|
27
|
+
item_json = run_command("get item #{item.shellescape}", session: session, raw: true)
|
|
28
|
+
raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success?
|
|
29
|
+
item_json = JSON.parse(item_json)
|
|
30
|
+
if fields.any?
|
|
31
|
+
results.merge! fetch_secrets_from_fields(fields, item, item_json)
|
|
32
|
+
elsif item_json.dig("login", "password")
|
|
33
|
+
results[item] = item_json.dig("login", "password")
|
|
34
|
+
elsif item_json["fields"]&.any?
|
|
35
|
+
fields = item_json["fields"].pluck("name")
|
|
36
|
+
results.merge! fetch_secrets_from_fields(fields, item, item_json)
|
|
37
|
+
else
|
|
38
|
+
raise RuntimeError, "Item #{item} is not a login type item and no fields were specified"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetch_secrets_from_fields(fields, item, item_json)
|
|
45
|
+
fields.to_h do |field|
|
|
46
|
+
item_field = item_json["fields"].find { |f| f["name"] == field }
|
|
47
|
+
raise RuntimeError, "Could not find field #{field} in item #{item} in Bitwarden" unless item_field
|
|
48
|
+
value = item_field["value"]
|
|
49
|
+
[ "#{item}/#{field}", value ]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def items_fields(secrets)
|
|
54
|
+
{}.tap do |items|
|
|
55
|
+
secrets.each do |secret|
|
|
56
|
+
item, field = secret.split("/")
|
|
57
|
+
items[item] ||= []
|
|
58
|
+
items[item] << field
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def signedin?(account)
|
|
64
|
+
run_command("status")["status"] != "unauthenticated"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def run_command(command, session: nil, raw: false)
|
|
68
|
+
full_command = [ *("BW_SESSION=#{session.shellescape}" if session), "bw", command ].join(" ")
|
|
69
|
+
result = `#{full_command}`.strip
|
|
70
|
+
raw ? result : JSON.parse(result)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def check_dependencies!
|
|
74
|
+
raise RuntimeError, "Bitwarden CLI is not installed" unless cli_installed?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def cli_installed?
|
|
78
|
+
`bw --version 2> /dev/null`
|
|
79
|
+
$?.success?
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
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, account:, session:)
|
|
15
|
+
items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json`
|
|
16
|
+
raise RuntimeError, "Could not read #{secrets} from LastPass" unless $?.success?
|
|
17
|
+
|
|
18
|
+
items = JSON.parse(items)
|
|
19
|
+
|
|
20
|
+
{}.tap do |results|
|
|
21
|
+
items.each do |item|
|
|
22
|
+
results[item["fullname"]] = item["password"]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if (missing_items = secrets - results.keys).any?
|
|
26
|
+
raise RuntimeError, "Could not find #{missing_items.join(", ")} in LassPass"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def check_dependencies!
|
|
32
|
+
raise RuntimeError, "LastPass CLI is not installed" unless cli_installed?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cli_installed?
|
|
36
|
+
`lpass --version 2> /dev/null`
|
|
37
|
+
$?.success?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
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, account:, session:)
|
|
19
|
+
{}.tap do |results|
|
|
20
|
+
vaults_items_fields(secrets).map do |vault, items|
|
|
21
|
+
items.each do |item, fields|
|
|
22
|
+
fields_json = JSON.parse(op_item_get(vault, item, fields, account: account, session: session))
|
|
23
|
+
fields_json = [ fields_json ] if fields.one?
|
|
24
|
+
|
|
25
|
+
fields_json.each do |field_json|
|
|
26
|
+
# The reference is in the form `op://vault/item/field[/field]`
|
|
27
|
+
field = field_json["reference"].delete_prefix("op://").delete_suffix("/password")
|
|
28
|
+
results[field] = field_json["value"]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_options(**options)
|
|
36
|
+
optionize(options.compact).join(" ")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def vaults_items_fields(secrets)
|
|
40
|
+
{}.tap do |vaults|
|
|
41
|
+
secrets.each do |secret|
|
|
42
|
+
secret = secret.delete_prefix("op://")
|
|
43
|
+
vault, item, *fields = secret.split("/")
|
|
44
|
+
fields << "password" if fields.empty?
|
|
45
|
+
|
|
46
|
+
vaults[vault] ||= {}
|
|
47
|
+
vaults[vault][item] ||= []
|
|
48
|
+
vaults[vault][item] << fields.join(".")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def op_item_get(vault, item, fields, account:, session:)
|
|
54
|
+
labels = fields.map { |field| "label=#{field}" }.join(",")
|
|
55
|
+
options = to_options(vault: vault, fields: labels, format: "json", account: account, session: session.presence)
|
|
56
|
+
|
|
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?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def check_dependencies!
|
|
63
|
+
raise RuntimeError, "1Password CLI is not installed" unless cli_installed?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def cli_installed?
|
|
67
|
+
`op --version 2> /dev/null`
|
|
68
|
+
$?.success?
|
|
69
|
+
end
|
|
70
|
+
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, account:, session:)
|
|
8
|
+
secrets.to_h { |secret| [ secret, secret.reverse ] }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def check_dependencies!
|
|
12
|
+
# no op
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
adapter_class(name)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.adapter_class(name)
|
|
10
|
+
Object.const_get("Kamal::Secrets::Adapters::#{name.camelize}").new
|
|
11
|
+
rescue NameError => e
|
|
12
|
+
raise RuntimeError, "Unknown secrets adapter: #{name}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
if command =~ /\A\s*kamal\s*secrets\s+/
|
|
18
|
+
# Inline the command
|
|
19
|
+
inline_secrets_command(command)
|
|
20
|
+
else
|
|
21
|
+
# Execute the command and return the value
|
|
22
|
+
`#{command}`.chomp
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def inline_secrets_command(command)
|
|
29
|
+
Kamal::Cli::Main.start(command.shellsplit[1..] + [ "--inline" ]).chomp
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
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))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def secrets_filenames
|
|
40
|
+
[ ".kamal/secrets-common", ".kamal/secrets#{(".#{@destination}" if @destination)}" ]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
require "sshkit"
|
|
2
|
+
require "sshkit/dsl"
|
|
3
|
+
require "net/scp"
|
|
4
|
+
require "active_support/core_ext/hash/deep_merge"
|
|
5
|
+
require "json"
|
|
6
|
+
require "concurrent/atomic/semaphore"
|
|
7
|
+
|
|
8
|
+
class SSHKit::Backend::Abstract
|
|
9
|
+
def capture_with_info(*args, **kwargs)
|
|
10
|
+
capture(*args, **kwargs, verbosity: Logger::INFO)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def capture_with_debug(*args, **kwargs)
|
|
14
|
+
capture(*args, **kwargs, verbosity: Logger::DEBUG)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def capture_with_pretty_json(*args, **kwargs)
|
|
18
|
+
JSON.pretty_generate(JSON.parse(capture(*args, **kwargs)))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def puts_by_host(host, output, type: "App")
|
|
22
|
+
puts "#{type} Host: #{host}\n#{output}\n\n"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Our execution pattern is for the CLI execute args lists returned
|
|
26
|
+
# from commands, but this doesn't support returning execution options
|
|
27
|
+
# from the command.
|
|
28
|
+
#
|
|
29
|
+
# Support this by using kwargs for CLI options and merging with the
|
|
30
|
+
# args-extracted options.
|
|
31
|
+
module CommandEnvMerge
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Override to merge options returned by commands in the args list with
|
|
35
|
+
# options passed by the CLI and pass them along as kwargs.
|
|
36
|
+
def command(args, options)
|
|
37
|
+
more_options, args = args.partition { |a| a.is_a? Hash }
|
|
38
|
+
more_options << options
|
|
39
|
+
|
|
40
|
+
build_command(args, **more_options.reduce(:deep_merge))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Destructure options to pluck out env for merge
|
|
44
|
+
def build_command(args, env: nil, **options)
|
|
45
|
+
# Rely on native Ruby kwargs precedence rather than explicit Hash merges
|
|
46
|
+
SSHKit::Command.new(*args, **default_command_options, **options, env: env_for(env))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def default_command_options
|
|
50
|
+
{ in: pwd_path, host: @host, user: @user, group: @group }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def env_for(env)
|
|
54
|
+
@env.to_h.merge(env.to_h)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
prepend CommandEnvMerge
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class SSHKit::Backend::Netssh::Configuration
|
|
61
|
+
attr_accessor :max_concurrent_starts
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class SSHKit::Backend::Netssh
|
|
65
|
+
module LimitConcurrentStartsClass
|
|
66
|
+
attr_reader :start_semaphore
|
|
67
|
+
|
|
68
|
+
def configure(&block)
|
|
69
|
+
super &block
|
|
70
|
+
# Create this here to avoid lazy creation by multiple threads
|
|
71
|
+
if config.max_concurrent_starts
|
|
72
|
+
@start_semaphore = Concurrent::Semaphore.new(config.max_concurrent_starts)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class << self
|
|
78
|
+
prepend LimitConcurrentStartsClass
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
module LimitConcurrentStartsInstance
|
|
82
|
+
private
|
|
83
|
+
def with_ssh(&block)
|
|
84
|
+
host.ssh_options = self.class.config.ssh_options.merge(host.ssh_options || {})
|
|
85
|
+
self.class.pool.with(
|
|
86
|
+
method(:start_with_concurrency_limit),
|
|
87
|
+
String(host.hostname),
|
|
88
|
+
host.username,
|
|
89
|
+
host.netssh_options,
|
|
90
|
+
&block
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def start_with_concurrency_limit(*args)
|
|
95
|
+
if self.class.start_semaphore
|
|
96
|
+
self.class.start_semaphore.acquire do
|
|
97
|
+
Net::SSH.start(*args)
|
|
98
|
+
end
|
|
99
|
+
else
|
|
100
|
+
Net::SSH.start(*args)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
prepend LimitConcurrentStartsInstance
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
class SSHKit::Runner::Parallel
|
|
109
|
+
# SSHKit joins the threads in sequence and fails on the first error it encounters, which means that we wait threads
|
|
110
|
+
# before the first failure to complete but not for ones after.
|
|
111
|
+
#
|
|
112
|
+
# We'll patch it to wait for them all to complete, and to record all the threads that errored so we can see when a
|
|
113
|
+
# problem occurs on multiple hosts.
|
|
114
|
+
module CompleteAll
|
|
115
|
+
def execute
|
|
116
|
+
threads = hosts.map do |host|
|
|
117
|
+
Thread.new(host) do |h|
|
|
118
|
+
backend(h, &block).run
|
|
119
|
+
rescue ::StandardError => e
|
|
120
|
+
e2 = SSHKit::Runner::ExecuteError.new e
|
|
121
|
+
raise e2, "Exception while executing #{host.user ? "as #{host.user}@" : "on host "}#{host}: #{e.message}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
exceptions = []
|
|
126
|
+
threads.each do |t|
|
|
127
|
+
begin
|
|
128
|
+
t.join
|
|
129
|
+
rescue SSHKit::Runner::ExecuteError => e
|
|
130
|
+
exceptions << e
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
if exceptions.one?
|
|
134
|
+
raise exceptions.first
|
|
135
|
+
elsif exceptions.many?
|
|
136
|
+
raise exceptions.first, [ "Exceptions on #{exceptions.count} hosts:", exceptions.map(&:message) ].join("\n")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
prepend CompleteAll
|
|
142
|
+
end
|
data/lib/kamal/tags.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
class Kamal::Tags
|
|
4
|
+
attr_reader :config, :tags
|
|
5
|
+
|
|
6
|
+
class << self
|
|
7
|
+
def from_config(config, **extra)
|
|
8
|
+
new(**default_tags(config), **extra)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def default_tags(config)
|
|
12
|
+
{ recorded_at: Time.now.utc.iso8601,
|
|
13
|
+
performer: Kamal::Git.email.presence || `whoami`.chomp,
|
|
14
|
+
destination: config.destination,
|
|
15
|
+
version: config.version,
|
|
16
|
+
service_version: service_version(config),
|
|
17
|
+
service: config.service }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def service_version(config)
|
|
21
|
+
[ config.service, config.abbreviated_version ].compact.join("@")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(**tags)
|
|
26
|
+
@tags = tags.compact
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def env
|
|
30
|
+
tags.transform_keys { |detail| "KAMAL_#{detail.upcase}" }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_s
|
|
34
|
+
tags.values.map { |value| "[#{value}]" }.join(" ")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def except(*tags)
|
|
38
|
+
self.class.new(**self.tags.except(*tags))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require "active_support/core_ext/module/delegation"
|
|
2
|
+
require "sshkit"
|
|
3
|
+
|
|
4
|
+
class Kamal::Utils::Sensitive
|
|
5
|
+
# So SSHKit knows to redact these values.
|
|
6
|
+
include SSHKit::Redaction
|
|
7
|
+
|
|
8
|
+
attr_reader :unredacted, :redaction
|
|
9
|
+
delegate :to_s, to: :unredacted
|
|
10
|
+
delegate :inspect, to: :redaction
|
|
11
|
+
|
|
12
|
+
def initialize(value, redaction: "[REDACTED]")
|
|
13
|
+
@unredacted, @redaction = value, redaction
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Sensitive values won't leak into YAML output.
|
|
17
|
+
def encode_with(coder)
|
|
18
|
+
coder.represent_scalar nil, redaction
|
|
19
|
+
end
|
|
20
|
+
end
|