kamal 0.16.0
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 +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
|