deployangel 0.1.18 → 0.1.20

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: 66584e07c5d50f166891f5b6779fa2fa8db383a923d074458e7bb09ff46e525a
4
- data.tar.gz: 95b445b37424f7b92ab0ec5394f5fa35c10864f5738f10959dfb9822c73c0980
3
+ metadata.gz: 59eed3c6629b0dc5b81cbec2636583d50f0038c2f51af8011108205a1a23eb8c
4
+ data.tar.gz: d10e3a967bed17b69b854a5b74deba72b66ad4e204b3c007bd1958fc81e1ca66
5
5
  SHA512:
6
- metadata.gz: 87ce3ddb256c2df177bb3a4c2390b2ce50a98d97bd25e654d90153b29d9474bd4208cfb80b2bca3cf61d6a5174042dbe8a4e2c966e374a38b0371bf2e3dc19ad
7
- data.tar.gz: ff4a1a677217b025f7393b0c586472a57b4b232406d44e4c76b3cb37ed4fd09d265f9052f47d9d50e182c9d3ddb5892e1757dbd7293b3910cb3a248000dabb0d
6
+ metadata.gz: cecef6e5f9328a9b72877c16bbd51388d67a5673b98a75a56c78e6916f5bf99fd033baaf9056f101b25cd36553d0e6033a253800f571ecfab3724655f1301901
7
+ data.tar.gz: cb8460812b9f04eaf761e23282a644c62f4de9aaca20fcf42e1bc81478f898e096675fd1bdfc67e70aef60ac3053bce969de7310539b9261b036c262dae70bd1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.20 (2026-10-09)
4
+
5
+ - `deployangel exercise --url=<production URL>` sends a release's exercise
6
+ plan's read-only requests (GET routes without path parameters, at most
7
+ 200, about 5 a second) so a quiet release can clear sooner, then records
8
+ what it sent on the release, which lists it as exercised from your side.
9
+ It skips and names routes that change data or need a path parameter.
10
+ `--dry-run` shows what it would send.
11
+ - The agent instructions `deployangel install agents` writes now tell the
12
+ agent to run `deployangel exercise` when a release isn't cleared yet.
13
+
14
+ ## 0.1.19 (2026-10-09)
15
+
16
+ - `deployangel install agents` sets up Claude Code, Cursor, and Codex in a
17
+ project: the MCP server in `.mcp.json`, `.cursor/mcp.json`, and
18
+ `.codex/config.toml` (forwarding `DEPLOYANGEL_API_TOKEN`), and instructions
19
+ to wait for a verdict after deploying in `AGENTS.md`, with a `CLAUDE.md`
20
+ that imports it. It never replaces existing entries, and re-running it only
21
+ updates its own instructions.
22
+
3
23
  ## 0.1.18 (2026-10-08)
4
24
 
5
25
  - In Rails, the agent gets its file digests when it starts, so the reporter
data/README.md CHANGED
@@ -392,6 +392,7 @@ bundle exec deployangel verify --wait # current git HEAD, until
392
392
  bundle exec deployangel verify --wait --until=initial # return at the 15-minute initial check
393
393
  bundle exec deployangel status # latest deployment
394
394
  bundle exec deployangel plan # what to exercise so a release clears sooner
395
+ bundle exec deployangel exercise --url=https://example.com # send the plan's read-only requests
395
396
  bundle exec deployangel release --commit=$SHA # register a deploy (manual or CI)
396
397
  bundle exec deployangel check --name="smoke: signup" --status=pass --covers=registration
397
398
  bundle exec deployangel exception <fingerprint>
@@ -413,6 +414,22 @@ choose with `--format=text|json`. Exit codes:
413
414
 
414
415
  ### MCP server
415
416
 
417
+ To set up Claude Code, Cursor, and Codex for a project in one step, run this
418
+ in the project:
419
+
420
+ ```bash
421
+ bundle exec deployangel install agents
422
+ ```
423
+
424
+ It adds the MCP server to `.mcp.json` (Claude Code), `.cursor/mcp.json`
425
+ (Cursor), and `.codex/config.toml` (Codex, which reads it only in projects you
426
+ trust), and the instructions below to `AGENTS.md`, with a `CLAUDE.md` that
427
+ imports it. It never replaces an existing entry, and running it again updates
428
+ only its own instructions. Commit the files; the token stays in your
429
+ environment.
430
+
431
+ Or add the server yourself:
432
+
416
433
  ```bash
417
434
  claude mcp add deployangel -- bundle exec deployangel mcp # Claude Code
418
435
  ```
