deployangel 0.1.23 → 0.1.24

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: 2635666118ec2250b5414245e3ea0aa1026d30e4369788e2c82bf6e6b0a52f71
4
- data.tar.gz: 0fac6aa9bf419e2ad7a93a289902c94fa94a6d0b42c1418d7370afbaf90501cd
3
+ metadata.gz: 2304a80caf17ea6df84a2487145d51438244f15e38528e817fb93ed45a07dff0
4
+ data.tar.gz: b28c3f544144e1b4a8cd9417fbdb4407bd814d814fe88522073e4d463d25f11a
5
5
  SHA512:
6
- metadata.gz: 41f77d3e77fae4bccf35c82570582e01501246314c540d6bf6d227cdcd15e033237d62d9b0b2769e80766f53293982b4e8fe07e644ced1e99d93ca13a57b93f2
7
- data.tar.gz: 674cb4c4972c88d861375904572c1048f247f041400c52b8d5380f44b3ff76d8b6f473362b404bedcf2377e1b6811cf96eea39f5cb9934b07271ddd627376afa
6
+ metadata.gz: ba778f41f2922f597a225d2bbdefc58ffce09443ec7f4c342f8df2729103e36a49115bcd76502222a95717eace53b4c737cc836fccff3a865bb52e835501fc0a
7
+ data.tar.gz: 98abb8b69f7cf9e61980a68befcff22dee0ede110019a2b6e398a92efaef155e2c700ce3eaf426fb9f3346fcbe0ce320b7bd3ef78979c0459a91bc4a6f0cd263
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.24 (2026-10-10)
4
+
5
+ - `deployangel doctor` checks the setup: the agent in the bundle, the
6
+ schedules in this checkout's files, and how production will know the
7
+ release; then, with a token, what DeployAngel has seen (processes
8
+ reporting and their releases, what the agent reported, and setup
9
+ problems with fixes). Only errors fail it. Run where the app runs, it
10
+ uses the agent's `DEPLOYANGEL_TOKEN` and shows the release that process
11
+ reports. Needs a DeployAngel server with `GET /api/v1/setup`.
12
+
3
13
  ## 0.1.23 (2026-10-09)
4
14
 
5
15
  - GoodJob's cron (`config.good_job.cron`, or `GOOD_JOB_CRON`) is read from
data/README.md CHANGED
@@ -441,8 +441,20 @@ bundle exec deployangel exercise --url=https://example.com # send the plan's re
441
441
  bundle exec deployangel release --commit=$SHA # register a deploy (manual or CI)
442
442
  bundle exec deployangel check --name="smoke: signup" --status=pass --covers=registration
443
443
  bundle exec deployangel exception <fingerprint>
