dash 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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +13 -0
- data/bin/dash +18 -0
- data/bin/kamal +18 -0
- data/lib/kamal/cli/accessory.rb +342 -0
- data/lib/kamal/cli/alias/command.rb +10 -0
- data/lib/kamal/cli/app/assets.rb +24 -0
- data/lib/kamal/cli/app/boot.rb +126 -0
- data/lib/kamal/cli/app/error_pages.rb +33 -0
- data/lib/kamal/cli/app/ssl_certificates.rb +28 -0
- data/lib/kamal/cli/app.rb +368 -0
- data/lib/kamal/cli/base.rb +324 -0
- data/lib/kamal/cli/build/clone.rb +59 -0
- data/lib/kamal/cli/build/port_forwarding.rb +66 -0
- data/lib/kamal/cli/build.rb +242 -0
- data/lib/kamal/cli/healthcheck/barrier.rb +33 -0
- data/lib/kamal/cli/healthcheck/error.rb +2 -0
- data/lib/kamal/cli/healthcheck/poller.rb +42 -0
- data/lib/kamal/cli/lock.rb +34 -0
- data/lib/kamal/cli/main.rb +299 -0
- data/lib/kamal/cli/proxy.rb +419 -0
- data/lib/kamal/cli/prune.rb +34 -0
- data/lib/kamal/cli/registry.rb +49 -0
- data/lib/kamal/cli/secrets.rb +50 -0
- data/lib/kamal/cli/server.rb +70 -0
- data/lib/kamal/cli/templates/deploy.yml +102 -0
- data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +3 -0
- data/lib/kamal/cli/templates/sample_hooks/post-app-boot.sample +3 -0
- data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +14 -0
- data/lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample +3 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample +3 -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 +122 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample +3 -0
- data/lib/kamal/cli/templates/secrets +22 -0
- data/lib/kamal/cli.rb +9 -0
- data/lib/kamal/commander/specifics.rb +62 -0
- data/lib/kamal/commander.rb +230 -0
- data/lib/kamal/commands/accessory/proxy.rb +16 -0
- data/lib/kamal/commands/accessory.rb +118 -0
- data/lib/kamal/commands/app/assets.rb +51 -0
- data/lib/kamal/commands/app/containers.rb +31 -0
- data/lib/kamal/commands/app/error_pages.rb +9 -0
- data/lib/kamal/commands/app/execution.rb +38 -0
- data/lib/kamal/commands/app/images.rb +13 -0
- data/lib/kamal/commands/app/logging.rb +28 -0
- data/lib/kamal/commands/app/proxy.rb +32 -0
- data/lib/kamal/commands/app.rb +125 -0
- data/lib/kamal/commands/auditor.rb +39 -0
- data/lib/kamal/commands/base.rb +147 -0
- data/lib/kamal/commands/builder/base.rb +143 -0
- data/lib/kamal/commands/builder/clone.rb +32 -0
- data/lib/kamal/commands/builder/cloud.rb +22 -0
- data/lib/kamal/commands/builder/hybrid.rb +21 -0
- data/lib/kamal/commands/builder/local.rb +20 -0
- data/lib/kamal/commands/builder/pack.rb +46 -0
- data/lib/kamal/commands/builder/remote.rb +75 -0
- data/lib/kamal/commands/builder.rb +54 -0
- data/lib/kamal/commands/docker.rb +50 -0
- data/lib/kamal/commands/hook.rb +20 -0
- data/lib/kamal/commands/loadbalancer.rb +130 -0
- data/lib/kamal/commands/lock.rb +70 -0
- data/lib/kamal/commands/proxy.rb +150 -0
- data/lib/kamal/commands/prune.rb +38 -0
- data/lib/kamal/commands/registry.rb +38 -0
- data/lib/kamal/commands/server.rb +15 -0
- data/lib/kamal/commands.rb +2 -0
- data/lib/kamal/configuration/accessory.rb +280 -0
- data/lib/kamal/configuration/alias.rb +15 -0
- data/lib/kamal/configuration/boot.rb +29 -0
- data/lib/kamal/configuration/builder.rb +218 -0
- data/lib/kamal/configuration/docs/accessory.yml +160 -0
- data/lib/kamal/configuration/docs/alias.yml +29 -0
- data/lib/kamal/configuration/docs/boot.yml +21 -0
- data/lib/kamal/configuration/docs/builder.yml +132 -0
- data/lib/kamal/configuration/docs/configuration.yml +228 -0
- data/lib/kamal/configuration/docs/env.yml +118 -0
- data/lib/kamal/configuration/docs/logging.yml +21 -0
- data/lib/kamal/configuration/docs/output.yml +25 -0
- data/lib/kamal/configuration/docs/proxy.yml +207 -0
- data/lib/kamal/configuration/docs/registry.yml +64 -0
- data/lib/kamal/configuration/docs/role.yml +54 -0
- data/lib/kamal/configuration/docs/servers.yml +27 -0
- data/lib/kamal/configuration/docs/ssh.yml +81 -0
- data/lib/kamal/configuration/docs/sshkit.yml +31 -0
- data/lib/kamal/configuration/env/tag.rb +13 -0
- data/lib/kamal/configuration/env.rb +42 -0
- data/lib/kamal/configuration/loadbalancer.rb +34 -0
- data/lib/kamal/configuration/logging.rb +33 -0
- data/lib/kamal/configuration/output.rb +34 -0
- data/lib/kamal/configuration/proxy/boot.rb +124 -0
- data/lib/kamal/configuration/proxy/run.rb +152 -0
- data/lib/kamal/configuration/proxy.rb +156 -0
- data/lib/kamal/configuration/registry.rb +40 -0
- data/lib/kamal/configuration/role.rb +247 -0
- data/lib/kamal/configuration/servers.rb +25 -0
- data/lib/kamal/configuration/ssh.rb +76 -0
- data/lib/kamal/configuration/sshkit.rb +26 -0
- data/lib/kamal/configuration/validation.rb +27 -0
- data/lib/kamal/configuration/validator/accessory.rb +13 -0
- data/lib/kamal/configuration/validator/alias.rb +15 -0
- data/lib/kamal/configuration/validator/builder.rb +15 -0
- data/lib/kamal/configuration/validator/configuration.rb +6 -0
- data/lib/kamal/configuration/validator/env.rb +54 -0
- data/lib/kamal/configuration/validator/proxy.rb +47 -0
- data/lib/kamal/configuration/validator/registry.rb +27 -0
- data/lib/kamal/configuration/validator/role.rb +13 -0
- data/lib/kamal/configuration/validator/servers.rb +7 -0
- data/lib/kamal/configuration/validator.rb +251 -0
- data/lib/kamal/configuration/volume.rb +29 -0
- data/lib/kamal/configuration.rb +465 -0
- data/lib/kamal/docker.rb +30 -0
- data/lib/kamal/env_file.rb +44 -0
- data/lib/kamal/git.rb +37 -0
- data/lib/kamal/otel_shipper.rb +176 -0
- data/lib/kamal/output/base_logger.rb +29 -0
- data/lib/kamal/output/file_logger.rb +51 -0
- data/lib/kamal/output/formatter.rb +36 -0
- data/lib/kamal/output/otel_logger.rb +70 -0
- data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +59 -0
- data/lib/kamal/secrets/adapters/base.rb +33 -0
- data/lib/kamal/secrets/adapters/bitwarden.rb +81 -0
- data/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb +66 -0
- data/lib/kamal/secrets/adapters/doppler.rb +57 -0
- data/lib/kamal/secrets/adapters/enpass.rb +71 -0
- data/lib/kamal/secrets/adapters/gcp_secret_manager.rb +112 -0
- data/lib/kamal/secrets/adapters/last_pass.rb +40 -0
- data/lib/kamal/secrets/adapters/one_password.rb +104 -0
- data/lib/kamal/secrets/adapters/passbolt.rb +129 -0
- data/lib/kamal/secrets/adapters/test.rb +16 -0
- data/lib/kamal/secrets/adapters.rb +16 -0
- data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +47 -0
- data/lib/kamal/secrets.rb +53 -0
- data/lib/kamal/sshkit_with_ext.rb +273 -0
- data/lib/kamal/tags.rb +40 -0
- data/lib/kamal/utils/sensitive.rb +20 -0
- data/lib/kamal/utils.rb +110 -0
- data/lib/kamal/version.rb +3 -0
- data/lib/kamal.rb +15 -0
- metadata +388 -0
|
@@ -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
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Base
|
|
2
|
+
def requires_account?
|
|
3
|
+
false
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
private
|
|
7
|
+
def login(_account)
|
|
8
|
+
nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def fetch_secrets(secrets, from:, account: nil, session:)
|
|
12
|
+
{}.tap do |results|
|
|
13
|
+
get_from_secrets_manager(prefixed_secrets(secrets, from: from), account: account).each do |secret|
|
|
14
|
+
secret_name = secret["Name"]
|
|
15
|
+
secret_string = JSON.parse(secret["SecretString"])
|
|
16
|
+
|
|
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)
|
|
23
|
+
end
|
|
24
|
+
rescue JSON::ParserError
|
|
25
|
+
results["#{secret_name}"] = secret["SecretString"]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def get_from_secrets_manager(secrets, account: nil)
|
|
31
|
+
args = [ "aws", "secretsmanager", "batch-get-secret-value", "--secret-id-list" ] + secrets.map(&:shellescape)
|
|
32
|
+
args += [ "--profile", account.shellescape ] if account
|
|
33
|
+
args += [ "--output", "json" ]
|
|
34
|
+
cmd = args.join(" ")
|
|
35
|
+
|
|
36
|
+
`#{cmd}`.tap do |secrets|
|
|
37
|
+
raise RuntimeError, "Could not read #{secrets} from AWS Secrets Manager" unless $?.success?
|
|
38
|
+
|
|
39
|
+
secrets = JSON.parse(secrets)
|
|
40
|
+
|
|
41
|
+
return secrets["SecretValues"] unless secrets["Errors"].present?
|
|
42
|
+
|
|
43
|
+
raise RuntimeError, secrets["Errors"].map { |error| "#{error['SecretId']}: #{error['Message']}" }.join(" ")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def stringify_secret_value(value)
|
|
48
|
+
value.is_a?(String) ? value : JSON.dump(value)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def check_dependencies!
|
|
52
|
+
raise RuntimeError, "AWS CLI is not installed" unless cli_installed?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cli_installed?
|
|
56
|
+
`aws --version 2> /dev/null`
|
|
57
|
+
$?.success?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class Kamal::Secrets::Adapters::Base
|
|
2
|
+
delegate :optionize, to: Kamal::Utils
|
|
3
|
+
|
|
4
|
+
def fetch(secrets, account: nil, from: nil)
|
|
5
|
+
raise RuntimeError, "Missing required option '--account'" if requires_account? && account.blank?
|
|
6
|
+
|
|
7
|
+
check_dependencies!
|
|
8
|
+
|
|
9
|
+
session = login(account)
|
|
10
|
+
fetch_secrets(secrets, from: from, account: account, session: session)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def requires_account?
|
|
14
|
+
true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
def login(...)
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fetch_secrets(...)
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def check_dependencies!
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def prefixed_secrets(secrets, from:)
|
|
31
|
+
secrets.map { |secret| [ from, secret ].compact.join("/") }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base
|
|
2
|
+
private
|
|
3
|
+
def login(account)
|
|
4
|
+
status = run_command("status")
|
|
5
|
+
|
|
6
|
+
if status["status"] == "unauthenticated"
|
|
7
|
+
run_command("login #{account.shellescape}", raw: true)
|
|
8
|
+
status = run_command("status")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
if status["status"] == "locked"
|
|
12
|
+
session = run_command("unlock --raw", raw: true).presence
|
|
13
|
+
status = run_command("status", session: session)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
raise RuntimeError, "Failed to login to and unlock Bitwarden" unless status["status"] == "unlocked"
|
|
17
|
+
|
|
18
|
+
run_command("sync", session: session, raw: true)
|
|
19
|
+
raise RuntimeError, "Failed to sync Bitwarden" unless $?.success?
|
|
20
|
+
|
|
21
|
+
session
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fetch_secrets(secrets, from:, account:, session:)
|
|
25
|
+
{}.tap do |results|
|
|
26
|
+
items_fields(prefixed_secrets(secrets, from: from)).each do |item, fields|
|
|
27
|
+
item_json = run_command("get item #{item.shellescape}", session: session, raw: true)
|
|
28
|
+
raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success?
|
|
29
|
+
item_json = JSON.parse(item_json)
|
|
30
|
+
if fields.any?
|
|
31
|
+
results.merge! fetch_secrets_from_fields(fields, item, item_json)
|
|
32
|
+
elsif item_json.dig("login", "password")
|
|
33
|
+
results[item] = item_json.dig("login", "password")
|
|
34
|
+
elsif item_json["fields"]&.any?
|
|
35
|
+
fields = item_json["fields"].pluck("name")
|
|
36
|
+
results.merge! fetch_secrets_from_fields(fields, item, item_json)
|
|
37
|
+
else
|
|
38
|
+
raise RuntimeError, "Item #{item} is not a login type item and no fields were specified"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetch_secrets_from_fields(fields, item, item_json)
|
|
45
|
+
fields.to_h do |field|
|
|
46
|
+
item_field = item_json["fields"].find { |f| f["name"] == field }
|
|
47
|
+
raise RuntimeError, "Could not find field #{field} in item #{item} in Bitwarden" unless item_field
|
|
48
|
+
value = item_field["value"]
|
|
49
|
+
[ "#{item}/#{field}", value ]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def items_fields(secrets)
|
|
54
|
+
{}.tap do |items|
|
|
55
|
+
secrets.each do |secret|
|
|
56
|
+
item, field = secret.split("/")
|
|
57
|
+
items[item] ||= []
|
|
58
|
+
items[item] << field
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def signedin?(account)
|
|
64
|
+
run_command("status")["status"] != "unauthenticated"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def run_command(command, session: nil, raw: false)
|
|
68
|
+
full_command = [ *("BW_SESSION=#{session.shellescape}" if session), "bw", command ].join(" ")
|
|
69
|
+
result = `#{full_command}`.strip
|
|
70
|
+
raw ? result : JSON.parse(result)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def check_dependencies!
|
|
74
|
+
raise RuntimeError, "Bitwarden CLI is not installed" unless cli_installed?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def cli_installed?
|
|
78
|
+
`bw --version 2> /dev/null`
|
|
79
|
+
$?.success?
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
class Kamal::Secrets::Adapters::BitwardenSecretsManager < Kamal::Secrets::Adapters::Base
|
|
2
|
+
def requires_account?
|
|
3
|
+
false
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
private
|
|
7
|
+
LIST_ALL_SELECTOR = "all"
|
|
8
|
+
LIST_ALL_FROM_PROJECT_SUFFIX = "/all"
|
|
9
|
+
LIST_COMMAND = "secret list"
|
|
10
|
+
GET_COMMAND = "secret get"
|
|
11
|
+
|
|
12
|
+
def fetch_secrets(secrets, from:, account:, session:)
|
|
13
|
+
raise RuntimeError, "You must specify what to retrieve from Bitwarden Secrets Manager" if secrets.length == 0
|
|
14
|
+
|
|
15
|
+
secrets = prefixed_secrets(secrets, from: from)
|
|
16
|
+
command, project = extract_command_and_project(secrets)
|
|
17
|
+
|
|
18
|
+
{}.tap do |results|
|
|
19
|
+
if command.nil?
|
|
20
|
+
secrets.each do |secret_uuid|
|
|
21
|
+
item_json = run_command("#{GET_COMMAND} #{secret_uuid.shellescape}")
|
|
22
|
+
raise RuntimeError, "Could not read #{secret_uuid} from Bitwarden Secrets Manager" unless $?.success?
|
|
23
|
+
item_json = JSON.parse(item_json)
|
|
24
|
+
results[item_json["key"]] = item_json["value"]
|
|
25
|
+
end
|
|
26
|
+
else
|
|
27
|
+
items_json = run_command(command)
|
|
28
|
+
raise RuntimeError, "Could not read secrets from Bitwarden Secrets Manager" unless $?.success?
|
|
29
|
+
|
|
30
|
+
JSON.parse(items_json).each do |item_json|
|
|
31
|
+
results[item_json["key"]] = item_json["value"]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def extract_command_and_project(secrets)
|
|
38
|
+
if secrets.length == 1
|
|
39
|
+
if secrets[0] == LIST_ALL_SELECTOR
|
|
40
|
+
[ LIST_COMMAND, nil ]
|
|
41
|
+
elsif secrets[0].end_with?(LIST_ALL_FROM_PROJECT_SUFFIX)
|
|
42
|
+
project = secrets[0].split(LIST_ALL_FROM_PROJECT_SUFFIX).first
|
|
43
|
+
[ "#{LIST_COMMAND} #{project.shellescape}", project ]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run_command(command, session: nil)
|
|
49
|
+
full_command = [ "bws", command ].join(" ")
|
|
50
|
+
`#{full_command}`
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def login(account)
|
|
54
|
+
run_command("project list")
|
|
55
|
+
raise RuntimeError, "Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?" unless $?.success?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def check_dependencies!
|
|
59
|
+
raise RuntimeError, "Bitwarden Secrets Manager CLI is not installed" unless cli_installed?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def cli_installed?
|
|
63
|
+
`bws --version 2> /dev/null`
|
|
64
|
+
$?.success?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base
|
|
2
|
+
def requires_account?
|
|
3
|
+
false
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
private
|
|
7
|
+
def login(*)
|
|
8
|
+
unless loggedin?
|
|
9
|
+
`doppler login -y`
|
|
10
|
+
raise RuntimeError, "Failed to login to Doppler" unless $?.success?
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def loggedin?
|
|
15
|
+
`doppler me --json 2> /dev/null`
|
|
16
|
+
$?.success?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fetch_secrets(secrets, from:, **)
|
|
20
|
+
secrets = prefixed_secrets(secrets, from: from)
|
|
21
|
+
flags = secrets_get_flags(secrets)
|
|
22
|
+
|
|
23
|
+
secret_names = secrets.collect { |s| s.split("/").last }
|
|
24
|
+
|
|
25
|
+
items = `doppler secrets get #{secret_names.map(&:shellescape).join(" ")} --json #{flags}`
|
|
26
|
+
raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success?
|
|
27
|
+
|
|
28
|
+
items = JSON.parse(items)
|
|
29
|
+
|
|
30
|
+
items.transform_values { |value| value["computed"] }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def secrets_get_flags(secrets)
|
|
34
|
+
unless service_token_set?
|
|
35
|
+
project, config, _ = secrets.first.split("/")
|
|
36
|
+
|
|
37
|
+
unless project && config
|
|
38
|
+
raise RuntimeError, "Missing project or config from '--from=project/config' option"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
project_and_config_flags = "-p #{project.shellescape} -c #{config.shellescape}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def service_token_set?
|
|
46
|
+
ENV["DOPPLER_TOKEN"] && ENV["DOPPLER_TOKEN"][0, 5] == "dp.st"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def check_dependencies!
|
|
50
|
+
raise RuntimeError, "Doppler CLI is not installed" unless cli_installed?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def cli_installed?
|
|
54
|
+
`doppler --version 2> /dev/null`
|
|
55
|
+
$?.success?
|
|
56
|
+
end
|
|
57
|
+
end
|