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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +1021 -0
  4. data/bin/kamal +18 -0
  5. data/lib/kamal/cli/accessory.rb +239 -0
  6. data/lib/kamal/cli/app.rb +296 -0
  7. data/lib/kamal/cli/base.rb +171 -0
  8. data/lib/kamal/cli/build.rb +106 -0
  9. data/lib/kamal/cli/healthcheck.rb +20 -0
  10. data/lib/kamal/cli/lock.rb +37 -0
  11. data/lib/kamal/cli/main.rb +249 -0
  12. data/lib/kamal/cli/prune.rb +30 -0
  13. data/lib/kamal/cli/registry.rb +18 -0
  14. data/lib/kamal/cli/server.rb +21 -0
  15. data/lib/kamal/cli/templates/deploy.yml +74 -0
  16. data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +14 -0
  17. data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +51 -0
  18. data/lib/kamal/cli/templates/sample_hooks/pre-connect.sample +47 -0
  19. data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +109 -0
  20. data/lib/kamal/cli/templates/template.env +2 -0
  21. data/lib/kamal/cli/traefik.rb +111 -0
  22. data/lib/kamal/cli.rb +7 -0
  23. data/lib/kamal/commander.rb +154 -0
  24. data/lib/kamal/commands/accessory.rb +113 -0
  25. data/lib/kamal/commands/app.rb +175 -0
  26. data/lib/kamal/commands/auditor.rb +28 -0
  27. data/lib/kamal/commands/base.rb +65 -0
  28. data/lib/kamal/commands/builder/base.rb +60 -0
  29. data/lib/kamal/commands/builder/multiarch/remote.rb +51 -0
  30. data/lib/kamal/commands/builder/multiarch.rb +29 -0
  31. data/lib/kamal/commands/builder/native/cached.rb +16 -0
  32. data/lib/kamal/commands/builder/native/remote.rb +59 -0
  33. data/lib/kamal/commands/builder/native.rb +20 -0
  34. data/lib/kamal/commands/builder.rb +62 -0
  35. data/lib/kamal/commands/docker.rb +21 -0
  36. data/lib/kamal/commands/healthcheck.rb +57 -0
  37. data/lib/kamal/commands/hook.rb +14 -0
  38. data/lib/kamal/commands/lock.rb +63 -0
  39. data/lib/kamal/commands/prune.rb +38 -0
  40. data/lib/kamal/commands/registry.rb +20 -0
  41. data/lib/kamal/commands/traefik.rb +104 -0
  42. data/lib/kamal/commands.rb +2 -0
  43. data/lib/kamal/configuration/accessory.rb +169 -0
  44. data/lib/kamal/configuration/boot.rb +20 -0
  45. data/lib/kamal/configuration/builder.rb +114 -0
  46. data/lib/kamal/configuration/role.rb +155 -0
  47. data/lib/kamal/configuration/ssh.rb +38 -0
  48. data/lib/kamal/configuration/sshkit.rb +20 -0
  49. data/lib/kamal/configuration.rb +251 -0
  50. data/lib/kamal/sshkit_with_ext.rb +104 -0
  51. data/lib/kamal/tags.rb +39 -0
  52. data/lib/kamal/utils/healthcheck_poller.rb +39 -0
  53. data/lib/kamal/utils/sensitive.rb +19 -0
  54. data/lib/kamal/utils.rb +100 -0
  55. data/lib/kamal/version.rb +3 -0
  56. data/lib/kamal.rb +10 -0
  57. 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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Kamal
2
+ VERSION = "0.16.0"
3
+ end
data/lib/kamal.rb ADDED
@@ -0,0 +1,10 @@
1
+ module Kamal
2
+ end
3
+
4
+ require "active_support"
5
+ require "zeitwerk"
6
+
7
+ loader = Zeitwerk::Loader.for_gem
8
+ loader.ignore("#{__dir__}/kamal/sshkit_with_ext.rb")
9
+ loader.setup
10
+ loader.eager_load # We need all commands loaded.