kamal-insecure 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +13 -0
- data/bin/kamal +18 -0
- data/lib/kamal/cli/accessory.rb +313 -0
- data/lib/kamal/cli/alias/command.rb +10 -0
- data/lib/kamal/cli/app/assets.rb +24 -0
- data/lib/kamal/cli/app/boot.rb +126 -0
- data/lib/kamal/cli/app/error_pages.rb +33 -0
- data/lib/kamal/cli/app/ssl_certificates.rb +28 -0
- data/lib/kamal/cli/app.rb +400 -0
- data/lib/kamal/cli/base.rb +223 -0
- data/lib/kamal/cli/build/clone.rb +61 -0
- data/lib/kamal/cli/build.rb +204 -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 +277 -0
- data/lib/kamal/cli/proxy.rb +290 -0
- data/lib/kamal/cli/prune.rb +34 -0
- data/lib/kamal/cli/registry.rb +19 -0
- data/lib/kamal/cli/secrets.rb +49 -0
- data/lib/kamal/cli/server.rb +50 -0
- data/lib/kamal/cli/templates/deploy.yml +101 -0
- data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +3 -0
- data/lib/kamal/cli/templates/sample_hooks/post-app-boot.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-app-boot.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 +122 -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 +9 -0
- data/lib/kamal/commander/specifics.rb +62 -0
- data/lib/kamal/commander.rb +167 -0
- data/lib/kamal/commands/accessory/proxy.rb +16 -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/error_pages.rb +9 -0
- data/lib/kamal/commands/app/execution.rb +32 -0
- data/lib/kamal/commands/app/images.rb +13 -0
- data/lib/kamal/commands/app/logging.rb +28 -0
- data/lib/kamal/commands/app/proxy.rb +32 -0
- data/lib/kamal/commands/app.rb +124 -0
- data/lib/kamal/commands/auditor.rb +39 -0
- data/lib/kamal/commands/base.rb +134 -0
- data/lib/kamal/commands/builder/base.rb +124 -0
- data/lib/kamal/commands/builder/clone.rb +31 -0
- data/lib/kamal/commands/builder/cloud.rb +22 -0
- data/lib/kamal/commands/builder/hybrid.rb +21 -0
- data/lib/kamal/commands/builder/local.rb +14 -0
- data/lib/kamal/commands/builder/pack.rb +46 -0
- data/lib/kamal/commands/builder/remote.rb +63 -0
- data/lib/kamal/commands/builder.rb +48 -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 +127 -0
- data/lib/kamal/commands/prune.rb +38 -0
- data/lib/kamal/commands/registry.rb +16 -0
- data/lib/kamal/commands/server.rb +15 -0
- data/lib/kamal/commands.rb +2 -0
- data/lib/kamal/configuration/accessory.rb +241 -0
- data/lib/kamal/configuration/alias.rb +15 -0
- data/lib/kamal/configuration/boot.rb +25 -0
- data/lib/kamal/configuration/builder.rb +211 -0
- data/lib/kamal/configuration/docs/accessory.yml +128 -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 +132 -0
- data/lib/kamal/configuration/docs/configuration.yml +184 -0
- data/lib/kamal/configuration/docs/env.yml +116 -0
- data/lib/kamal/configuration/docs/logging.yml +21 -0
- data/lib/kamal/configuration/docs/proxy.yml +164 -0
- data/lib/kamal/configuration/docs/registry.yml +56 -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 +38 -0
- data/lib/kamal/configuration/logging.rb +33 -0
- data/lib/kamal/configuration/proxy/boot.rb +129 -0
- data/lib/kamal/configuration/proxy.rb +124 -0
- data/lib/kamal/configuration/registry.rb +32 -0
- data/lib/kamal/configuration/role.rb +222 -0
- data/lib/kamal/configuration/servers.rb +25 -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 +13 -0
- data/lib/kamal/configuration/validator/alias.rb +15 -0
- data/lib/kamal/configuration/validator/builder.rb +15 -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 +25 -0
- data/lib/kamal/configuration/validator/registry.rb +25 -0
- data/lib/kamal/configuration/validator/role.rb +13 -0
- data/lib/kamal/configuration/validator/servers.rb +7 -0
- data/lib/kamal/configuration/validator.rb +191 -0
- data/lib/kamal/configuration/volume.rb +22 -0
- data/lib/kamal/configuration.rb +372 -0
- data/lib/kamal/docker.rb +30 -0
- data/lib/kamal/env_file.rb +44 -0
- data/lib/kamal/git.rb +37 -0
- data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +51 -0
- data/lib/kamal/secrets/adapters/base.rb +33 -0
- data/lib/kamal/secrets/adapters/bitwarden.rb +81 -0
- data/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb +66 -0
- data/lib/kamal/secrets/adapters/doppler.rb +57 -0
- data/lib/kamal/secrets/adapters/enpass.rb +71 -0
- data/lib/kamal/secrets/adapters/gcp_secret_manager.rb +112 -0
- data/lib/kamal/secrets/adapters/last_pass.rb +40 -0
- data/lib/kamal/secrets/adapters/one_password.rb +104 -0
- data/lib/kamal/secrets/adapters/passbolt.rb +130 -0
- data/lib/kamal/secrets/adapters/test.rb +14 -0
- data/lib/kamal/secrets/adapters.rb +16 -0
- data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +33 -0
- data/lib/kamal/secrets.rb +42 -0
- data/lib/kamal/sshkit_with_ext.rb +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 +365 -0
@@ -0,0 +1,104 @@
|
|
1
|
+
class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base
|
2
|
+
delegate :optionize, to: Kamal::Utils
|
3
|
+
|
4
|
+
private
|
5
|
+
def login(account)
|
6
|
+
unless loggedin?(account)
|
7
|
+
`op signin #{to_options(account: account, force: true, raw: true)}`.tap do
|
8
|
+
raise RuntimeError, "Failed to login to 1Password" unless $?.success?
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def loggedin?(account)
|
14
|
+
`op account get --account #{account.shellescape} 2> /dev/null`
|
15
|
+
$?.success?
|
16
|
+
end
|
17
|
+
|
18
|
+
def fetch_secrets(secrets, from:, account:, session:)
|
19
|
+
if secrets.blank?
|
20
|
+
fetch_all_secrets(from: from, account: account, session: session)
|
21
|
+
else
|
22
|
+
fetch_specified_secrets(secrets, from: from, account: account, session: session)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def fetch_specified_secrets(secrets, from:, account:, session:)
|
27
|
+
{}.tap do |results|
|
28
|
+
vaults_items_fields(prefixed_secrets(secrets, from: from)).map do |vault, items|
|
29
|
+
items.each do |item, fields|
|
30
|
+
fields_json = JSON.parse(op_item_get(vault, item, fields: fields, account: account, session: session))
|
31
|
+
fields_json = [ fields_json ] if fields.one?
|
32
|
+
|
33
|
+
results.merge!(fields_map(fields_json))
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def fetch_all_secrets(from:, account:, session:)
|
40
|
+
{}.tap do |results|
|
41
|
+
vault_items(from).each do |vault, items|
|
42
|
+
items.each do |item|
|
43
|
+
fields_json = JSON.parse(op_item_get(vault, item, account: account, session: session)).fetch("fields")
|
44
|
+
|
45
|
+
results.merge!(fields_map(fields_json))
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def to_options(**options)
|
52
|
+
optionize(options.compact).join(" ")
|
53
|
+
end
|
54
|
+
|
55
|
+
def vaults_items_fields(secrets)
|
56
|
+
{}.tap do |vaults|
|
57
|
+
secrets.each do |secret|
|
58
|
+
secret = secret.delete_prefix("op://")
|
59
|
+
vault, item, *fields = secret.split("/")
|
60
|
+
fields << "password" if fields.empty?
|
61
|
+
|
62
|
+
vaults[vault] ||= {}
|
63
|
+
vaults[vault][item] ||= []
|
64
|
+
vaults[vault][item] << fields.join(".")
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def vault_items(from)
|
70
|
+
from = from.delete_prefix("op://")
|
71
|
+
vault, item = from.split("/")
|
72
|
+
{ vault => [ item ] }
|
73
|
+
end
|
74
|
+
|
75
|
+
def fields_map(fields_json)
|
76
|
+
fields_json.to_h do |field_json|
|
77
|
+
# The reference is in the form `op://vault/item/field[/field]`
|
78
|
+
field = field_json["reference"].delete_prefix("op://").delete_suffix("/password")
|
79
|
+
[ field, field_json["value"] ]
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def op_item_get(vault, item, fields: nil, account:, session:)
|
84
|
+
options = { vault: vault, format: "json", account: account, session: session.presence }
|
85
|
+
|
86
|
+
if fields.present?
|
87
|
+
labels = fields.map { |field| "label=#{field}" }.join(",")
|
88
|
+
options.merge!(fields: labels)
|
89
|
+
end
|
90
|
+
|
91
|
+
`op item get #{item.shellescape} #{to_options(**options)}`.tap do
|
92
|
+
raise RuntimeError, "Could not read #{"#{fields.join(", ")} " if fields.present?}from #{item} in the #{vault} 1Password vault" unless $?.success?
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def check_dependencies!
|
97
|
+
raise RuntimeError, "1Password CLI is not installed" unless cli_installed?
|
98
|
+
end
|
99
|
+
|
100
|
+
def cli_installed?
|
101
|
+
`op --version 2> /dev/null`
|
102
|
+
$?.success?
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
class Kamal::Secrets::Adapters::Passbolt < Kamal::Secrets::Adapters::Base
|
2
|
+
def requires_account?
|
3
|
+
false
|
4
|
+
end
|
5
|
+
|
6
|
+
private
|
7
|
+
|
8
|
+
def login(*)
|
9
|
+
`passbolt verify`
|
10
|
+
raise RuntimeError, "Failed to login to Passbolt" unless $?.success?
|
11
|
+
end
|
12
|
+
|
13
|
+
def fetch_secrets(secrets, from:, **)
|
14
|
+
secrets = prefixed_secrets(secrets, from: from)
|
15
|
+
raise ArgumentError, "No secrets given to fetch" if secrets.empty?
|
16
|
+
|
17
|
+
secret_names = secrets.collect { |s| s.split("/").last }
|
18
|
+
folders = secrets_get_folders(secrets)
|
19
|
+
|
20
|
+
# build filter conditions for each secret with its corresponding folder
|
21
|
+
filter_conditions = []
|
22
|
+
secrets.each do |secret|
|
23
|
+
parts = secret.split("/")
|
24
|
+
secret_name = parts.last
|
25
|
+
|
26
|
+
if parts.size > 1
|
27
|
+
# get the folder path without the secret name
|
28
|
+
folder_path = parts[0..-2]
|
29
|
+
|
30
|
+
# find the most nested folder for this path
|
31
|
+
current_folder = nil
|
32
|
+
current_path = []
|
33
|
+
|
34
|
+
folder_path.each do |folder_name|
|
35
|
+
current_path << folder_name
|
36
|
+
matching_folders = folders.select { |f| get_folder_path(f, folders) == current_path.join("/") }
|
37
|
+
current_folder = matching_folders.first if matching_folders.any?
|
38
|
+
end
|
39
|
+
|
40
|
+
if current_folder
|
41
|
+
filter_conditions << "(Name == #{secret_name.shellescape.inspect} && FolderParentID == #{current_folder["id"].shellescape.inspect})"
|
42
|
+
end
|
43
|
+
else
|
44
|
+
# for root level secrets (no folders)
|
45
|
+
filter_conditions << "Name == #{secret_name.shellescape.inspect}"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
filter_condition = filter_conditions.any? ? "--filter '#{filter_conditions.join(" || ")}'" : ""
|
50
|
+
items = `passbolt list resources #{filter_condition} #{folders.map { |item| "--folder #{item["id"]}" }.join(" ")} --json`
|
51
|
+
raise RuntimeError, "Could not read #{secrets} from Passbolt" unless $?.success?
|
52
|
+
|
53
|
+
items = JSON.parse(items)
|
54
|
+
found_names = items.map { |item| item["name"] }
|
55
|
+
missing_secrets = secret_names - found_names
|
56
|
+
raise RuntimeError, "Could not find the following secrets in Passbolt: #{missing_secrets.join(", ")}" if missing_secrets.any?
|
57
|
+
|
58
|
+
items.to_h { |item| [ item["name"], item["password"] ] }
|
59
|
+
end
|
60
|
+
|
61
|
+
def secrets_get_folders(secrets)
|
62
|
+
# extract all folder paths (both parent and nested)
|
63
|
+
folder_paths = secrets
|
64
|
+
.select { |s| s.include?("/") }
|
65
|
+
.map { |s| s.split("/")[0..-2] } # get all parts except the secret name
|
66
|
+
.uniq
|
67
|
+
|
68
|
+
return [] if folder_paths.empty?
|
69
|
+
|
70
|
+
all_folders = []
|
71
|
+
|
72
|
+
# first get all top-level folders
|
73
|
+
parent_folders = folder_paths.map(&:first).uniq
|
74
|
+
filter_condition = "--filter '#{parent_folders.map { |name| "Name == #{name.shellescape.inspect}" }.join(" || ")}'"
|
75
|
+
fetch_folders = `passbolt list folders #{filter_condition} --json`
|
76
|
+
raise RuntimeError, "Could not read folders from Passbolt" unless $?.success?
|
77
|
+
|
78
|
+
parent_folder_items = JSON.parse(fetch_folders)
|
79
|
+
all_folders.concat(parent_folder_items)
|
80
|
+
|
81
|
+
# get nested folders for each parent
|
82
|
+
folder_paths.each do |path|
|
83
|
+
next if path.size <= 1 # skip non-nested folders
|
84
|
+
|
85
|
+
parent = path[0]
|
86
|
+
parent_folder = parent_folder_items.find { |f| f["name"] == parent }
|
87
|
+
next unless parent_folder
|
88
|
+
|
89
|
+
# for each nested level, get the folders using the parent's ID
|
90
|
+
current_parent = parent_folder
|
91
|
+
path[1..-1].each do |folder_name|
|
92
|
+
filter_condition = "--filter 'Name == #{folder_name.shellescape.inspect} && FolderParentID == #{current_parent["id"].shellescape.inspect}'"
|
93
|
+
fetch_nested = `passbolt list folders #{filter_condition} --json`
|
94
|
+
next unless $?.success?
|
95
|
+
|
96
|
+
nested_folders = JSON.parse(fetch_nested)
|
97
|
+
break if nested_folders.empty?
|
98
|
+
|
99
|
+
all_folders.concat(nested_folders)
|
100
|
+
current_parent = nested_folders.first
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# check if we found all required folders
|
105
|
+
found_paths = all_folders.map { |f| get_folder_path(f, all_folders) }
|
106
|
+
missing_paths = folder_paths.map { |path| path.join("/") } - found_paths
|
107
|
+
raise RuntimeError, "Could not find the following folders in Passbolt: #{missing_paths.join(", ")}" if missing_paths.any?
|
108
|
+
|
109
|
+
all_folders
|
110
|
+
end
|
111
|
+
|
112
|
+
def get_folder_path(folder, all_folders, path = [])
|
113
|
+
path.unshift(folder["name"])
|
114
|
+
return path.join("/") if folder["folder_parent_id"].to_s.empty?
|
115
|
+
|
116
|
+
parent = all_folders.find { |f| f["id"] == folder["folder_parent_id"] }
|
117
|
+
return path.join("/") unless parent
|
118
|
+
|
119
|
+
get_folder_path(parent, all_folders, path)
|
120
|
+
end
|
121
|
+
|
122
|
+
def check_dependencies!
|
123
|
+
raise RuntimeError, "Passbolt CLI is not installed" unless cli_installed?
|
124
|
+
end
|
125
|
+
|
126
|
+
def cli_installed?
|
127
|
+
`passbolt --version 2> /dev/null`
|
128
|
+
$?.success?
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base
|
2
|
+
private
|
3
|
+
def login(account)
|
4
|
+
true
|
5
|
+
end
|
6
|
+
|
7
|
+
def fetch_secrets(secrets, from:, account:, session:)
|
8
|
+
prefixed_secrets(secrets, from: from).to_h { |secret| [ secret, secret.reverse ] }
|
9
|
+
end
|
10
|
+
|
11
|
+
def check_dependencies!
|
12
|
+
# no op
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require "active_support/core_ext/string/inflections"
|
2
|
+
module Kamal::Secrets::Adapters
|
3
|
+
def self.lookup(name)
|
4
|
+
name = "one_password" if name.downcase == "1password"
|
5
|
+
name = "last_pass" if name.downcase == "lastpass"
|
6
|
+
name = "gcp_secret_manager" if name.downcase == "gcp"
|
7
|
+
name = "bitwarden_secrets_manager" if name.downcase == "bitwarden-sm"
|
8
|
+
adapter_class(name)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.adapter_class(name)
|
12
|
+
Object.const_get("Kamal::Secrets::Adapters::#{name.camelize}").new
|
13
|
+
rescue NameError => e
|
14
|
+
raise RuntimeError, "Unknown secrets adapter: #{name}"
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
class Kamal::Secrets::Dotenv::InlineCommandSubstitution
|
2
|
+
class << self
|
3
|
+
def install!
|
4
|
+
::Dotenv::Parser.substitutions.map! { |sub| sub == ::Dotenv::Substitutions::Command ? self : sub }
|
5
|
+
end
|
6
|
+
|
7
|
+
def call(value, env, overwrite: false)
|
8
|
+
# Process interpolated shell commands
|
9
|
+
value.gsub(Dotenv::Substitutions::Command.singleton_class::INTERPOLATED_SHELL_COMMAND) do |*|
|
10
|
+
# Eliminate opening and closing parentheses
|
11
|
+
command = $LAST_MATCH_INFO[:cmd][1..-2]
|
12
|
+
|
13
|
+
if $LAST_MATCH_INFO[:backslash]
|
14
|
+
# Command is escaped, don't replace it.
|
15
|
+
$LAST_MATCH_INFO[0][1..]
|
16
|
+
else
|
17
|
+
command = ::Dotenv::Substitutions::Variable.call(command, env)
|
18
|
+
if command =~ /\A\s*kamal\s*secrets\s+/
|
19
|
+
# Inline the command
|
20
|
+
inline_secrets_command(command)
|
21
|
+
else
|
22
|
+
# Execute the command and return the value
|
23
|
+
`#{command}`.chomp
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def inline_secrets_command(command)
|
30
|
+
Kamal::Cli::Main.start(command.shellsplit[1..] + [ "--inline" ]).chomp
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require "dotenv"
|
2
|
+
|
3
|
+
class Kamal::Secrets
|
4
|
+
Kamal::Secrets::Dotenv::InlineCommandSubstitution.install!
|
5
|
+
|
6
|
+
def initialize(destination: nil)
|
7
|
+
@destination = destination
|
8
|
+
@mutex = Mutex.new
|
9
|
+
end
|
10
|
+
|
11
|
+
def [](key)
|
12
|
+
# Fetching secrets may ask the user for input, so ensure only one thread does that
|
13
|
+
@mutex.synchronize do
|
14
|
+
secrets.fetch(key)
|
15
|
+
end
|
16
|
+
rescue KeyError
|
17
|
+
if secrets_files.present?
|
18
|
+
raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secrets_files.join(", ")}"
|
19
|
+
else
|
20
|
+
raise Kamal::ConfigurationError, "Secret '#{key}' not found, no secret files (#{secrets_filenames.join(", ")}) provided"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_h
|
25
|
+
secrets
|
26
|
+
end
|
27
|
+
|
28
|
+
def secrets_files
|
29
|
+
@secrets_files ||= secrets_filenames.select { |f| File.exist?(f) }
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
def secrets
|
34
|
+
@secrets ||= secrets_files.inject({}) do |secrets, secrets_file|
|
35
|
+
secrets.merge!(::Dotenv.parse(secrets_file, overwrite: true))
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def secrets_filenames
|
40
|
+
[ ".kamal/secrets-common", ".kamal/secrets#{(".#{@destination}" if @destination)}" ]
|
41
|
+
end
|
42
|
+
end
|
@@ -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
|
data/lib/kamal/utils.rb
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
require "active_support/core_ext/object/try"
|
2
|
+
|
3
|
+
module Kamal::Utils
|
4
|
+
extend self
|
5
|
+
|
6
|
+
DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX = /\$(?!{[^\}]*\})/
|
7
|
+
|
8
|
+
# Return a list of escaped shell arguments using the same named argument against the passed attributes (hash or array).
|
9
|
+
def argumentize(argument, attributes, sensitive: false)
|
10
|
+
Array(attributes).flat_map do |key, value|
|
11
|
+
if value.present?
|
12
|
+
attr = "#{key}=#{escape_shell_value(value)}"
|
13
|
+
attr = self.sensitive(attr, redaction: "#{key}=[REDACTED]") if sensitive
|
14
|
+
[ argument, attr ]
|
15
|
+
elsif value == false
|
16
|
+
[ argument, "#{key}=false" ]
|
17
|
+
else
|
18
|
+
[ argument, key ]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option.
|
24
|
+
def optionize(args, with: nil)
|
25
|
+
options = if with
|
26
|
+
flatten_args(args).collect { |(key, value)| value == true ? "--#{key}" : "--#{key}#{with}#{escape_shell_value(value)}" }
|
27
|
+
else
|
28
|
+
flatten_args(args).collect { |(key, value)| [ "--#{key}", value == true ? nil : escape_shell_value(value) ] }
|
29
|
+
end
|
30
|
+
|
31
|
+
options.flatten.compact
|
32
|
+
end
|
33
|
+
|
34
|
+
# Flattens a one-to-many structure into an array of two-element arrays each containing a key-value pair
|
35
|
+
def flatten_args(args)
|
36
|
+
args.flat_map { |key, value| value.try(:map) { |entry| [ key, entry ] } || [ [ key, value ] ] }
|
37
|
+
end
|
38
|
+
|
39
|
+
# Marks sensitive values for redaction in logs and human-visible output.
|
40
|
+
# Pass `redaction:` to change the default `"[REDACTED]"` redaction, e.g.
|
41
|
+
# `sensitive "#{arg}=#{secret}", redaction: "#{arg}=xxxx"
|
42
|
+
def sensitive(...)
|
43
|
+
Kamal::Utils::Sensitive.new(...)
|
44
|
+
end
|
45
|
+
|
46
|
+
def redacted(value)
|
47
|
+
case
|
48
|
+
when value.respond_to?(:redaction)
|
49
|
+
value.redaction
|
50
|
+
when value.respond_to?(:transform_values)
|
51
|
+
value.transform_values { |value| redacted value }
|
52
|
+
when value.respond_to?(:map)
|
53
|
+
value.map { |element| redacted element }
|
54
|
+
else
|
55
|
+
value
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Escape a value to make it safe for shell use.
|
60
|
+
def escape_shell_value(value)
|
61
|
+
value.to_s.scan(/[\x00-\x7F]+|[^\x00-\x7F]+/) \
|
62
|
+
.map { |part| part.ascii_only? ? escape_ascii_shell_value(part) : part }
|
63
|
+
.join
|
64
|
+
end
|
65
|
+
|
66
|
+
def escape_ascii_shell_value(value)
|
67
|
+
value.to_s.dump
|
68
|
+
.gsub(/`/, '\\\\`')
|
69
|
+
.gsub(DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX, '\$')
|
70
|
+
end
|
71
|
+
|
72
|
+
# Apply a list of host or role filters, including wildcard matches
|
73
|
+
def filter_specific_items(filters, items)
|
74
|
+
matches = []
|
75
|
+
|
76
|
+
Array(filters).select do |filter|
|
77
|
+
matches += Array(items).select do |item|
|
78
|
+
# Only allow * for a wildcard
|
79
|
+
# items are roles or hosts
|
80
|
+
File.fnmatch(filter, item.to_s, File::FNM_EXTGLOB)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
matches.uniq
|
85
|
+
end
|
86
|
+
|
87
|
+
def stable_sort!(elements, &block)
|
88
|
+
elements.sort_by!.with_index { |element, index| [ block.call(element), index ] }
|
89
|
+
end
|
90
|
+
|
91
|
+
def join_commands(commands)
|
92
|
+
commands.map(&:strip).join(" ")
|
93
|
+
end
|
94
|
+
|
95
|
+
def docker_arch
|
96
|
+
arch = `docker info --format '{{.Architecture}}'`.strip
|
97
|
+
case arch
|
98
|
+
when /aarch64/
|
99
|
+
"arm64"
|
100
|
+
when /x86_64/
|
101
|
+
"amd64"
|
102
|
+
else
|
103
|
+
arch
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def older_version?(version, other_version)
|
108
|
+
Gem::Version.new(version.delete_prefix("v")) < Gem::Version.new(other_version.delete_prefix("v"))
|
109
|
+
end
|
110
|
+
end
|
data/lib/kamal.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
module Kamal
|
2
|
+
class ConfigurationError < StandardError; end
|
3
|
+
end
|
4
|
+
|
5
|
+
require "active_support"
|
6
|
+
require "zeitwerk"
|
7
|
+
require "yaml"
|
8
|
+
require "tmpdir"
|
9
|
+
require "pathname"
|
10
|
+
|
11
|
+
loader = Zeitwerk::Loader.for_gem
|
12
|
+
loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb"))
|
13
|
+
loader.setup
|
14
|
+
loader.eager_load_namespace(Kamal::Cli) # We need all commands loaded.
|