deployangel 0.1.22 → 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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +21 -1
- data/lib/deployangel/cli.rb +35 -1
- data/lib/deployangel/client.rb +1 -0
- data/lib/deployangel/doctor.rb +237 -0
- data/lib/deployangel/rails/metadata.rb +31 -4
- data/lib/deployangel/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2304a80caf17ea6df84a2487145d51438244f15e38528e817fb93ed45a07dff0
|
|
4
|
+
data.tar.gz: b28c3f544144e1b4a8cd9417fbdb4407bd814d814fe88522073e4d463d25f11a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba778f41f2922f597a225d2bbdefc58ffce09443ec7f4c342f8df2729103e36a49115bcd76502222a95717eace53b4c737cc836fccff3a865bb52e835501fc0a
|
|
7
|
+
data.tar.gz: 98abb8b69f7cf9e61980a68befcff22dee0ede110019a2b6e398a92efaef155e2c700ce3eaf426fb9f3346fcbe0ce320b7bd3ef78979c0459a91bc4a6f0cd263
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
13
|
+
## 0.1.23 (2026-10-09)
|
|
14
|
+
|
|
15
|
+
- GoodJob's cron (`config.good_job.cron`, or `GOOD_JOB_CRON`) is read from
|
|
16
|
+
the processes that run it, so its jobs are expected on schedule. Entries
|
|
17
|
+
whose schedule or class is a lambda, or that are off by default, are
|
|
18
|
+
left out; schedules without a zone are read in the server's.
|
|
19
|
+
|
|
3
20
|
## 0.1.22 (2026-10-09)
|
|
4
21
|
|
|
5
22
|
- Work scheduled outside the app's own scheduler is watched too.
|
data/README.md
CHANGED
|
@@ -98,7 +98,8 @@ the dashboard says so.
|
|
|
98
98
|
as it is. If your app's messages might hold personal or health data, [turn
|
|
99
99
|
messages off](#exception-messages).
|
|
100
100
|
- Once per process: the route table, job classes, recurring schedules
|
|
101
|
-
declared for Solid Queue, sidekiq-cron,
|
|
101
|
+
declared for Solid Queue, GoodJob, sidekiq-cron, sidekiq-scheduler, or
|
|
102
|
+
whenever, critical
|
|
102
103
|
flows, and file digests (relative paths and hashes, never file contents) so
|
|
103
104
|
DeployAngel can tell which routes changed in a release.
|
|
104
105
|
Disable digests with `DEPLOYANGEL_FILE_DIGESTS=false`.
|
|
@@ -210,6 +211,13 @@ end
|
|
|
210
211
|
Jobs that exist only in Redis, such as ones created in code or in the
|
|
211
212
|
Sidekiq web UI, aren't read.
|
|
212
213
|
|
|
214
|
+
GoodJob's cron (`config.good_job.cron`, or `GOOD_JOB_CRON`) is read from the
|
|
215
|
+
processes that run it, the ones with cron enabled. Schedules without a zone
|
|
216
|
+
are read in the server's, as GoodJob reads them. An entry whose `cron:` or
|
|
217
|
+
`class:` is a lambda, or that's `enabled_by_default: false`, isn't expected.
|
|
218
|
+
The agent doesn't see entries turned off in GoodJob's dashboard, so one you
|
|
219
|
+
switch off there will look missed after the next deploy.
|
|
220
|
+
|
|
213
221
|
#### Cron, Heroku Scheduler, and whenever
|
|
214
222
|
|
|
215
223
|
Work that something outside the app starts on a schedule, such as cron,
|
|
@@ -433,8 +441,20 @@ bundle exec deployangel exercise --url=https://example.com # send the plan's re
|
|
|
433
441
|
bundle exec deployangel release --commit=$SHA # register a deploy (manual or CI)
|
|
434
442
|
bundle exec deployangel check --name="smoke: signup" --status=pass --covers=registration
|
|
435
443
|
bundle exec deployangel exception <fingerprint>
|
|
444
|
+
bundle exec deployangel doctor # check the setup
|
|
436
445
|
```
|
|
437
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
|
+
|
|
438
458
|
Output is text on a terminal and JSON (the verdict document) when piped, or
|
|
439
459
|
choose with `--format=text|json`. Exit codes:
|
|
440
460
|
|
data/lib/deployangel/cli.rb
CHANGED
|
@@ -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
|
data/lib/deployangel/client.rb
CHANGED
|
@@ -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
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module DeployAngel
|
|
4
4
|
module Rails
|
|
5
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
|
|
6
|
+
# Active Job, and Solid Queue's and GoodJob's recurring tasks. The schedule files
|
|
7
7
|
# sidekiq-cron and sidekiq-scheduler read, and the file digests, are
|
|
8
8
|
# gathered in DeployAngel::Metadata.
|
|
9
9
|
class Metadata < ::DeployAngel::Metadata
|
|
@@ -44,10 +44,10 @@ module DeployAngel
|
|
|
44
44
|
[]
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
# Solid Queue's
|
|
48
|
-
# on every framework.
|
|
47
|
+
# Solid Queue's and GoodJob's schedules live in the Rails app; the rest
|
|
48
|
+
# are read the same way on every framework.
|
|
49
49
|
def schedules
|
|
50
|
-
solid_queue_schedules + super
|
|
50
|
+
solid_queue_schedules + good_job_schedules + super
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
# Solid Queue recurring tasks for the current environment.
|
|
@@ -69,6 +69,33 @@ module DeployAngel
|
|
|
69
69
|
[]
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# GoodJob's cron, from a process that runs it. GoodJob turns cron on
|
|
73
|
+
# per process (often only the worker's, with GOOD_JOB_ENABLE_CRON), and
|
|
74
|
+
# its CLI applies --enable-cron after boot, before metadata is first
|
|
75
|
+
# sent. A schedule without a zone is read in the server's, as Fugit
|
|
76
|
+
# reads it. A schedule or class given as a lambda can't be known
|
|
77
|
+
# without running app code, so it's left out.
|
|
78
|
+
def good_job_schedules
|
|
79
|
+
return [] unless defined?(::GoodJob) && ::GoodJob.respond_to?(:configuration)
|
|
80
|
+
|
|
81
|
+
configuration = ::GoodJob.configuration
|
|
82
|
+
return [] unless configuration.enable_cron?
|
|
83
|
+
|
|
84
|
+
configuration.cron.filter_map do |key, entry|
|
|
85
|
+
next unless entry.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
entry = entry.transform_keys(&:to_sym)
|
|
88
|
+
cron, job_class = entry.values_at(:cron, :class)
|
|
89
|
+
job_class = job_class.name if job_class.is_a?(Module)
|
|
90
|
+
next if entry[:enabled_by_default] == false
|
|
91
|
+
next unless cron.is_a?(String) && !cron.strip.empty? && job_class.is_a?(String) && !job_class.empty?
|
|
92
|
+
|
|
93
|
+
{ "key" => key.to_s, "class" => job_class, "schedule" => cron, "source" => "good_job", "time_zone" => local_time_zone }
|
|
94
|
+
end
|
|
95
|
+
rescue StandardError
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
|
|
72
99
|
# A command task runs as a job of this class, so its runs show up in
|
|
73
100
|
# job telemetry under that name.
|
|
74
101
|
def command_job_class
|
data/lib/deployangel/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: deployangel
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.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
|