kamal 2.10.1 → 2.12.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/kamal/cli/accessory.rb +48 -39
  4. data/lib/kamal/cli/alias/command.rb +2 -2
  5. data/lib/kamal/cli/app.rb +57 -48
  6. data/lib/kamal/cli/base.rb +118 -17
  7. data/lib/kamal/cli/build.rb +10 -7
  8. data/lib/kamal/cli/lock.rb +5 -16
  9. data/lib/kamal/cli/main.rb +59 -53
  10. data/lib/kamal/cli/proxy.rb +9 -9
  11. data/lib/kamal/cli/prune.rb +3 -3
  12. data/lib/kamal/cli/server.rb +34 -15
  13. data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +1 -1
  14. data/lib/kamal/cli/templates/sample_hooks/post-app-boot.sample +1 -1
  15. data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +1 -1
  16. data/lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample +1 -1
  17. data/lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample +1 -1
  18. data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +1 -1
  19. data/lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample +1 -1
  20. data/lib/kamal/cli/templates/secrets +4 -0
  21. data/lib/kamal/commander.rb +71 -17
  22. data/lib/kamal/commands/accessory.rb +3 -2
  23. data/lib/kamal/commands/app/logging.rb +1 -1
  24. data/lib/kamal/commands/app.rb +1 -1
  25. data/lib/kamal/commands/base.rb +15 -2
  26. data/lib/kamal/commands/builder/clone.rb +2 -1
  27. data/lib/kamal/commands/docker.rb +17 -1
  28. data/lib/kamal/commands/proxy.rb +1 -1
  29. data/lib/kamal/configuration/accessory.rb +13 -5
  30. data/lib/kamal/configuration/docs/alias.yml +3 -0
  31. data/lib/kamal/configuration/docs/configuration.yml +37 -2
  32. data/lib/kamal/configuration/docs/env.yml +6 -4
  33. data/lib/kamal/configuration/docs/output.yml +25 -0
  34. data/lib/kamal/configuration/docs/role.yml +1 -0
  35. data/lib/kamal/configuration/docs/ssh.yml +8 -0
  36. data/lib/kamal/configuration/output.rb +34 -0
  37. data/lib/kamal/configuration/proxy/run.rb +10 -1
  38. data/lib/kamal/configuration/role.rb +18 -6
  39. data/lib/kamal/configuration/ssh.rb +5 -1
  40. data/lib/kamal/configuration/validator.rb +29 -2
  41. data/lib/kamal/configuration.rb +41 -3
  42. data/lib/kamal/git.rb +1 -1
  43. data/lib/kamal/otel_shipper.rb +176 -0
  44. data/lib/kamal/output/base_logger.rb +29 -0
  45. data/lib/kamal/output/file_logger.rb +51 -0
  46. data/lib/kamal/output/formatter.rb +36 -0
  47. data/lib/kamal/output/otel_logger.rb +70 -0
  48. data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +10 -2
  49. data/lib/kamal/secrets/adapters/passbolt.rb +1 -1
  50. data/lib/kamal/secrets.rb +1 -1
  51. data/lib/kamal/sshkit_with_ext.rb +9 -4
  52. data/lib/kamal/version.rb +1 -1
  53. metadata +23 -2
