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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +30 -3
- data/lib/deployangel/cli/formatter.rb +157 -31
- data/lib/deployangel/cli.rb +40 -1
- data/lib/deployangel/core/release.rb +8 -2
- data/lib/deployangel/mcp.rb +18 -0
- data/lib/deployangel/metadata.rb +188 -0
- data/lib/deployangel/rack/http.rb +112 -0
- data/lib/deployangel/rails/http.rb +28 -67
- data/lib/deployangel/rails/metadata.rb +16 -151
- data/lib/deployangel/rails/railtie.rb +1 -1
- data/lib/deployangel/version.rb +1 -1
- data/lib/deployangel.rb +2 -0
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c3835d3c334a91d9ad7a75a3247ede4797a27e207b123f684bc1563fffd33cb0
|
|
4
|
+
data.tar.gz: c578f30dc3f828dabce95b7c4e48682b1248d306b0982d50483f17d3c3cf57c8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
`
|
|
389
|
-
`register_deployment` when the token allows it.
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
75
|
+
notes << "Initial check: #{check["result"].tr("_", " ")}. Not cleared yet."
|
|
29
76
|
end
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"#{
|
|
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 << " #{
|
|
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)
|
data/lib/deployangel/cli.rb
CHANGED
|
@@ -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
|
|
23
|
-
build(env["HEROKU_RELEASE_VERSION"], env
|
|
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
|
data/lib/deployangel/mcp.rb
CHANGED
|
@@ -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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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.
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
"#{
|
|
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
|
|
90
|
-
env["action_dispatch.
|
|
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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
#
|
|
63
|
-
#
|
|
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 +
|
|
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?(
|
|
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
|
data/lib/deployangel/version.rb
CHANGED
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.
|
|
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
|
|
13
|
-
per process per minute to DeployAngel,
|
|
14
|
-
you
|
|
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
|