@@ -448,15 +465,27 @@ check labels what it covered; only the requests themselves count as
448
465
  evidence. A release deployed during an app's first 24 hours can't clear,
449
466
  so its plan lists nothing.
450
467
 
468
+ `deployangel exercise --url=<production URL>` does the read-only part for
469
+ you, from wherever it runs: it requests the plan's GET routes that have no
470
+ path parameters, spreading any request shortfall across them (at most 200
471
+ requests, about 5 a second, as `DeployAngel-Exercise`), then records what it
472
+ sent on the release, where the page lists it under "Exercised from your
473
+ side". It skips and names routes that change data or need a path
474
+ parameter. A "CLI & coding agents" token can run it. `--dry-run` shows what
475
+ it would send.
476
+
451
477
  ### Suggested instructions for your coding agent
452
478
 
453
- Add this to your `CLAUDE.md` or `AGENTS.md`:
479
+ `deployangel install agents` adds instructions like these. To add them
480
+ yourself, put this in your `CLAUDE.md` or `AGENTS.md`:
454
481
 
455
482
  ```markdown
456
- ## Production verification
483
+ ## Production verification (DeployAngel)
457
484
 
458
- After deploying, run `bundle exec deployangel verify --wait --until initial`
459
- (or call the `wait_for_verification` MCP tool with `until: "initial"`).
485
+ After deploying, or pushing a commit that deploys, wait for DeployAngel's
486
+ verdict: call the `wait_for_verification` MCP tool with the commit and
487
+ `until: "initial"`, calling again while it's in progress, or run
488
+ `bundle exec deployangel verify --commit=<sha> --wait --until=initial`.
460
489
 
461
490
  - Exit 0 / verified: the release is cleared. Report the clearance line and
462
491
  anything DeployAngel is still watching, then move on.
@@ -466,17 +495,16 @@ After deploying, run `bundle exec deployangel verify --wait --until initial`
466
495
  - Exit 7: warnings at the initial check. Report them and review the findings.
467
496
  The release is NOT cleared.
468
497
  - Exit 2 / inconclusive: the release is NOT verified. Do not claim success.
469
- - Exit 1 / failed: read the findings and exceptions, investigate the likely
470
- cause, and propose a fix. Do not roll back or change production without
471
- explicit approval.
498
+ - Exit 1 / failed: read the findings and exceptions (`get_exception`),
499
+ investigate the likely cause, and propose a fix. Do not roll back or change
500
+ production without explicit approval.
472
501
  - Exit 3: still verifying; run the command again.
473
- - Not cleared yet and you can reach production: run
474
- `bundle exec deployangel plan --format=json` (or `get_exercise_plan`). If
475
- its status is "exercisable" or "waiting_for_activity", exercise the listed
476
- items against production: read-only routes freely, routes marked mutating
477
- only with a test account or after asking. Then run its `report_with`
478
- command and wait again. If the status is "warm_up", don't exercise
479
- anything: the release can't clear until DeployAngel has a day of history.
502
+ - Not cleared yet: call `get_exercise_plan` (or `bundle exec deployangel plan`).
503
+ If its status is "exercisable" or "waiting_for_activity", run
504
+ `bundle exec deployangel exercise --url=<production URL>`: it sends the
505
+ plan's read-only requests and records them on the release. Offer to
506
+ exercise what it skips: routes that change data only with a test account
507
+ or after asking. If the status is "warm_up", nothing run can clear it.
480
508
  ```
481
509
 
482
510
  ## Development
@@ -11,13 +11,14 @@ require_relative "client"
11
11
  require_relative "ci_environment"
12
12
  require_relative "verification_waiter"
13
13
  require_relative "cli/formatter"
14
+ require_relative "exerciser"
14
15
 
15
16
  module DeployAngel
16
17
  # `deployangel` command for developers, CI, and coding agents.
17
18
  # Runs without booting Rails.
18
19
  class CLI
19
20
  USAGE_ERROR = 5