444
+ bundle exec deployangel doctor # check the setup
444
445
  ```
445
446
 
447
+ `deployangel doctor` checks this checkout (the agent in the bundle, the
448
+ schedules in its files, how production will know the release) and, with a
449
+ token, what DeployAngel has seen of the app: which processes report and the
450
+ release each runs, the routes, job classes, and scheduled jobs the agent
451
+ reported, and setup problems with what to do about each. Only errors (a
452
+ rejected token, an unreachable server, no agent in the bundle) exit 5;
453
+ warnings exit 0, so it's safe in CI. It reads `DEPLOYANGEL_API_TOKEN`, or
454
+ `DEPLOYANGEL_TOKEN` where the app runs, so
455
+ `heroku run bundle exec deployangel doctor` also shows the release that
456
+ process reports.
457
+
446
458
  Output is text on a terminal and JSON (the verdict document) when piped, or
447
459
  choose with `--format=text|json`. Exit codes:
448
460
 
@@ -12,13 +12,14 @@ require_relative "ci_environment"
12
12
  require_relative "verification_waiter"
13
13
  require_relative "cli/formatter"
14
14
  require_relative "exerciser"
15
+ require_relative "doctor"
15
16
 
16
17
  module DeployAngel
17
18
  # `deployangel` command for developers, CI, and coding agents.
18
19
  # Runs without booting Rails.
19
20
  class CLI
20
21
  USAGE_ERROR = 5
21
- COMMANDS = %w[release verify status plan exercise exception check install mcp version help].freeze
22
+ COMMANDS = %w[release verify status plan exercise exception check install doctor mcp version help].freeze
22
23
 
23
24
  HELP = <<~TEXT
24
25
  Usage: deployangel <command> [options]
@@ -39,6 +40,8 @@ module DeployAngel
39
40
  install Add a Kamal deploy hook, deployangel install kamal
40
41
  bake the commit into an image, deployangel install docker
41
42
  or set up coding agents deployangel install agents
43
+ doctor Check the setup: this checkout [--format=text|json]
44
+ and what DeployAngel has seen
42
45
  mcp Run the MCP server over stdio (for coding agents)
43
46
 
44
47
  With no target, verify, plan, and check use the current git HEAD commit.
@@ -148,6 +151,7 @@ module DeployAngel
148
151
  when "exception" then exception
149
152
  when "check" then check
150
153
  when "install" then install
154
+ when "doctor" then doctor
151
155
  when "mcp" then mcp
152
156
  when "version", "--version", "-v" then @stdout.puts(DeployAngel::VERSION) || 0
153
157
  when nil, "help", "--help", "-h" then @stdout.puts(HELP) || 0
@@ -323,6 +327,36 @@ module DeployAngel
323
327
  0
324
328
  end
325
329
 
330
+ # What this checkout will report and what DeployAngel has seen of the
331
+ # app. Warnings are advice, so only errors (a rejected token, an
332
+ # unreachable server, no agent in the bundle) fail it.
333
+ def doctor
334
+ format = parse(format: nil) { |o, opts| o.on("--format=FORMAT", %w[text json]) { |v| opts[:format] = v } }[:format]
335
+ doctor = Doctor.new(root: @root, client: doctor_client, env: @env)
336
+ output(doctor.to_h, format) { doctor_text(doctor.checks) }
337
+ doctor.failed? ? USAGE_ERROR : 0
338
+ end
339
+
340
+ DOCTOR_MARKS = { ok: "✓", warn: "!", error: "✗", info: "·" }.freeze
341
+
342
+ def doctor_text(checks)
343
+ lines = [ "DeployAngel doctor" ]
344
+ checks.each do |check|
345
+ lines << " #{DOCTOR_MARKS.fetch(check.status)} #{check.message}"
346
+ lines << " #{check.fix}" if check.fix
347
+ end
348
+ lines.join("\n")
349
+ end
350
+
351
+ # Either token works: the setup endpoint answers any of the app's. The
352
+ # agent's own token is for running it where the app runs.
353
+ def doctor_client
354
+ return @client if @client
355
+
356
+ token = [ @env["DEPLOYANGEL_API_TOKEN"], @env["DEPLOYANGEL_TOKEN"] ].find { |value| !value.to_s.empty? } or return
357
+ Client.new(token: token, endpoint: @env["DEPLOYANGEL_URL"] || Configuration::DEFAULT_ENDPOINT)
358
+ end
359
+
326
360
  def install
327
361
  case (target = @argv.shift)
328
362
  when "kamal" then install_kamal
@@ -31,6 +31,7 @@ module DeployAngel
31
31
  end
32
32
 
33
33
  def token_info = get("/api/v1/token")
34
+ def setup = get("/api/v1/setup")
34
35
  def latest_deployment = get("/api/v1/deployments/latest")
35
36
  def deployments(**filters) = get("/api/v1/deployments", filters.compact)["deployments"]
36
37
  def exception(fingerprint) = get("/api/v1/exceptions/#{URI.encode_www_form_component(fingerprint)}")
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "configuration"
5
+ require_relative "metadata"
6
+ require_relative "rails/metadata"
7
+ require_relative "core/release"
8
+
9
+ module DeployAngel
10
+ # `deployangel doctor`: what this checkout will report, and what
11
+ # DeployAngel has seen of the app so far (GET /api/v1/setup), as checks
12
+ # with what to do about each. It runs without booting the app, so it
13
+ # reads only the schedules kept in files.
14
+ class Doctor
15
+ # status is ok, warn, error, or info. Only errors fail the command.
16
+ Check = Struct.new(:status, :message, :fix, keyword_init: true) do
17
+ def to_h = { "status" => status.to_s, "message" => message, "fix" => fix }.compact
18
+ end
19
+
20
+ SOURCES = { "solid_queue" => "Solid Queue", "sidekiq_cron" => "sidekiq-cron", "sidekiq_scheduler" => "sidekiq-scheduler",
21
+ "whenever" => "whenever", "good_job" => "GoodJob", "config" => "agent config" }.freeze
22
+ TOKEN_HELP = "Set DEPLOYANGEL_API_TOKEN (a \"CLI & coding agents\" token from the app's Settings) or DEPLOYANGEL_TOKEN " \
23
+ "(the app's agent token), then run this again."
24
+ MAX_HOSTS = 5
25
+
26
+ attr_reader :setup
27
+
28
+ # client is nil when no token is set; the local checks still run.
29
+ def initialize(root:, client:, env: {})
30
+ @root = root
31
+ @client = client
32
+ @env = env
33
+ end
34
+
35
+ def checks
36
+ @checks ||= begin
37
+ @setup, server = server_checks
38
+ [ *agent_checks, here_check, schedule_check, *server, release_check, *comparison_checks ].compact
39
+ end
40
+ end
41
+
42
+ def failed?
43
+ checks.any? { |check| check.status == :error }
44
+ end
45
+
46
+ def to_h
47
+ { "checks" => checks.map(&:to_h), "setup" => setup }
48
+ end
49
+
50
+ private
51
+ def agent_checks
52
+ lock = read("Gemfile.lock") or return []
53
+ version = lock[/^ deployangel \(([^)]+)\)/, 1]
54
+ return [ Check.new(status: :ok, message: "Agent: deployangel #{version} in Gemfile.lock") ] if version
55
+
56
+ [ Check.new(status: :error, message: "The agent isn't in the bundle", fix: "Add gem \"deployangel\" to the Gemfile and run bundle install.") ]
57
+ end
58
+
59
+ def schedule_check
60
+ counts = local_schedules.group_by { |schedule| schedule["source"] }.map { |source, list| "#{SOURCES.fetch(source, source)} #{list.size}" }
61
+ later = [ ("GoodJob's cron" if read("Gemfile.lock").to_s.include?(" good_job (")), "config.recurring_jobs" ].compact
62
+ note = "#{later.join(" and ")} #{later.one? ? "is" : "are"} read when the app runs."
63
+ if counts.any?
64
+ Check.new(status: :ok, message: "#{count(local_schedules.size, "scheduled job")} in this checkout (#{counts.join(", ")}). #{note}")
65
+ else
66
+ Check.new(status: :info, message: "No scheduled jobs in this checkout's schedule files. #{note}")
67
+ end
68
+ end
69
+
70
+ # The token's app and what DeployAngel has seen of it.
71
+ def server_checks
72
+ return [ nil, [ Check.new(status: :warn, message: "No token, so only this checkout was checked", fix: TOKEN_HELP) ] ] unless @client
73
+
74
+ setup = @client.setup
75
+ checks = [ app_check(setup), agent_check(setup), metadata_check(setup), *gap_checks(setup), progress_check(setup) ]
76
+ [ setup, checks.compact ]
77
+ rescue Client::Unauthorized => e
78
+ [ nil, [ Check.new(status: :error, message: "DeployAngel rejected the token (#{e.message})", fix: TOKEN_HELP) ] ]
79
+ rescue Client::NotFound
80
+ [ nil, [ Check.new(status: :warn, message: "This DeployAngel server doesn't support doctor yet; only this checkout was checked") ] ]
81
+ rescue Client::Error => e
82
+ [ nil, [ Check.new(status: :error, message: "Couldn't reach DeployAngel: #{e.message}") ] ]
83
+ end
84
+
85
+ def app_check(setup)
86
+ app = setup["app"] || {}
87
+ Check.new(status: :ok, message: "App: #{app["name"]} (#{app["environment"]})")
88
+ end
89
+
90
+ def agent_check(setup)
91
+ agent = setup["agent"] || {}
92
+ processes = Array(agent["processes"])
93
+ if agent["reporting"]
94
+ releases = processes.map { |process| process["release"] }.uniq
95
+ release = releases.compact.one? && releases.size == 1 ? ", release #{releases.first}" : ""
96
+ from = processes.any? ? " from #{hosts(processes)}" : ""
97
+ Check.new(status: :ok, message: "Agent reporting#{from}#{release}")
98
+ elsif agent["last_received_at"]
99
+ Check.new(status: :warn, message: "The agent last reported #{time(agent["last_received_at"])}, and nothing since",
100
+ fix: "Check the app is running, and that DEPLOYANGEL_TOKEN is still set on its processes.")
101
+ else
102
+ Check.new(status: :warn, message: "The agent hasn't reported yet",
103
+ fix: "Deploy with DEPLOYANGEL_TOKEN set to the app's agent token (Settings, Tokens: \"Deployed application\").")
104
+ end
105
+ end
106
+
107
+ def metadata_check(setup)
108
+ metadata = setup["metadata"] or return
109
+ jobs = Array(setup["scheduled_jobs"])
110
+ problems = jobs.select { |job| %w[overdue failed].include?(job["status"]) }
111
+ problem_text = problems.any? ? ", #{problems.size} needing attention (#{problems.first(3).map { |job| "#{job["label"]} #{job["status"]}" }.join(", ")})" : ""
112
+ Check.new(status: problems.any? ? :warn : :ok,
113
+ message: "Reported: #{count(metadata["routes"], "route")}, #{count(metadata["job_classes"], "job class", "job classes")}, " \
114
+ "#{count(metadata["schedules"], "scheduled job")}#{problem_text}",
115
+ fix: ("See them on the app page, under Scheduled jobs." if problems.any?))
116
+ end
117
+
118
+ def gap_checks(setup)
119
+ Array(setup["gaps"]).map { |gap| Check.new(status: :warn, message: gap["title"], fix: gap["fix"]) }
120
+ end
121
+
122
+ def progress_check(setup)
123
+ deployments = setup["deployments"] || {}
124
+ latest = deployments["latest"]
125
+ message = if setup["warm_up_ends_at"]
126
+ "Learning what's normal for this app until #{time(setup["warm_up_ends_at"])}; deploys before then are checked, not cleared"
127
+ elsif latest
128
+ "#{count(deployments["count"], "deploy")} so far; the latest, #{latest["label"]}, is #{latest["verdict"] || latest["state"]}"
129
+ elsif setup.dig("agent", "last_received_at")
130
+ "No deploys yet: your next deploy gets the first verdict"
131
+ end
132
+ message && Check.new(status: :info, message: message)
133
+ end
134
+
135
+ # Run where the app runs (`heroku run`, a container shell), the release
136
+ # this process would report. On a developer machine it would only be
137
+ # the checkout's git HEAD, which says nothing about production, so
138
+ # it's left out there.
139
+ def here_check
140
+ release = Release.resolve(config: Configuration.new(@env), env: @env, root: @root)
141
+ if release.unknown?
142
+ return unless where_the_app_runs?
143
+
144
+ Check.new(status: :warn, message: "Here, the agent can't tell which release is running",
145
+ fix: "Set DEPLOYANGEL_REVISION to the deployed commit, or see https://deployangel.com/docs#containers.")
146
+ elsif release.source != "git_head"
147
+ Check.new(status: :ok, message: "Here, the agent would report release #{release.version || release.commit} (from #{release.source})")
148
+ end
149
+ rescue StandardError
150
+ nil
151
+ end
152
+
153
+ def where_the_app_runs?
154
+ !@env["DEPLOYANGEL_TOKEN"].to_s.empty? && @env["DEPLOYANGEL_API_TOKEN"].to_s.empty?
155
+ end
156
+
157
+ # How production will know the release, judged from what's reporting
158
+ # once anything is, and from this checkout's deploy files before then.
159
+ def release_check
160
+ return if Array(setup&.dig("agent", "processes")).any?
161
+
162
+ if file?("config/deploy.yml")
163
+ Check.new(status: :ok, message: "Kamal deploys: the agent reads the release from KAMAL_VERSION")
164
+ elsif file?("render.yaml")
165
+ Check.new(status: :ok, message: "Render deploys: the agent reads the commit from RENDER_GIT_COMMIT")
166
+ elsif read(".do/app.yaml").to_s.include?("DEPLOYANGEL_REVISION")
167
+ Check.new(status: :ok, message: "The DigitalOcean app spec passes the commit in DEPLOYANGEL_REVISION")
168
+ elsif (dockerfile = read("Dockerfile"))
169
+ if dockerfile.include?("DEPLOYANGEL_REVISION")
170
+ Check.new(status: :ok, message: "The Dockerfile passes the commit in DEPLOYANGEL_REVISION")
171
+ else
172
+ # Platforms can pass it another way, so this is advice until the
173
+ # agent reports: then the release it names settles it.
174
+ Check.new(status: :info, message: "The Dockerfile doesn't set DEPLOYANGEL_REVISION; unless your platform passes the commit, " \
175
+ "releases won't be named by commit",
176
+ fix: "Run `deployangel install docker`, then build with --build-arg GIT_SHA=$(git rev-parse HEAD).")
177
+ end
178
+ elsif file?("Capfile")
179
+ Check.new(status: :ok, message: "Capistrano deploys: the agent reads the REVISION file Capistrano writes")
180
+ elsif file?("Procfile") || file?("app.json")
181
+ Check.new(status: :info, message: "On Heroku, turn on dyno metadata so the agent knows the commit",
182
+ fix: "heroku labs:enable runtime-dyno-metadata && heroku labs:enable runtime-dyno-build-metadata")
183
+ end
184
+ end
185
+
186
+ # Schedules in this checkout that the running agent hasn't reported:
187
+ # not deployed yet, or an agent too old to read them.
188
+ def comparison_checks
189
+ reported = setup&.dig("metadata", "schedules") or return []
190
+ return [] unless local_schedules.size > reported
191
+
192
+ [ Check.new(status: :warn, message: "This checkout declares #{count(local_schedules.size, "scheduled job")}; the running agent reported #{reported}",
193
+ fix: "Deploy this code, or update the agent: whenever schedules need deployangel 0.1.22 or later, GoodJob's 0.1.23.") ]
194
+ end
195
+
196
+ def local_schedules
197
+ @local_schedules ||= begin
198
+ config = Configuration.new({})
199
+ metadata = if file?("config/application.rb")
200
+ Rails::Metadata.new(app: nil, config: config, root: @root, environment: "production")
201
+ else
202
+ DeployAngel::Metadata.new(config: config, root: @root, environment: "production")
203
+ end
204
+ metadata.schedules
205
+ rescue StandardError, ScriptError
206
+ []
207
+ end
208
+ end
209
+
210
+ def hosts(processes)
211
+ names = processes.map { |process| process["processes"].to_i > 1 ? "#{process["host"]} ×#{process["processes"]}" : process["host"] }
212
+ total = processes.sum { |process| process["processes"].to_i }
213
+ listed = names.first(MAX_HOSTS).join(", ")
214
+ listed += ", and others" if names.size > MAX_HOSTS
215
+ "#{count(total, "process", "processes")} (#{listed})"
216
+ end
217
+
218
+ def time(iso)
219
+ Time.iso8601(iso).utc.strftime("%b %-d, %H:%M UTC")
220
+ rescue ArgumentError, TypeError
221
+ iso.to_s
222
+ end
223
+
224
+ def count(number, singular, plural = "#{singular}s")
225
+ "#{number.to_i} #{number.to_i == 1 ? singular : plural}"
226
+ end
227
+
228
+ def file?(relative) = File.file?(File.join(@root, relative))
229
+
230
+ def read(relative)
231
+ path = File.join(@root, relative)
232
+ File.read(path) if File.file?(path)
233
+ rescue SystemCallError
234
+ nil
235
+ end
236
+ end
237
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.23"
4
+ VERSION = "0.1.24"
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.23
4
+ version: 0.1.24
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/doctor.rb
49
50
  - lib/deployangel/exerciser.rb
50
51
  - lib/deployangel/fork_hook.rb
51
52
  - lib/deployangel/mcp.rb