nocoffee-kamal 2.3.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|