kamal 0.16.0

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