deployangel 0.1.10 → 0.1.12

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fd5de9ae625d8780f853e4ee71b662be91f6dc702f59aa15806b359c075045a
4
- data.tar.gz: 5823184602177d55b1abc39dd920b01879162a335afabe3dee46c5cdbfec1295
3
+ metadata.gz: c3835d3c334a91d9ad7a75a3247ede4797a27e207b123f684bc1563fffd33cb0
4
+ data.tar.gz: c578f30dc3f828dabce95b7c4e48682b1248d306b0982d50483f17d3c3cf57c8
5
5
  SHA512:
6
- metadata.gz: 5c899072c049b8854d5772c05be7cb95588d427a571324ab303134418081b3815ae39eb6efaea92b1e46f6857f24379674f9298050435638bb674baca942aeb7
7
- data.tar.gz: 3ce76ca3b6232505e4d1afaa95348939a1ffcac19fe02cf296f30fdf02fd447aba85991c5d8b292ace00d19b38a0e73418a668d0cef8993a86119a1a3d2abc26
6
+ metadata.gz: 5a8e5b23a9d1a06732add016e3d31e813d08a10afc13016408c26944112098948476cfe51d7c39c60710c9f411cf6d0af01d145a4bd0a125ebcc90728d06b695
7
+ data.tar.gz: 7dea64425c2cd98cda899277a2561d67dde2d0871e38156c9efad654769fdcb6198ab94089d56fad0d72c7e15e48300d5b40d5018851491c6378043b435bb470
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.12 (2026-10-06)
4
+
5
+ - `deployangel plan` and the MCP tool `get_exercise_plan` say what stands
6
+ between a release and clearance, and what to exercise against production
7
+ so it clears sooner. Routes that change data are marked. `deployangel
8
+ verify` and the GitHub Actions job summary list the first items when a
9
+ release isn't cleared yet. Needs a DeployAngel server that returns
10
+ exercise plans; older ones get a note instead.
11
+
12
+ ## 0.1.11 (2026-10-05)
13
+
14
+ - Heroku: the commit comes from `HEROKU_BUILD_COMMIT`
15
+ (`heroku labs:enable runtime-dyno-build-metadata`) when it's set, and from
16
+ `HEROKU_SLUG_COMMIT`, which Heroku has deprecated, otherwise.
17
+ - In GitHub Actions, `deployangel verify` and `status` also add the verdict,
18
+ its failing and warning findings, new exceptions, and a dashboard link to
19
+ the job's summary page (`GITHUB_STEP_SUMMARY`). Output and exit codes are
20
+ unchanged.
21
+
3
22
  ## 0.1.10 (2026-10-04)
4
23
 
5
24
  - `config.exception_messages = false` (or `DEPLOYANGEL_EXCEPTION_MESSAGES=false`)
data/README.md CHANGED
@@ -24,6 +24,8 @@ The agent must know which release it is running. It finds it in this order:
24
24
 
25
25
  1. `DEPLOYANGEL_REVISION` (the commit SHA) and `DEPLOYANGEL_RELEASE_VERSION`, if you set them
26
26
  2. Heroku dyno metadata. Enable it with `heroku labs:enable runtime-dyno-metadata`
27
+ and `heroku labs:enable runtime-dyno-build-metadata` (for `HEROKU_BUILD_COMMIT`,
28
+ which replaces the deprecated `HEROKU_SLUG_COMMIT`); both take effect on the next deploy
27
29
  3. Kamal: `KAMAL_VERSION`, which Kamal sets in every container
28
30
  4. Render: `RENDER_GIT_COMMIT`
29
31
  5. Fly.io: the deploy's image tag, from `FLY_IMAGE_REF`. Fly.io sets no commit, so
@@ -243,6 +245,9 @@ to the run. Explicit options still win.
243
245
  DEPLOYANGEL_API_TOKEN: ${{ secrets.DEPLOYANGEL_API_TOKEN }}
244
246
  ```
245
247
 
248
+ In GitHub Actions, `verify` also puts the verdict, its failing findings, and
249
+ new exceptions on the job's summary page.
250
+
246
251
  ### When a release never reports
247
252
 
248
253
  A registered release must start reporting within 15 minutes of being
@@ -359,6 +364,7 @@ export DEPLOYANGEL_API_TOKEN=da_live_...
359
364
  bundle exec deployangel verify --wait # current git HEAD, until a verdict
360
365
  bundle exec deployangel verify --wait --until=initial # return at the 15-minute initial check
361
366
  bundle exec deployangel status # latest deployment
367
+ bundle exec deployangel plan # what to exercise so a release clears sooner
362
368
  bundle exec deployangel release --commit=$SHA # register a deploy (manual or CI)
363
369
  bundle exec deployangel check --name="smoke: signup" --status=pass --covers=registration
364
370
  bundle exec deployangel exception <fingerprint>
@@ -385,9 +391,23 @@ claude mcp add deployangel -- bundle exec deployangel mcp
385
391
  ```
386
392
 
387
393
  Tools: `get_verification`, `wait_for_verification` (up to 5 minutes per call),
388
- `list_deployments`, `get_exception`, `list_late_regressions`, and
389
- `register_deployment` when the token allows it. The tools are read-only with
390
- respect to production.
394
+ `get_exercise_plan`, `list_deployments`, `get_exception`,
395
+ `list_late_regressions`, and `register_deployment` when the token allows it.
396
+ The tools are read-only with respect to production.
397
+
398
+ ### Clearing quiet releases sooner
399
+
400
+ A quiet app can take hours to clear a release. `deployangel plan` (or the
401
+ `get_exercise_plan` MCP tool) says what stands between a release and
402
+ clearance, in the numbers of the rule it's judged by, and what to exercise
403
+ against production so it clears sooner: normally active routes short of
404
+ their runs, routes this release changed that haven't run, and critical
405
+ flows. Requests to them count like any traffic. Routes that change data
406
+ are marked; use a test account for them, or ask first. Report what you
407
+ ran with the `deployangel check` command the plan gives you. A passing
408
+ check labels what it covered; only the requests themselves count as
409
+ evidence. A release deployed during an app's first 24 hours can't clear,
410
+ so its plan lists nothing.
391
411
 
392
412
  ### Suggested instructions for your coding agent
393
413
 
@@ -411,6 +431,13 @@ After deploying, run `bundle exec deployangel verify --wait --until initial`
411
431
  cause, and propose a fix. Do not roll back or change production without
412
432
  explicit approval.
413
433
  - Exit 3: still verifying; run the command again.