@@ -0,0 +1,176 @@
1
+ require "active_support/core_ext/numeric/time"
2
+ require "net/http"
3
+ require "json"
4
+ require "securerandom"
5
+ require "uri"
6
+
7
+ class Kamal::OtelShipper
8
+ BATCH_SIZE = 100
9
+ FLUSH_INTERVAL = 5.seconds
10
+
11
+ OTEL_ATTRIBUTE_KEYS = {
12
+ service: "service.namespace",
13
+ version: "kamal.deploy_version",
14
+ performer: "kamal.performer",
15
+ destination: "deployment.environment.name"
16
+ }
17
+
18
+ SEVERITIES = {
19
+ debug: { severityNumber: 5, severityText: "DEBUG" },
20
+ info: { severityNumber: 9, severityText: "INFO" },
21
+ warn: { severityNumber: 13, severityText: "WARN" },
22
+ error: { severityNumber: 17, severityText: "ERROR" },
23
+ fatal: { severityNumber: 21, severityText: "FATAL" }
24
+ }
25
+
26
+ LOGGER_SEVERITIES = {
27
+ Logger::DEBUG => :debug,
28
+ Logger::INFO => :info,
29
+ Logger::WARN => :warn,
30
+ Logger::ERROR => :error,
31
+ Logger::FATAL => :fatal
32
+ }
33
+
34
+ attr_reader :run_id
35
+
36
+ def initialize(endpoint:, tags:)
37
+ @endpoint = URI("#{endpoint.chomp('/')}/v1/logs")
38
+ @run_id = SecureRandom.uuid
39
+ @resource_attributes = [
40
+ { key: "service.name", value: { stringValue: "kamal" } },
41
+ { key: "service.version", value: { stringValue: Kamal::VERSION } },
42
+ { key: "kamal.run_id", value: { stringValue: @run_id } },
43
+ *tags.tags.map do |key, value|
44
+ otel_key = OTEL_ATTRIBUTE_KEYS.fetch(key, "kamal.#{key}")
45
+ { key: otel_key, value: { stringValue: value.to_s } }
46
+ end
47
+ ]
48
+ @buffer = Queue.new
49
+ @flush_mutex = Mutex.new
50
+ @running = true
51
+ @signal = Queue.new
52
+ @thread = start_flush_thread
53
+ end
54
+
55
+ def <<(str)
56
+ append(str)
57
+ end
58
+
59
+ def append(str, host: nil, iostream: nil, severity: nil)
60
+ otel_severity = LOGGER_SEVERITIES.fetch(severity, :info)
61
+ extra = build_context_attributes(host: host, iostream: iostream)
62
+ str.to_s.each_line do |line|
63
+ enqueue build_record(line.chomp, severity: otel_severity, attributes: extra)
64
+ end
65
+
66
+ self
67
+ end
68
+
69
+ def event(name, severity: :info, **attributes)
70
+ attrs = attributes.map { |k, v| { key: k.to_s, value: typed_value(v) } }
71
+ enqueue build_record(name, severity: severity, event_name: name, attributes: attrs)
72
+
73
+ self
74
+ end
75
+
76
+ def flush
77
+ @flush_mutex.synchronize do
78
+ lines = drain_buffer
79
+ ship(lines) if lines.any?
80
+ end
81
+ end
82
+
83
+ def shutdown
84
+ @running = false
85
+ @signal << true
86
+ @thread&.join(FLUSH_INTERVAL + 1.second)
87
+ flush
88
+ end
89
+
90
+ private
91
+ def enqueue(record)
92
+ @buffer << record
93
+ @signal << true if @buffer.size >= BATCH_SIZE
94
+ end
95
+
96
+ def start_flush_thread
97
+ Thread.new do
98
+ while @running
99
+ @signal.pop(timeout: FLUSH_INTERVAL)
100
+ flush
101
+ end
102
+ end
103
+ end
104
+
105
+ def drain_buffer
106
+ records = []
107
+ records << @buffer.pop(true) until @buffer.empty?
108
+ records
109
+ end
110
+
111
+ def ship(records)
112
+ with_connection do |http|
113
+ records.each_slice(BATCH_SIZE) do |batch|
114
+ ship_records(http, batch)
115
+ end
116
+ end
117
+ end
118
+
119
+ def build_record(body, severity: :info, event_name: nil, attributes: nil)
120
+ now = time_ns
121
+ { timeUnixNano: now, observedTimeUnixNano: now, **SEVERITIES.fetch(severity),
122
+ body: { stringValue: body }, eventName: event_name, attributes: attributes }.compact
123
+ end
124
+
125
+ def build_context_attributes(host:, iostream:)
126
+ attrs = []
127
+ attrs << { key: "server.address", value: { stringValue: host } } if host
128
+ attrs << { key: "log.iostream", value: { stringValue: iostream } } if iostream
129
+ attrs.presence
130
+ end
131
+
132
+ def typed_value(v)
133
+ case v
134
+ when Integer then { intValue: v }
135
+ when Float then { doubleValue: v }
136
+ when Array then { arrayValue: { values: v.map { |e| typed_value(e) } } }
137
+ else { stringValue: v.to_s }
138
+ end
139
+ end
140
+
141
+ def with_connection
142
+ http = Net::HTTP.new(@endpoint.host, @endpoint.port)
143
+ http.use_ssl = @endpoint.scheme == "https"
144
+ http.open_timeout = 2.seconds
145
+ http.read_timeout = 5.seconds
146
+ http.start { |conn| yield conn }
147
+ rescue StandardError => e
148
+ unless @ship_error_logged
149
+ @ship_error_logged = true
150
+ $stderr.puts "OTel log shipping failed: #{e.class}: #{e.message}"
151
+ $stderr.puts e.backtrace.join("\n") if ENV["VERBOSE"]
152
+ end
153
+ end
154
+
155
+ def ship_records(http, records)
156
+ payload = {
157
+ resourceLogs: [ {
158
+ resource: { attributes: @resource_attributes },
159
+ scopeLogs: [ { scope: { name: "kamal", version: Kamal::VERSION }, logRecords: records } ]
160
+ } ]
161
+ }
162
+
163
+ req = Net::HTTP::Post.new(@endpoint.request_uri, "Content-Type" => "application/json")
164
+ req.body = JSON.generate(payload)
165
+ response = http.request(req)
166
+
167
+ unless response.is_a?(Net::HTTPSuccess) || @ship_error_logged
168
+ @ship_error_logged = true
169
+ $stderr.puts "OTel log shipping failed: HTTP #{response.code} #{response.message}"
170
+ end
171
+ end
172
+
173
+ def time_ns
174
+ Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond).to_s
175
+ end
176
+ end
@@ -0,0 +1,29 @@
1
+ require "logger"
2
+
3
+ class Kamal::Output::BaseLogger < ::Logger
4
+ def initialize
5
+ super(nil)
6
+ @subscription = ActiveSupport::Notifications.subscribe("modify.kamal", self)
7
+ end
8
+
9
+ def start(name, id, payload)
10
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
11
+ on_start(payload)
12
+ end
13
+
14
+ def finish(name, id, payload)
15
+ runtime = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at).round(1)
16
+ on_finish(payload, runtime)
17
+ end
18
+
19
+ def add(severity, message = nil, progname = nil, &block)
20
+ if msg = message || (block ? block.call : progname)
21
+ self << msg.to_s
22
+ end
23
+ end
24
+
25
+ def close
26
+ ActiveSupport::Notifications.unsubscribe(@subscription)
27
+ on_close
28
+ end
29
+ end
@@ -0,0 +1,51 @@
1
+ class Kamal::Output::FileLogger < Kamal::Output::BaseLogger
2
+ attr_reader :path
3
+
4
+ def self.build(settings:, config:)
5
+ raise ArgumentError, "file path is required" unless settings["path"]
6
+ new(path: settings["path"])
7
+ end
8
+
9
+ def initialize(path:)
10
+ @path = Pathname.new(path)
11
+ super()
12
+ end
13
+
14
+ def <<(message)
15
+ @file&.print(message)
16
+ end
17
+
18
+ private
19
+ def on_start(payload)
20
+ path.mkpath
21
+ @file_path = path.join(filename_for(payload))
22
+ @file = File.open(@file_path, "a")
23
+ @file.sync = true
24
+ end
25
+
26
+ def on_finish(payload, runtime)
27
+ if @file
28
+ if payload[:exception]
29
+ error_class, error_message = payload[:exception]
30
+ @file.puts "# FAILED: #{error_class}: #{error_message} (#{runtime}s)"
31
+ else
32
+ @file.puts "# Completed in #{runtime}s"
33
+ end
34
+ @file.close
35
+ @file = nil
36
+ puts "Logs written to #{@file_path}"
37
+ end
38
+ end
39
+
40
+ def on_close
41
+ if @file
42
+ @file.close
43
+ @file = nil
44
+ end
45
+ end
46
+
47
+ def filename_for(payload)
48
+ command = [ payload[:command], payload[:subcommand] ].compact.join("_")
49
+ [ Time.now.strftime("%Y-%m-%dT%H-%M-%S"), payload[:destination], command ].compact.join("_") + ".log"
50
+ end
51
+ end
@@ -0,0 +1,36 @@
1
+ class Kamal::Output::Formatter < SSHKit::Formatter::Pretty
2
+ def initialize(output, logger)
3
+ @logger = logger
4
+ super(output)
5
+ end
6
+
7
+ def log_command_start(command)
8
+ with_command_context(command) { super }
9
+ end
10
+
11
+ def log_command_data(command, stream_type, stream_data)
12
+ with_command_context(command, iostream: stream_type.to_s) { super }
13
+ end
14
+
15
+ def log_command_exit(command)
16
+ with_command_context(command) { super }
17
+ end
18
+
19
+ private
20
+ def write_message(verbosity, message, uuid = nil)
21
+ super
22
+ Thread.current[:kamal_severity] = verbosity
23
+ @logger << "#{format_message(verbosity, message, uuid)}\n" rescue nil
24
+ ensure
25
+ Thread.current[:kamal_severity] = nil
26
+ end
27
+
28
+ def with_command_context(command, iostream: nil)
29
+ Thread.current[:kamal_host] = command.host.to_s
30
+ Thread.current[:kamal_iostream] = iostream
31
+ yield
32
+ ensure
33
+ Thread.current[:kamal_host] = nil
34
+ Thread.current[:kamal_iostream] = nil
35
+ end
36
+ end
@@ -0,0 +1,70 @@
1
+ class Kamal::Output::OtelLogger < Kamal::Output::BaseLogger
2
+ def self.build(settings:, config:)
3
+ raise ArgumentError, "OTel endpoint is required" unless settings["endpoint"]
4
+ new(
5
+ endpoint: settings["endpoint"],
6
+ tags: Kamal::Tags.from_config(config).except(:service_version, :recorded_at),
7
+ service: config.service
8
+ )
9
+ end
10
+
11
+ def initialize(endpoint:, tags:, service: nil)
12
+ @endpoint = endpoint
13
+ @shipper = Kamal::OtelShipper.new(endpoint: endpoint, tags: tags)
14
+ @service = service
15
+ super()
16
+ end
17
+
18
+ def <<(message)
19
+ host = Thread.current[:kamal_host]
20
+ iostream = Thread.current[:kamal_iostream]
21
+ severity = Thread.current[:kamal_severity]
22
+ @shipper.append(message, host: host, iostream: iostream, severity: severity)
23
+ end
24
+
25
+ DEPLOY_COMMANDS = %w[ deploy redeploy rollback setup ].freeze
26
+
27
+ private
28
+ def on_start(payload)
29
+ @shipper.event("kamal.start",
30
+ "kamal.command": full_command(payload),
31
+ **deployment_attrs(payload))
32
+ end
33
+
34
+ def on_finish(payload, runtime)
35
+ if payload[:exception]
36
+ error_class, error_message = payload[:exception]
37
+ @shipper.event("kamal.failed", severity: :error,
38
+ "kamal.command": full_command(payload), "kamal.runtime": runtime,
39
+ "exception.type": error_class, "exception.message": error_message,
40
+ **deployment_attrs(payload, status: "failed"))
41
+ else
42
+ @shipper.event("kamal.complete",
43
+ "kamal.command": full_command(payload), "kamal.runtime": runtime,
44
+ **deployment_attrs(payload, status: "succeeded"))
45
+ end
46
+ puts "Logs sent to #{@endpoint}"
47
+ end
48
+
49
+ def on_close
50
+ @shipper.shutdown
51
+ end
52
+
53
+ def full_command(payload)
54
+ [ payload[:command], payload[:subcommand] ].compact.join(" ")
55
+ end
56
+
57
+ def deploy?(payload)
58
+ DEPLOY_COMMANDS.include?(payload[:command])
59
+ end
60
+
61
+ def deployment_attrs(payload, status: nil)
62
+ if deploy?(payload)
63
+ attrs = { "deployment.id": @shipper.run_id, "deployment.name": "#{full_command(payload)} #{@service}" }
64
+ attrs[:"deployment.status"] = status if status
65
+ attrs
66
+ else
67
+ {}
68
+ end
69
+ end
70
+ end
@@ -14,8 +14,12 @@ class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Ba
14
14
  secret_name = secret["Name"]
