deployangel 0.1.10 → 0.1.11

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: 832e7ada784cf0e86d34f802ef299dfd9114b844f542444a69331b7d622e3d35
4
+ data.tar.gz: 31c8f9e2efc46a96325e7813c8b5902c23cbf758c55dccdb3536048af881f7a7
5
5
  SHA512:
6
- metadata.gz: 5c899072c049b8854d5772c05be7cb95588d427a571324ab303134418081b3815ae39eb6efaea92b1e46f6857f24379674f9298050435638bb674baca942aeb7
7
- data.tar.gz: 3ce76ca3b6232505e4d1afaa95348939a1ffcac19fe02cf296f30fdf02fd447aba85991c5d8b292ace00d19b38a0e73418a668d0cef8993a86119a1a3d2abc26
6
+ metadata.gz: a0e4683b471211a023e925fa4cbacdd57f908c0a1c1ee18af950490b291b47c9cfe9a33bb4d3a5e376e5992f8d478919167d0d32320448c5c3c362a9873e6afa
7
+ data.tar.gz: 2cc10162cd04a158808c71eb51fb30b36716a3d9ca8e70c231d35289de887810194bf0b172481fd0f68d5ad025e36e3b8dda6b34335b2701b7b9b6980e5ea1d0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.11 (2026-10-05)
4
+
5
+ - Heroku: the commit comes from `HEROKU_BUILD_COMMIT`
6
+ (`heroku labs:enable runtime-dyno-build-metadata`) when it's set, and from
7
+ `HEROKU_SLUG_COMMIT`, which Heroku has deprecated, otherwise.
8
+ - In GitHub Actions, `deployangel verify` and `status` also add the verdict,
9
+ its failing and warning findings, new exceptions, and a dashboard link to
10
+ the job's summary page (`GITHUB_STEP_SUMMARY`). Output and exit codes are
11
+ unchanged.
12
+
3
13
  ## 0.1.10 (2026-10-04)
4
14
 
