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.
Files changed (142) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +13 -0
  4. data/bin/dash +18 -0
  5. data/bin/kamal +18 -0
  6. data/lib/kamal/cli/accessory.rb +342 -0
  7. data/lib/kamal/cli/alias/command.rb +10 -0
  8. data/lib/kamal/cli/app/assets.rb +24 -0
  9. data/lib/kamal/cli/app/boot.rb +126 -0
  10. data/lib/kamal/cli/app/error_pages.rb +33 -0
  11. data/lib/kamal/cli/app/ssl_certificates.rb +28 -0
  12. data/lib/kamal/cli/app.rb +368 -0
  13. data/lib/kamal/cli/base.rb +324 -0
  14. data/lib/kamal/cli/build/clone.rb +59 -0
  15. data/lib/kamal/cli/build/port_forwarding.rb +66 -0
  16. data/lib/kamal/cli/build.rb +242 -0
  17. data/lib/kamal/cli/healthcheck/barrier.rb +33 -0
  18. data/lib/kamal/cli/healthcheck/error.rb +2 -0
  19. data/lib/kamal/cli/healthcheck/poller.rb +42 -0
  20. data/lib/kamal/cli/lock.rb +34 -0
  21. data/lib/kamal/cli/main.rb +299 -0
  22. data/lib/kamal/cli/proxy.rb +419 -0
  23. data/lib/kamal/cli/prune.rb +34 -0
  24. data/lib/kamal/cli/registry.rb +49 -0
  25. data/lib/kamal/cli/secrets.rb +50 -0
  26. data/lib/kamal/cli/server.rb +70 -0
  27. data/lib/kamal/cli/templates/deploy.yml +102 -0
  28. data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +3 -0
  29. data/lib/kamal/cli/templates/sample_hooks/post-app-boot.sample +3 -0
  30. data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +14 -0
  31. data/lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample +3 -0
  32. data/lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample +3 -0
  33. data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +51 -0
  34. data/lib/kamal/cli/templates/sample_hooks/pre-connect.sample +47 -0
  35. data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +122 -0
  36. data/lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample +3 -0
  37. data/lib/kamal/cli/templates/secrets +22 -0
  38. data/lib/kamal/cli.rb +9 -0
  39. data/lib/kamal/commander/specifics.rb +62 -0
  40. data/lib/kamal/commander.rb +230 -0
  41. data/lib/kamal/commands/accessory/proxy.rb +16 -0
  42. data/lib/kamal/commands/accessory.rb +118 -0
  43. data/lib/kamal/commands/app/assets.rb +51 -0
  44. data/lib/kamal/commands/app/containers.rb +31 -0
  45. data/lib/kamal/commands/app/error_pages.rb +9 -0
  46. data/lib/kamal/commands/app/execution.rb +38 -0
  47. data/lib/kamal/commands/app/images.rb +13 -0
  48. data/lib/kamal/commands/app/logging.rb +28 -0
  49. data/lib/kamal/commands/app/proxy.rb +32 -0
  50. data/lib/kamal/commands/app.rb +125 -0
  51. data/lib/kamal/commands/auditor.rb +39 -0
  52. data/lib/kamal/commands/base.rb +147 -0
  53. data/lib/kamal/commands/builder/base.rb +143 -0
  54. data/lib/kamal/commands/builder/clone.rb +32 -0
  55. data/lib/kamal/commands/builder/cloud.rb +22 -0
  56. data/lib/kamal/commands/builder/hybrid.rb +21 -0
  57. data/lib/kamal/commands/builder/local.rb +20 -0
  58. data/lib/kamal/commands/builder/pack.rb +46 -0
  59. data/lib/kamal/commands/builder/remote.rb +75 -0
  60. data/lib/kamal/commands/builder.rb +54 -0
  61. data/lib/kamal/commands/docker.rb +50 -0
  62. data/lib/kamal/commands/hook.rb +20 -0
  63. data/lib/kamal/commands/loadbalancer.rb +130 -0
  64. data/lib/kamal/commands/lock.rb +70 -0
  65. data/lib/kamal/commands/proxy.rb +150 -0
  66. data/lib/kamal/commands/prune.rb +38 -0
  67. data/lib/kamal/commands/registry.rb +38 -0
  68. data/lib/kamal/commands/server.rb +15 -0
  69. data/lib/kamal/commands.rb +2 -0
  70. data/lib/kamal/configuration/accessory.rb +280 -0
  71. data/lib/kamal/configuration/alias.rb +15 -0
  72. data/lib/kamal/configuration/boot.rb +29 -0
  73. data/lib/kamal/configuration/builder.rb +218 -0
  74. data/lib/kamal/configuration/docs/accessory.yml +160 -0
  75. data/lib/kamal/configuration/docs/alias.yml +29 -0
  76. data/lib/kamal/configuration/docs/boot.yml +21 -0
  77. data/lib/kamal/configuration/docs/builder.yml +132 -0
  78. data/lib/kamal/configuration/docs/configuration.yml +228 -0
  79. data/lib/kamal/configuration/docs/env.yml +118 -0
  80. data/lib/kamal/configuration/docs/logging.yml +21 -0
  81. data/lib/kamal/configuration/docs/output.yml +25 -0
  82. data/lib/kamal/configuration/docs/proxy.yml +207 -0
  83. data/lib/kamal/configuration/docs/registry.yml +64 -0
  84. data/lib/kamal/configuration/docs/role.yml +54 -0
  85. data/lib/kamal/configuration/docs/servers.yml +27 -0
  86. data/lib/kamal/configuration/docs/ssh.yml +81 -0
  87. data/lib/kamal/configuration/docs/sshkit.yml +31 -0
  88. data/lib/kamal/configuration/env/tag.rb +13 -0
  89. data/lib/kamal/configuration/env.rb +42 -0
  90. data/lib/kamal/configuration/loadbalancer.rb +34 -0
  91. data/lib/kamal/configuration/logging.rb +33 -0
  92. data/lib/kamal/configuration/output.rb +34 -0
  93. data/lib/kamal/configuration/proxy/boot.rb +124 -0
  94. data/lib/kamal/configuration/proxy/run.rb +152 -0
  95. data/lib/kamal/configuration/proxy.rb +156 -0
  96. data/lib/kamal/configuration/registry.rb +40 -0
  97. data/lib/kamal/configuration/role.rb +247 -0
  98. data/lib/kamal/configuration/servers.rb +25 -0
  99. data/lib/kamal/configuration/ssh.rb +76 -0
  100. data/lib/kamal/configuration/sshkit.rb +26 -0
  101. data/lib/kamal/configuration/validation.rb +27 -0
  102. data/lib/kamal/configuration/validator/accessory.rb +13 -0
  103. data/lib/kamal/configuration/validator/alias.rb +15 -0
  104. data/lib/kamal/configuration/validator/builder.rb +15 -0
  105. data/lib/kamal/configuration/validator/configuration.rb +6 -0
  106. data/lib/kamal/configuration/validator/env.rb +54 -0
  107. data/lib/kamal/configuration/validator/proxy.rb +47 -0
  108. data/lib/kamal/configuration/validator/registry.rb +27 -0
  109. data/lib/kamal/configuration/validator/role.rb +13 -0
  110. data/lib/kamal/configuration/validator/servers.rb +7 -0
  111. data/lib/kamal/configuration/validator.rb +251 -0
  112. data/lib/kamal/configuration/volume.rb +29 -0
  113. data/lib/kamal/configuration.rb +465 -0
  114. data/lib/kamal/docker.rb +30 -0
  115. data/lib/kamal/env_file.rb +44 -0
  116. data/lib/kamal/git.rb +37 -0
  117. data/lib/kamal/otel_shipper.rb +176 -0
  118. data/lib/kamal/output/base_logger.rb +29 -0
  119. data/lib/kamal/output/file_logger.rb +51 -0
  120. data/lib/kamal/output/formatter.rb +36 -0
  121. data/lib/kamal/output/otel_logger.rb +70 -0
  122. data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +59 -0
  123. data/lib/kamal/secrets/adapters/base.rb +33 -0
  124. data/lib/kamal/secrets/adapters/bitwarden.rb +81 -0
  125. data/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb +66 -0
  126. data/lib/kamal/secrets/adapters/doppler.rb +57 -0
  127. data/lib/kamal/secrets/adapters/enpass.rb +71 -0
  128. data/lib/kamal/secrets/adapters/gcp_secret_manager.rb +112 -0
  129. data/lib/kamal/secrets/adapters/last_pass.rb +40 -0
  130. data/lib/kamal/secrets/adapters/one_password.rb +104 -0
  131. data/lib/kamal/secrets/adapters/passbolt.rb +129 -0
  132. data/lib/kamal/secrets/adapters/test.rb +16 -0
  133. data/lib/kamal/secrets/adapters.rb +16 -0
  134. data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +47 -0
  135. data/lib/kamal/secrets.rb +53 -0
  136. data/lib/kamal/sshkit_with_ext.rb +273 -0
  137. data/lib/kamal/tags.rb +40 -0
  138. data/lib/kamal/utils/sensitive.rb +20 -0
  139. data/lib/kamal/utils.rb +110 -0
  140. data/lib/kamal/version.rb +3 -0
  141. data/lib/kamal.rb +15 -0
  142. 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