15
15
  secret_string = JSON.parse(secret["SecretString"])
16
16
 
17
- secret_string.each do |key, value|
18
- results["#{secret_name}/#{key}"] = value
17
+ if secret_string.is_a?(Hash)
18
+ secret_string.each do |key, value|
19
+ results["#{secret_name}/#{key}"] = stringify_secret_value(value)
20
+ end
21
+ else
22
+ results["#{secret_name}"] = stringify_secret_value(secret_string)
19
23
  end
20
24
  rescue JSON::ParserError
21
25
  results["#{secret_name}"] = secret["SecretString"]
@@ -40,6 +44,10 @@ class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Ba
40
44
  end
41
45
  end
42
46
 
47
+ def stringify_secret_value(value)
48
+ value.is_a?(String) ? value : JSON.dump(value)
49
+ end
50
+
43
51
  def check_dependencies!
44
52
  raise RuntimeError, "AWS CLI is not installed" unless cli_installed?
45
53
  end
@@ -47,7 +47,7 @@ class Kamal::Secrets::Adapters::Passbolt < Kamal::Secrets::Adapters::Base
47
47
  end
48
48
 
49
49
  filter_condition = filter_conditions.any? ? "--filter '#{filter_conditions.join(" || ")}'" : ""
50
- items = `passbolt list resources #{filter_condition} #{folders.map { |item| "--folder #{item["id"].to_s.shellescape}" }.join(" ")} --json`
50
+ items = `passbolt list resources #{filter_condition} #{folders.map { |item| "--folder #{item["id"].to_s.shellescape}" }.join(" ")} --column name --column password --json`
51
51
  raise RuntimeError, "Could not read #{secrets} from Passbolt" unless $?.success?
