kamal 0.16.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 +1021 -0
- data/bin/kamal +18 -0
- data/lib/kamal/cli/accessory.rb +239 -0
- data/lib/kamal/cli/app.rb +296 -0
- data/lib/kamal/cli/base.rb +171 -0
- data/lib/kamal/cli/build.rb +106 -0
- data/lib/kamal/cli/healthcheck.rb +20 -0
- data/lib/kamal/cli/lock.rb +37 -0
- data/lib/kamal/cli/main.rb +249 -0
- data/lib/kamal/cli/prune.rb +30 -0
- data/lib/kamal/cli/registry.rb +18 -0
- data/lib/kamal/cli/server.rb +21 -0
- data/lib/kamal/cli/templates/deploy.yml +74 -0
- data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +14 -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/template.env +2 -0
- data/lib/kamal/cli/traefik.rb +111 -0
- data/lib/kamal/cli.rb +7 -0
- data/lib/kamal/commander.rb +154 -0
- data/lib/kamal/commands/accessory.rb +113 -0
- data/lib/kamal/commands/app.rb +175 -0
- data/lib/kamal/commands/auditor.rb +28 -0
- data/lib/kamal/commands/base.rb +65 -0
- data/lib/kamal/commands/builder/base.rb +60 -0
- data/lib/kamal/commands/builder/multiarch/remote.rb +51 -0
- data/lib/kamal/commands/builder/multiarch.rb +29 -0
- data/lib/kamal/commands/builder/native/cached.rb +16 -0
- data/lib/kamal/commands/builder/native/remote.rb +59 -0
- data/lib/kamal/commands/builder/native.rb +20 -0
- data/lib/kamal/commands/builder.rb +62 -0
- data/lib/kamal/commands/docker.rb +21 -0
- data/lib/kamal/commands/healthcheck.rb +57 -0
- data/lib/kamal/commands/hook.rb +14 -0
- data/lib/kamal/commands/lock.rb +63 -0
- data/lib/kamal/commands/prune.rb +38 -0
- data/lib/kamal/commands/registry.rb +20 -0
- data/lib/kamal/commands/traefik.rb +104 -0
- data/lib/kamal/commands.rb +2 -0
- data/lib/kamal/configuration/accessory.rb +169 -0
- data/lib/kamal/configuration/boot.rb +20 -0
- data/lib/kamal/configuration/builder.rb +114 -0
- data/lib/kamal/configuration/role.rb +155 -0
- data/lib/kamal/configuration/ssh.rb +38 -0
- data/lib/kamal/configuration/sshkit.rb +20 -0
- data/lib/kamal/configuration.rb +251 -0
- data/lib/kamal/sshkit_with_ext.rb +104 -0
- data/lib/kamal/tags.rb +39 -0
- data/lib/kamal/utils/healthcheck_poller.rb +39 -0
- data/lib/kamal/utils/sensitive.rb +19 -0
- data/lib/kamal/utils.rb +100 -0
- data/lib/kamal/version.rb +3 -0
- data/lib/kamal.rb +10 -0
- metadata +266 -0
@@ -0,0 +1,38 @@
|
|
1
|
+
class Kamal::Configuration::Ssh
|
2
|
+
LOGGER = ::Logger.new(STDERR)
|
3
|
+
|
4
|
+
def initialize(config:)
|
5
|
+
@config = config.raw_config.ssh || {}
|
6
|
+
end
|
7
|
+
|
8
|
+
def user
|
9
|
+
config.fetch("user", "root")
|
10
|
+
end
|
11
|
+
|
12
|
+
def proxy
|
13
|
+
if (proxy = config["proxy"])
|
14
|
+
Net::SSH::Proxy::Jump.new(proxy.include?("@") ? proxy : "root@#{proxy}")
|
15
|
+
elsif (proxy_command = config["proxy_command"])
|
16
|
+
Net::SSH::Proxy::Command.new(proxy_command)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def options
|
21
|
+
{ user: user, proxy: proxy, auth_methods: [ "publickey" ], logger: logger, keepalive: true, keepalive_interval: 30 }.compact
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_h
|
25
|
+
options.except(:logger).merge(log_level: log_level)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
attr_accessor :config
|
30
|
+
|
31
|
+
def logger
|
32
|
+
LOGGER.tap { |logger| logger.level = log_level }
|
33
|
+
end
|
34
|
+
|
35
|
+
def log_level
|
36
|
+
config.fetch("log_level", :fatal)
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class Kamal::Configuration::Sshkit
|
2
|
+
def initialize(config:)
|
3
|
+
@options = config.raw_config.sshkit || {}
|
4
|
+
end
|
5
|
+
|
6
|
+
def max_concurrent_starts
|
7
|
+
options.fetch("max_concurrent_starts", 30)
|
8
|
+
end
|
9
|
+
|
10
|
+
def pool_idle_timeout
|
11
|
+
options.fetch("pool_idle_timeout", 900)
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_h
|
15
|
+
options
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
attr_accessor :options
|
20
|
+
end
|
@@ -0,0 +1,251 @@
|
|
1
|
+
require "active_support/ordered_options"
|
2
|
+
require "active_support/core_ext/string/inquiry"
|
3
|
+
require "active_support/core_ext/module/delegation"
|
4
|
+
require "pathname"
|
5
|
+
require "erb"
|
6
|
+
require "net/ssh/proxy/jump"
|
7
|
+
|
8
|
+
class Kamal::Configuration
|
9
|
+
delegate :service, :image, :servers, :env, :labels, :registry, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true
|
10
|
+
delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Kamal::Utils
|
11
|
+
|
12
|
+
attr_accessor :destination
|
13
|
+
attr_accessor :raw_config
|
14
|
+
|
15
|
+
class << self
|
16
|
+
def create_from(config_file:, destination: nil, version: nil)
|
17
|
+
raw_config = load_config_files(config_file, *destination_config_file(config_file, destination))
|
18
|
+
|
19
|
+
new raw_config, destination: destination, version: version
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
def load_config_files(*files)
|
24
|
+
files.inject({}) { |config, file| config.deep_merge! load_config_file(file) }
|
25
|
+
end
|
26
|
+
|
27
|
+
def load_config_file(file)
|
28
|
+
if file.exist?
|
29
|
+
YAML.load(ERB.new(IO.read(file)).result).symbolize_keys
|
30
|
+
else
|
31
|
+
raise "Configuration file not found in #{file}"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def destination_config_file(base_config_file, destination)
|
36
|
+
base_config_file.sub_ext(".#{destination}.yml") if destination
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def initialize(raw_config, destination: nil, version: nil, validate: true)
|
41
|
+
@raw_config = ActiveSupport::InheritableOptions.new(raw_config)
|
42
|
+
@destination = destination
|
43
|
+
@declared_version = version
|
44
|
+
valid? if validate
|
45
|
+
end
|
46
|
+
|
47
|
+
|
48
|
+
def version=(version)
|
49
|
+
@declared_version = version
|
50
|
+
end
|
51
|
+
|
52
|
+
def version
|
53
|
+
@declared_version.presence || ENV["VERSION"] || git_version
|
54
|
+
end
|
55
|
+
|
56
|
+
def abbreviated_version
|
57
|
+
Kamal::Utils.abbreviate_version(version)
|
58
|
+
end
|
59
|
+
|
60
|
+
|
61
|
+
def roles
|
62
|
+
@roles ||= role_names.collect { |role_name| Role.new(role_name, config: self) }
|
63
|
+
end
|
64
|
+
|
65
|
+
def role(name)
|
66
|
+
roles.detect { |r| r.name == name.to_s }
|
67
|
+
end
|
68
|
+
|
69
|
+
def accessories
|
70
|
+
@accessories ||= raw_config.accessories&.keys&.collect { |name| Kamal::Configuration::Accessory.new(name, config: self) } || []
|
71
|
+
end
|
72
|
+
|
73
|
+
def accessory(name)
|
74
|
+
accessories.detect { |a| a.name == name.to_s }
|
75
|
+
end
|
76
|
+
|
77
|
+
|
78
|
+
def all_hosts
|
79
|
+
roles.flat_map(&:hosts).uniq
|
80
|
+
end
|
81
|
+
|
82
|
+
def primary_web_host
|
83
|
+
role(:web).primary_host
|
84
|
+
end
|
85
|
+
|
86
|
+
def traefik_hosts
|
87
|
+
roles.select(&:running_traefik?).flat_map(&:hosts).uniq
|
88
|
+
end
|
89
|
+
|
90
|
+
def boot
|
91
|
+
Kamal::Configuration::Boot.new(config: self)
|
92
|
+
end
|
93
|
+
|
94
|
+
|
95
|
+
def repository
|
96
|
+
[ raw_config.registry["server"], image ].compact.join("/")
|
97
|
+
end
|
98
|
+
|
99
|
+
def absolute_image
|
100
|
+
"#{repository}:#{version}"
|
101
|
+
end
|
102
|
+
|
103
|
+
def latest_image
|
104
|
+
"#{repository}:latest"
|
105
|
+
end
|
106
|
+
|
107
|
+
def service_with_version
|
108
|
+
"#{service}-#{version}"
|
109
|
+
end
|
110
|
+
|
111
|
+
|
112
|
+
def env_args
|
113
|
+
if raw_config.env.present?
|
114
|
+
argumentize_env_with_secrets(raw_config.env)
|
115
|
+
else
|
116
|
+
[]
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def volume_args
|
121
|
+
if raw_config.volumes.present?
|
122
|
+
argumentize "--volume", raw_config.volumes
|
123
|
+
else
|
124
|
+
[]
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def logging_args
|
129
|
+
if raw_config.logging.present?
|
130
|
+
optionize({ "log-driver" => raw_config.logging["driver"] }.compact) +
|
131
|
+
argumentize("--log-opt", raw_config.logging["options"])
|
132
|
+
else
|
133
|
+
argumentize("--log-opt", { "max-size" => "10m" })
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
|
138
|
+
def ssh
|
139
|
+
Kamal::Configuration::Ssh.new(config: self)
|
140
|
+
end
|
141
|
+
|
142
|
+
def sshkit
|
143
|
+
Kamal::Configuration::Sshkit.new(config: self)
|
144
|
+
end
|
145
|
+
|
146
|
+
|
147
|
+
def healthcheck
|
148
|
+
{ "path" => "/up", "port" => 3000, "max_attempts" => 7 }.merge(raw_config.healthcheck || {})
|
149
|
+
end
|
150
|
+
|
151
|
+
def readiness_delay
|
152
|
+
raw_config.readiness_delay || 7
|
153
|
+
end
|
154
|
+
|
155
|
+
def minimum_version
|
156
|
+
raw_config.minimum_version
|
157
|
+
end
|
158
|
+
|
159
|
+
def valid?
|
160
|
+
ensure_required_keys_present && ensure_valid_kamal_version
|
161
|
+
end
|
162
|
+
|
163
|
+
|
164
|
+
def to_h
|
165
|
+
{
|
166
|
+
roles: role_names,
|
167
|
+
hosts: all_hosts,
|
168
|
+
primary_host: primary_web_host,
|
169
|
+
version: version,
|
170
|
+
repository: repository,
|
171
|
+
absolute_image: absolute_image,
|
172
|
+
service_with_version: service_with_version,
|
173
|
+
env_args: env_args,
|
174
|
+
volume_args: volume_args,
|
175
|
+
ssh_options: ssh.to_h,
|
176
|
+
sshkit: sshkit.to_h,
|
177
|
+
builder: builder.to_h,
|
178
|
+
accessories: raw_config.accessories,
|
179
|
+
logging: logging_args,
|
180
|
+
healthcheck: healthcheck
|
181
|
+
}.compact
|
182
|
+
end
|
183
|
+
|
184
|
+
def traefik
|
185
|
+
raw_config.traefik || {}
|
186
|
+
end
|
187
|
+
|
188
|
+
def hooks_path
|
189
|
+
raw_config.hooks_path || ".kamal/hooks"
|
190
|
+
end
|
191
|
+
|
192
|
+
def builder
|
193
|
+
Kamal::Configuration::Builder.new(config: self)
|
194
|
+
end
|
195
|
+
|
196
|
+
# Will raise KeyError if any secret ENVs are missing
|
197
|
+
def ensure_env_available
|
198
|
+
env_args
|
199
|
+
roles.each(&:env_args)
|
200
|
+
|
201
|
+
true
|
202
|
+
end
|
203
|
+
|
204
|
+
private
|
205
|
+
# Will raise ArgumentError if any required config keys are missing
|
206
|
+
def ensure_required_keys_present
|
207
|
+
%i[ service image registry servers ].each do |key|
|
208
|
+
raise ArgumentError, "Missing required configuration for #{key}" unless raw_config[key].present?
|
209
|
+
end
|
210
|
+
|
211
|
+
if raw_config.registry["username"].blank?
|
212
|
+
raise ArgumentError, "You must specify a username for the registry in config/deploy.yml"
|
213
|
+
end
|
214
|
+
|
215
|
+
if raw_config.registry["password"].blank?
|
216
|
+
raise ArgumentError, "You must specify a password for the registry in config/deploy.yml (or set the ENV variable if that's used)"
|
217
|
+
end
|
218
|
+
|
219
|
+
roles.each do |role|
|
220
|
+
if role.hosts.empty?
|
221
|
+
raise ArgumentError, "No servers specified for the #{role.name} role"
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
true
|
226
|
+
end
|
227
|
+
|
228
|
+
def ensure_valid_kamal_version
|
229
|
+
if minimum_version && Gem::Version.new(minimum_version) > Gem::Version.new(Kamal::VERSION)
|
230
|
+
raise ArgumentError, "Current version is #{Kamal::VERSION}, minimum required is #{minimum_version}"
|
231
|
+
end
|
232
|
+
|
233
|
+
true
|
234
|
+
end
|
235
|
+
|
236
|
+
|
237
|
+
def role_names
|
238
|
+
raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
|
239
|
+
end
|
240
|
+
|
241
|
+
def git_version
|
242
|
+
@git_version ||=
|
243
|
+
if system("git rev-parse")
|
244
|
+
uncommitted_suffix = Kamal::Utils.uncommitted_changes.present? ? "_uncommitted_#{SecureRandom.hex(8)}" : ""
|
245
|
+
|
246
|
+
"#{`git rev-parse HEAD`.strip}#{uncommitted_suffix}"
|
247
|
+
else
|
248
|
+
raise "Can't use commit hash as version, no git repository found in #{Dir.pwd}"
|
249
|
+
end
|
250
|
+
end
|
251
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
require "sshkit"
|
2
|
+
require "sshkit/dsl"
|
3
|
+
require "active_support/core_ext/hash/deep_merge"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
class SSHKit::Backend::Abstract
|
7
|
+
def capture_with_info(*args, **kwargs)
|
8
|
+
capture(*args, **kwargs, verbosity: Logger::INFO)
|
9
|
+
end
|
10
|
+
|
11
|
+
def capture_with_debug(*args, **kwargs)
|
12
|
+
capture(*args, **kwargs, verbosity: Logger::DEBUG)
|
13
|
+
end
|
14
|
+
|
15
|
+
def capture_with_pretty_json(*args, **kwargs)
|
16
|
+
JSON.pretty_generate(JSON.parse(capture(*args, **kwargs)))
|
17
|
+
end
|
18
|
+
|
19
|
+
def puts_by_host(host, output, type: "App")
|
20
|
+
puts "#{type} Host: #{host}\n#{output}\n\n"
|
21
|
+
end
|
22
|
+
|
23
|
+
# Our execution pattern is for the CLI execute args lists returned
|
24
|
+
# from commands, but this doesn't support returning execution options
|
25
|
+
# from the command.
|
26
|
+
#
|
27
|
+
# Support this by using kwargs for CLI options and merging with the
|
28
|
+
# args-extracted options.
|
29
|
+
module CommandEnvMerge
|
30
|
+
private
|
31
|
+
|
32
|
+
# Override to merge options returned by commands in the args list with
|
33
|
+
# options passed by the CLI and pass them along as kwargs.
|
34
|
+
def command(args, options)
|
35
|
+
more_options, args = args.partition { |a| a.is_a? Hash }
|
36
|
+
more_options << options
|
37
|
+
|
38
|
+
build_command(args, **more_options.reduce(:deep_merge))
|
39
|
+
end
|
40
|
+
|
41
|
+
# Destructure options to pluck out env for merge
|
42
|
+
def build_command(args, env: nil, **options)
|
43
|
+
# Rely on native Ruby kwargs precedence rather than explicit Hash merges
|
44
|
+
SSHKit::Command.new(*args, **default_command_options, **options, env: env_for(env))
|
45
|
+
end
|
46
|
+
|
47
|
+
def default_command_options
|
48
|
+
{ in: pwd_path, host: @host, user: @user, group: @group }
|
49
|
+
end
|
50
|
+
|
51
|
+
def env_for(env)
|
52
|
+
@env.to_h.merge(env.to_h)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
prepend CommandEnvMerge
|
56
|
+
end
|
57
|
+
|
58
|
+
class SSHKit::Backend::Netssh::Configuration
|
59
|
+
attr_accessor :max_concurrent_starts
|
60
|
+
end
|
61
|
+
|
62
|
+
class SSHKit::Backend::Netssh
|
63
|
+
module LimitConcurrentStartsClass
|
64
|
+
attr_reader :start_semaphore
|
65
|
+
|
66
|
+
def configure(&block)
|
67
|
+
super &block
|
68
|
+
# Create this here to avoid lazy creation by multiple threads
|
69
|
+
if config.max_concurrent_starts
|
70
|
+
@start_semaphore = Concurrent::Semaphore.new(config.max_concurrent_starts)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
class << self
|
76
|
+
prepend LimitConcurrentStartsClass
|
77
|
+
end
|
78
|
+
|
79
|
+
module LimitConcurrentStartsInstance
|
80
|
+
private
|
81
|
+
def with_ssh(&block)
|
82
|
+
host.ssh_options = self.class.config.ssh_options.merge(host.ssh_options || {})
|
83
|
+
self.class.pool.with(
|
84
|
+
method(:start_with_concurrency_limit),
|
85
|
+
String(host.hostname),
|
86
|
+
host.username,
|
87
|
+
host.netssh_options,
|
88
|
+
&block
|
89
|
+
)
|
90
|
+
end
|
91
|
+
|
92
|
+
def start_with_concurrency_limit(*args)
|
93
|
+
if self.class.start_semaphore
|
94
|
+
self.class.start_semaphore.acquire do
|
95
|
+
Net::SSH.start(*args)
|
96
|
+
end
|
97
|
+
else
|
98
|
+
Net::SSH.start(*args)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
prepend LimitConcurrentStartsInstance
|
104
|
+
end
|
data/lib/kamal/tags.rb
ADDED
@@ -0,0 +1,39 @@
|
|
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: `whoami`.chomp,
|
14
|
+
destination: config.destination,
|
15
|
+
version: config.version,
|
16
|
+
service_version: service_version(config) }
|
17
|
+
end
|
18
|
+
|
19
|
+
def service_version(config)
|
20
|
+
[ config.service, config.abbreviated_version ].compact.join("@")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def initialize(**tags)
|
25
|
+
@tags = tags.compact
|
26
|
+
end
|
27
|
+
|
28
|
+
def env
|
29
|
+
tags.transform_keys { |detail| "KAMAL_#{detail.upcase}" }
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_s
|
33
|
+
tags.values.map { |value| "[#{value}]" }.join(" ")
|
34
|
+
end
|
35
|
+
|
36
|
+
def except(*tags)
|
37
|
+
self.class.new(**self.tags.except(*tags))
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
class Kamal::Utils::HealthcheckPoller
|
2
|
+
TRAEFIK_HEALTHY_DELAY = 2
|
3
|
+
|
4
|
+
class HealthcheckError < StandardError; end
|
5
|
+
|
6
|
+
class << self
|
7
|
+
def wait_for_healthy(pause_after_ready: false, &block)
|
8
|
+
attempt = 1
|
9
|
+
max_attempts = KAMAL.config.healthcheck["max_attempts"]
|
10
|
+
|
11
|
+
begin
|
12
|
+
case status = block.call
|
13
|
+
when "healthy"
|
14
|
+
sleep TRAEFIK_HEALTHY_DELAY if pause_after_ready
|
15
|
+
when "running" # No health check configured
|
16
|
+
sleep KAMAL.config.readiness_delay if pause_after_ready
|
17
|
+
else
|
18
|
+
raise HealthcheckError, "container not ready (#{status})"
|
19
|
+
end
|
20
|
+
rescue HealthcheckError => e
|
21
|
+
if attempt <= max_attempts
|
22
|
+
info "#{e.message}, retrying in #{attempt}s (attempt #{attempt}/#{max_attempts})..."
|
23
|
+
sleep attempt
|
24
|
+
attempt += 1
|
25
|
+
retry
|
26
|
+
else
|
27
|
+
raise
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
info "Container is healthy!"
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
def info(message)
|
36
|
+
SSHKit.config.output.info(message)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
require "active_support/core_ext/module/delegation"
|
2
|
+
|
3
|
+
class Kamal::Utils::Sensitive
|
4
|
+
# So SSHKit knows to redact these values.
|
5
|
+
include SSHKit::Redaction
|
6
|
+
|
7
|
+
attr_reader :unredacted, :redaction
|
8
|
+
delegate :to_s, to: :unredacted
|
9
|
+
delegate :inspect, to: :redaction
|
10
|
+
|
11
|
+
def initialize(value, redaction: "[REDACTED]")
|
12
|
+
@unredacted, @redaction = value, redaction
|
13
|
+
end
|
14
|
+
|
15
|
+
# Sensitive values won't leak into YAML output.
|
16
|
+
def encode_with(coder)
|
17
|
+
coder.represent_scalar nil, redaction
|
18
|
+
end
|
19
|
+
end
|
data/lib/kamal/utils.rb
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
module Kamal::Utils
|
2
|
+
extend self
|
3
|
+
|
4
|
+
DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX = /\$(?!{[^\}]*\})/
|
5
|
+
|
6
|
+
# Return a list of escaped shell arguments using the same named argument against the passed attributes (hash or array).
|
7
|
+
def argumentize(argument, attributes, sensitive: false)
|
8
|
+
Array(attributes).flat_map do |key, value|
|
9
|
+
if value.present?
|
10
|
+
attr = "#{key}=#{escape_shell_value(value)}"
|
11
|
+
attr = self.sensitive(attr, redaction: "#{key}=[REDACTED]") if sensitive
|
12
|
+
[ argument, attr]
|
13
|
+
else
|
14
|
+
[ argument, key ]
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# Return a list of shell arguments using the same named argument against the passed attributes,
|
20
|
+
# but redacts and expands secrets.
|
21
|
+
def argumentize_env_with_secrets(env)
|
22
|
+
if (secrets = env["secret"]).present?
|
23
|
+
argumentize("-e", secrets.to_h { |key| [ key, ENV.fetch(key) ] }, sensitive: true) + argumentize("-e", env["clear"])
|
24
|
+
else
|
25
|
+
argumentize "-e", env.fetch("clear", env)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option.
|
30
|
+
def optionize(args, with: nil)
|
31
|
+
options = if with
|
32
|
+
flatten_args(args).collect { |(key, value)| value == true ? "--#{key}" : "--#{key}#{with}#{escape_shell_value(value)}" }
|
33
|
+
else
|
34
|
+
flatten_args(args).collect { |(key, value)| [ "--#{key}", value == true ? nil : escape_shell_value(value) ] }
|
35
|
+
end
|
36
|
+
|
37
|
+
options.flatten.compact
|
38
|
+
end
|
39
|
+
|
40
|
+
# Flattens a one-to-many structure into an array of two-element arrays each containing a key-value pair
|
41
|
+
def flatten_args(args)
|
42
|
+
args.flat_map { |key, value| value.try(:map) { |entry| [key, entry] } || [ [ key, value ] ] }
|
43
|
+
end
|
44
|
+
|
45
|
+
# Marks sensitive values for redaction in logs and human-visible output.
|
46
|
+
# Pass `redaction:` to change the default `"[REDACTED]"` redaction, e.g.
|
47
|
+
# `sensitive "#{arg}=#{secret}", redaction: "#{arg}=xxxx"
|
48
|
+
def sensitive(...)
|
49
|
+
Kamal::Utils::Sensitive.new(...)
|
50
|
+
end
|
51
|
+
|
52
|
+
def redacted(value)
|
53
|
+
case
|
54
|
+
when value.respond_to?(:redaction)
|
55
|
+
value.redaction
|
56
|
+
when value.respond_to?(:transform_values)
|
57
|
+
value.transform_values { |value| redacted value }
|
58
|
+
when value.respond_to?(:map)
|
59
|
+
value.map { |element| redacted element }
|
60
|
+
else
|
61
|
+
value
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def unredacted(value)
|
66
|
+
case
|
67
|
+
when value.respond_to?(:unredacted)
|
68
|
+
value.unredacted
|
69
|
+
when value.respond_to?(:transform_values)
|
70
|
+
value.transform_values { |value| unredacted value }
|
71
|
+
when value.respond_to?(:map)
|
72
|
+
value.map { |element| unredacted element }
|
73
|
+
else
|
74
|
+
value
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# Escape a value to make it safe for shell use.
|
79
|
+
def escape_shell_value(value)
|
80
|
+
value.to_s.dump
|
81
|
+
.gsub(/`/, '\\\\`')
|
82
|
+
.gsub(DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX, '\$')
|
83
|
+
end
|
84
|
+
|
85
|
+
# Abbreviate a git revhash for concise display
|
86
|
+
def abbreviate_version(version)
|
87
|
+
if version
|
88
|
+
# Don't abbreviate <sha>_uncommitted_<etc>
|
89
|
+
if version.include?("_")
|
90
|
+
version
|
91
|
+
else
|
92
|
+
version[0...7]
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def uncommitted_changes
|
98
|
+
`git status --porcelain`.strip
|
99
|
+
end
|
100
|
+
end
|