434
+ - Not cleared yet and you can reach production: run
435
+ `bundle exec deployangel plan --format=json` (or `get_exercise_plan`). If
436
+ its status is "exercisable" or "waiting_for_activity", exercise the listed
437
+ items against production: read-only routes freely, routes marked mutating
438
+ only with a test account or after asking. Then run its `report_with`
439
+ command and wait again. If the status is "warm_up", don't exercise
440
+ anything: the release can't clear until DeployAngel has a day of history.
414
441
  ```
415
442
 
416
443
  ## Development
@@ -13,50 +13,174 @@ module DeployAngel
13
13
  "checkpoint_rate" => "Checkpoint"
14
14
  }.freeze
15
15
  DURATIONS = %w[p95_latency job_duration queue_latency].freeze
16
+ # Exercise plan statuses with something worth running now.
17
+ EXERCISABLE = %w[exercisable waiting_for_activity].freeze
18
+ REASONS = { "normally_active" => "normally active", "changed_in_release" => "changed in this release",
19
+ "rarely_used" => "rarely used", "critical_flow" => "critical flow" }.freeze
16
20
  COUNTS = %w[new_fingerprint checkpoint_rate].freeze
17
21
 
18
22
  module_function
19
23
 
20
24
  def verification(document)
25
+ lines = [ statement(document), status_line(document), *notes(document) ]
26
+ problems = problems(document)
27
+ if problems.any?
28
+ lines << "Findings:"
29
+ problems.each { |f| lines << " #{f["status"].ljust(8)} #{finding_line(f)}" }
30
+ end
31
+ if (gathering = gathering_line(document))
32
+ lines << "#{gathering} (--format=json for details)"
33
+ end
34
+ sections(document).each { |title, items| section(lines, title, items) }
35
+ lines << document["dashboard_url"] if document["dashboard_url"]
36
+ lines.compact.join("\n")
37
+ end
38
+
39
+ # The same document as GitHub-flavored Markdown, for a CI job's
40
+ # summary page.
41
+ def markdown(document)
42
+ lines = [ "### DeployAngel: #{release_name(document)} #{outcome(document)}", "", statement(document).to_s,
43
+ "", status_line(document), *notes(document).map { |note| "\n#{note}" } ]
44
+ problems = problems(document)
45
+ if problems.any?
46
+ lines.push("", "| Status | Finding |", "| --- | --- |")
47
+ problems.each { |f| lines << "| #{f["status"]} | #{cell(finding_line(f))} |" }
48
+ end
49
+ if (gathering = gathering_line(document))
50
+ lines.push("", gathering)
51
+ end
52
+ sections(document).each do |title, items|
53
+ lines.push("", "**#{title}**", "")
54
+ items.each { |item| lines << "- #{item}" }
55
+ end
56
+ lines.push("", "[Open in DeployAngel](#{document["dashboard_url"]})") if document["dashboard_url"]
57
+ lines.compact.join("\n") + "\n"
58
+ end
59
+
60
+ def statement(document)
61
+ (document["clearance"] || {})["statement"] || document["summary"]
62
+ end
63
+
64
+ def status_line(document)
21
65
  v = document["verification"] || {}
22
- clearance = document["clearance"] || {}
23
- lines = [ clearance["statement"] || document["summary"] ]
24
- lines << [ "State: #{v["state"]}", ("Verdict: #{v["verdict"]}" if v["verdict"]),
25
- ("Confidence: #{v["confidence"]}" if v["confidence"]),
26
- ("Coverage: #{(v["coverage"] * 100).round}%" if v["coverage"]) ].compact.join(" · ")
66
+ [ "State: #{v["state"]}", ("Verdict: #{v["verdict"]}" if v["verdict"]),
67
+ ("Confidence: #{v["confidence"]}" if v["confidence"]),
68
+ ("Coverage: #{(v["coverage"] * 100).round}%" if v["coverage"]) ].compact.join(" · ")
69
+ end
70
+
71
+ def notes(document)
72
+ v = document["verification"] || {}
73
+ notes = []
27
74
  if (check = v["initial_check"]) && v["verdict"].nil?
28
- lines << "Initial check: #{check["result"].tr("_", " ")}. Not cleared yet."
75
+ notes << "Initial check: #{check["result"].tr("_", " ")}. Not cleared yet."
29
76
  end
30
- lines << "Expected clearance: #{v["expected_clearance_at"]}" if v["expected_clearance_at"] && v["verdict"].nil?
77
+ notes << "Expected clearance: #{v["expected_clearance_at"]}" if v["expected_clearance_at"] && v["verdict"].nil?
31
78
  if (source = document.dig("deployment", "promoted_from"))
32
79
  outcome = { "verified" => "cleared", "failed" => "failed", "inconclusive" => "not cleared" }.fetch(source["verdict"], "still verifying")
33
- lines << "Promoted from #{source["environment"]} #{source["version"] || source["commit"].to_s[0, 7]} (#{outcome})"
80
+ notes << "Promoted from #{source["environment"]} #{source["version"] || source["commit"].to_s[0, 7]} (#{outcome})"
34
81
  end
82
+ notes
83
+ end
35
84
 
36
- findings = Array(document["findings"])
37
- problems = findings.select { |f| %w[failing warning].include?(f["status"]) }
38
- if problems.any?
39
- lines << "Findings:"
40
- problems.each { |f| lines << " #{f["status"].ljust(8)} #{finding_line(f)}" }
85
+ def problems(document)
86
+ Array(document["findings"]).select { |f| %w[failing warning].include?(f["status"]) }
87
+ end
88
+
89
+ def gathering_line(document)
90
+ gathering = Array(document["findings"]).count { |f| f["status"] == "insufficient_data" }
91
+ "#{gathering} signal#{"s" unless gathering == 1} still gathering data" if gathering.positive?
92
+ end
93
+
94
+ # Titled lists that follow the findings, each item already a line.
95
+ def sections(document)
96
+ v = document["verification"] || {}
97
+ clearance = document["clearance"] || {}
98
+ changed = Array(document["rare_items_pending"]).select { |item| item["changed_in_release"] == "changed" }
99
+ [
100
+ [ "New exceptions", Array(document["exceptions"]).map do |e|
101
+ "#{e["exception_class"]} in #{e["top_frame"]} (#{e["count"]}x)#{" #{e["sources"].keys.join(", ")}" if e["sources"].is_a?(Hash) && e["sources"].any?}"
102
+ end ],
103
+ [ "Missing evidence", v["verdict"] == "failed" ? [] : Array(clearance["missing_evidence"]) ],
104
+ [ "Not observable", Array(clearance["not_observable"]).map { |item| "#{item["label"]} (#{item["reason"]})" } ],
105
+ [ "Still watching", Array(clearance["still_watching"]).map { |e| "#{e["job_class"]} (expected by #{e["expected_by"]})" } ],
106
+ [ "Critical flows", Array(document["critical_flows"]).map do |flow|
107
+ "#{flow["name"]}: #{flow["status"].tr("_", " ")}#{" (changed in this release)" if flow["changed_in_release"]}"
108
+ end ],
109
+ [ "Changed but not yet exercised", changed.map { |item| item["key"] } ],
110
+ [ "To clear sooner, exercise (deployangel plan for details)", exercisable_items(document).first(5) ],
111
+ [ "Late regressions", Array(document["late_regressions"]).map { |late| late["summary"] } ]
112
+ ].reject { |_, items| items.empty? }
113
+ end
114
+
115
+ # The release's exercise plan, for `deployangel plan`.
116
+ def exercise_plan(document)
117
+ plan = document["exercise_plan"] || {}
118
+ lines = [ "#{release_name(document)}: #{plan["summary"] || "No exercise plan in this response; update the server."}" ]
119
+ shortfall = shortfall_lines(plan["shortfall"] || {})
120
+ if shortfall.any?
121
+ lines << "Short of:"
122
+ shortfall.each { |line| lines << " #{line}" }
41
123
  end
42
- gathering = findings.count { |f| f["status"] == "insufficient_data" }
43
- lines << "#{gathering} signal#{"s" unless gathering == 1} still gathering data (--format=json for details)" if gathering.positive?
44
- section(lines, "New exceptions", Array(document["exceptions"])) do |e|
45
- "#{e["exception_class"]} in #{e["top_frame"]} (#{e["count"]}x)#{" #{e["sources"].keys.join(", ")}" if e["sources"].is_a?(Hash) && e["sources"].any?}"
124
+ items = Array(plan["items"])
125
+ if items.any?
126
+ lines << (EXERCISABLE.include?(plan["status"]) ? "Exercise against production:" : "Optional:")
127
+ items.each { |item| lines << " #{item_line(item)}" }
128
+ lines << "Use a test account, or ask first, for routes marked [changes data]." if items.any? { |item| item["mutating"] }
46
129
  end
47
- unless v["verdict"] == "failed"
48
- section(lines, "Missing evidence", Array(clearance["missing_evidence"])) { |item| item }
130
+ lines << "Then report it: #{plan["report_with"]}" if plan["report_with"]
131
+ lines.join("\n")
132
+ end
133
+
134
+ def exercisable_items(document)
135
+ plan = document["exercise_plan"] || {}
136
+ EXERCISABLE.include?(plan["status"]) ? Array(plan["items"]).map { |item| item_line(item) } : []
137
+ end
138
+
139
+ def shortfall_lines(shortfall)
140
+ lines = []
141
+ if (requests = shortfall["requests"])
142
+ lines << "requests: #{requests["have"]} of #{requests["need"]}#{" (low-traffic rule)" if shortfall["rule"] == "low_volume"}"
49
143
  end
50
- section(lines, "Not observable", Array(clearance["not_observable"])) { |item| "#{item["label"]} (#{item["reason"]})" }
51
- section(lines, "Still watching", Array(clearance["still_watching"])) { |e| "#{e["job_class"]} (expected by #{e["expected_by"]})" }
52
- section(lines, "Critical flows", Array(document["critical_flows"])) do |flow|
53
- "#{flow["name"]}: #{flow["status"].tr("_", " ")}#{" (changed in this release)" if flow["changed_in_release"]}"
144
+ shortfall.each do |key, value|
145
+ next unless key.start_with?("routes_run_")
146
+
147
+ lines << "routes run #{key[/\d+/]}+ times: #{value["have"]} of the #{value["need"]} needed (#{value["of"]} normally active)"
148
+ end
149
+ lines << "coverage: #{(shortfall.dig("coverage", "have").to_f * 100).round}% of #{(shortfall.dig("coverage", "need").to_f * 100).round}%" if shortfall["coverage"]
150
+ if (jobs = shortfall["jobs"])
151
+ lines << (jobs["classes_not_run"] ? "jobs not run yet: #{Array(jobs["classes_not_run"]).join(", ")}" :
152
+ "job attempts: #{jobs.dig("attempts", "have")} of #{jobs.dig("attempts", "need")}")
153
+ end
154
+ lines << "elevated, review before exercising more: #{Array(shortfall["elevated"]).join(", ")}" if shortfall["elevated"]
155
+ lines << "critical flows not run: #{Array(shortfall["critical_flows"]).join(", ")}" if shortfall["critical_flows"]
156
+ lines
157
+ end
158
+
159
+ def item_line(item)
160
+ runs = item["runs_needed"].to_i > 1 ? "run #{item["runs"]} of #{item["runs_needed"]}" : "not run yet"
161
+ runs = "runs when the app starts it" if item["triggered_by"] == "app_behavior"
162
+ [ item["key"], "(#{REASONS.fetch(item["reason"], item["reason"])}, #{runs})",
163
+ ("[changes data]" if item["mutating"]), ("checked by #{Array(item["checked_by"]).join(", ")}" if item["checked_by"]) ].compact.join(" ")
164
+ end
165
+
166
+ def release_name(document)
167
+ deployment = document["deployment"] || {}
168
+ deployment["version"] || deployment["commit"].to_s[0, 7]
169
+ end
170
+
171
+ def outcome(document)
172
+ v = document["verification"] || {}
173
+ case v["verdict"]
174
+ when "verified" then "cleared"
175
+ when "failed" then "failed"
176
+ when "inconclusive" then "not cleared"
177
+ else
178
+ case v.dig("initial_check", "result")
179
+ when "warnings" then "has warnings, not cleared yet"
180
+ when nil then "is still being verified"
181
+ else "has no problems so far, not cleared yet"
182
+ end
54
183
  end
55
- changed = Array(document["rare_items_pending"]).select { |item| item["changed_in_release"] == "changed" }
56
- section(lines, "Changed but not yet exercised", changed) { |item| item["key"] }
57
- section(lines, "Late regressions", Array(document["late_regressions"])) { |late| late["summary"] }
58
- lines << document["dashboard_url"] if document["dashboard_url"]
59
- lines.compact.join("\n")
60
184
  end
61
185
 
62
186
  def exception(details)
@@ -82,10 +206,12 @@ module DeployAngel
82
206
  end
83
207
 
84
208
  def section(lines, title, items)
85
- return if items.empty?
86
-
87
209
  lines << "#{title}:"
88
- items.each { |item| lines << " #{yield(item)}" }
210
+ items.each { |item| lines << " #{item}" }
211
+ end
212
+
213
+ def cell(text)
214
+ text.gsub("|", "\\|")
89
215
  end
90
216
 
91
217
  def value(finding, raw)
@@ -28,13 +28,15 @@ module DeployAngel
28
28
  [--wait] [--until=initial|verdict|closed] [--timeout=30m]
29
29
  [--format=text|json] [--all-findings]
30
30
  status Latest deployment and its verification
31
+ plan What to exercise so a release [--commit=SHA | --version=V | --deployment=ID]
32
+ clears sooner [--format=text|json]
31
33
  exception Details for a fingerprint deployangel exception FINGERPRINT
32
34
  check Report a smoke test result --name=NAME --status=pass|fail [--covers=a,b]
33
35
  [--commit=SHA | --deployment=ID] [--details-url=URL]
34
36
  install Add a deploy hook deployangel install kamal
35
37
  mcp Run the MCP server over stdio (for coding agents)
36
38
 
37
- With no target, verify and check use the current git HEAD commit.
39
+ With no target, verify, plan, and check use the current git HEAD commit.
38
40
  In GitHub Actions, GitLab CI, CircleCI, and Buildkite, release fills in the
39
41
  commit, a build label (e.g. run-123), and a link to the run automatically.
40
42
  In a Kamal hook, it registers Kamal's release (KAMAL_VERSION).
@@ -75,6 +77,7 @@ module DeployAngel
75
77
  when "release" then release
76
78
  when "verify" then verify
77
79
  when "status" then verify(status: true)
80
+ when "plan" then plan
78
81
  when "exception" then exception
79
82
  when "check" then check
80
83
  when "install" then install
@@ -139,15 +142,41 @@ module DeployAngel
139
142
  outcome = waiter.wait(target, until_mode: options[:until_mode], timeout: options[:timeout], wait: options[:wait])
140
143
  if outcome.not_found
141
144
  @stderr.puts("deployangel: no deployment found for #{target.values.first}")
145
+ step_summary("### DeployAngel: no deployment found for #{target.values.first}\n")
142
146
  return outcome.exit_code
143
147
  end
144
148
 
145
149
  document = options[:all_findings] ? client.verification(outcome.document.dig("deployment", "id"), all_findings: true) : outcome.document
146
150
  output(document, options[:format]) { Formatter.verification(document) }
151
+ step_summary(Formatter.markdown(document))
147
152
  @stderr.puts("deployangel: timed out; verification is still in progress") if outcome.timed_out
148
153
  outcome.exit_code
149
154
  end
150
155
 
156
+ # The release's exercise plan: what stands between it and clearance,
157
+ # and what to exercise against production so it clears sooner.
158
+ def plan
159
+ options = parse(format: nil) do |o, opts|
160
+ o.on("--commit=SHA") { |v| opts[:commit] = v }
161
+ o.on("--version=VERSION") { |v| opts[:version] = v }
162
+ o.on("--deployment=ID") { |v| opts[:deployment_id] = v }
163
+ o.on("--format=FORMAT", %w[text json]) { |v| opts[:format] = v }
164
+ end
165
+ target = target_from(options)
166
+ return usage_error("no target: pass --commit, --version, or --deployment, or run inside a git repository") unless target
167
+
168
+ outcome = VerificationWaiter.new(client: client, sleeper: @sleeper, clock: @clock).wait(target, wait: false)
169
+ if outcome.not_found
170
+ @stderr.puts("deployangel: no deployment found for #{target.values.first}")
171
+ return outcome.exit_code
172
+ end
173
+
174
+ document = outcome.document
175
+ json = { "deployment" => document["deployment"], "exercise_plan" => document["exercise_plan"] }
176
+ output(json, options[:format]) { Formatter.exercise_plan(document) }
177
+ 0
178
+ end
179
+
151
180
  def exception
152
181
  fingerprint = @argv.shift or return usage_error("exception needs a FINGERPRINT")
153
182
  format = parse(format: nil) { |o, opts| o.on("--format=FORMAT", %w[text json]) { |v| opts[:format] = v } }[:format]
@@ -228,6 +257,16 @@ module DeployAngel
228
257
  @stdout.puts(format == "json" ? JSON.pretty_generate(document) : yield)
229
258
  end
230
259
 
260
+ # In GitHub Actions, the verdict also goes on the run's summary page.
261
+ def step_summary(markdown)
262
+ path = @env["GITHUB_STEP_SUMMARY"].to_s
263
+ return if path.empty?
264
+
265
+ File.open(path, "a") { |file| file.puts(markdown) }
266
+ rescue SystemCallError => e
267
+ @stderr.puts("deployangel: couldn't write the job summary (#{e.message})")
268
+ end
269
+
231
270
  def parse_duration(value)
232
271
  match = value.to_s.match(/\A(\d+)(s|m|h)?\z/) or raise OptionParser::InvalidArgument, "--timeout=#{value}"
233
272
  match[1].to_i * { nil => 1, "s" => 1, "m" => 60, "h" => 3600 }.fetch(match[2])
@@ -19,8 +19,8 @@ module DeployAngel
19
19
  def self.resolve(config:, env: ENV, root: nil, http: method(:fetch_metadata))
20
20
  if present?(config.release_version) || present?(config.revision)
21
21
  build(config.release_version, config.revision, "config")
22
- elsif present?(env["HEROKU_RELEASE_VERSION"]) || present?(env["HEROKU_SLUG_COMMIT"])
23
- build(env["HEROKU_RELEASE_VERSION"], env["HEROKU_SLUG_COMMIT"], "heroku_dyno_metadata")
22
+ elsif present?(env["HEROKU_RELEASE_VERSION"]) || present?(heroku_commit(env))
23
+ build(env["HEROKU_RELEASE_VERSION"], heroku_commit(env), "heroku_dyno_metadata")
24
24
  elsif present?(env["KAMAL_VERSION"])
25
25
  kamal(env["KAMAL_VERSION"])
26
26
  elsif present?(env["RENDER_GIT_COMMIT"])
@@ -114,6 +114,12 @@ module DeployAngel
114
114
  source)
115
115
  end
116
116
 
117
+ # HEROKU_BUILD_COMMIT (runtime-dyno-build-metadata) replaces the
118
+ # deprecated HEROKU_SLUG_COMMIT (runtime-dyno-metadata).
119
+ def self.heroku_commit(env)
120
+ present?(env["HEROKU_BUILD_COMMIT"]) ? env["HEROKU_BUILD_COMMIT"] : env["HEROKU_SLUG_COMMIT"]
121
+ end
122
+
117
123
  def self.present?(value)
118
124
  !value.to_s.strip.empty?
119
125
  end
@@ -21,6 +21,10 @@ module DeployAngel
21
21
  failed means production regressed: read the findings and exceptions and investigate.
22
22
  Findings are deterministic evidence; any "investigation" field is AI inference. These tools cannot
23
23
  change production. Do not roll back or change production without explicit approval.
24
+ If a release isn't cleared yet, get_exercise_plan says what to exercise against production so it
25
+ clears sooner. Only act on status "exercisable" or "waiting_for_activity"; for "warm_up" or
26
+ "no_baseline" nothing you run can clear it. Exercise routes marked mutating only with a test account
27
+ or after asking. Report what you ran with the plan's report_with command (deployangel check).
24
28
  TEXT
25
29
 
26
30
  TARGET_PROPERTIES = {
@@ -96,6 +100,11 @@ module DeployAngel
96
100
  "until" => { "type" => "string", "enum" => %w[initial verdict], "default" => "initial" },
97
101
  "timeout_seconds" => { "type" => "integer", "minimum" => 1, "maximum" => MAX_WAIT_SECONDS, "default" => MAX_WAIT_SECONDS }
98
102
  )),
103
+ tool("get_exercise_plan", "What stands between a release and clearance, and what to exercise against " \
104
+ "production so it clears sooner: normally active routes short of their runs, routes this release changed " \
105
+ "that haven't run, and critical flows. Routes marked mutating change data: use a test account or ask first. " \
106
+ "Your requests count as ordinary traffic; report what you ran with report_with. Defaults to the current git HEAD.",
107
+ TARGET_PROPERTIES),
99
108
  tool("list_deployments", "Recent deployments with their verification state and verdict.",
100
109
  { "limit" => { "type" => "integer", "minimum" => 1, "maximum" => 50, "default" => 10 } }),
101
110
  tool("get_exception", "Sanitized details and the application stack trace for an exception fingerprint.",
@@ -127,6 +136,7 @@ module DeployAngel
127
136
  case name
128
137
  when "get_verification" then waiter.wait(target(arguments), wait: false).then { |o| verification_content(o) }
129
138
  when "wait_for_verification" then wait_content(arguments)
139
+ when "get_exercise_plan" then waiter.wait(target(arguments), wait: false).then { |o| plan_content(o) }
130
140
  when "list_deployments" then @client.deployments(limit: arguments.fetch("limit", 10))
131
141
  when "get_exception" then @client.exception(arguments.fetch("fingerprint") { raise ArgumentError, "fingerprint is required" })
132
142
  when "list_late_regressions" then @client.late_regressions(since: arguments["since"], limit: arguments.fetch("limit", 10))
@@ -157,6 +167,14 @@ module DeployAngel
157
167
  "in_progress" => outcome.exit_code == VerificationWaiter::TIMED_OUT, "verification" => outcome.document }
158
168
  end
159
169
 
170
+ def plan_content(outcome)
171
+ return { "found" => false, "note" => "No matching deployment is registered yet." } if outcome.not_found
172
+
173
+ { "deployment" => outcome.document["deployment"],
174
+ "exercise_plan" => outcome.document["exercise_plan"] ||
175
+ { "note" => "This DeployAngel server doesn't return exercise plans yet." } }
176
+ end
177
+
160
178
  def meaning(code)
161
179
  {
162
180
  0 => "verified: the release is cleared",
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "erb"
5
+ require "yaml"
6
+
7
+ module DeployAngel
8
+ # What the application contains, sent once per process: the route table,
9
+ # job classes, declared recurring schedules, critical flows, and file
10
+ # digests. Paths and hashes only; source code never leaves the
11
+ # application.
12
+ #
13
+ # Routes and job classes are the framework's to answer, so this base
14
+ # reports none and DeployAngel::Rails::Metadata adds them. What no
15
+ # framework owns is gathered here: the schedule files sidekiq-cron and
16
+ # sidekiq-scheduler read, and the file digests.
17
+ class Metadata
18
+ # A Rails-style layout, which most Ruby apps share. An adapter for a
19
+ # framework laid out differently overrides digest_globs.
20
+ DIGEST_GLOBS = %w[app/**/* config/**/* lib/**/* db/migrate/**/* Gemfile.lock].freeze
21
+ MAX_FILES = 20_000
22
+ DIGEST_LENGTH = 16
23
+
24
+ # environment names the deployment ("production"), which the schedulers
25
+ # read to tell whether a job runs here. The agent already knows it, so
26
+ # nothing in this class reaches for a framework to ask.
27
+ def initialize(config:, root:, environment:)
28
+ @config = config
29
+ @root = root
30
+ @environment = environment.to_s
31
+ end
32
+
33
+ def to_protocol
34
+ {
35
+ "routes" => routes,
36
+ "job_classes" => job_classes,
37
+ "schedules" => schedules,
38
+ "critical_flows" => @config.critical_flows.transform_keys(&:to_s).transform_values { |items| Array(items).map(&:to_s) },
39
+ "file_manifest" => file_manifest.slice("hash", "count", "truncated")
40
+ }
41
+ end
42
+
43
+ def files
44
+ file_manifest["files"]
45
+ end
46
+
47
+ # The routes the app serves, as { "key", "controller", "action" }.
48
+ def routes
49
+ []
50
+ end
51
+
52
+ # The job classes the app defines, by name.
53
+ def job_classes
54
+ []
55
+ end
56
+
57
+ # The files digested to tell which routes a release changed, as globs
58
+ # relative to the app's root. Only paths and hashes are sent.
59
+ def digest_globs
60
+ DIGEST_GLOBS
61
+ end
62
+
63
+ # Declared recurring jobs. Each source is read on its own, so a file that
64
+ # can't be read leaves the others' schedules in place.
65
+ def schedules
66
+ sidekiq_cron_schedules + sidekiq_scheduler_schedules
67
+ end
68
+
69
+ # sidekiq-cron jobs from its schedule file (config/schedule.yml unless
70
+ # configured otherwise), which it loads when Sidekiq starts, or from
71
+ # the file in config.sidekiq_cron_schedule_file for an app that loads
72
+ # them itself. Jobs created in code live only in Redis, which the agent
73
+ # doesn't read.
74
+ def sidekiq_cron_schedules
75
+ return [] unless defined?(::Sidekiq::Cron)
76
+
77
+ path = yaml_path(sidekiq_cron_schedule_file) or return []
78
+ jobs = load_yaml(path)
79
+ jobs = jobs.map { |name, job| job.is_a?(Hash) ? job.merge("name" => name) : job } if jobs.is_a?(Hash)
80
+ Array(jobs).filter_map do |job|
81
+ next unless job.is_a?(Hash) && job["cron"] && job["status"].to_s != "disabled"
82
+
83
+ { "key" => job["name"].to_s, "class" => (job["klass"] || job["class"])&.to_s, "schedule" => job["cron"].to_s,
84
+ "source" => "sidekiq_cron", "time_zone" => local_time_zone }
85
+ end
86
+ rescue StandardError
87
+ []
88
+ end
89
+
90
+ def sidekiq_cron_schedule_file
91
+ configured = @config.sidekiq_cron_schedule_file
92
+ configured ||= ::Sidekiq::Cron.configuration.cron_schedule_file if ::Sidekiq::Cron.respond_to?(:configuration)
93
+ File.expand_path(configured || "config/schedule.yml", @root)
94
+ rescue StandardError
95
+ File.join(@root, "config", "schedule.yml")
96
+ end
97
+
98
+ # sidekiq-scheduler jobs from Sidekiq's config file, where it reads
99
+ # them under :scheduler: :schedule: (or :schedule: in older versions).
100
+ # A cron runs at set times; every and interval repeat from when the
101
+ # scheduler starts, which a deploy restarts. One-off at and in jobs
102
+ # aren't recurring, so they're left out.
103
+ def sidekiq_scheduler_schedules
104
+ return [] unless defined?(::SidekiqScheduler)
105
+
106
+ path = sidekiq_config_file or return []
107
+ config = normalize_keys(load_yaml(path) || {})
108
+ config = config.merge(config.delete(@environment) || {}) if config.is_a?(Hash)
109
+ jobs = config.dig("scheduler", "schedule") || config["schedule"]
110
+ return [] unless jobs.is_a?(Hash)
111
+
112
+ jobs.filter_map do |name, job|
113
+ next unless job.is_a?(Hash) && job["enabled"] != false && scheduled_in_this_environment?(job)
114
+
115
+ type = %w[cron every at in interval].find { |key| Array(job[key]).first.to_s.strip != "" }
116
+ next unless %w[cron every interval].include?(type)
117
+
118
+ value = Array(job[type]).first.to_s
119
+ schedule = { "key" => name.to_s, "class" => (job["class"] || name).to_s, "source" => "sidekiq_scheduler",
120
+ "time_zone" => local_time_zone }
121
+ type == "cron" ? schedule.merge("schedule" => value) : schedule.merge("schedule" => nil, "every" => value)
122
+ end
123
+ rescue StandardError
124
+ []
125
+ end
126
+
127
+ # The file Sidekiq loads: the -C path in the Procfile's sidekiq
128
+ # command, else config/sidekiq.yml.
129
+ def sidekiq_config_file
130
+ procfile = File.join(@root, "Procfile")
131
+ named = File.read(procfile)[/\bsidekiq\b[^\n]*?\s(?:-C|--config)[\s=]+(\S+)/, 1] if File.file?(procfile)
132
+ named ? yaml_path(File.expand_path(named, @root)) : yaml_path(File.join(@root, "config", "sidekiq.yml"))
133
+ rescue StandardError
134
+ nil
135
+ end
136
+
137
+ # sidekiq-scheduler skips a job whose rails_env doesn't list this one.
138
+ def scheduled_in_this_environment?(job)
139
+ job["rails_env"].nil? || job["rails_env"].to_s.gsub(/\s/, "").split(",").include?(@environment)
140
+ end
141
+
142
+ # The zone sidekiq-cron and sidekiq-scheduler read a schedule in when it
143
+ # names none: the process's local zone, as Fugit and rufus-scheduler
144
+ # find it. nil if that can't be told.
145
+ def local_time_zone
146
+ ::EtOrbi.determine_local_tzone&.name if defined?(::EtOrbi)
147
+ rescue StandardError
148
+ nil
149
+ end
150
+
151
+ # The path, or the same name with .yaml if only that exists.
152
+ def yaml_path(path)
153
+ [ path, path.sub(/\.yml\z/, ".yaml") ].find { |candidate| File.file?(candidate) }
154
+ end
155
+
156
+ def load_yaml(path)
157
+ YAML.safe_load(ERB.new(File.read(path), trim_mode: "-").result, permitted_classes: [ Symbol ], aliases: true)
158
+ end
159
+
160
+ # Sidekiq's config uses symbol keys (:scheduler:); compare them as
161
+ # plain strings.
162
+ def normalize_keys(object)
163
+ case object
164
+ when Hash then object.to_h { |key, value| [ key.to_s.delete_prefix(":"), normalize_keys(value) ] }
165
+ when Array then object.map { |value| normalize_keys(value) }
166
+ else object
167
+ end
168
+ end
169
+
170
+ def file_manifest
171
+ @file_manifest ||= begin
172
+ if @config.file_digests
173
+ paths = digest_globs.flat_map { |glob| Dir.glob(File.join(@root, glob)) }.select { |path| File.file?(path) }.uniq.sort
174
+ truncated = paths.size > MAX_FILES
175
+ files = paths.first(MAX_FILES).to_h do |path|
176
+ [ path.delete_prefix(@root).delete_prefix("/"), Digest::SHA256.file(path).hexdigest[0, DIGEST_LENGTH] ]
177
+ end
178
+ hash = Digest::SHA256.hexdigest(files.map { |path, digest| "#{path}:#{digest}" }.join("\n"))
179
+ { "hash" => hash, "count" => files.size, "truncated" => truncated, "files" => files }
180
+ else
181
+ { "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
182
+ end
183
+ end
184
+ rescue StandardError
185
+ { "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeployAngel
4
+ # Rack adapters. Inside this namespace a bare Rack means DeployAngel::Rack,
5
+ # so reach for the Rack gem itself as ::Rack.
6
+ module Rack
7
+ # The base for a framework's request middleware, not a middleware to use
8
+ # on its own: without an adapter naming routes, every successful request
9
+ # is unrouted and skipped, and only errors are recorded, as "unmatched".
10
+ # An adapter subclasses it, answers route_pattern (and rendered_exception
11
+ # if the framework renders exceptions itself), and is inserted at the top
12
+ # of the stack, so it sees the final status after the framework's own
13
+ # error pages. DeployAngel::Rails::Http is the Rails adapter.
14
+ #
15
+ # A request is only counted against a route pattern the framework
16
+ # matched, never the raw path, which keeps IDs out of route keys.
17
+ class Http
18
+ # Health checks served by a lambda or a mounted Rack app at a
19
+ # conventional path. Load balancers and uptime monitors call them all
20
+ # the time and they answer fast, so counting them would make any app
21
+ # look busy and healthy. Others can be left out with
22
+ # config.ignored_routes.
23
+ HEALTH_CHECK_PATHS = %w[up health healthz healthcheck health_check livez readyz statusz ping].freeze
24
+
25
+ def initialize(app)
26
+ @app = app
27
+ end
28
+
29
+ def call(env)
30
+ return @app.call(env) unless DeployAngel.recording?
31
+
32
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
+ Redaction.request_host = env["HTTP_HOST"]
34
+ begin
35
+ status, headers, body = @app.call(env)
36
+ rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
37
+ record(env, 500, started, unhandled: true, exception: e)
38
+ raise
39
+ end
40
+ # A framework that renders an exception itself swallows it before it
41
+ # reaches this middleware, and renders some as 4xx; only those that
42
+ # end in a 5xx count as unhandled.
43
+ exception = rendered_exception(env) if status.to_i >= 500
44
+ record(env, status, started, unhandled: !exception.nil?, exception: exception)
45
+ [ status, headers, body ]
46
+ end
47
+
48
+ private
49
+ # The route the framework matched, as a pattern with placeholders
50
+ # ("/users/:id"), or nil when nothing matched. Placeholders may be
51
+ # spelled :name, *name, or {name}; health_check? reads them, and
52
+ # otherwise the pattern is only ever compared with itself.
53
+ def route_pattern(env)
54
+ nil
55
+ end
56
+
57
+ # An exception the framework caught and rendered itself, or nil. Only
58
+ # asked for on a 5xx.
59
+ def rendered_exception(env)
60
+ nil
61
+ end
62
+
63
+ def route_key(env)
64
+ pattern = route_pattern(env) or return nil
65
+
66
+ "#{env["REQUEST_METHOD"]} #{pattern}"
67
+ end
68
+
69
+ # A static route ending in a health-check name, such as "/healthz" or
70
+ # "/api/livez". A route with a placeholder, such as
71
+ # "/patients/:id/health", is a real page about something, so it's
72
+ # recorded.
73
+ def health_check?(env)
74
+ pattern = route_pattern(env) or return false
75
+
76
+ segments = pattern.split("/").reject(&:empty?)
77
+ HEALTH_CHECK_PATHS.include?(segments.last) && segments.none? { |segment| placeholder?(segment) }
78
+ end
79
+
80
+ def placeholder?(segment)
81
+ segment.start_with?(":", "*") || segment.include?("{")
82
+ end
83
+
84
+ def record(env, status, started, unhandled:, exception: nil)
85
+ return if health_check?(env)
86
+
87
+ route = route_key(env)
88
+ return if route && DeployAngel.configuration.ignored_route?(route)
89
+ # Unrouted successes are static files and similar; unrouted
90
+ # errors (such as routing 404s) are still recorded.
91
+ return if route.nil? && status.to_i < 400
92
+
93
+ key = route || "#{env["REQUEST_METHOD"]} unmatched"
94
+ DeployAngel.record_exception(exception, source: "route:#{key}") if exception
95
+ DeployAngel.record_request(
96
+ route_key: key,
97
+ status: status.to_i,
98
+ duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0,
99
+ unhandled: unhandled,
100
+ # A 4xx no route matched is mostly bots probing paths like
101
+ # /wp-admin, or middleware turning requests away. It stays
102
+ # visible under "unmatched", but out of the app's totals, so it
103
+ # doesn't add to the evidence or dilute real pages' latency. An
104
+ # unrouted 5xx still counts: something broke.
105
+ in_totals: !route.nil? || status.to_i >= 500
106
+ )
107
+ rescue StandardError
108
+ nil
109
+ end
110
+ end
111
+ end
112
+ end
@@ -2,92 +2,53 @@
2
2
 
3
3
  module DeployAngel
4
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
5
+ # The Rails adapter: route identity from Action Dispatch, and the
6
+ # exceptions Rails renders itself. Everything else about recording a
7
+ # request is in DeployAngel::Rack::Http.
8
+ class Http < ::DeployAngel::Rack::Http
9
9
  FORMAT_SUFFIX = "(.:format)"
10
10
  # Health checks: Rails' own (/up by default), OkComputer, health_check,
11
- # and rails-healthcheck, wherever they're mounted. Load balancers and
12
- # uptime monitors call them all the time and they answer fast, so
13
- # counting them would make any app look busy and healthy. Others can
14
- # be left out with config.ignored_routes.
11
+ # and rails-healthcheck, wherever they're mounted. A lambda or a
12
+ # mounted Rack app at a conventional path is left out by the Rack
13
+ # adapter's HEALTH_CHECK_PATHS.
15
14
  HEALTH_CHECK_CONTROLLERS = %w[rails/health ok_computer/ok_computer health_check/health_check healthcheck/healthchecks].freeze
16
- # Health checks served by a lambda or a mounted Rack app rather than a
17
- # controller, at a conventional path such as /healthz.
18
- HEALTH_CHECK_PATHS = %w[up health healthz healthcheck health_check livez readyz statusz ping].freeze
19
-
20
- def initialize(app)
21
- @app = app
22
- end
23
-
24
- def call(env)
25
- return @app.call(env) unless DeployAngel.recording?
26
-
27
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
- Redaction.request_host = env["HTTP_HOST"]
29
- begin
30
- status, headers, body = @app.call(env)
31
- rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
32
- record(env, 500, started, unhandled: true, exception: e)
33
- raise
34
- end
35
- # Rails renders some exceptions as 4xx (routing errors, RecordNotFound,
36
- # ParameterMissing); only those that end in a 5xx count as unhandled.
37
- exception = env["action_dispatch.exception"] if status.to_i >= 500
38
- record(env, status, started, unhandled: !exception.nil?, exception: exception)
39
- [ status, headers, body ]
40
- end
41
15
 
42
16
  private
43
- def record(env, status, started, unhandled:, exception: nil)
44
- return if health_check?(env)
45
-
46
- route = route_key(env)
47
- return if route && DeployAngel.configuration.ignored_route?(route)
48
- # Unrouted successes are static files and similar; unrouted
49
- # errors (such as routing 404s) are still recorded.
50
- return if route.nil? && status.to_i < 400
17
+ def route_pattern(env)
18
+ pattern = env["action_dispatch.route_uri_pattern"] || env["action_dispatch.route"]&.path&.spec&.to_s
19
+ pattern&.delete_suffix(FORMAT_SUFFIX)
20
+ end
51
21
 
52
- key = route || "#{env["REQUEST_METHOD"]} unmatched"
53
- DeployAngel.record_exception(exception, source: "route:#{key}") if exception
54
- DeployAngel.record_request(
55
- route_key: key,
56
- status: status.to_i,
57
- duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0,
58
- unhandled: unhandled,
59
- # A 4xx no route matched is mostly bots probing paths like
60
- # /wp-admin, or middleware turning requests away. It stays
61
- # visible under "unmatched", but out of the app's totals, so it
62
- # doesn't add to the evidence or dilute real pages' latency. An
63
- # unrouted 5xx still counts: something broke.
64
- in_totals: !route.nil? || status.to_i >= 500
65
- )
66
- rescue StandardError
67
- nil
22
+ # Rails renders some exceptions as 4xx (routing errors,
23
+ # RecordNotFound, ParameterMissing), which is why only a 5xx asks.
24
+ def rendered_exception(env)
25
+ env["action_dispatch.exception"]
68
26
  end
69
27
 
70
28
  # A controller at a health-check path may be a real page, so only
71
- # controllerless routes are judged by their path.
29
+ # controllerless routes (a lambda or a mounted Rack app) are judged by
30
+ # their path, by its last segment as in 0.1.8. A controller decides
31
+ # anything else, so Rails needs none of the Rack base's placeholder
32
+ # rule.
72
33
  def health_check?(env)
73
- controller = env["action_dispatch.request.path_parameters"]&.dig(:controller)
34
+ controller = controller_name(env)
74
35
  return HEALTH_CHECK_CONTROLLERS.include?(controller) if controller
75
36
 
76
37
  pattern = route_pattern(env) or return false
77
- HEALTH_CHECK_PATHS.include?(pattern.delete_suffix(FORMAT_SUFFIX).split("/").last)
38
+ HEALTH_CHECK_PATHS.include?(pattern.split("/").last)
78
39
  end
79
40
 
41
+ # The matched pattern, else the controller and action, which a route
42
+ # reached without one still has.
80
43
  def route_key(env)
81
- method = env["REQUEST_METHOD"]
82
- if (pattern = route_pattern(env))
83
- "#{method} #{pattern.delete_suffix(FORMAT_SUFFIX)}"
84
- elsif (params = env["action_dispatch.request.path_parameters"]) && params[:controller]
85
- "#{method} #{params[:controller]}##{params[:action]}"
44
+ super || begin
45
+ controller = controller_name(env)
46
+ "#{env["REQUEST_METHOD"]} #{controller}##{env["action_dispatch.request.path_parameters"][:action]}" if controller
86
47
  end
87
48
  end
88
49
 
89
- def route_pattern(env)
90
- env["action_dispatch.route_uri_pattern"] || env["action_dispatch.route"]&.path&.spec&.to_s
50
+ def controller_name(env)
51
+ env["action_dispatch.request.path_parameters"]&.dig(:controller)
91
52
  end
92
53
  end
93
54
  end
@@ -1,39 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
- require "erb"
5
- require "yaml"
6
-
7
3
  module DeployAngel
8
4
  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
5
+ # The Rails adapter: the route table from the router, job classes from
6
+ # Active Job, and Solid Queue's recurring tasks. The schedule files
7
+ # sidekiq-cron and sidekiq-scheduler read, and the file digests, are
8
+ # gathered in DeployAngel::Metadata.
9
+ class Metadata < ::DeployAngel::Metadata
17
10
  FORMAT_SUFFIX = "(.:format)"
18
11
 
19
- def initialize(app:, config:, root:)
12
+ def initialize(app:, config:, root:, environment: nil)
13
+ super(config: config, root: root, environment: environment || rails_environment)
20
14
  @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
15
  end
34
16
 
35
- def files
36
- file_manifest["files"]
17
+ # Rails names the deployment, but this can be built with Rails not
18
+ # loaded (the bench does), and an unknown environment is better than a
19
+ # constructor that raises.
20
+ def rails_environment
21
+ defined?(::Rails) && ::Rails.respond_to?(:env) ? ::Rails.env.to_s : ""
37
22
  end
38
23
 
39
24
  def routes
@@ -59,11 +44,10 @@ module DeployAngel
59
44
  []
60
45
  end
61
46
 
62
- # Declared recurring jobs from Solid Queue, sidekiq-cron, and
63
- # sidekiq-scheduler. Each source is read on its own, so a file that
64
- # can't be read leaves the others' schedules in place.
47
+ # Solid Queue's schedule is Rails' own; the rest are read the same way
48
+ # on every framework.
65
49
  def schedules
66
- solid_queue_schedules + sidekiq_cron_schedules + sidekiq_scheduler_schedules
50
+ solid_queue_schedules + super
67
51
  end
68
52
 
69
53
  # Solid Queue recurring tasks for the current environment.
@@ -72,7 +56,7 @@ module DeployAngel
72
56
  return [] unless File.file?(path)
73
57
 
74
58
  config = YAML.safe_load(ERB.new(File.read(path)).result, aliases: true) || {}
75
- tasks = config.key?(::Rails.env) ? config[::Rails.env] : config
59
+ tasks = config.key?(@environment) ? config[@environment] : config
76
60
  Array(tasks).filter_map do |key, task|
77
61
  next unless task.is_a?(Hash) && task["schedule"]
78
62
 
@@ -85,107 +69,6 @@ module DeployAngel
85
69
  []
86
70
  end
87
71
 
88
- # sidekiq-cron jobs from its schedule file (config/schedule.yml unless
89
- # configured otherwise), which it loads when Sidekiq starts, or from
90
- # the file in config.sidekiq_cron_schedule_file for an app that loads
91
- # them itself. Jobs created in code live only in Redis, which the agent
92
- # doesn't read.
93
- def sidekiq_cron_schedules
94
- return [] unless defined?(::Sidekiq::Cron)
95
-
96
- path = yaml_path(sidekiq_cron_schedule_file) or return []
97
- jobs = load_yaml(path)
98
- jobs = jobs.map { |name, job| job.is_a?(Hash) ? job.merge("name" => name) : job } if jobs.is_a?(Hash)
99
- Array(jobs).filter_map do |job|
100
- next unless job.is_a?(Hash) && job["cron"] && job["status"].to_s != "disabled"
101
-
102
- { "key" => job["name"].to_s, "class" => (job["klass"] || job["class"])&.to_s, "schedule" => job["cron"].to_s,
103
- "source" => "sidekiq_cron", "time_zone" => local_time_zone }
104
- end
105
- rescue StandardError
106
- []
107
- end
108
-
109
- def sidekiq_cron_schedule_file
110
- configured = @config.sidekiq_cron_schedule_file
111
- configured ||= ::Sidekiq::Cron.configuration.cron_schedule_file if ::Sidekiq::Cron.respond_to?(:configuration)
112
- File.expand_path(configured || "config/schedule.yml", @root)
113
- rescue StandardError
114
- File.join(@root, "config", "schedule.yml")
115
- end
116
-
117
- # sidekiq-scheduler jobs from Sidekiq's config file, where it reads
118
- # them under :scheduler: :schedule: (or :schedule: in older versions).
119
- # A cron runs at set times; every and interval repeat from when the
120
- # scheduler starts, which a deploy restarts. One-off at and in jobs
121
- # aren't recurring, so they're left out.
122
- def sidekiq_scheduler_schedules
123
- return [] unless defined?(::SidekiqScheduler)
124
-
125
- path = sidekiq_config_file or return []
126
- config = normalize_keys(load_yaml(path) || {})
127
- config = config.merge(config.delete(::Rails.env.to_s) || {}) if config.is_a?(Hash)
128
- jobs = config.dig("scheduler", "schedule") || config["schedule"]
129
- return [] unless jobs.is_a?(Hash)
130
-
131
- jobs.filter_map do |name, job|
132
- next unless job.is_a?(Hash) && job["enabled"] != false && scheduled_in_this_environment?(job)
133
-
134
- type = %w[cron every at in interval].find { |key| Array(job[key]).first.to_s.strip != "" }
135
- next unless %w[cron every interval].include?(type)
136
-
137
- value = Array(job[type]).first.to_s
138
- schedule = { "key" => name.to_s, "class" => (job["class"] || name).to_s, "source" => "sidekiq_scheduler",
139
- "time_zone" => local_time_zone }
140
- type == "cron" ? schedule.merge("schedule" => value) : schedule.merge("schedule" => nil, "every" => value)
141
- end
142
- rescue StandardError
143
- []
144
- end
145
-
146
- # The file Sidekiq loads: the -C path in the Procfile's sidekiq
147
- # command, else config/sidekiq.yml.
148
- def sidekiq_config_file
149
- procfile = File.join(@root, "Procfile")
150
- named = File.read(procfile)[/\bsidekiq\b[^\n]*?\s(?:-C|--config)[\s=]+(\S+)/, 1] if File.file?(procfile)
151
- named ? yaml_path(File.expand_path(named, @root)) : yaml_path(File.join(@root, "config", "sidekiq.yml"))
152
- rescue StandardError
153
- nil
154
- end
155
-
156
- # sidekiq-scheduler skips a job whose rails_env doesn't list this one.
157
- def scheduled_in_this_environment?(job)
158
- job["rails_env"].nil? || job["rails_env"].to_s.gsub(/\s/, "").split(",").include?(::Rails.env.to_s)
159
- end
160
-
161
- # The zone sidekiq-cron and sidekiq-scheduler read a schedule in when it
162
- # names none: the process's local zone, as Fugit and rufus-scheduler
163
- # find it. nil if that can't be told.
164
- def local_time_zone
165
- ::EtOrbi.determine_local_tzone&.name if defined?(::EtOrbi)
166
- rescue StandardError
167
- nil
168
- end
169
-
170
- # The path, or the same name with .yaml if only that exists.
171
- def yaml_path(path)
172
- [ path, path.sub(/\.yml\z/, ".yaml") ].find { |candidate| File.file?(candidate) }
173
- end
174
-
175
- def load_yaml(path)
176
- YAML.safe_load(ERB.new(File.read(path), trim_mode: "-").result, permitted_classes: [ Symbol ], aliases: true)
177
- end
178
-
179
- # Sidekiq's config uses symbol keys (:scheduler:); compare them as
180
- # plain strings.
181
- def normalize_keys(object)
182
- case object
183
- when Hash then object.to_h { |key, value| [ key.to_s.delete_prefix(":"), normalize_keys(value) ] }
184
- when Array then object.map { |value| normalize_keys(value) }
185
- else object
186
- end
187
- end
188
-
189
72
  # A command task runs as a job of this class, so its runs show up in
190
73
  # job telemetry under that name.
191
74
  def command_job_class
@@ -205,24 +88,6 @@ module DeployAngel
205
88
  rescue StandardError
206
89
  nil
207
90
  end
208
-
209
- def file_manifest
210
- @file_manifest ||= begin
211
- if @config.file_digests
212
- paths = DIGEST_GLOBS.flat_map { |glob| Dir.glob(File.join(@root, glob)) }.select { |path| File.file?(path) }.sort
213
- truncated = paths.size > MAX_FILES
214
- files = paths.first(MAX_FILES).to_h do |path|
215
- [ path.delete_prefix(@root).delete_prefix("/"), Digest::SHA256.file(path).hexdigest[0, DIGEST_LENGTH] ]
216
- end
217
- hash = Digest::SHA256.hexdigest(files.map { |path, digest| "#{path}:#{digest}" }.join("\n"))
218
- { "hash" => hash, "count" => files.size, "truncated" => truncated, "files" => files }
219
- else
220
- { "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
221
- end
222
- end
223
- rescue StandardError
224
- { "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
225
- end
226
91
  end
227
92
  end
228
93
  end
@@ -29,7 +29,7 @@ module DeployAngel
29
29
  framework_version: ::Rails.version,
30
30
  logger: ::Rails.logger
31
31
  )
32
- agent&.metadata = DeployAngel::Rails::Metadata.new(app: app, config: DeployAngel.configuration, root: ::Rails.root.to_s)
32
+ agent&.metadata = DeployAngel::Rails::Metadata.new(app: app, config: DeployAngel.configuration, root: ::Rails.root.to_s, environment: ::Rails.env)
33
33
  end
34
34
  end
35
35
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.10"
4
+ VERSION = "0.1.12"
5
5
  end
data/lib/deployangel.rb CHANGED
@@ -111,9 +111,11 @@ module DeployAngel
111
111
  end
112
112
  end
113
113
 
114
+ require_relative "deployangel/rack/http"
114
115
  require_relative "deployangel/rails/http"
115
116
  require_relative "deployangel/rails/active_job"
116
117
  require_relative "deployangel/rails/error_subscriber"
118
+ require_relative "deployangel/metadata"
117
119
  require_relative "deployangel/rails/metadata"
118
120
  require_relative "deployangel/sidekiq"
119
121
  require_relative "deployangel/apartment"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: deployangel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.10
4
+ version: 0.1.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Owens
@@ -9,9 +9,11 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: Aggregates HTTP request behavior in-process and reports one small payload
13
- per process per minute to DeployAngel, which verifies each deployment and tells
14
- you when it is safe to stop watching it.
12
+ description: Aggregates HTTP requests, background jobs, exceptions, and recurring
13
+ schedules in-process and reports one small payload per process per minute to DeployAngel,
14
+ which verifies each deployment and tells you, or your coding agent, when a release
15
+ is cleared and safe to stop watching. It never sends request bodies, parameters,
16
+ SQL, or user data.
15
17
  email:
16
18
  - jordan@deployangel.com
17
19
  executables:
@@ -45,6 +47,8 @@ files:
45
47
  - lib/deployangel/core/transport.rb
46
48
  - lib/deployangel/fork_hook.rb
47
49
  - lib/deployangel/mcp.rb
50
+ - lib/deployangel/metadata.rb
51
+ - lib/deployangel/rack/http.rb
48
52
  - lib/deployangel/rails/active_job.rb
49
53
  - lib/deployangel/rails/error_subscriber.rb
50
54
  - lib/deployangel/rails/http.rb