20
- COMMANDS = %w[release verify status exception check install mcp version help].freeze
21
+ COMMANDS = %w[release verify status plan exercise exception check install mcp version help].freeze
21
22
 
22
23
  HELP = <<~TEXT
23
24
  Usage: deployangel <command> [options]
@@ -30,11 +31,14 @@ module DeployAngel
30
31
  status Latest deployment and its verification
31
32
  plan What to exercise so a release [--commit=SHA | --version=V | --deployment=ID]
32
33
  clears sooner [--format=text|json]
34
+ exercise Send the plan's read-only --url=PRODUCTION_URL [--commit=SHA | --version=V | --deployment=ID]
35
+ requests to production [--max-requests=200] [--dry-run]
33
36
  exception Details for a fingerprint deployangel exception FINGERPRINT
34
37
  check Report a smoke test result --name=NAME --status=pass|fail [--covers=a,b]
35
38
  [--commit=SHA | --deployment=ID] [--details-url=URL]
36
- install Add a Kamal deploy hook, or deployangel install kamal
37
- bake the commit into an image deployangel install docker
39
+ install Add a Kamal deploy hook, deployangel install kamal
40
+ bake the commit into an image, deployangel install docker
41
+ or set up coding agents deployangel install agents
38
42
  mcp Run the MCP server over stdio (for coding agents)
39
43
 
40
44
  With no target, verify, plan, and check use the current git HEAD commit.
@@ -71,6 +75,47 @@ module DeployAngel
71
75
  Kamal apps don't need this: DeployAngel reads Kamal's KAMAL_VERSION.
72
76
  HELP
73
77
 
78
+ # The block `deployangel install agents` keeps in AGENTS.md (and CLAUDE.md
79
+ # when it doesn't import AGENTS.md), replaced whole on each run.
80
+ AGENTS_START = "<!-- deployangel:start -->"
81
+ AGENTS_END = "<!-- deployangel:end -->"
82
+ AGENT_INSTRUCTIONS = <<~MARKDOWN
83
+ #{AGENTS_START}
84
+ ## Production verification (DeployAngel)
85
+
86
+ After deploying, or pushing a commit that deploys, wait for DeployAngel's
87
+ verdict: call the `wait_for_verification` MCP tool with the commit and
88
+ `until: "initial"`, calling again while it's in progress, or run
89
+ `bundle exec deployangel verify --commit=<sha> --wait --until=initial`.
90
+
91
+ - Exit 0 / verified: the release is cleared. Report the clearance line and
92
+ anything DeployAngel is still watching, then move on.
93
+ - Exit 6: no problems so far, but NOT cleared. Report "no problems so far, not
94
+ yet cleared" and the expected clearance time. DeployAngel keeps verifying and
95
+ alerts on failure.
96
+ - Exit 7: warnings at the initial check. Report them and review the findings.
97
+ The release is NOT cleared.
98
+ - Exit 2 / inconclusive: the release is NOT verified. Do not claim success.
99
+ - Exit 1 / failed: read the findings and exceptions (`get_exception`),
100
+ investigate the likely cause, and propose a fix. Do not roll back or change
101
+ production without explicit approval.
102
+ - Exit 3: still verifying; run the command again.
103
+ - Not cleared yet: call `get_exercise_plan` (or `bundle exec deployangel plan`).
104
+ If its status is "exercisable" or "waiting_for_activity", run
105
+ `bundle exec deployangel exercise --url=<production URL>`: it sends the
106
+ plan's read-only requests and records them on the release. Offer to
107
+ exercise what it skips: routes that change data only with a test account
108
+ or after asking. If the status is "warm_up", nothing run can clear it.
109
+ #{AGENTS_END}
110
+ MARKDOWN
111
+
112
+ AGENTS_TOKEN_HELP = <<~HELP.chomp
113
+ The MCP server and CLI need DEPLOYANGEL_API_TOKEN, a "CLI & coding agents" token
114
+ from the app's Settings, in the environment your agent runs in (for example in
115
+ an .envrc with direnv). Never put it in these files: they're meant to be committed.
116
+ Codex reads .codex/config.toml only in projects you've marked as trusted.
117
+ HELP
118
+
74
119
  DOCKERFILE_LINES = <<~DOCKERFILE
75
120
  # The commit this image runs, for DeployAngel. Build with --build-arg GIT_SHA=$(git rev-parse HEAD).