5
15
  - `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
@@ -18,45 +18,113 @@ module DeployAngel
18
18
  module_function
19
19
 
20
20
  def verification(document)
21
- 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(" · ")
27
- if (check = v["initial_check"]) && v["verdict"].nil?
28
- lines << "Initial check: #{check["result"].tr("_", " ")}. Not cleared yet."
21
+ lines = [ statement(document), status_line(document), *notes(document) ]
22
+ problems = problems(document)
23
+ if problems.any?
24
+ lines << "Findings:"
25
+ problems.each { |f| lines << " #{f["status"].ljust(8)} #{finding_line(f)}" }
29
26
  end
30
- lines << "Expected clearance: #{v["expected_clearance_at"]}" if v["expected_clearance_at"] && v["verdict"].nil?
31
- if (source = document.dig("deployment", "promoted_from"))
32
- 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})"
27
+ if (gathering = gathering_line(document))
28
+ lines << "#{gathering} (--format=json for details)"
34
29
  end
30
+ sections(document).each { |title, items| section(lines, title, items) }
31
+ lines << document["dashboard_url"] if document["dashboard_url"]
32
+ lines.compact.join("\n")
33
+ end
35
34
 
36
- findings = Array(document["findings"])
37
- problems = findings.select { |f| %w[failing warning].include?(f["status"]) }
35
+ # The same document as GitHub-flavored Markdown, for a CI job's
36
+ # summary page.
37
+ def markdown(document)
38
+ lines = [ "### DeployAngel: #{release_name(document)} #{outcome(document)}", "", statement(document).to_s,
39
+ "", status_line(document), *notes(document).map { |note| "\n#{note}" } ]
40
+ problems = problems(document)
38
41
  if problems.any?
39
- lines << "Findings:"
40
- problems.each { |f| lines << " #{f["status"].ljust(8)} #{finding_line(f)}" }
42
+ lines.push("", "| Status | Finding |", "| --- | --- |")
43
+ problems.each { |f| lines << "| #{f["status"]} | #{cell(finding_line(f))} |" }
41
44
  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?}"
45
+ if (gathering = gathering_line(document))
46
+ lines.push("", gathering)
46
47
  end
47
- unless v["verdict"] == "failed"
48
- section(lines, "Missing evidence", Array(clearance["missing_evidence"])) { |item| item }
48
+ sections(document).each do |title, items|
49
+ lines.push("", "**#{title}**", "")
50
+ items.each { |item| lines << "- #{item}" }
49
51
  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"]}"
52
+ lines.push("", "[Open in DeployAngel](#{document["dashboard_url"]})") if document["dashboard_url"]
53
+ lines.compact.join("\n") + "\n"
54
+ end
55
+
56
+ def statement(document)
57
+ (document["clearance"] || {})["statement"] || document["summary"]
58
+ end
59
+
60
+ def status_line(document)
61
+ v = document["verification"] || {}
62
+ [ "State: #{v["state"]}", ("Verdict: #{v["verdict"]}" if v["verdict"]),
63
+ ("Confidence: #{v["confidence"]}" if v["confidence"]),
64
+ ("Coverage: #{(v["coverage"] * 100).round}%" if v["coverage"]) ].compact.join(" · ")
65
+ end
66
+
67
+ def notes(document)
68
+ v = document["verification"] || {}
69
+ notes = []
70
+ if (check = v["initial_check"]) && v["verdict"].nil?
71
+ notes << "Initial check: #{check["result"].tr("_", " ")}. Not cleared yet."
54
72
  end
73
+ notes << "Expected clearance: #{v["expected_clearance_at"]}" if v["expected_clearance_at"] && v["verdict"].nil?
74
+ if (source = document.dig("deployment", "promoted_from"))
75
+ outcome = { "verified" => "cleared", "failed" => "failed", "inconclusive" => "not cleared" }.fetch(source["verdict"], "still verifying")
76
+ notes << "Promoted from #{source["environment"]} #{source["version"] || source["commit"].to_s[0, 7]} (#{outcome})"
77
+ end
78
+ notes
79
+ end
80
+
81
+ def problems(document)
82
+ Array(document["findings"]).select { |f| %w[failing warning].include?(f["status"]) }
83
+ end
84
+
85
+ def gathering_line(document)
86
+ gathering = Array(document["findings"]).count { |f| f["status"] == "insufficient_data" }
87
+ "#{gathering} signal#{"s" unless gathering == 1} still gathering data" if gathering.positive?
88
+ end
89
+
90
+ # Titled lists that follow the findings, each item already a line.
91
+ def sections(document)
92
+ v = document["verification"] || {}
93
+ clearance = document["clearance"] || {}
55
94
  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")
95
+ [
96
+ [ "New exceptions", Array(document["exceptions"]).map do |e|
97
+ "#{e["exception_class"]} in #{e["top_frame"]} (#{e["count"]}x)#{" #{e["sources"].keys.join(", ")}" if e["sources"].is_a?(Hash) && e["sources"].any?}"
98
+ end ],
99
+ [ "Missing evidence", v["verdict"] == "failed" ? [] : Array(clearance["missing_evidence"]) ],
100
+ [ "Not observable", Array(clearance["not_observable"]).map { |item| "#{item["label"]} (#{item["reason"]})" } ],
101
+ [ "Still watching", Array(clearance["still_watching"]).map { |e| "#{e["job_class"]} (expected by #{e["expected_by"]})" } ],
102
+ [ "Critical flows", Array(document["critical_flows"]).map do |flow|
103
+ "#{flow["name"]}: #{flow["status"].tr("_", " ")}#{" (changed in this release)" if flow["changed_in_release"]}"
104
+ end ],
105
+ [ "Changed but not yet exercised", changed.map { |item| item["key"] } ],
106
+ [ "Late regressions", Array(document["late_regressions"]).map { |late| late["summary"] } ]
107
+ ].reject { |_, items| items.empty? }
108
+ end
109
+
110
+ def release_name(document)
111
+ deployment = document["deployment"] || {}
112
+ deployment["version"] || deployment["commit"].to_s[0, 7]
113
+ end
114
+
115
+ def outcome(document)
116
+ v = document["verification"] || {}
117
+ case v["verdict"]
118
+ when "verified" then "cleared"
119
+ when "failed" then "failed"
120
+ when "inconclusive" then "not cleared"
121
+ else
122
+ case v.dig("initial_check", "result")
123
+ when "warnings" then "has warnings, not cleared yet"
124
+ when nil then "is still being verified"
125
+ else "has no problems so far, not cleared yet"
126
+ end
127
+ end
60
128
  end
61
129
 
62
130
  def exception(details)
@@ -82,10 +150,12 @@ module DeployAngel
82
150
  end
83
151
 
84
152
  def section(lines, title, items)
85
- return if items.empty?
86
-
87
153
  lines << "#{title}:"
88
- items.each { |item| lines << " #{yield(item)}" }
154
+ items.each { |item| lines << " #{item}" }
155
+ end
156
+
157
+ def cell(text)
158
+ text.gsub("|", "\\|")
89
159
  end
90
160
 
91
161
  def value(finding, raw)
@@ -139,11 +139,13 @@ module DeployAngel
139
139
  outcome = waiter.wait(target, until_mode: options[:until_mode], timeout: options[:timeout], wait: options[:wait])
140
140
  if outcome.not_found
141
141
  @stderr.puts("deployangel: no deployment found for #{target.values.first}")
142
+ step_summary("### DeployAngel: no deployment found for #{target.values.first}\n")
142
143
  return outcome.exit_code
143
144
  end
144
145
 
145
146
  document = options[:all_findings] ? client.verification(outcome.document.dig("deployment", "id"), all_findings: true) : outcome.document
146
147
  output(document, options[:format]) { Formatter.verification(document) }
148
+ step_summary(Formatter.markdown(document))
147
149
  @stderr.puts("deployangel: timed out; verification is still in progress") if outcome.timed_out
148
150
  outcome.exit_code
149
151
  end
@@ -228,6 +230,16 @@ module DeployAngel
228
230
  @stdout.puts(format == "json" ? JSON.pretty_generate(document) : yield)
229
231
  end
230
232
 
233
+ # In GitHub Actions, the verdict also goes on the run's summary page.
234
+ def step_summary(markdown)
235
+ path = @env["GITHUB_STEP_SUMMARY"].to_s
236
+ return if path.empty?
237
+
238
+ File.open(path, "a") { |file| file.puts(markdown) }
239
+ rescue SystemCallError => e
240
+ @stderr.puts("deployangel: couldn't write the job summary (#{e.message})")
241
+ end
242
+
231
243
  def parse_duration(value)
232
244
  match = value.to_s.match(/\A(\d+)(s|m|h)?\z/) or raise OptionParser::InvalidArgument, "--timeout=#{value}"
233
245
  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
@@ -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.11"
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.11
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