deployangel 0.1.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/CHANGELOG.md +33 -0
- data/LICENSE.txt +21 -0
- data/README.md +293 -0
- data/exe/deployangel +6 -0
- data/lib/deployangel/agent.rb +242 -0
- data/lib/deployangel/capistrano/steps.rb +61 -0
- data/lib/deployangel/capistrano/tasks.rake +54 -0
- data/lib/deployangel/capistrano.rb +13 -0
- data/lib/deployangel/ci_environment.rb +46 -0
- data/lib/deployangel/cli/formatter.rb +96 -0
- data/lib/deployangel/cli.rb +257 -0
- data/lib/deployangel/client.rb +92 -0
- data/lib/deployangel/configuration.rb +49 -0
- data/lib/deployangel/core/aggregator.rb +181 -0
- data/lib/deployangel/core/buffer.rb +43 -0
- data/lib/deployangel/core/fingerprint.rb +91 -0
- data/lib/deployangel/core/histogram.rb +42 -0
- data/lib/deployangel/core/instance.rb +30 -0
- data/lib/deployangel/core/protocol.rb +60 -0
- data/lib/deployangel/core/release.rb +88 -0
- data/lib/deployangel/core/transport.rb +66 -0
- data/lib/deployangel/fork_hook.rb +15 -0
- data/lib/deployangel/mcp.rb +205 -0
- data/lib/deployangel/rails/active_job.rb +70 -0
- data/lib/deployangel/rails/error_subscriber.rb +19 -0
- data/lib/deployangel/rails/http.rb +65 -0
- data/lib/deployangel/rails/metadata.rb +96 -0
- data/lib/deployangel/rails/railtie.rb +35 -0
- data/lib/deployangel/sidekiq.rb +71 -0
- data/lib/deployangel/verification_waiter.rb +97 -0
- data/lib/deployangel/version.rb +5 -0
- data/lib/deployangel.rb +118 -0
- metadata +81 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module DeployAngel
|
|
6
|
+
module MCP
|
|
7
|
+
# A Model Context Protocol server over stdio (JSON-RPC 2.0, one message
|
|
8
|
+
# per line) so coding agents can ask whether their release passed
|
|
9
|
+
# production. stdout carries only protocol messages.
|
|
10
|
+
#
|
|
11
|
+
# The tools are read-only with respect to production: nothing here rolls
|
|
12
|
+
# back, restarts, or changes customer infrastructure.
|
|
13
|
+
class Server
|
|
14
|
+
PROTOCOL_VERSIONS = %w[2025-06-18 2025-03-26 2024-11-05].freeze
|
|
15
|
+
MAX_WAIT_SECONDS = 300
|
|
16
|
+
|
|
17
|
+
INSTRUCTIONS = <<~TEXT.freeze
|
|
18
|
+
DeployAngel verifies deployments in production. After deploying, call wait_for_verification
|
|
19
|
+
with until "initial" (about 15 minutes) or "verdict". Verdicts: verified means cleared, so you may
|
|
20
|
+
report the release as successful. inconclusive means NOT verified: never report it as success.
|
|
21
|
+
failed means production regressed: read the findings and exceptions and investigate.
|
|
22
|
+
Findings are deterministic evidence; any "investigation" field is AI inference. These tools cannot
|
|
23
|
+
change production. Do not roll back or change production without explicit approval.
|
|
24
|
+
TEXT
|
|
25
|
+
|
|
26
|
+
TARGET_PROPERTIES = {
|
|
27
|
+
"commit" => { "type" => "string", "description" => "Commit SHA (prefix of 7+ characters is fine)." },
|
|
28
|
+
"version" => { "type" => "string", "description" => "Release version, e.g. v184." },
|
|
29
|
+
"deployment_id" => { "type" => "string", "description" => "DeployAngel deployment ID." },
|
|
30
|
+
"latest" => { "type" => "boolean", "description" => "Use the most recent deployment." }
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
ERROR_CODES = { parse: -32_700, invalid: -32_600, method: -32_601, params: -32_602, internal: -32_603 }.freeze
|
|
34
|
+
|
|
35
|
+
def initialize(client:, input: $stdin, output: $stdout, error_output: $stderr,
|
|
36
|
+
sleeper: ->(seconds) { sleep(seconds) }, clock: -> { Time.now }, git_head: -> {})
|
|
37
|
+
@client = client
|
|
38
|
+
@input = input
|
|
39
|
+
@output = output
|
|
40
|
+
@error_output = error_output
|
|
41
|
+
@sleeper = sleeper
|
|
42
|
+
@clock = clock
|
|
43
|
+
@git_head = git_head
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def run
|
|
47
|
+
@input.each_line do |line|
|
|
48
|
+
next if line.strip.empty?
|
|
49
|
+
|
|
50
|
+
response = handle_line(line)
|
|
51
|
+
next unless response
|
|
52
|
+
|
|
53
|
+
@output.puts(JSON.generate(response))
|
|
54
|
+
@output.flush
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def handle_line(line)
|
|
59
|
+
message = JSON.parse(line)
|
|
60
|
+
return error_response(nil, :invalid, "expected a JSON object") unless message.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
handle(message)
|
|
63
|
+
rescue JSON::ParserError
|
|
64
|
+
error_response(nil, :parse, "invalid JSON")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def handle(message)
|
|
68
|
+
id = message["id"]
|
|
69
|
+
method = message["method"]
|
|
70
|
+
return nil if id.nil? # notifications, including notifications/initialized
|
|
71
|
+
|
|
72
|
+
case method
|
|
73
|
+
when "initialize" then result(id, initialize_result(message.dig("params", "protocolVersion")))
|
|
74
|
+
when "ping" then result(id, {})
|
|
75
|
+
when "tools/list" then result(id, { "tools" => tools })
|
|
76
|
+
when "tools/call" then result(id, call_tool(message.dig("params", "name"), message.dig("params", "arguments") || {}))
|
|
77
|
+
else error_response(id, :method, "method not found: #{method}")
|
|
78
|
+
end
|
|
79
|
+
rescue ArgumentError => e
|
|
80
|
+
error_response(id, :params, e.message)
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
@error_output.puts("deployangel mcp: #{e.class}: #{e.message}")
|
|
83
|
+
error_response(id, :internal, "internal error")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def tools
|
|
87
|
+
list = [
|
|
88
|
+
tool("get_verification", "Current verdict document for a deployment: state, verdict, confidence, coverage, " \
|
|
89
|
+
"findings, new exceptions, clearance report, and what is still watched. Defaults to the current git HEAD.",
|
|
90
|
+
TARGET_PROPERTIES),
|
|
91
|
+
tool("wait_for_verification", "Wait for a deployment's verification. until=initial returns at the 15-minute " \
|
|
92
|
+
"initial check (\"no problems so far\" is NOT clearance); until=verdict waits for verified, failed, or " \
|
|
93
|
+
"inconclusive. Returns the in-progress document if timeout_seconds (max 300) is reached; call again " \
|
|
94
|
+
"while it is still in progress. Inconclusive means not verified.",
|
|
95
|
+
TARGET_PROPERTIES.merge(
|
|
96
|
+
"until" => { "type" => "string", "enum" => %w[initial verdict], "default" => "initial" },
|
|
97
|
+
"timeout_seconds" => { "type" => "integer", "minimum" => 1, "maximum" => MAX_WAIT_SECONDS, "default" => MAX_WAIT_SECONDS }
|
|
98
|
+
)),
|
|
99
|
+
tool("list_deployments", "Recent deployments with their verification state and verdict.",
|
|
100
|
+
{ "limit" => { "type" => "integer", "minimum" => 1, "maximum" => 50, "default" => 10 } }),
|
|
101
|
+
tool("get_exception", "Sanitized details and the application stack trace for an exception fingerprint.",
|
|
102
|
+
{ "fingerprint" => { "type" => "string" } }, required: %w[fingerprint]),
|
|
103
|
+
tool("list_late_regressions", "Failures found after a release was cleared, on paths the clearance did not cover.",
|
|
104
|
+
{ "since" => { "type" => "string", "description" => "ISO 8601 time" },
|
|
105
|
+
"limit" => { "type" => "integer", "minimum" => 1, "maximum" => 50, "default" => 10 } })
|
|
106
|
+
]
|
|
107
|
+
if scopes.include?("deployments")
|
|
108
|
+
list << tool("register_deployment", "Register a deployment so it is verified (manual and CI deploys).",
|
|
109
|
+
{ "commit" => { "type" => "string" }, "version" => { "type" => "string" },
|
|
110
|
+
"kind" => { "type" => "string", "enum" => %w[code config rollback promotion] } })
|
|
111
|
+
end
|
|
112
|
+
list
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
def initialize_result(requested)
|
|
117
|
+
{
|
|
118
|
+
"protocolVersion" => PROTOCOL_VERSIONS.include?(requested) ? requested : PROTOCOL_VERSIONS.first,
|
|
119
|
+
"capabilities" => { "tools" => { "listChanged" => false } },
|
|
120
|
+
"serverInfo" => { "name" => "deployangel", "version" => DeployAngel::VERSION },
|
|
121
|
+
"instructions" => INSTRUCTIONS
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def call_tool(name, arguments)
|
|
126
|
+
content =
|
|
127
|
+
case name
|
|
128
|
+
when "get_verification" then waiter.wait(target(arguments), wait: false).then { |o| verification_content(o) }
|
|
129
|
+
when "wait_for_verification" then wait_content(arguments)
|
|
130
|
+
when "list_deployments" then @client.deployments(limit: arguments.fetch("limit", 10))
|
|
131
|
+
when "get_exception" then @client.exception(arguments.fetch("fingerprint") { raise ArgumentError, "fingerprint is required" })
|
|
132
|
+
when "list_late_regressions" then @client.late_regressions(since: arguments["since"], limit: arguments.fetch("limit", 10))
|
|
133
|
+
when "register_deployment"
|
|
134
|
+
raise ArgumentError, "register_deployment is not available for this token" unless scopes.include?("deployments")
|
|
135
|
+
|
|
136
|
+
@client.register_deployment(commit: arguments["commit"], version: arguments["version"], kind: arguments["kind"])
|
|
137
|
+
else raise ArgumentError, "unknown tool: #{name}"
|
|
138
|
+
end
|
|
139
|
+
{ "content" => [ { "type" => "text", "text" => JSON.pretty_generate(content) } ],
|
|
140
|
+
"structuredContent" => content, "isError" => false }
|
|
141
|
+
rescue Client::Error => e
|
|
142
|
+
{ "content" => [ { "type" => "text", "text" => "DeployAngel API error: #{e.message}" } ], "isError" => true }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def wait_content(arguments)
|
|
146
|
+
until_mode = arguments.fetch("until", "initial")
|
|
147
|
+
raise ArgumentError, "until must be initial or verdict" unless %w[initial verdict].include?(until_mode)
|
|
148
|
+
|
|
149
|
+
timeout = arguments.fetch("timeout_seconds", MAX_WAIT_SECONDS).to_i.clamp(1, MAX_WAIT_SECONDS)
|
|
150
|
+
verification_content(waiter.wait(target(arguments), until_mode: until_mode, timeout: timeout))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def verification_content(outcome)
|
|
154
|
+
return { "found" => false, "note" => "No matching deployment is registered yet." } if outcome.not_found
|
|
155
|
+
|
|
156
|
+
{ "exit_code" => outcome.exit_code, "meaning" => meaning(outcome.exit_code),
|
|
157
|
+
"in_progress" => outcome.exit_code == VerificationWaiter::TIMED_OUT, "verification" => outcome.document }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def meaning(code)
|
|
161
|
+
{
|
|
162
|
+
0 => "verified: the release is cleared",
|
|
163
|
+
1 => "failed: production regressed",
|
|
164
|
+
2 => "inconclusive: NOT verified; do not report success",
|
|
165
|
+
3 => "still in progress; call wait_for_verification again",
|
|
166
|
+
6 => "initial check: no problems so far, NOT cleared yet",
|
|
167
|
+
7 => "initial check: warnings present, NOT cleared"
|
|
168
|
+
}.fetch(code, "unknown")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def target(arguments)
|
|
172
|
+
return { deployment_id: arguments["deployment_id"].to_s } if arguments["deployment_id"]
|
|
173
|
+
return { version: arguments["version"].to_s } if arguments["version"]
|
|
174
|
+
return { commit: arguments["commit"].to_s.downcase } if arguments["commit"]
|
|
175
|
+
return { latest: true } if arguments["latest"]
|
|
176
|
+
|
|
177
|
+
head = @git_head.call
|
|
178
|
+
head ? { commit: head.downcase } : { latest: true }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def waiter
|
|
182
|
+
VerificationWaiter.new(client: @client, sleeper: @sleeper, clock: @clock)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def scopes
|
|
186
|
+
@scopes ||= Array(@client.token_info["scopes"])
|
|
187
|
+
rescue Client::Error
|
|
188
|
+
[]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def tool(name, description, properties, required: [])
|
|
192
|
+
{ "name" => name, "description" => description,
|
|
193
|
+
"inputSchema" => { "type" => "object", "properties" => properties, "required" => required } }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def result(id, payload)
|
|
197
|
+
{ "jsonrpc" => "2.0", "id" => id, "result" => payload }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def error_response(id, kind, message)
|
|
201
|
+
{ "jsonrpc" => "2.0", "id" => id, "error" => { "code" => ERROR_CODES.fetch(kind), "message" => message } }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeployAngel
|
|
4
|
+
module Rails
|
|
5
|
+
# Instruments every ActiveJob adapter (Solid Queue, Sidekiq, GoodJob,
|
|
6
|
+
# ...) through ActiveSupport::Notifications.
|
|
7
|
+
#
|
|
8
|
+
# Failures handled by retry_on or discard_on never surface on the
|
|
9
|
+
# perform event, so enqueue_retry, discard, and retry_stopped mark the
|
|
10
|
+
# job while it performs. Everything is then counted exactly once when
|
|
11
|
+
# perform finishes.
|
|
12
|
+
module ActiveJob
|
|
13
|
+
STATE = :@__deployangel_job_state
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def install
|
|
18
|
+
return if @installed || !defined?(::ActiveSupport::Notifications)
|
|
19
|
+
|
|
20
|
+
@installed = true
|
|
21
|
+
DeployAngel.add_capability("jobs")
|
|
22
|
+
subscribe("perform_start.active_job") { |payload| state(payload)[:queue_latency_ms] = queue_latency_ms(payload[:job]) }
|
|
23
|
+
subscribe("enqueue_retry.active_job") { |payload| state(payload).merge!(failed: true, error: payload[:error]) }
|
|
24
|
+
subscribe("discard.active_job") { |payload| state(payload).merge!(failed: true, discarded: true, error: payload[:error]) }
|
|
25
|
+
subscribe("retry_stopped.active_job") { |payload| state(payload).merge!(failed: true, discarded: true, error: payload[:error]) }
|
|
26
|
+
::ActiveSupport::Notifications.subscribe("perform.active_job") { |event| record(event) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def record(event)
|
|
30
|
+
return unless DeployAngel.recording?
|
|
31
|
+
|
|
32
|
+
job = event.payload[:job]
|
|
33
|
+
state = job&.instance_variable_get(STATE) || {}
|
|
34
|
+
error = event.payload[:exception_object] || state[:error]
|
|
35
|
+
DeployAngel.record_exception(error, source: "job_class:#{job.class.name}") if error
|
|
36
|
+
DeployAngel.record_job(
|
|
37
|
+
job_class: job.class.name,
|
|
38
|
+
duration_ms: event.duration,
|
|
39
|
+
failed: state[:failed] || !event.payload[:exception_object].nil?,
|
|
40
|
+
discarded: state[:discarded] || false,
|
|
41
|
+
queue_latency_ms: state[:queue_latency_ms]
|
|
42
|
+
)
|
|
43
|
+
rescue StandardError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def subscribe(name)
|
|
48
|
+
::ActiveSupport::Notifications.subscribe(name) do |*, payload|
|
|
49
|
+
yield payload if DeployAngel.recording? && payload[:job]
|
|
50
|
+
rescue StandardError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def state(payload)
|
|
56
|
+
job = payload[:job]
|
|
57
|
+
job.instance_variable_get(STATE) || job.instance_variable_set(STATE, {})
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# From when the job became runnable: its scheduled time, else when it
|
|
61
|
+
# was enqueued.
|
|
62
|
+
def queue_latency_ms(job)
|
|
63
|
+
runnable_at = job.scheduled_at || job.enqueued_at
|
|
64
|
+
return unless runnable_at
|
|
65
|
+
|
|
66
|
+
[ (Time.now - runnable_at) * 1000.0, 0 ].max
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeployAngel
|
|
4
|
+
module Rails
|
|
5
|
+
# Receives Rails.error reports, for handled errors only (Rails.error.handle
|
|
6
|
+
# and Rails.error.report(handled: true)), shown for context.
|
|
7
|
+
#
|
|
8
|
+
# Rails also reports unhandled request and job exceptions here, before the
|
|
9
|
+
# middleware and job instrumentation see them. Those paths record them
|
|
10
|
+
# with their route or job class, so unhandled reports are skipped here.
|
|
11
|
+
class ErrorSubscriber
|
|
12
|
+
def report(error, handled:, **)
|
|
13
|
+
DeployAngel.record_exception(error, handled: true) if handled
|
|
14
|
+
rescue StandardError
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeployAngel
|
|
4
|
+
module Rails
|
|
5
|
+
# Rack middleware at the top of the stack, so it sees the final status
|
|
6
|
+
# after Rails renders exceptions. Records the matched route pattern,
|
|
7
|
+
# never the raw path, which keeps IDs out of route keys.
|
|
8
|
+
class Http
|
|
9
|
+
FORMAT_SUFFIX = "(.:format)"
|
|
10
|
+
|
|
11
|
+
def initialize(app)
|
|
12
|
+
@app = app
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(env)
|
|
16
|
+
return @app.call(env) unless DeployAngel.recording?
|
|
17
|
+
|
|
18
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
19
|
+
begin
|
|
20
|
+
status, headers, body = @app.call(env)
|
|
21
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
|
|
22
|
+
record(env, 500, started, unhandled: true, exception: e)
|
|
23
|
+
raise
|
|
24
|
+
end
|
|
25
|
+
# Rails renders some exceptions as 4xx (routing errors, RecordNotFound,
|
|
26
|
+
# ParameterMissing); only those that end in a 5xx count as unhandled.
|
|
27
|
+
exception = env["action_dispatch.exception"] if status.to_i >= 500
|
|
28
|
+
record(env, status, started, unhandled: !exception.nil?, exception: exception)
|
|
29
|
+
[ status, headers, body ]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
def record(env, status, started, unhandled:, exception: nil)
|
|
34
|
+
route = route_key(env)
|
|
35
|
+
# Unrouted successes are static files and similar; unrouted
|
|
36
|
+
# errors (such as routing 404s) still count.
|
|
37
|
+
return if route.nil? && status.to_i < 400
|
|
38
|
+
|
|
39
|
+
key = route || "#{env["REQUEST_METHOD"]} unmatched"
|
|
40
|
+
DeployAngel.record_exception(exception, source: "route:#{key}") if exception
|
|
41
|
+
DeployAngel.record_request(
|
|
42
|
+
route_key: key,
|
|
43
|
+
status: status.to_i,
|
|
44
|
+
duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0,
|
|
45
|
+
unhandled: unhandled
|
|
46
|
+
)
|
|
47
|
+
rescue StandardError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def route_key(env)
|
|
52
|
+
method = env["REQUEST_METHOD"]
|
|
53
|
+
if (pattern = route_pattern(env))
|
|
54
|
+
"#{method} #{pattern.delete_suffix(FORMAT_SUFFIX)}"
|
|
55
|
+
elsif (params = env["action_dispatch.request.path_parameters"]) && params[:controller]
|
|
56
|
+
"#{method} #{params[:controller]}##{params[:action]}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def route_pattern(env)
|
|
61
|
+
env["action_dispatch.route_uri_pattern"] || env["action_dispatch.route"]&.path&.spec&.to_s
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "erb"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module DeployAngel
|
|
8
|
+
module Rails
|
|
9
|
+
# What the application contains, sent once per process: the route table,
|
|
10
|
+
# job classes, declared recurring schedules, critical flows, and file
|
|
11
|
+
# digests. Paths and hashes only; source code never leaves the
|
|
12
|
+
# application.
|
|
13
|
+
class Metadata
|
|
14
|
+
DIGEST_GLOBS = %w[app/**/* config/**/* lib/**/* db/migrate/**/* Gemfile.lock].freeze
|
|
15
|
+
MAX_FILES = 20_000
|
|
16
|
+
DIGEST_LENGTH = 16
|
|
17
|
+
FORMAT_SUFFIX = "(.:format)"
|
|
18
|
+
|
|
19
|
+
def initialize(app:, config:, root:)
|
|
20
|
+
@app = app
|
|
21
|
+
@config = config
|
|
22
|
+
@root = root
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_protocol
|
|
26
|
+
{
|
|
27
|
+
"routes" => routes,
|
|
28
|
+
"job_classes" => job_classes,
|
|
29
|
+
"schedules" => schedules,
|
|
30
|
+
"critical_flows" => @config.critical_flows.transform_keys(&:to_s).transform_values { |items| Array(items).map(&:to_s) },
|
|
31
|
+
"file_manifest" => file_manifest.slice("hash", "count", "truncated")
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def files
|
|
36
|
+
file_manifest["files"]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def routes
|
|
40
|
+
@app.routes.routes.flat_map do |route|
|
|
41
|
+
controller = route.defaults[:controller]
|
|
42
|
+
next [] if controller.nil? || controller.start_with?("rails/", "active_storage/", "action_mailbox/")
|
|
43
|
+
|
|
44
|
+
path = route.path.spec.to_s.delete_suffix(FORMAT_SUFFIX)
|
|
45
|
+
route.verb.to_s.split("|").reject(&:empty?).map do |verb|
|
|
46
|
+
{ "key" => "#{verb} #{path}", "controller" => controller, "action" => route.defaults[:action].to_s }
|
|
47
|
+
end
|
|
48
|
+
end.uniq { |route| route["key"] }
|
|
49
|
+
rescue StandardError
|
|
50
|
+
[]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def job_classes
|
|
54
|
+
return [] unless defined?(::ActiveJob::Base)
|
|
55
|
+
|
|
56
|
+
::ActiveJob::Base.descendants.filter_map(&:name).reject { |name| name == "ApplicationJob" }.sort
|
|
57
|
+
rescue StandardError
|
|
58
|
+
[]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Solid Queue recurring tasks for the current environment.
|
|
62
|
+
def schedules
|
|
63
|
+
path = File.join(@root, "config", "recurring.yml")
|
|
64
|
+
return [] unless File.file?(path)
|
|
65
|
+
|
|
66
|
+
config = YAML.safe_load(ERB.new(File.read(path)).result, aliases: true) || {}
|
|
67
|
+
tasks = config.key?(::Rails.env) ? config[::Rails.env] : config
|
|
68
|
+
Array(tasks).filter_map do |key, task|
|
|
69
|
+
next unless task.is_a?(Hash) && task["schedule"]
|
|
70
|
+
|
|
71
|
+
{ "key" => key.to_s, "class" => task["class"]&.to_s, "schedule" => task["schedule"].to_s, "source" => "solid_queue" }
|
|
72
|
+
end
|
|
73
|
+
rescue StandardError
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def file_manifest
|
|
78
|
+
@file_manifest ||= begin
|
|
79
|
+
if @config.file_digests
|
|
80
|
+
paths = DIGEST_GLOBS.flat_map { |glob| Dir.glob(File.join(@root, glob)) }.select { |path| File.file?(path) }.sort
|
|
81
|
+
truncated = paths.size > MAX_FILES
|
|
82
|
+
files = paths.first(MAX_FILES).to_h do |path|
|
|
83
|
+
[ path.delete_prefix(@root).delete_prefix("/"), Digest::SHA256.file(path).hexdigest[0, DIGEST_LENGTH] ]
|
|
84
|
+
end
|
|
85
|
+
hash = Digest::SHA256.hexdigest(files.map { |path, digest| "#{path}:#{digest}" }.join("\n"))
|
|
86
|
+
{ "hash" => hash, "count" => files.size, "truncated" => truncated, "files" => files }
|
|
87
|
+
else
|
|
88
|
+
{ "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
rescue StandardError
|
|
92
|
+
{ "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module DeployAngel
|
|
6
|
+
module Rails
|
|
7
|
+
class Railtie < ::Rails::Railtie
|
|
8
|
+
initializer "deployangel.middleware" do |app|
|
|
9
|
+
app.config.middleware.insert(0, DeployAngel::Rails::Http)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
initializer "deployangel.jobs" do
|
|
13
|
+
ActiveSupport.on_load(:active_job) { DeployAngel::Rails::ActiveJob.install }
|
|
14
|
+
DeployAngel::Sidekiq.install
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Handled reports (Rails.error.handle / Rails.error.report) are shown for
|
|
18
|
+
# context; only unhandled exceptions count toward verdicts.
|
|
19
|
+
initializer "deployangel.error_reporter" do |app|
|
|
20
|
+
app.executor.error_reporter&.subscribe(DeployAngel::Rails::ErrorSubscriber.new) if app.executor.respond_to?(:error_reporter)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
config.after_initialize do |app|
|
|
24
|
+
agent = DeployAngel.start(
|
|
25
|
+
environment: ::Rails.env,
|
|
26
|
+
root: ::Rails.root.to_s,
|
|
27
|
+
framework: "rails",
|
|
28
|
+
framework_version: ::Rails.version,
|
|
29
|
+
logger: ::Rails.logger
|
|
30
|
+
)
|
|
31
|
+
agent&.metadata = DeployAngel::Rails::Metadata.new(app: app, config: DeployAngel.configuration, root: ::Rails.root.to_s)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeployAngel
|
|
4
|
+
# Server middleware for native Sidekiq jobs (Sidekiq::Job without
|
|
5
|
+
# ActiveJob). ActiveJob jobs on Sidekiq are recorded by the ActiveJob
|
|
6
|
+
# instrumentation instead, so they are skipped here to avoid double counts.
|
|
7
|
+
module Sidekiq
|
|
8
|
+
ACTIVE_JOB_WRAPPERS = %w[
|
|
9
|
+
Sidekiq::ActiveJob::Wrapper
|
|
10
|
+
ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
class ServerMiddleware
|
|
14
|
+
def call(_job_instance, job, _queue)
|
|
15
|
+
return yield if ACTIVE_JOB_WRAPPERS.include?(job["class"]) || !DeployAngel.recording?
|
|
16
|
+
|
|
17
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
18
|
+
latency = DeployAngel::Sidekiq.queue_latency_ms(job)
|
|
19
|
+
begin
|
|
20
|
+
yield
|
|
21
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
|
|
22
|
+
DeployAngel.record_exception(e, source: "job_class:#{job["class"]}")
|
|
23
|
+
record(job, started, latency, failed: true)
|
|
24
|
+
raise
|
|
25
|
+
end
|
|
26
|
+
record(job, started, latency, failed: false)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
def record(job, started, latency, failed:)
|
|
31
|
+
DeployAngel.record_job(job_class: job["class"], failed: failed, queue_latency_ms: latency,
|
|
32
|
+
duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0)
|
|
33
|
+
rescue StandardError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
def install
|
|
41
|
+
return if @installed || !defined?(::Sidekiq) || !::Sidekiq.respond_to?(:configure_server)
|
|
42
|
+
|
|
43
|
+
@installed = true
|
|
44
|
+
DeployAngel.add_capability("jobs")
|
|
45
|
+
::Sidekiq.configure_server do |config|
|
|
46
|
+
config.server_middleware { |chain| chain.add(ServerMiddleware) }
|
|
47
|
+
# Jobs that exhausted their retries are discarded.
|
|
48
|
+
config.death_handlers << ->(job, _error) { record_death(job) } if config.respond_to?(:death_handlers)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A dead job was already counted as a failed attempt by the middleware;
|
|
53
|
+
# this only records that it will not run again.
|
|
54
|
+
def record_death(job)
|
|
55
|
+
return if ACTIVE_JOB_WRAPPERS.include?(job["class"]) || !DeployAngel.agent
|
|
56
|
+
|
|
57
|
+
DeployAngel.agent.record_discard(job_class: job["class"])
|
|
58
|
+
rescue StandardError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Sidekiq 8 stores epoch milliseconds; earlier versions store seconds.
|
|
63
|
+
def queue_latency_ms(job)
|
|
64
|
+
enqueued = job["enqueued_at"]
|
|
65
|
+
return unless enqueued.is_a?(Numeric)
|
|
66
|
+
|
|
67
|
+
enqueued_ms = enqueued > 100_000_000_000 ? enqueued : enqueued * 1000.0
|
|
68
|
+
[ (Time.now.to_f * 1000.0) - enqueued_ms, 0 ].max
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeployAngel
|
|
4
|
+
# Finds a deployment and polls its verdict document until the requested
|
|
5
|
+
# point: the 15-minute initial check, a verdict, or the end of watching.
|
|
6
|
+
# Shared by the CLI and the MCP server.
|
|
7
|
+
class VerificationWaiter
|
|
8
|
+
Outcome = Struct.new(:document, :exit_code, :timed_out, :not_found, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# Exit codes are the primary signal for agents and CI.
|
|
11
|
+
EXIT_CODES = { "verified" => 0, "failed" => 1, "inconclusive" => 2 }.freeze
|
|
12
|
+
TIMED_OUT = 3
|
|
13
|
+
NOT_FOUND = 4
|
|
14
|
+
INITIAL_OK = 6
|
|
15
|
+
INITIAL_WARNINGS = 7
|
|
16
|
+
UNTIL_MODES = %w[initial verdict closed].freeze
|
|
17
|
+
PENDING_POLL = 15
|
|
18
|
+
|
|
19
|
+
def initialize(client:, sleeper: ->(seconds) { sleep(seconds) }, clock: -> { Time.now }, on_progress: nil)
|
|
20
|
+
@client = client
|
|
21
|
+
@sleeper = sleeper
|
|
22
|
+
@clock = clock
|
|
23
|
+
@on_progress = on_progress
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# target: { deployment_id: } | { version: } | { commit: } | { latest: true }
|
|
27
|
+
def wait(target, until_mode: "verdict", timeout: 1800, wait: true)
|
|
28
|
+
deadline = @clock.call + timeout
|
|
29
|
+
last_state = nil
|
|
30
|
+
loop do
|
|
31
|
+
deployment = find(target)
|
|
32
|
+
if deployment.nil?
|
|
33
|
+
return Outcome.new(not_found: true, exit_code: NOT_FOUND) if !wait || @clock.call >= deadline
|
|
34
|
+
|
|
35
|
+
progress("Waiting for deployment #{describe(target)} to be registered…") unless last_state == :pending_registration
|
|
36
|
+
last_state = :pending_registration
|
|
37
|
+
@sleeper.call([ PENDING_POLL, deadline - @clock.call ].min.clamp(1, PENDING_POLL))
|
|
38
|
+
next
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
document = @client.verification(deployment["id"])
|
|
42
|
+
state = document.dig("verification", "state")
|
|
43
|
+
progress("#{document.dig("deployment", "version") || document.dig("deployment", "commit")}: #{state}") if state != last_state
|
|
44
|
+
last_state = state
|
|
45
|
+
|
|
46
|
+
code = self.class.exit_code(document, until_mode)
|
|
47
|
+
return Outcome.new(document: document, exit_code: code) if code
|
|
48
|
+
return Outcome.new(document: document, exit_code: TIMED_OUT) unless wait
|
|
49
|
+
return Outcome.new(document: document, exit_code: TIMED_OUT, timed_out: true) if @clock.call >= deadline
|
|
50
|
+
|
|
51
|
+
poll = (document["poll_after_seconds"] || 30).to_i.clamp(5, 60)
|
|
52
|
+
@sleeper.call([ poll, deadline - @clock.call ].min.clamp(1, 60))
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# nil means "keep waiting". Failed always returns immediately.
|
|
57
|
+
def self.exit_code(document, until_mode)
|
|
58
|
+
verification = document["verification"] || {}
|
|
59
|
+
verdict = verification["verdict"]
|
|
60
|
+
return EXIT_CODES["failed"] if verdict == "failed"
|
|
61
|
+
|
|
62
|
+
case until_mode
|
|
63
|
+
when "closed"
|
|
64
|
+
EXIT_CODES[verdict] if verdict && verification["state"] == "closed"
|
|
65
|
+
when "initial"
|
|
66
|
+
if verdict
|
|
67
|
+
EXIT_CODES[verdict]
|
|
68
|
+
elsif (check = verification["initial_check"])
|
|
69
|
+
check["result"] == "warnings" ? INITIAL_WARNINGS : INITIAL_OK
|
|
70
|
+
end
|
|
71
|
+
else
|
|
72
|
+
EXIT_CODES[verdict] if verdict
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
def find(target)
|
|
78
|
+
if target[:deployment_id]
|
|
79
|
+
{ "id" => target[:deployment_id] }
|
|
80
|
+
elsif target[:latest]
|
|
81
|
+
@client.latest_deployment
|
|
82
|
+
else
|
|
83
|
+
@client.deployments(commit: target[:commit], version: target[:version], limit: 1).first
|
|
84
|
+
end
|
|
85
|
+
rescue Client::NotFound
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def describe(target)
|
|
90
|
+
target[:version] || target[:commit]&.slice(0, 12) || target[:deployment_id] || "latest"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def progress(message)
|
|
94
|
+
@on_progress&.call(message)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|