52
52
  items = JSON.parse(items)
53
53
  found_names = items.map { |item| item["name"] }
data/lib/kamal/secrets.rb CHANGED
@@ -3,7 +3,7 @@ require "dotenv"
3
3
  class Kamal::Secrets
4
4
  Kamal::Secrets::Dotenv::InlineCommandSubstitution.install!
5
5
 
6
- def initialize(destination: nil, secrets_path:)
6
+ def initialize(destination: nil, secrets_path: ".kamal/secrets")
7
7
  @destination = destination
8
8
  @secrets_path = secrets_path
9
9
  @mutex = Mutex.new
@@ -19,11 +19,16 @@ class SSHKit::Backend::Abstract
19
19
  JSON.pretty_generate(JSON.parse(capture(*args, **kwargs)))
20
20
  end
21
21
 
22
- def puts_by_host(host, output, type: "App", quiet: false)
23
- unless quiet
24
- puts "#{type} Host: #{host}"
22
+ def puts_by_host(host, output, type: "App", quiet: false, raw: false)
23
+ if raw
24
+ $stdout.binmode
25
+ $stdout.write(output)
26
+ else
27
+ unless quiet
28
+ puts "#{type} Host: #{host}"
29
+ end
30
+ puts "#{output}\n\n"
25
31
  end
