deployangel 0.1.35 → 0.1.36
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 +11 -0
- data/README.md +18 -4
- data/lib/deployangel/cli.rb +35 -8
- data/lib/deployangel/exerciser.rb +62 -13
- data/lib/deployangel/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb03031f58bd01feb61e1f478f4108eb3c969527e28cf86d970355a6bede0031
|
|
4
|
+
data.tar.gz: 3dd9679d37e80b23e449ee99ffd29bd3db9700bf8e2b5bfa665bf4413e2e4fb7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bbaff8f03fce84081cdf13786dac7d26912ffeb9eae1c3f032f91d3b00afd23ba95a076c6213846cd44390017e8b7f8cdfd7b63833a07fcef9f782b4e9576dc0
|
|
7
|
+
data.tar.gz: 2919b83e169d1ac91175c733d9366df8e05b4910f8722a99cb3fdae57a07dda205be55dce418f2c0c7e1d8e382dc17a9007e23c96126b0174d24ed24588c13be
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.36 (2026-11-01)
|
|
4
|
+
|
|
5
|
+
- `deployangel exercise --param id=42` fills path parameters (`:id`,
|
|
6
|
+
`{id}`, `<int:id>`), so routes like `GET /apps/:app_id/deployments/:id`
|
|
7
|
+
can be exercised. Skipped routes name the parameters they need.
|
|
8
|
+
- `deployangel exercise --header="Name: value"`, or
|
|
9
|
+
`DEPLOYANGEL_EXERCISE_HEADERS` (one per line), sends headers with every
|
|
10
|
+
request, such as a Bearer token or a test account's session cookie. The
|
|
11
|
+
output names them but never shows their values, and they go only to the
|
|
12
|
+
`--url` host.
|
|
13
|
+
|
|
3
14
|
## 0.1.35 (2026-11-01)
|
|
4
15
|
|
|
5
16
|
- `deployangel exercise` prints each route's status codes (`404 ×1`)
|
data/README.md
CHANGED
|
@@ -597,8 +597,8 @@ evidence. A release deployed during an app's first 24 hours can't clear,
|
|
|
597
597
|
so its plan lists nothing.
|
|
598
598
|
|
|
599
599
|
`deployangel exercise --url=<production URL>` does the read-only part for
|
|
600
|
-
you, from wherever it runs: it requests the plan's GET routes
|
|
601
|
-
|
|
600
|
+
you, from wherever it runs: it requests the plan's GET routes whose path
|
|
601
|
+
parameters it can fill, spreading any request shortfall across them (at most 200
|
|
602
602
|
requests, about 5 a second, as `DeployAngel-Exercise`), then records what it
|
|
603
603
|
sent on the release, where the page lists it under "Exercised from your
|
|
604
604
|
side". It skips and names routes that change data or need a path
|
|
@@ -608,6 +608,19 @@ every page answers 404, 405, or 410, it records nothing and exits 5, since
|
|
|
608
608
|
the URL is probably not the app's own host (a marketing site, say). A "CLI
|
|
609
609
|
& coding agents" token can run it. `--dry-run` shows what it would send.
|
|
610
610
|
|
|
611
|
+
`--param id=42` fills a path parameter (`:id`, `{id}`, `<int:id>`) in every
|
|
612
|
+
route that has it, so `--param app_id=3 --param id=212` reaches
|
|
613
|
+
`GET /apps/:app_id/deployments/:id`. A value fills every route with that
|
|
614
|
+
name, so a wrong one just gets a 404 and no more requests.
|
|
615
|
+
|
|
616
|
+
`--header="Name: value"` sends a header with every request, such as
|
|
617
|
+
`Authorization: Bearer …` for an API or a test account's session cookie for
|
|
618
|
+
signed-in pages. Put secrets in `DEPLOYANGEL_EXERCISE_HEADERS` instead (one
|
|
619
|
+
header per line), which keeps them out of shell history. Headers go only to
|
|
620
|
+
the `--url` host, since redirects aren't followed. The output names them
|
|
621
|
+
but never shows their values, and they're never sent to DeployAngel. Use a
|
|
622
|
+
test account with as little access as possible: its cookie is a full sign-in.
|
|
623
|
+
|
|
611
624
|
### Suggested instructions for your coding agent
|
|
612
625
|
|
|
613
626
|
`deployangel install agents` adds instructions like these. To add them
|
|
@@ -639,8 +652,9 @@ verdict: call the `wait_for_verification` MCP tool with the commit and
|
|
|
639
652
|
If its status is "exercisable" or "waiting_for_activity", run
|
|
640
653
|
`bundle exec deployangel exercise --url=<production URL>`: it sends the
|
|
641
654
|
plan's read-only requests and records them on the release. Offer to
|
|
642
|
-
exercise what it skips:
|
|
643
|
-
|
|
655
|
+
exercise what it skips: fill a route's IDs with `--param name=value`,
|
|
656
|
+
using an ID you can see in the app, and run routes that change data only
|
|
657
|
+
with a test account or after asking. If the status is "warm_up", nothing run can clear it.
|
|
644
658
|
```
|
|
645
659
|
|
|
646
660
|
## Development
|
data/lib/deployangel/cli.rb
CHANGED
|
@@ -34,7 +34,8 @@ module DeployAngel
|
|
|
34
34
|
plan What to exercise so a release [--commit=SHA | --version=V | --deployment=ID]
|
|
35
35
|
clears sooner [--format=text|json]
|
|
36
36
|
exercise Send the plan's read-only --url=PRODUCTION_URL [--commit=SHA | --version=V | --deployment=ID]
|
|
37
|
-
requests to production [--
|
|
37
|
+
requests to production [--param=NAME=VALUE] [--header="Name: value"]
|
|
38
|
+
[--max-requests=200] [--dry-run]
|
|
38
39
|
exception Details for a fingerprint deployangel exception FINGERPRINT
|
|
39
40
|
check Report a smoke test result --name=NAME --status=pass|fail [--covers=a,b]
|
|
40
41
|
[--commit=SHA | --deployment=ID] [--details-url=URL]
|
|
@@ -116,8 +117,9 @@ module DeployAngel
|
|
|
116
117
|
If its status is "exercisable" or "waiting_for_activity", run
|
|
117
118
|
`bundle exec deployangel exercise --url=<production URL>`: it sends the
|
|
118
119
|
plan's read-only requests and records them on the release. Offer to
|
|
119
|
-
exercise what it skips:
|
|
120
|
-
|
|
120
|
+
exercise what it skips: fill a route's IDs with `--param name=value`,
|
|
121
|
+
using an ID you can see in the app, and run routes that change data only
|
|
122
|
+
with a test account or after asking. If the status is "warm_up", nothing run can clear it.
|
|
121
123
|
- Confidence `medium` clears a release just as `high` does (high also needs 80%
|
|
122
124
|
coverage and a full baseline), so don't call a medium clearance weak.
|
|
123
125
|
Warnings (`suspicious: true`) aren't failures: report them as "watching
|
|
@@ -282,8 +284,10 @@ module DeployAngel
|
|
|
282
284
|
# Sends the plan's read-only requests from here, then records what it
|
|
283
285
|
# sent on the release (spec §16, Exercise records).
|
|
284
286
|
def exercise
|
|
285
|
-
options = parse(max_requests: Exerciser::MAX_REQUESTS, dry_run: false) do |o, opts|
|
|
287
|
+
options = parse(max_requests: Exerciser::MAX_REQUESTS, dry_run: false, headers: [], params: []) do |o, opts|
|
|
286
288
|
o.on("--url=URL") { |v| opts[:url] = v }
|
|
289
|
+
o.on("--header=HEADER") { |v| opts[:headers] << v }
|
|
290
|
+
o.on("--param=NAME=VALUE") { |v| opts[:params] << v }
|
|
287
291
|
o.on("--commit=SHA") { |v| opts[:commit] = v }
|
|
288
292
|
o.on("--version=VERSION") { |v| opts[:version] = v }
|
|
289
293
|
o.on("--deployment=ID") { |v| opts[:deployment_id] = v }
|
|
@@ -292,6 +296,15 @@ module DeployAngel
|
|
|
292
296
|
end
|
|
293
297
|
return usage_error("exercise needs --url, the app's production URL, like https://example.com") unless options[:url].to_s.match?(%r{\Ahttps?://[^/\s]+})
|
|
294
298
|
|
|
299
|
+
# DEPLOYANGEL_EXERCISE_HEADERS, one per line, keeps a secret out of
|
|
300
|
+
# shell history and the process list.
|
|
301
|
+
begin
|
|
302
|
+
headers = Exerciser.parse_headers(options[:headers] + @env["DEPLOYANGEL_EXERCISE_HEADERS"].to_s.split("\n"))
|
|
303
|
+
params = Exerciser.parse_params(options[:params])
|
|
304
|
+
rescue ArgumentError => e
|
|
305
|
+
return usage_error(e.message)
|
|
306
|
+
end
|
|
307
|
+
|
|
295
308
|
target = target_from(options)
|
|
296
309
|
return usage_error("no target: pass --commit, --version, or --deployment, or run inside a git repository") unless target
|
|
297
310
|
|
|
@@ -308,19 +321,22 @@ module DeployAngel
|
|
|
308
321
|
return 0
|
|
309
322
|
end
|
|
310
323
|
|
|
311
|
-
exerciser = Exerciser.new(base_url: options[:url], max_requests: options[:max_requests].clamp(1, Exerciser::MAX_REQUESTS),
|
|
324
|
+
exerciser = Exerciser.new(base_url: options[:url], headers: headers, params: params, max_requests: options[:max_requests].clamp(1, Exerciser::MAX_REQUESTS),
|
|
312
325
|
sleeper: @sleeper, requester: @requester)
|
|
326
|
+
header_names = ("With headers: #{headers.keys.join(", ")} (values not shown)" if headers.any?)
|
|
313
327
|
if options[:dry_run]
|
|
314
328
|
targets, skipped = exerciser.plan(plan)
|
|
315
329
|
@stdout.puts("Would send #{targets.sum(&:count)} requests:", *targets.map { |t| " #{exerciser.url_for(t.path)} ×#{t.count}" })
|
|
316
|
-
@stdout.puts(
|
|
330
|
+
@stdout.puts(header_names) if header_names
|
|
331
|
+
print_skipped(skipped)
|
|
317
332
|
return 0
|
|
318
333
|
end
|
|
319
334
|
|
|
320
335
|
result = exerciser.run(plan)
|
|
321
336
|
@stdout.puts("Sent #{result.sent} requests to #{options[:url]}:",
|
|
322
337
|
*result.routes.map { |r| " #{r["key"]} ×#{r["requests"]}: #{status_codes(result.codes[r["key"]])}" })
|
|
323
|
-
@stdout.puts(
|
|
338
|
+
@stdout.puts(header_names) if header_names
|
|
339
|
+
print_skipped(result.skipped)
|
|
324
340
|
@stdout.puts("Stopped early: #{options[:url]} isn't answering.") if result.unreachable
|
|
325
341
|
# Requests that reached no page aren't worth recording: the app
|
|
326
342
|
# likely never saw them.
|
|
@@ -332,12 +348,23 @@ module DeployAngel
|
|
|
332
348
|
return USAGE_ERROR
|
|
333
349
|
end
|
|
334
350
|
if result.routes.any?
|
|
335
|
-
client.record_exercise(document.dig("deployment", "id"), routes: result.routes,
|
|
351
|
+
client.record_exercise(document.dig("deployment", "id"), routes: result.routes,
|
|
352
|
+
skipped: result.skipped.map { |s| s.except("missing") }, ran_at: @clock.call.utc.iso8601)
|
|
336
353
|
@stdout.puts("Recorded on the release. Wait for the verdict with: deployangel verify --wait")
|
|
337
354
|
end
|
|
338
355
|
result.unreachable ? USAGE_ERROR : 0
|
|
339
356
|
end
|
|
340
357
|
|
|
358
|
+
# Each skipped route and why, naming the parameters a --param would fill.
|
|
359
|
+
def print_skipped(skipped)
|
|
360
|
+
return if skipped.empty?
|
|
361
|
+
|
|
362
|
+
@stdout.puts("Skipped:", *skipped.map { |s| " #{s["key"]} (#{s["reason"]}#{": #{s["missing"].join(", ")}" if s["missing"]&.any?})" })
|
|
363
|
+
return unless skipped.any? { |s| s["missing"]&.any? }
|
|
364
|
+
|
|
365
|
+
@stdout.puts("Fill path parameters with --param, such as --param #{skipped.find { |s| s["missing"]&.any? }["missing"].first}=42.")
|
|
366
|
+
end
|
|
367
|
+
|
|
341
368
|
# "200 ×4, 500 ×1", in the order they came.
|
|
342
369
|
def status_codes(codes)
|
|
343
370
|
codes.map { |code, n| "#{code || "no answer"} ×#{n}" }.join(", ")
|
|
@@ -7,9 +7,10 @@ module DeployAngel
|
|
|
7
7
|
# `deployangel exercise`: sends a release's exercise plan's read-only
|
|
8
8
|
# requests to production from the customer's side, then records what it
|
|
9
9
|
# sent, so the release page says which routes were exercised that way.
|
|
10
|
-
# Only GET routes
|
|
11
|
-
# the
|
|
12
|
-
#
|
|
10
|
+
# Only GET routes not marked as changing data, and only those whose path
|
|
11
|
+
# parameters the caller filled (--param id=42); the rest are skipped and
|
|
12
|
+
# named, for the agent or a smoke test with a test account. The requests
|
|
13
|
+
# count like any traffic through the app's agent.
|
|
13
14
|
class Exerciser
|
|
14
15
|
MAX_REQUESTS = 200
|
|
15
16
|
INTERVAL = 0.2 # about 5 requests a second
|
|
@@ -19,8 +20,14 @@ module DeployAngel
|
|
|
19
20
|
# A route table can list pages an app doesn't serve, like the edit page
|
|
20
21
|
# of a resource that has none. One answer like this is enough to know.
|
|
21
22
|
NOT_SERVED = [ 404, 405, 410 ].freeze
|
|
22
|
-
# Rails' :id and *path,
|
|
23
|
-
|
|
23
|
+
# A path parameter, capturing its name: Rails' :id and *path, FastAPI's
|
|
24
|
+
# and ASP.NET Core's {id}, {id:int}, and {*path}, Django's <int:pk>.
|
|
25
|
+
PARAMETER = /[:*]([A-Za-z_]\w*)|\{\*{0,2}([A-Za-z_]\w*)(?::[^}]*)?\??\}|<(?:\w+:)?([A-Za-z_]\w*)>/
|
|
26
|
+
# One that can span segments, so its value keeps its slashes.
|
|
27
|
+
GLOB = %r{\A(?:\*|\{\*|\{\w+:path\}|<path:)}
|
|
28
|
+
PARAM = /\A([A-Za-z_]\w*)=(.+)\z/
|
|
29
|
+
# "Name: value", as with curl -H: a header name, then a value on one line.
|
|
30
|
+
HEADER = /\A([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*(\S[^\r\n]*)\z/
|
|
24
31
|
|
|
25
32
|
Target = Struct.new(:key, :path, :count)
|
|
26
33
|
# codes: each route's exact status codes, nil for no answer, for
|
|
@@ -36,8 +43,30 @@ module DeployAngel
|
|
|
36
43
|
end
|
|
37
44
|
end
|
|
38
45
|
|
|
39
|
-
|
|
46
|
+
# Headers to send with every request, such as a token or a test
|
|
47
|
+
# account's session cookie, from "Name: value" lines. Raises
|
|
48
|
+
# ArgumentError naming the line, never its value.
|
|
49
|
+
def self.parse_headers(lines)
|
|
50
|
+
lines.map(&:strip).reject(&:empty?).each_with_index.to_h do |line, i|
|
|
51
|
+
match = HEADER.match(line) or raise ArgumentError, "header #{i + 1} must look like \"Name: value\""
|
|
52
|
+
[ match[1], match[2].rstrip ]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Path parameter values, from "name=value" (--param id=42). Each fills
|
|
57
|
+
# that name in every route that has it.
|
|
58
|
+
def self.parse_params(pairs)
|
|
59
|
+
pairs.to_h do |pair|
|
|
60
|
+
match = PARAM.match(pair) or raise ArgumentError, "--param must look like name=value, such as --param id=42 (got #{pair.inspect})"
|
|
61
|
+
[ match[1], match[2] ]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# headers go only to base_url's host: redirects aren't followed.
|
|
66
|
+
def initialize(base_url:, headers: {}, params: {}, max_requests: MAX_REQUESTS, sleeper: ->(seconds) { sleep(seconds) }, requester: nil)
|
|
40
67
|
@base = URI(base_url)
|
|
68
|
+
@headers = headers
|
|
69
|
+
@params = params
|
|
41
70
|
@max_requests = max_requests
|
|
42
71
|
@sleeper = sleeper
|
|
43
72
|
@requester = requester || method(:get)
|
|
@@ -51,11 +80,12 @@ module DeployAngel
|
|
|
51
80
|
next unless item["kind"] == "route"
|
|
52
81
|
|
|
53
82
|
method, path = item["key"].to_s.split(" ", 2)
|
|
54
|
-
reason
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
83
|
+
next skipped << { "key" => item["key"], "reason" => "changes data" } if item["mutating"] || method != "GET"
|
|
84
|
+
|
|
85
|
+
path, missing = path ? fill(path) : [ nil, [] ]
|
|
86
|
+
if path.nil? || missing.any?
|
|
87
|
+
next skipped << { "key" => item["key"], "reason" => "needs a path parameter", "missing" => missing }
|
|
88
|
+
end
|
|
59
89
|
|
|
60
90
|
needed = item["runs_needed"] ? item["runs_needed"].to_i - item["runs"].to_i : 1
|
|
61
91
|
targets << Target.new(item["key"], path, [ needed, 1 ].max)
|
|
@@ -98,6 +128,24 @@ module DeployAngel
|
|
|
98
128
|
codes: routes.to_h { |route| [ route["key"], @codes[route["key"]].to_h ] }, unsent: unsent)
|
|
99
129
|
end
|
|
100
130
|
|
|
131
|
+
# The path with the given parameters filled in, and the names of any
|
|
132
|
+
# left unfilled.
|
|
133
|
+
def fill(path)
|
|
134
|
+
missing = []
|
|
135
|
+
filled = path.gsub(PARAMETER) do |segment|
|
|
136
|
+
name = $1 || $2 || $3
|
|
137
|
+
value = @params[name]
|
|
138
|
+
unless value
|
|
139
|
+
missing << name
|
|
140
|
+
next segment
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
glob = segment.match?(GLOB)
|
|
144
|
+
(glob ? value.split("/", -1) : [ value ]).map { |part| URI.encode_www_form_component(part).gsub("+", "%20") }.join("/")
|
|
145
|
+
end
|
|
146
|
+
[ filled, missing.uniq ]
|
|
147
|
+
end
|
|
148
|
+
|
|
101
149
|
def url_for(path)
|
|
102
150
|
uri = @base.dup
|
|
103
151
|
uri.path = "#{@base.path.chomp("/")}#{path}"
|
|
@@ -108,7 +156,7 @@ module DeployAngel
|
|
|
108
156
|
private
|
|
109
157
|
def send_request(target)
|
|
110
158
|
@sleeper.call(INTERVAL) if @sent.positive?
|
|
111
|
-
status = @requester.call(url_for(target.path))
|
|
159
|
+
status = @requester.call(url_for(target.path), @headers)
|
|
112
160
|
@sent += 1
|
|
113
161
|
kind = status ? "#{status / 100}xx" : "error"
|
|
114
162
|
@statuses[target.key][kind] += 1
|
|
@@ -139,11 +187,12 @@ module DeployAngel
|
|
|
139
187
|
|
|
140
188
|
# The status code, or nil when there was no answer. Redirects aren't
|
|
141
189
|
# followed: the request already reached the app.
|
|
142
|
-
def get(url)
|
|
190
|
+
def get(url, headers)
|
|
143
191
|
uri = URI(url)
|
|
144
192
|
request = Net::HTTP::Get.new(uri)
|
|
145
193
|
request["User-Agent"] = "DeployAngel-Exercise/#{DeployAngel::VERSION} (+https://www.deployangel.com/docs#exercise)"
|
|
146
194
|
request["Accept"] = "text/html,application/json;q=0.9,*/*;q=0.8"
|
|
195
|
+
headers.each { |name, value| request[name] = value }
|
|
147
196
|
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
|
148
197
|
http.request(request).code.to_i
|
|
149
198
|
end
|
data/lib/deployangel/version.rb
CHANGED