76
121
  ARG GIT_SHA
@@ -78,7 +123,7 @@ module DeployAngel
78
123
  DOCKERFILE
79
124
 
80
125
  def initialize(argv, env: ENV, stdin: $stdin, stdout: $stdout, stderr: $stderr, client: nil,
81
- sleeper: ->(seconds) { sleep(seconds) }, clock: -> { Time.now }, git_head: nil, root: Dir.pwd)
126
+ sleeper: ->(seconds) { sleep(seconds) }, clock: -> { Time.now }, git_head: nil, root: Dir.pwd, requester: nil)
82
127
  @root = root
83
128
  @argv = argv.dup
84
129
  @env = env
@@ -89,6 +134,7 @@ module DeployAngel
89
134
  @sleeper = sleeper
90
135
  @clock = clock
91
136
  @git_head = git_head
137
+ @requester = requester
92
138
  end
93
139
 
94
140
  def run
@@ -98,6 +144,7 @@ module DeployAngel
98
144
  when "verify" then verify
99
145
  when "status" then verify(status: true)
100
146
  when "plan" then plan
147
+ when "exercise" then exercise
101
148
  when "exception" then exception
102
149
  when "check" then check
103
150
  when "install" then install
@@ -197,6 +244,56 @@ module DeployAngel
197
244
  0
198
245
  end
199
246
 