26
- puts "#{output}\n\n"
27
32
  end
28
33
 
29
34
  # Our execution pattern is for the CLI execute args lists returned
data/lib/kamal/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kamal
2
- VERSION = "2.10.1"
2
+ VERSION = "2.12.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kamal
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.10.1
4
+ version: 2.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Heinemeier Hansson
@@ -175,6 +175,20 @@ dependencies:
175
175
  - - ">="
176
176
  - !ruby/object:Gem::Version
177
177
  version: '0'
178
+ - !ruby/object:Gem::Dependency
179
+ name: minitest
180
+ requirement: !ruby/object:Gem::Requirement
181
+ requirements:
182
+ - - "<"
183
+ - !ruby/object:Gem::Version
184
+ version: '6'
185
+ type: :development
186
+ prerelease: false
187
+ version_requirements: !ruby/object:Gem::Requirement
188
+ requirements:
189
+ - - "<"
190
+ - !ruby/object:Gem::Version
191
+ version: '6'
178
192
  - !ruby/object:Gem::Dependency
179
193
  name: mocha
180
194
  requirement: !ruby/object:Gem::Requirement
@@ -288,6 +302,7 @@ files:
288
302
  - lib/kamal/configuration/docs/configuration.yml
289
303
  - lib/kamal/configuration/docs/env.yml
290
304
  - lib/kamal/configuration/docs/logging.yml
305
+ - lib/kamal/configuration/docs/output.yml
291
306
  - lib/kamal/configuration/docs/proxy.yml
292
307
  - lib/kamal/configuration/docs/registry.yml
293
308
  - lib/kamal/configuration/docs/role.yml
@@ -297,6 +312,7 @@ files:
297
312
  - lib/kamal/configuration/env.rb
298
313
  - lib/kamal/configuration/env/tag.rb
299
314
  - lib/kamal/configuration/logging.rb
315
+ - lib/kamal/configuration/output.rb
300
316
  - lib/kamal/configuration/proxy.rb
301
317
  - lib/kamal/configuration/proxy/boot.rb
302
318
  - lib/kamal/configuration/proxy/run.rb
@@ -320,6 +336,11 @@ files:
320
336
  - lib/kamal/docker.rb
321
337
  - lib/kamal/env_file.rb
322
338
  - lib/kamal/git.rb
339
+ - lib/kamal/otel_shipper.rb
340
+ - lib/kamal/output/base_logger.rb
341
+ - lib/kamal/output/file_logger.rb
342
+ - lib/kamal/output/formatter.rb
343
+ - lib/kamal/output/otel_logger.rb
323
344
  - lib/kamal/secrets.rb
324
345
  - lib/kamal/secrets/adapters.rb
325
346
  - lib/kamal/secrets/adapters/aws_secrets_manager.rb
@@ -357,7 +378,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
357
378
  - !ruby/object:Gem::Version
358
379
  version: '0'
359
380
  requirements: []
360
- rubygems_version: 3.6.9
381
+ rubygems_version: 4.0.10
361
382
  specification_version: 4
362
383
  summary: Deploy web apps in containers to servers running Docker with zero downtime.
363
384
  test_files: []