247
+ # Sends the plan's read-only requests from here, then records what it
248
+ # sent on the release (spec §16, Exercise records).
249
+ def exercise
250
+ options = parse(max_requests: Exerciser::MAX_REQUESTS, dry_run: false) do |o, opts|
251
+ o.on("--url=URL") { |v| opts[:url] = v }
252
+ o.on("--commit=SHA") { |v| opts[:commit] = v }
253
+ o.on("--version=VERSION") { |v| opts[:version] = v }
254
+ o.on("--deployment=ID") { |v| opts[:deployment_id] = v }
255
+ o.on("--max-requests=N", Integer) { |v| opts[:max_requests] = v }
256
+ o.on("--dry-run") { opts[:dry_run] = true }
257
+ end
258
+ return usage_error("exercise needs --url, the app's production URL, like https://example.com") unless options[:url].to_s.match?(%r{\Ahttps?://[^/\s]+})
259
+
260
+ target = target_from(options)
261
+ return usage_error("no target: pass --commit, --version, or --deployment, or run inside a git repository") unless target
262
+
263
+ outcome = VerificationWaiter.new(client: client, sleeper: @sleeper, clock: @clock).wait(target, wait: false)
264
+ if outcome.not_found
265
+ @stderr.puts("deployangel: no deployment found for #{target.values.first}")
266
+ return outcome.exit_code
267
+ end
268
+
269
+ document = outcome.document
270
+ plan = document["exercise_plan"] || {}
271
+ unless %w[exercisable waiting_for_activity].include?(plan["status"])
272
+ @stdout.puts("Nothing to exercise. #{plan["summary"]}".rstrip)
273
+ return 0
274
+ end
275
+
276
+ exerciser = Exerciser.new(base_url: options[:url], max_requests: options[:max_requests].clamp(1, Exerciser::MAX_REQUESTS),
277
+ sleeper: @sleeper, requester: @requester)
278
+ if options[:dry_run]
279
+ targets, skipped = exerciser.plan(plan)
280
+ @stdout.puts("Would send #{targets.sum(&:count)} requests:", *targets.map { |t| " #{exerciser.url_for(t.path)} ×#{t.count}" })
281
+ @stdout.puts("Skipped:", *skipped.map { |s| " #{s["key"]} (#{s["reason"]})" }) if skipped.any?
282
+ return 0
283
+ end
284
+
285
+ result = exerciser.run(plan)
286
+ @stdout.puts("Sent #{result.sent} requests to #{options[:url]}:",
287
+ *result.routes.map { |r| " #{r["key"]} ×#{r["requests"]}: #{r["statuses"].map { |kind, n| "#{n} #{kind}" }.join(", ")}" })
288
+ @stdout.puts("Skipped:", *result.skipped.map { |s| " #{s["key"]} (#{s["reason"]})" }) if result.skipped.any?
289
+ @stdout.puts("Stopped early: #{options[:url]} isn't answering.") if result.unreachable
290
+ if result.routes.any?
291
+ client.record_exercise(document.dig("deployment", "id"), routes: result.routes, skipped: result.skipped, ran_at: @clock.call.utc.iso8601)
292
+ @stdout.puts("Recorded on the release. Wait for the verdict with: deployangel verify --wait")
293
+ end
294
+ result.unreachable ? USAGE_ERROR : 0
295
+ end
296
+
200
297
  def exception
201
298
  fingerprint = @argv.shift or return usage_error("exception needs a FINGERPRINT")
202
299
  format = parse(format: nil) { |o, opts| o.on("--format=FORMAT", %w[text json]) { |v| opts[:format] = v } }[:format]
@@ -230,7 +327,8 @@ module DeployAngel
230
327
  case (target = @argv.shift)
231
328
  when "kamal" then install_kamal
232
329
  when "docker" then install_docker
233
- else usage_error("install needs a target: deployangel install kamal, or deployangel install docker")
330
+ when "agents" then install_agents
331
+ else usage_error("install needs a target: deployangel install kamal, docker, or agents")
234
332
  end
235
333
  end
236
334
 
@@ -256,6 +354,93 @@ module DeployAngel
256
354
  0
257
355
  end
258
356
 
357
+ # Sets up Claude Code, Cursor, and Codex in this project: the MCP server
358
+ # in each one's project config, and instructions to wait for a verdict
359
+ # after deploying. Never overwrites what's there: an existing entry is
360
+ # kept, and a file it can't read is left for you to edit.
361
+ def install_agents
362
+ command = %w[bundle exec deployangel mcp]
363
+ lines = [
364
+ install_json_mcp(".mcp.json", command, "Claude Code"),
365
+ install_json_mcp(File.join(".cursor", "mcp.json"), command, "Cursor"),
366
+ install_codex_mcp(command),
367
+ *install_agent_instructions
368
+ ]
369
+ @stdout.puts(*lines, "", AGENTS_TOKEN_HELP)
370
+ 0
371
+ end
372
+
373
+ def install_json_mcp(relative, command, agent)
374
+ path = File.join(@root, relative)
375
+ config = File.exist?(path) ? JSON.parse(File.read(path)) : {}
376
+ raise JSON::ParserError, "not a JSON object" unless config.is_a?(Hash)
377
+
378
+ servers = (config["mcpServers"] ||= {})
379
+ return "#{relative} already has a deployangel MCP server (#{agent})." if servers.key?("deployangel")
380
+
381
+ created = !File.exist?(path)
382
+ servers["deployangel"] = { "command" => command.first, "args" => command.drop(1) }
383
+ FileUtils.mkdir_p(File.dirname(path))
384
+ File.write(path, "#{JSON.pretty_generate(config)}\n")
385
+ "#{created ? "Created" : "Updated"} #{relative}: the DeployAngel MCP server for #{agent}."
386
+ rescue JSON::ParserError
387
+ "Couldn't read #{relative}, so it's unchanged. Add a \"deployangel\" server to its mcpServers: " \
388
+ "command \"#{command.first}\", args #{command.drop(1).to_json}."
389
+ end
390
+
391
+ def install_codex_mcp(command)
392
+ relative = File.join(".codex", "config.toml")
393
+ path = File.join(@root, relative)
394
+ existing = File.exist?(path) ? File.read(path) : nil
395
+ return "#{relative} already has a deployangel MCP server (Codex)." if existing&.include?("[mcp_servers.deployangel]")
396
+
397
+ table = <<~TOML
398
+ [mcp_servers.deployangel]
399
+ command = #{command.first.to_json}
400
+ args = #{command.drop(1).to_json.gsub(",", ", ")}
401
+ env_vars = ["DEPLOYANGEL_API_TOKEN", "DEPLOYANGEL_URL"]
402
+ TOML
403
+ FileUtils.mkdir_p(File.dirname(path))
404
+ File.write(path, existing ? "#{existing.sub(/\n*\z/, "\n")}\n#{table}" : table)
405
+ "#{existing ? "Updated" : "Created"} #{relative}: the DeployAngel MCP server for Codex."
406
+ end
407
+
408
+ # AGENTS.md serves Codex and Cursor. Claude Code reads CLAUDE.md, so it
409
+ # gets the block too, unless it already imports AGENTS.md; a project
410
+ # without one gets a CLAUDE.md that does.
411
+ def install_agent_instructions
412
+ lines = [ upsert_instructions("AGENTS.md") ]
413
+ claude = File.join(@root, "CLAUDE.md")
414
+ if !File.exist?(claude)
415
+ File.write(claude, "@AGENTS.md\n")
416
+ lines << "Created CLAUDE.md, which imports AGENTS.md for Claude Code."
417
+ elsif !File.read(claude).match?(/^@AGENTS\.md\s*$/)
418
+ lines << upsert_instructions("CLAUDE.md")
419
+ end
420
+ lines
421
+ end
422
+
423
+ def upsert_instructions(relative)
424
+ path = File.join(@root, relative)
425
+ unless File.exist?(path)
426
+ File.write(path, AGENT_INSTRUCTIONS)
427
+ return "Created #{relative} with instructions to wait for DeployAngel's verdict after deploying."
428
+ end
429
+
430
+ content = File.read(path)
431
+ block = /#{Regexp.escape(AGENTS_START)}.*?#{Regexp.escape(AGENTS_END)}\n?/m
432
+ if content.match?(block)
433
+ updated = content.sub(block, AGENT_INSTRUCTIONS)
434
+ return "#{relative} already has DeployAngel's instructions." if updated == content
435
+
436
+ File.write(path, updated)
437
+ "Updated DeployAngel's instructions in #{relative}."
438
+ else
439
+ File.write(path, "#{content.sub(/\n*\z/, "\n")}\n#{AGENT_INSTRUCTIONS}")
440
+ "Added instructions to wait for DeployAngel's verdict after deploying to #{relative}."
441
+ end
442
+ end
443
+
259
444
  # Adds DEPLOYANGEL_REVISION, from a GIT_SHA build arg, to the Dockerfile.
260
445
  # It never edits CI workflows; it says what to pass instead.
261
446
  def install_docker
@@ -49,6 +49,12 @@ module DeployAngel
49
49
  { name: name, status: status, covers: covers, details_url: details_url }.compact)
50
50
  end
51
51
 
52
+ # What `deployangel exercise` sent (spec §16, Exercise records). A
53
+ # "CLI & coding agents" token may record one: it only labels the release.
54
+ def record_exercise(deployment_id, routes:, skipped:, ran_at:)
55
+ post("/api/v1/deployments/#{deployment_id}/exercises", { routes: routes, skipped: skipped, ran_at: ran_at })
56
+ end
57
+
52
58
  private
53
59
  def get(path, params = {})
54
60
  uri = URI("#{@endpoint}#{path}")
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module DeployAngel
7
+ # `deployangel exercise`: sends a release's exercise plan's read-only
8
+ # requests to production from the customer's side, then records what it
9
+ # sent, so the release page says which routes were exercised that way.
10
+ # Only GET routes with no path parameters and not marked as changing data;
11
+ # the rest are skipped and named, for the agent or a smoke test with a test
12
+ # account. The requests count like any traffic through the app's agent.
13
+ class Exerciser
14
+ MAX_REQUESTS = 200
15
+ INTERVAL = 0.2 # about 5 requests a second
16
+ TIMEOUT = 10
17
+ # Stop when the app isn't answering, rather than send the rest into it.
18
+ MAX_CONSECUTIVE_ERRORS = 5
19
+ # Rails' :id and *path, Django's <int:pk>, FastAPI's {id}.
20
+ PARAMETER = /\/[:*{<]/
21
+
22
+ Target = Struct.new(:key, :path, :count)
23
+ Result = Struct.new(:routes, :skipped, :sent, :unreachable, keyword_init: true)
24
+
25
+ def initialize(base_url:, max_requests: MAX_REQUESTS, sleeper: ->(seconds) { sleep(seconds) }, requester: nil)
26
+ @base = URI(base_url)
27
+ @max_requests = max_requests
28
+ @sleeper = sleeper
29
+ @requester = requester || method(:get)
30
+ end
31
+
32
+ # What would be sent: [targets, skipped].
33
+ def plan(exercise_plan)
34
+ skipped = []
35
+ targets = []
36
+ Array(exercise_plan["items"]).each do |item|
37
+ next unless item["kind"] == "route"
38
+
39
+ method, path = item["key"].to_s.split(" ", 2)
40
+ reason =
41
+ if item["mutating"] || method != "GET" then "changes data"
42
+ elsif path.nil? || path.match?(PARAMETER) then "needs a path parameter"
43
+ end
44
+ next skipped << { "key" => item["key"], "reason" => reason } if reason
45
+
46
+ needed = item["runs_needed"] ? item["runs_needed"].to_i - item["runs"].to_i : 1
47
+ targets << Target.new(item["key"], path, [ needed, 1 ].max)
48
+ end
49
+ spread_shortfall(targets, exercise_plan.dig("shortfall", "requests"))
50
+ cap(targets)
51
+ [ targets, skipped ]
52
+ end
53
+
54
+ def run(exercise_plan)
55
+ targets, skipped = plan(exercise_plan)
56
+ routes = []
57
+ errors_in_a_row = 0
58
+ sent = 0
59
+ targets.each do |target|
60
+ statuses = Hash.new(0)
61
+ target.count.times do
62
+ break if errors_in_a_row >= MAX_CONSECUTIVE_ERRORS
63
+
64
+ @sleeper.call(INTERVAL) if sent.positive?
65
+ status = @requester.call(url_for(target.path))
66
+ sent += 1
67
+ kind = status ? "#{status / 100}xx" : "error"
68
+ statuses[kind] += 1
69
+ errors_in_a_row = kind == "error" ? errors_in_a_row + 1 : 0
70
+ end
71
+ requests = statuses.values.sum
72
+ routes << { "key" => target.key, "requests" => requests, "statuses" => statuses.to_h } if requests.positive?
73
+ end
74
+ Result.new(routes: routes, skipped: skipped, sent: sent, unreachable: errors_in_a_row >= MAX_CONSECUTIVE_ERRORS)
75
+ end
76
+
77
+ def url_for(path)
78
+ uri = @base.dup
79
+ uri.path = "#{@base.path.chomp("/")}#{path}"
80
+ uri.query = nil
81
+ uri.to_s
82
+ end
83
+
84
+ private
85
+ # Requests the release is short of overall, spread across the routes,
86
+ # or sent to the home page when the plan names no route to send them to.
87
+ def spread_shortfall(targets, requests)
88
+ return unless requests
89
+
90
+ extra = requests["need"].to_i - requests["have"].to_i - targets.sum(&:count)
91
+ return unless extra.positive?
92
+
93
+ targets << Target.new("GET /", "/", 0) if targets.empty?
94
+ extra.times { |i| targets[i % targets.size].count += 1 }
95
+ end
96
+
97
+ def cap(targets)
98
+ targets.max_by(&:count).count -= 1 while targets.sum(&:count) > @max_requests
99
+ end
100
+
101
+ # The status code, or nil when there was no answer. Redirects aren't
102
+ # followed: the request already reached the app.
103
+ def get(url)
104
+ uri = URI(url)
105
+ request = Net::HTTP::Get.new(uri)
106
+ request["User-Agent"] = "DeployAngel-Exercise/#{DeployAngel::VERSION} (+https://www.deployangel.com/docs#exercise)"
107
+ request["Accept"] = "text/html,application/json;q=0.9,*/*;q=0.8"
108
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
109
+ http.request(request).code.to_i
110
+ end
111
+ rescue StandardError
112
+ nil
113
+ end
114
+ end
115
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.18"
4
+ VERSION = "0.1.20"
5
5
  end
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.18
4
+ version: 0.1.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Owens
@@ -46,6 +46,7 @@ files:
46
46
  - lib/deployangel/core/redaction.rb
47
47
  - lib/deployangel/core/release.rb
48
48
  - lib/deployangel/core/transport.rb
49
+ - lib/deployangel/exerciser.rb
49
50
  - lib/deployangel/fork_hook.rb
50
51
  - lib/deployangel/mcp.rb
51
52
  - lib/deployangel/metadata.rb
@@ -82,7 +83,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
82
83
  - !ruby/object:Gem::Version
83
84
  version: '0'
84
85
  requirements: []
85
- rubygems_version: 4.0.0
86
+ rubygems_version: 4.0.10
86
87
  specification_version: 4
87
88
  summary: 'DeployAngel agent for Rails: production verification for every deployment.'
88
89
  test_files: []