onair-cli 0.1.0 → 0.3.0
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 +15 -0
- data/README.md +40 -13
- data/lib/onair/cli.rb +5 -4
- data/lib/onair/config.rb +1 -1
- data/lib/onair/git.rb +1 -1
- data/lib/onair/orchestrator.rb +4 -3
- data/lib/onair/platform/base.rb +6 -1
- data/lib/onair/platform/heroku.rb +100 -20
- data/lib/onair/remote_head.rb +1 -1
- data/lib/onair/renderer/json.rb +37 -7
- data/lib/onair/renderer/tty.rb +56 -6
- data/lib/onair/report.rb +35 -14
- data/lib/onair/version.rb +1 -1
- data/lib/onair.rb +22 -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: 1eb5f2eb79c1eb2da95ef69cb65f8bef60b72dc3d98563a6235573b4ae139fd6
|
|
4
|
+
data.tar.gz: f5a38c5ef53340a88e41c9c007e68c34d1035d0427b953ac7b4193421201dde7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 133e4480b8e8c22835294d0c2b18e79f9ee6ba065ee080c7c5f0e0c61a1a8bf92aecc6ee2e6092bce8ba095ffe7937ce8b6cb6526ca20938ae3d3d54dc39062e
|
|
7
|
+
data.tar.gz: b4e08f469025a62273c6c848b4d790e8b9a1520a6ce6ab24f0b845bcd7453cfa2040b1d40b3d0a29fe73c13e9c3d4242779022b0b2c43ba11c968f0e5ae74be5
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.3.0] - 2026-10-08
|
|
6
|
+
|
|
7
|
+
- Heroku: a rollout line under Deployed counts the dynos of each process type
|
|
8
|
+
running the deployed release (`web 1/3, 2 starting`) and says when every
|
|
9
|
+
dyno is up on it. With preboot on, the handoff from the previous web dynos
|
|
10
|
+
shows as an estimate. `--json` gains a `rollout` object.
|
|
11
|
+
|
|
12
|
+
## [0.2.0] - 2026-10-07
|
|
13
|
+
|
|
14
|
+
- Heroku: Deployed is the release Heroku is serving, not the newest one. A
|
|
15
|
+
newer release still in its release phase shows as `Releasing`, a failed one
|
|
16
|
+
as `Failed`, and neither counts as on air. While such a release carries a
|
|
17
|
+
different commit, `pinned` and `★ current` stay quiet. `--json` gains a
|
|
18
|
+
`release` object.
|
|
19
|
+
|
|
5
20
|
## [0.1.0]
|
|
6
21
|
|
|
7
22
|
- Initial release: Heroku adapter with netrc auth, full behavior parity with
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<div align="center">
|
|
6
6
|
|
|
7
|
-
### See what's on air
|
|
7
|
+
### See what's on air - which commit is actually running in production.
|
|
8
8
|
|
|
9
9
|
[](https://rubygems.org/gems/onair-cli)
|
|
10
10
|
[](https://github.com/amberpixels/onair-cli/actions/workflows/ci.yml)
|
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
|
|
17
17
|
<p align="center">
|
|
18
18
|
<img src="assets/demo.svg"
|
|
19
|
-
alt="onair report: a
|
|
19
|
+
alt="onair report: a release still in its release phase, the deployed release one commit behind origin/main, and your commit absorbed by the current deploy"
|
|
20
20
|
width="760">
|
|
21
21
|
</p>
|
|
22
22
|
|
|
23
23
|
`onair` answers one question fast and truthfully. The deployed commit comes
|
|
24
|
-
from the running release's *slug*, never from "newest build"
|
|
24
|
+
from the running release's *slug*, never from "newest build" - after a
|
|
25
25
|
rollback those differ, and the whole point of this tool is to not lie in that
|
|
26
26
|
case.
|
|
27
27
|
|
|
@@ -33,7 +33,7 @@ case.
|
|
|
33
33
|
an explicit lazy `git fetch`.
|
|
34
34
|
4. **Platform-agnostic core.** Heroku is the first adapter, not the
|
|
35
35
|
architecture.
|
|
36
|
-
5. **Degrade gracefully.** Offline, missing commits, no color support
|
|
36
|
+
5. **Degrade gracefully.** Offline, missing commits, no color support - every
|
|
37
37
|
failure mode renders something useful, never a stack trace.
|
|
38
38
|
|
|
39
39
|
## Installation
|
|
@@ -61,21 +61,30 @@ is not a TTY or `NO_COLOR` is set.
|
|
|
61
61
|
### Speed
|
|
62
62
|
|
|
63
63
|
The remote head of `origin/<branch>` is resolved through the GitHub API
|
|
64
|
-
(~0.4s) when a token is ambient
|
|
65
|
-
`gh` CLI
|
|
64
|
+
(~0.4s) when a token is ambient - `GH_TOKEN`, `GITHUB_TOKEN`, or a logged-in
|
|
65
|
+
`gh` CLI - with `git ls-remote` always racing alongside as the fallback
|
|
66
66
|
(no token, non-GitHub remote, API failure). Both report the live remote ref;
|
|
67
67
|
the API is a faster transport, never a cache.
|
|
68
68
|
|
|
69
69
|
### What the report shows
|
|
70
70
|
|
|
71
|
-
- **Deployed**
|
|
71
|
+
- **Deployed** - the commit the running release was built from, its age,
|
|
72
72
|
author, and a delta against `origin/main`: `★ current` or
|
|
73
73
|
`↓ N commits behind`. If the relationship is unknown (diverged history,
|
|
74
|
-
commits unavailable), no marker is shown
|
|
75
|
-
- **
|
|
76
|
-
|
|
74
|
+
commits unavailable), no marker is shown - silence over speculation.
|
|
75
|
+
- **Rollout** - under the Deployed row, how many dynos of each process type
|
|
76
|
+
run that release: `⟳ rolling out v1234: web 1/3, 2 starting` while dynos
|
|
77
|
+
restart, `✓ rolled out` once every dyno is up on it. With preboot on, the
|
|
78
|
+
previous web dynos keep serving for about three minutes after the new ones
|
|
79
|
+
are up; that window shows as an estimate.
|
|
80
|
+
- **Pending** - an in-flight build that's about to replace the deploy.
|
|
81
|
+
- **Releasing** - a newer release whose release phase (e.g. migrations) is
|
|
82
|
+
still running; production still serves the Deployed row until it finishes.
|
|
83
|
+
- **Failed** - the newest release failed its release phase and never went
|
|
84
|
+
live. It stays until a newer release succeeds.
|
|
85
|
+
- **⏸ pinned** - a newer build succeeded but is *not* what's running
|
|
77
86
|
(rollback / pinned release).
|
|
78
|
-
- **Yours**
|
|
87
|
+
- **Yours** - when someone else's commit is deployed but yours sits just
|
|
79
88
|
below it: your merge made it to prod, absorbed by a later deploy.
|
|
80
89
|
|
|
81
90
|
## Configuration
|
|
@@ -105,7 +114,7 @@ task:
|
|
|
105
114
|
|
|
106
115
|
Matched ids in commit subjects become clickable terminal hyperlinks (the text
|
|
107
116
|
stays identical), and `--json` gains a `task` object (`{ "id", "url" }`) per
|
|
108
|
-
commit. No `task:` section
|
|
117
|
+
commit. No `task:` section - no behavior, nothing to opt out of.
|
|
109
118
|
|
|
110
119
|
## JSON schema
|
|
111
120
|
|
|
@@ -125,6 +134,14 @@ additively:
|
|
|
125
134
|
"author": "Eugene", "task": null
|
|
126
135
|
},
|
|
127
136
|
"pending": null,
|
|
137
|
+
"release": null,
|
|
138
|
+
"rollout": {
|
|
139
|
+
"version": 1234, "complete": true,
|
|
140
|
+
"processes": [
|
|
141
|
+
{ "type": "web", "up": 3, "total": 3, "waiting": {}, "previous": 0 }
|
|
142
|
+
],
|
|
143
|
+
"overlap_until": null
|
|
144
|
+
},
|
|
128
145
|
"delta": { "status": "current", "behind_by": 0 },
|
|
129
146
|
"pinned": null,
|
|
130
147
|
"yours": null
|
|
@@ -134,6 +151,16 @@ additively:
|
|
|
134
151
|
- `delta.status` is `"current"`, `"behind"`, or `"unknown"`.
|
|
135
152
|
- `pending`: `{ "sha", "started_at", "subject", "author" }` when a build is
|
|
136
153
|
in flight.
|
|
154
|
+
- `release`: `{ "sha", "version", "description", "status", "started_at",
|
|
155
|
+
"subject", "author" }` when a release newer than the running one is not
|
|
156
|
+
live; `status` is `"pending"` (release phase running) or `"failed"`. While
|
|
157
|
+
it carries a different sha, `delta.status` is never `"current"`.
|
|
158
|
+
- `rollout`: dynos per process type against the running release. `up`
|
|
159
|
+
counts dynos serving it, `waiting` counts the rest of its dynos by state
|
|
160
|
+
(`starting`, `crashed`, ...), `previous` counts dynos on an older release.
|
|
161
|
+
`overlap_until` is an estimated end of the preboot handoff while it lasts,
|
|
162
|
+
otherwise `null`. `complete` is true once every dyno serves the running
|
|
163
|
+
release and no handoff remains. `null` when the dynos can't be read.
|
|
137
164
|
- `pinned`: `{ "version", "description", "latest_built_sha" }` when a newer
|
|
138
165
|
build succeeded but is not running.
|
|
139
166
|
- `yours`: `{ "sha", "had_own_build", "subject", "author" }` when your commit
|
|
@@ -146,7 +173,7 @@ additively:
|
|
|
146
173
|
|
|
147
174
|
```sh
|
|
148
175
|
bundle install
|
|
149
|
-
bundle exec rake # specs +
|
|
176
|
+
bundle exec rake # specs + standard
|
|
150
177
|
```
|
|
151
178
|
|
|
152
179
|
The behavioral source of truth is `reference/prod-release.sh`, the
|
data/lib/onair/cli.rb
CHANGED
|
@@ -64,16 +64,17 @@ module Onair
|
|
|
64
64
|
git = Git.new(fetch_allowed: config.fetch)
|
|
65
65
|
adapter = Platform.build(config)
|
|
66
66
|
repo = config.repo || git.origin_repo
|
|
67
|
-
|
|
67
|
+
now = Time.now
|
|
68
|
+
report = Orchestrator.new(config: config, adapter: adapter, git: git, repo: repo, now: now).run
|
|
68
69
|
|
|
69
70
|
if flags[:json]
|
|
70
71
|
puts Renderer::Json.new(report: report, app: config.app, platform: config.platform,
|
|
71
|
-
|
|
72
|
+
branch: config.branch, repo: repo, task: config.task).render
|
|
72
73
|
else
|
|
73
74
|
tty = $stdout.tty?
|
|
74
75
|
print Renderer::Tty.new(report: report, app: config.app, platform_label: adapter.display_name,
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
branch: config.branch, repo: repo, task: config.task,
|
|
77
|
+
color: tty && !no_color?, hyperlinks: tty, now: now).render
|
|
77
78
|
end
|
|
78
79
|
end
|
|
79
80
|
|
data/lib/onair/config.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Onair
|
|
|
22
22
|
def self.resolve(flags = {}, env: ENV, dir: Dir.pwd)
|
|
23
23
|
file = find_file(dir) || {}
|
|
24
24
|
app = flags[:app] || env["HEROKU_APP"] || file["app"]
|
|
25
|
-
raise Error, "no app configured
|
|
25
|
+
raise Error, "no app configured - pass --app NAME, set HEROKU_APP, or run `onair init`" if app.nil?
|
|
26
26
|
|
|
27
27
|
new(
|
|
28
28
|
platform: file["platform"] || "heroku",
|
data/lib/onair/git.rb
CHANGED
data/lib/onair/orchestrator.rb
CHANGED
|
@@ -4,11 +4,12 @@ module Onair
|
|
|
4
4
|
# Runs the platform adapter and git concurrently, lazily fetches missing
|
|
5
5
|
# commits at most once, and assembles the Report.
|
|
6
6
|
class Orchestrator
|
|
7
|
-
def initialize(config:, adapter:, git:, repo: nil)
|
|
7
|
+
def initialize(config:, adapter:, git:, repo: nil, now: Time.now)
|
|
8
8
|
@config = config
|
|
9
9
|
@adapter = adapter
|
|
10
10
|
@git = git
|
|
11
11
|
@repo = repo
|
|
12
|
+
@now = now
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def run
|
|
@@ -17,10 +18,10 @@ module Onair
|
|
|
17
18
|
snapshot = snapshot_thread.value
|
|
18
19
|
remote_head = head_thread.value
|
|
19
20
|
|
|
20
|
-
needed = [snapshot.deployed&.sha, snapshot.pending&.sha, remote_head].compact
|
|
21
|
+
needed = [snapshot.deployed&.sha, snapshot.pending&.sha, snapshot.release&.sha, remote_head].compact
|
|
21
22
|
@git.fetch_once! if needed.any? { |sha| !@git.has_commit?(sha) }
|
|
22
23
|
|
|
23
|
-
Report.build(snapshot: snapshot, remote_head: remote_head, git: @git)
|
|
24
|
+
Report.build(snapshot: snapshot, remote_head: remote_head, git: @git, now: @now)
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
private
|
data/lib/onair/platform/base.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Onair
|
|
|
13
13
|
klass = @registry[config.platform]
|
|
14
14
|
if klass.nil?
|
|
15
15
|
raise Error,
|
|
16
|
-
|
|
16
|
+
"unknown platform #{config.platform.inspect} (available: #{@registry.keys.join(", ")})"
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
klass.new(config)
|
|
@@ -25,8 +25,13 @@ module Onair
|
|
|
25
25
|
# deployed: the release CURRENTLY RUNNING (nil sha only if
|
|
26
26
|
# truly unresolvable)
|
|
27
27
|
# pending: newest in-flight build, or nil
|
|
28
|
+
# release: newest release above the running one that is not
|
|
29
|
+
# running (release phase pending or failed), or nil
|
|
28
30
|
# latest_built_sha: newest successfully built sha, or nil
|
|
29
31
|
# succeeded_shas: recent succeeded build shas, newest first
|
|
32
|
+
# rollout: per process type, how many dynos run the running
|
|
33
|
+
# release, plus an optional estimate of when the
|
|
34
|
+
# previous release stops serving; nil when unknown
|
|
30
35
|
#
|
|
31
36
|
# Adapters own their internal concurrency and auth. Everything above
|
|
32
37
|
# (delta, pinned, mine, rendering) is platform-agnostic and must not
|
|
@@ -8,15 +8,26 @@ module Onair
|
|
|
8
8
|
module Platform
|
|
9
9
|
class Heroku < Base
|
|
10
10
|
HOST = "api.heroku.com"
|
|
11
|
+
RELEASE_WINDOW = 10
|
|
12
|
+
IN_FLIGHT_STATUSES = %w[pending failed].freeze
|
|
13
|
+
ONE_OFF_TYPES = %w[run scheduler release].freeze
|
|
14
|
+
# An eco dyno asleep on the running release boots that release on wake.
|
|
15
|
+
SERVING_STATES = %w[up idle].freeze
|
|
16
|
+
# With preboot, Heroku keeps routing to the previous web dynos for about
|
|
17
|
+
# three minutes after the new ones come up.
|
|
18
|
+
PREBOOT_HANDOFF = 180
|
|
11
19
|
|
|
12
20
|
def snapshot
|
|
13
21
|
token = resolve_token
|
|
14
|
-
|
|
22
|
+
releases_thread = quiet_thread { releases(token) }
|
|
15
23
|
builds_thread = quiet_thread { builds(token) }
|
|
16
|
-
|
|
24
|
+
dynos_thread = quiet_thread { dynos(token) }
|
|
25
|
+
deployed, release = releases_thread.value
|
|
17
26
|
pending, succeeded_shas = builds_thread.value
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
dyno_rows, preboot = dynos_thread.value
|
|
28
|
+
Snapshot.new(deployed: deployed, pending: pending, release: release,
|
|
29
|
+
latest_built_sha: succeeded_shas.first, succeeded_shas: succeeded_shas,
|
|
30
|
+
rollout: rollout(dyno_rows, preboot, deployed.version))
|
|
20
31
|
end
|
|
21
32
|
|
|
22
33
|
private
|
|
@@ -35,28 +46,47 @@ module Onair
|
|
|
35
46
|
def resolve_token
|
|
36
47
|
token = Auth::Netrc.token(HOST)
|
|
37
48
|
token = Auth::HerokuCli.token if token.nil? || token.empty?
|
|
38
|
-
raise Error, "no Heroku credentials found
|
|
49
|
+
raise Error, "no Heroku credentials found - run `heroku login`" if token.nil? || token.empty?
|
|
39
50
|
|
|
40
51
|
token
|
|
41
52
|
end
|
|
42
53
|
|
|
43
|
-
# The
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
|
|
54
|
+
# The running release is the one Heroku routes to (`current`), not the
|
|
55
|
+
# newest: while a release phase runs, or after it fails, the previous
|
|
56
|
+
# release keeps serving. The slug records the commit a release was built
|
|
57
|
+
# from - the only reliable source after a rollback, when the builds list
|
|
58
|
+
# still shows the newer (no-longer-running) build on top.
|
|
59
|
+
def releases(token)
|
|
47
60
|
with_http do |http|
|
|
48
|
-
|
|
49
|
-
raise Error, "no releases found for app #{app}" if
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
)
|
|
61
|
+
rows = get(http, token, "/apps/#{app}/releases", range: "version ..; order=desc, max=#{RELEASE_WINDOW}")
|
|
62
|
+
raise Error, "no releases found for app #{app}" if rows.empty?
|
|
63
|
+
|
|
64
|
+
running = rows.find { |row| row["current"] } || rows.find { |row| row["status"] == "succeeded" }
|
|
65
|
+
raise Error, "no succeeded release among the last #{RELEASE_WINDOW} for app #{app}" if running.nil?
|
|
66
|
+
|
|
67
|
+
deployed_sha = slug_commit(http, token, running.dig("slug", "id"))
|
|
68
|
+
[deployed(running, deployed_sha), in_flight(http, token, rows.first, running, deployed_sha)]
|
|
57
69
|
end
|
|
58
70
|
end
|
|
59
71
|
|
|
72
|
+
def deployed(row, sha)
|
|
73
|
+
Deployed.new(sha: sha, version: row["version"], description: row["description"],
|
|
74
|
+
deployed_at: parse_time(row["created_at"]))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Only the newest release counts: an older failed one was superseded by
|
|
78
|
+
# whatever came after it. Without a commit there is no row to render.
|
|
79
|
+
def in_flight(http, token, newest, running, running_sha)
|
|
80
|
+
return nil if newest.equal?(running) || !IN_FLIGHT_STATUSES.include?(newest["status"])
|
|
81
|
+
|
|
82
|
+
slug_id = newest.dig("slug", "id")
|
|
83
|
+
sha = (slug_id == running.dig("slug", "id")) ? running_sha : slug_commit(http, token, slug_id)
|
|
84
|
+
return nil if sha.nil?
|
|
85
|
+
|
|
86
|
+
Release.new(sha: sha, version: newest["version"], description: newest["description"],
|
|
87
|
+
status: newest["status"].to_sym, started_at: parse_time(newest["created_at"]))
|
|
88
|
+
end
|
|
89
|
+
|
|
60
90
|
def slug_commit(http, token, slug_id)
|
|
61
91
|
return nil if slug_id.nil?
|
|
62
92
|
|
|
@@ -75,12 +105,62 @@ module Onair
|
|
|
75
105
|
pending_sha = pending_build&.dig("source_blob", "version")
|
|
76
106
|
pending = pending_sha && Pending.new(sha: pending_sha, started_at: parse_time(pending_build["created_at"]))
|
|
77
107
|
succeeded = builds.select { |build| build["status"] == "succeeded" }
|
|
78
|
-
|
|
108
|
+
.filter_map { |build| build.dig("source_blob", "version") }
|
|
79
109
|
[pending, succeeded]
|
|
80
110
|
rescue Error
|
|
81
111
|
[nil, []]
|
|
82
112
|
end
|
|
83
113
|
|
|
114
|
+
# A failed dynos call drops the rollout and nothing else; a failed
|
|
115
|
+
# preboot lookup only drops the handoff estimate.
|
|
116
|
+
def dynos(token)
|
|
117
|
+
with_http do |http|
|
|
118
|
+
rows = get(http, token, "/apps/#{app}/dynos")
|
|
119
|
+
[rows, preboot?(http, token)]
|
|
120
|
+
end
|
|
121
|
+
rescue Error
|
|
122
|
+
[nil, false]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def preboot?(http, token)
|
|
126
|
+
get(http, token, "/apps/#{app}/features/preboot")["enabled"] == true
|
|
127
|
+
rescue Error
|
|
128
|
+
false
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def rollout(rows, preboot, version)
|
|
132
|
+
return nil if rows.nil? || version.nil?
|
|
133
|
+
|
|
134
|
+
formation = rows.reject { |dyno| ONE_OFF_TYPES.include?(dyno["type"]) }
|
|
135
|
+
return nil if formation.empty?
|
|
136
|
+
|
|
137
|
+
processes = formation.group_by { |dyno| dyno["type"] }
|
|
138
|
+
.sort_by { |type, _| [(type == "web") ? 0 : 1, type] }
|
|
139
|
+
.map { |type, dynos| process_rollout(type, dynos, version) }
|
|
140
|
+
Rollout.new(version: version, processes: processes,
|
|
141
|
+
overlap_until: preboot ? preboot_handoff(formation, version) : nil)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def process_rollout(type, dynos, version)
|
|
145
|
+
current, previous = dynos.partition { |dyno| dyno.dig("release", "version") == version }
|
|
146
|
+
waiting = current.map { |dyno| dyno["state"] }.reject { |state| SERVING_STATES.include?(state) }.tally
|
|
147
|
+
ProcessRollout.new(type: type, total: dynos.size, up: current.size - waiting.values.sum,
|
|
148
|
+
waiting: waiting, previous: previous.size)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Old web dynos still listed are already counted as previous; the
|
|
152
|
+
# estimate covers only the handoff the dynos list does not show.
|
|
153
|
+
def preboot_handoff(formation, version)
|
|
154
|
+
web = formation.select { |dyno| dyno["type"] == "web" }
|
|
155
|
+
return nil if web.empty?
|
|
156
|
+
return nil unless web.all? do |dyno|
|
|
157
|
+
dyno.dig("release", "version") == version && SERVING_STATES.include?(dyno["state"])
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
last_up = web.filter_map { |dyno| parse_time(dyno["updated_at"]) }.max
|
|
161
|
+
last_up && (last_up + PREBOOT_HANDOFF)
|
|
162
|
+
end
|
|
163
|
+
|
|
84
164
|
# One connection per thread — the dependent releases → slug pair must
|
|
85
165
|
# not pay a second TLS handshake.
|
|
86
166
|
def with_http(&)
|
|
@@ -112,7 +192,7 @@ module Onair
|
|
|
112
192
|
|
|
113
193
|
def error_message(response, path)
|
|
114
194
|
case response.code.to_i
|
|
115
|
-
when 401 then "Heroku rejected the token (401)
|
|
195
|
+
when 401 then "Heroku rejected the token (401) - run `heroku login`"
|
|
116
196
|
when 404 then "Heroku app not found: #{app}"
|
|
117
197
|
else "Heroku API returned #{response.code} for #{path}"
|
|
118
198
|
end
|
data/lib/onair/remote_head.rb
CHANGED
data/lib/onair/renderer/json.rb
CHANGED
|
@@ -30,6 +30,8 @@ module Onair
|
|
|
30
30
|
remote_head: @report.remote_head,
|
|
31
31
|
deployed: deployed_payload,
|
|
32
32
|
pending: pending_payload,
|
|
33
|
+
release: release_payload,
|
|
34
|
+
rollout: rollout_payload,
|
|
33
35
|
delta: delta_payload,
|
|
34
36
|
pinned: pinned_payload,
|
|
35
37
|
yours: yours_payload
|
|
@@ -58,14 +60,42 @@ module Onair
|
|
|
58
60
|
pending = snapshot.pending
|
|
59
61
|
return nil if pending.nil?
|
|
60
62
|
|
|
61
|
-
{
|
|
63
|
+
{sha: pending.sha, started_at: iso(pending.started_at)}.merge(commit_fields(pending.sha))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def release_payload
|
|
67
|
+
release = snapshot.release
|
|
68
|
+
return nil if release.nil?
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
sha: release.sha,
|
|
72
|
+
version: release.version,
|
|
73
|
+
description: release.description,
|
|
74
|
+
status: release.status.to_s,
|
|
75
|
+
started_at: iso(release.started_at)
|
|
76
|
+
}.merge(commit_fields(release.sha))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def rollout_payload
|
|
80
|
+
rollout = @report.rollout
|
|
81
|
+
return nil if rollout.nil?
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
version: rollout.version,
|
|
85
|
+
complete: rollout.complete?,
|
|
86
|
+
processes: rollout.processes.map do |process|
|
|
87
|
+
{type: process.type, up: process.up, total: process.total,
|
|
88
|
+
waiting: process.waiting, previous: process.previous}
|
|
89
|
+
end,
|
|
90
|
+
overlap_until: iso(rollout.overlap_until)
|
|
91
|
+
}
|
|
62
92
|
end
|
|
63
93
|
|
|
64
94
|
def delta_payload
|
|
65
95
|
case @report.delta
|
|
66
|
-
when :current then {
|
|
67
|
-
when Integer then {
|
|
68
|
-
else {
|
|
96
|
+
when :current then {status: "current", behind_by: 0}
|
|
97
|
+
when Integer then {status: "behind", behind_by: @report.delta}
|
|
98
|
+
else {status: "unknown", behind_by: nil}
|
|
69
99
|
end
|
|
70
100
|
end
|
|
71
101
|
|
|
@@ -84,19 +114,19 @@ module Onair
|
|
|
84
114
|
mine = @report.mine
|
|
85
115
|
return nil if mine.nil?
|
|
86
116
|
|
|
87
|
-
{
|
|
117
|
+
{sha: mine.sha, had_own_build: mine.had_own_build}.merge(commit_fields(mine.sha))
|
|
88
118
|
end
|
|
89
119
|
|
|
90
120
|
def commit_fields(sha)
|
|
91
121
|
info = sha && @report.commits[sha]
|
|
92
|
-
{
|
|
122
|
+
{subject: info&.subject, author: info&.author_name, task: task_fields(info&.subject)}
|
|
93
123
|
end
|
|
94
124
|
|
|
95
125
|
def task_fields(subject)
|
|
96
126
|
task_id = @task&.find(subject)
|
|
97
127
|
return nil if task_id.nil?
|
|
98
128
|
|
|
99
|
-
{
|
|
129
|
+
{id: task_id, url: @task.url_for(task_id)}
|
|
100
130
|
end
|
|
101
131
|
|
|
102
132
|
def iso(time)
|
data/lib/onair/renderer/tty.rb
CHANGED
|
@@ -7,6 +7,7 @@ module Onair
|
|
|
7
7
|
COLORS = {
|
|
8
8
|
bold: "\e[1m",
|
|
9
9
|
green: "\e[0;32m",
|
|
10
|
+
red: "\e[0;31m",
|
|
10
11
|
yellow: "\e[1;33m",
|
|
11
12
|
dim: "\e[2m",
|
|
12
13
|
purple: "\e[38;5;176m",
|
|
@@ -15,6 +16,7 @@ module Onair
|
|
|
15
16
|
}.freeze
|
|
16
17
|
|
|
17
18
|
NOT_FOUND_SUBJECT = "(commit not found in local git)"
|
|
19
|
+
LABEL_WIDTH = "Releasing:".length
|
|
18
20
|
|
|
19
21
|
def initialize(report:, app:, platform_label:, branch:, repo:, color:, hyperlinks:, now:, task: nil)
|
|
20
22
|
@report = report
|
|
@@ -31,6 +33,7 @@ module Onair
|
|
|
31
33
|
def render
|
|
32
34
|
lines = ["", " #{code(:purple)}#{@platform_label} #{code(:bold)}#{@app}#{code(:reset)}", ""]
|
|
33
35
|
lines.concat(pending_lines)
|
|
36
|
+
lines.concat(release_lines)
|
|
34
37
|
lines.concat(deployed_lines)
|
|
35
38
|
lines << ""
|
|
36
39
|
"#{lines.join("\n")}\n"
|
|
@@ -46,13 +49,30 @@ module Onair
|
|
|
46
49
|
pending = snapshot.pending
|
|
47
50
|
return [] if pending.nil?
|
|
48
51
|
|
|
49
|
-
row_lines("Pending:
|
|
52
|
+
row_lines("Pending:", :yellow, pending.sha, age(pending.started_at)) + [""]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Same-commit releases (config changes, rollbacks) name what they are,
|
|
56
|
+
# since the sha alone would look like the deploy below.
|
|
57
|
+
def release_lines
|
|
58
|
+
release = snapshot.release
|
|
59
|
+
return [] if release.nil?
|
|
60
|
+
|
|
61
|
+
detail = "v#{release.version}"
|
|
62
|
+
detail = "#{detail}: #{release.description}" if release.sha == snapshot.deployed&.sha
|
|
63
|
+
label, color, note = if release.status == :failed
|
|
64
|
+
["Failed:", :red, "✗ release phase failed (#{detail})"]
|
|
65
|
+
else
|
|
66
|
+
["Releasing:", :yellow, "⟳ release phase (#{detail})"]
|
|
67
|
+
end
|
|
68
|
+
row_lines(label, color, release.sha, age(release.started_at), extra: paint(note, color)) + [""]
|
|
50
69
|
end
|
|
51
70
|
|
|
52
71
|
def deployed_lines
|
|
53
72
|
deployed = snapshot.deployed
|
|
54
73
|
if deployed&.sha
|
|
55
74
|
lines = row_lines("Deployed:", :green, deployed.sha, age(deployed.deployed_at), extra: delta_text)
|
|
75
|
+
lines << rollout_line if @report.rollout
|
|
56
76
|
lines << pinned_line if @report.pinned
|
|
57
77
|
lines.concat(yours_lines)
|
|
58
78
|
lines
|
|
@@ -62,13 +82,42 @@ module Onair
|
|
|
62
82
|
end
|
|
63
83
|
end
|
|
64
84
|
|
|
85
|
+
# The dynos list can look finished while preboot still routes some
|
|
86
|
+
# traffic to the previous release, so that window gets its own line.
|
|
87
|
+
def rollout_line
|
|
88
|
+
rollout = @report.rollout
|
|
89
|
+
text, color = if rollout.complete?
|
|
90
|
+
["✓ rolled out v#{rollout.version}: #{process_counts(rollout)}", :dim]
|
|
91
|
+
elsif rollout.processes.all?(&:complete?)
|
|
92
|
+
["⟳ preboot: previous web dynos may still serve for " \
|
|
93
|
+
"~#{remaining(rollout.overlap_until)} (estimate)", :yellow]
|
|
94
|
+
else
|
|
95
|
+
["⟳ rolling out v#{rollout.version}: #{process_counts(rollout)}", :yellow]
|
|
96
|
+
end
|
|
97
|
+
" #{paint(text, color)}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def process_counts(rollout)
|
|
101
|
+
rollout.processes.map do |process|
|
|
102
|
+
parts = ["#{process.type} #{process.up}/#{process.total}"]
|
|
103
|
+
parts.concat(process.waiting.map { |state, count| "#{count} #{state}" })
|
|
104
|
+
parts << "#{process.previous} on older release" if process.previous.positive?
|
|
105
|
+
parts.join(", ")
|
|
106
|
+
end.join(" · ")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def remaining(time)
|
|
110
|
+
seconds = (time - @now).ceil.clamp(0..)
|
|
111
|
+
(seconds < 60) ? "#{seconds}s" : "#{(seconds / 60.0).ceil}m"
|
|
112
|
+
end
|
|
113
|
+
|
|
65
114
|
def yours_lines
|
|
66
115
|
mine = @report.mine
|
|
67
116
|
return [] if mine.nil?
|
|
68
117
|
|
|
69
118
|
note = mine.had_own_build ? "✓ released, then absorbed by current" : "✓ absorbed by current deploy"
|
|
70
119
|
committed_at = @report.commits[mine.sha]&.committed_at
|
|
71
|
-
[""] + row_lines("Yours:
|
|
120
|
+
[""] + row_lines("Yours:", :cyan, mine.sha, age(committed_at), extra: paint(note, :cyan))
|
|
72
121
|
end
|
|
73
122
|
|
|
74
123
|
def row_lines(label, color_key, sha, age, extra: nil)
|
|
@@ -76,9 +125,10 @@ module Onair
|
|
|
76
125
|
subject = info&.subject || NOT_FOUND_SUBJECT
|
|
77
126
|
author = info&.author_name || "?"
|
|
78
127
|
age_blurb = age ? "(#{age}) " : ""
|
|
79
|
-
|
|
128
|
+
padded = label.ljust(LABEL_WIDTH)
|
|
129
|
+
first = " #{paint(padded, color_key)} #{paint(sha[0, 9], :bold)} #{paint("#{age_blurb}by #{author}", :dim)}"
|
|
80
130
|
first = "#{first} #{extra}" if extra
|
|
81
|
-
[first, " #{paint(
|
|
131
|
+
[first, " #{paint("→", :dim)} #{linkify(subject, info ? sha : nil)}"]
|
|
82
132
|
end
|
|
83
133
|
|
|
84
134
|
def delta_text
|
|
@@ -87,13 +137,13 @@ module Onair
|
|
|
87
137
|
paint("★ current", :green)
|
|
88
138
|
when Integer
|
|
89
139
|
count = @report.delta
|
|
90
|
-
paint("↓ #{count} #{count == 1 ?
|
|
140
|
+
paint("↓ #{count} #{(count == 1) ? "commit" : "commits"} behind origin/#{@branch}", :yellow)
|
|
91
141
|
end
|
|
92
142
|
end
|
|
93
143
|
|
|
94
144
|
def pinned_line
|
|
95
145
|
deployed = snapshot.deployed
|
|
96
|
-
" #{paint("⏸ v#{deployed.version} (#{deployed.description})
|
|
146
|
+
" #{paint("⏸ v#{deployed.version} (#{deployed.description}) - newer build " \
|
|
97
147
|
"#{snapshot.latest_built_sha[0, 9]} succeeded but is not running", :yellow)}"
|
|
98
148
|
end
|
|
99
149
|
|
data/lib/onair/report.rb
CHANGED
|
@@ -10,40 +10,54 @@ module Onair
|
|
|
10
10
|
# mine: the local user's commit just below someone else's deployed head.
|
|
11
11
|
# commits: sha => CommitInfo (or nil when absent locally) for every sha a
|
|
12
12
|
# renderer may need to describe.
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
# rollout: the snapshot's rollout, its handoff estimate dropped once `now`
|
|
14
|
+
# has passed it, or nil when unknown.
|
|
15
|
+
Report = Data.define(:snapshot, :remote_head, :delta, :pinned, :mine, :commits, :rollout) do
|
|
16
|
+
def self.build(snapshot:, remote_head:, git:, now:)
|
|
15
17
|
# Pinned is judged before the stale pending is dropped: during the
|
|
16
18
|
# stale window the newest *succeeded* build is still the previous
|
|
17
19
|
# deploy, which must not read as a rollback.
|
|
18
20
|
pinned = pinned?(snapshot)
|
|
19
|
-
snapshot =
|
|
21
|
+
snapshot = drop_superseded_pending(snapshot)
|
|
20
22
|
deployed_sha = snapshot.deployed&.sha
|
|
21
23
|
mine = compute_mine(snapshot, git)
|
|
22
|
-
commits = [deployed_sha, snapshot.pending&.sha, mine&.sha]
|
|
23
|
-
|
|
24
|
+
commits = [deployed_sha, snapshot.pending&.sha, snapshot.release&.sha, mine&.sha]
|
|
25
|
+
.compact.uniq.to_h { |sha| [sha, git.commit_info(sha)] }
|
|
24
26
|
new(
|
|
25
27
|
snapshot: snapshot,
|
|
26
28
|
remote_head: remote_head,
|
|
27
|
-
delta: compute_delta(
|
|
29
|
+
delta: compute_delta(snapshot, remote_head, git),
|
|
28
30
|
pinned: pinned,
|
|
29
31
|
mine: mine,
|
|
30
|
-
commits: commits
|
|
32
|
+
commits: commits,
|
|
33
|
+
rollout: live_rollout(snapshot.rollout, now)
|
|
31
34
|
)
|
|
32
35
|
end
|
|
33
36
|
|
|
37
|
+
def self.live_rollout(rollout, now)
|
|
38
|
+
return rollout if rollout.nil? || rollout.overlap_until.nil? || rollout.overlap_until > now
|
|
39
|
+
|
|
40
|
+
rollout.with(overlap_until: nil)
|
|
41
|
+
end
|
|
42
|
+
|
|
34
43
|
# Right after a deploy finishes, the platform's builds list can still
|
|
35
44
|
# report the just-released build as pending while the releases endpoint
|
|
36
45
|
# already shows it running — the same commit would render as both
|
|
37
|
-
# Pending and Deployed. A build that is already on air isn't pending
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
# Pending and Deployed. A build that is already on air isn't pending, and
|
|
47
|
+
# one already in its release phase is shown by the release row.
|
|
48
|
+
def self.drop_superseded_pending(snapshot)
|
|
49
|
+
pending_sha = snapshot.pending&.sha
|
|
50
|
+
return snapshot unless pending_sha && [snapshot.deployed&.sha, snapshot.release&.sha].include?(pending_sha)
|
|
40
51
|
|
|
41
52
|
snapshot.with(pending: nil)
|
|
42
53
|
end
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
# "Current" compares only the running sha to the head, so it stays silent
|
|
56
|
+
# while a release of a different commit is in flight or failed.
|
|
57
|
+
def self.compute_delta(snapshot, head, git)
|
|
58
|
+
sha = snapshot.deployed&.sha
|
|
45
59
|
return nil if sha.nil? || head.nil?
|
|
46
|
-
return :current if sha == head
|
|
60
|
+
return (release_of_other_commit?(snapshot) ? nil : :current) if sha == head
|
|
47
61
|
return nil unless git.has_commit?(sha) && git.has_commit?(head)
|
|
48
62
|
return nil unless git.ancestor?(sha, head)
|
|
49
63
|
|
|
@@ -51,10 +65,16 @@ module Onair
|
|
|
51
65
|
count&.positive? ? count : nil
|
|
52
66
|
end
|
|
53
67
|
|
|
68
|
+
# A release of another commit, in flight or failed, explains the newer
|
|
69
|
+
# build on its own; a same-commit one (config change) does not.
|
|
54
70
|
def self.pinned?(snapshot)
|
|
55
71
|
sha = snapshot.deployed&.sha
|
|
56
72
|
latest = snapshot.latest_built_sha
|
|
57
|
-
!sha.nil? && !latest.nil? && latest != sha && snapshot.pending.nil?
|
|
73
|
+
!sha.nil? && !latest.nil? && latest != sha && snapshot.pending.nil? && !release_of_other_commit?(snapshot)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.release_of_other_commit?(snapshot)
|
|
77
|
+
!snapshot.release.nil? && snapshot.release.sha != snapshot.deployed&.sha
|
|
58
78
|
end
|
|
59
79
|
|
|
60
80
|
# "Did my merge just ship?" — deliberately a 2-commit first-parent window
|
|
@@ -79,6 +99,7 @@ module Onair
|
|
|
79
99
|
(!identity.name.to_s.empty? && name == identity.name)
|
|
80
100
|
end
|
|
81
101
|
|
|
82
|
-
private_class_method :
|
|
102
|
+
private_class_method :drop_superseded_pending, :compute_delta, :pinned?, :release_of_other_commit?,
|
|
103
|
+
:compute_mine, :identity_match?, :live_rollout
|
|
83
104
|
end
|
|
84
105
|
end
|
data/lib/onair/version.rb
CHANGED
data/lib/onair.rb
CHANGED
|
@@ -14,10 +14,31 @@ module Onair
|
|
|
14
14
|
|
|
15
15
|
Pending = Data.define(:sha, :started_at)
|
|
16
16
|
|
|
17
|
+
# A release newer than the running one that is not serving traffic: its
|
|
18
|
+
# release phase is still running (status :pending) or it failed (:failed).
|
|
19
|
+
Release = Data.define(:sha, :version, :description, :status, :started_at)
|
|
20
|
+
|
|
21
|
+
# Dynos of one process type against the running release. `up` counts dynos
|
|
22
|
+
# on the running version that serve (or idle until woken); `waiting` counts
|
|
23
|
+
# the rest of them by state; `previous` counts dynos on an older release.
|
|
24
|
+
ProcessRollout = Data.define(:type, :total, :up, :waiting, :previous) do
|
|
25
|
+
def complete?
|
|
26
|
+
up == total
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# How far the running release has reached its dynos. `overlap_until` is an
|
|
31
|
+
# estimate of when the previous release stops serving traffic, or nil.
|
|
32
|
+
Rollout = Data.define(:version, :processes, :overlap_until) do
|
|
33
|
+
def complete?
|
|
34
|
+
processes.all?(&:complete?) && overlap_until.nil?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
17
38
|
# What a platform adapter returns. `latest_built_sha` is the newest
|
|
18
39
|
# successfully built sha (rollback detection); `succeeded_shas` lists all
|
|
19
40
|
# recent succeeded build shas, newest first ("yours had its own deploy").
|
|
20
|
-
Snapshot = Data.define(:deployed, :pending, :latest_built_sha, :succeeded_shas)
|
|
41
|
+
Snapshot = Data.define(:deployed, :pending, :release, :latest_built_sha, :succeeded_shas, :rollout)
|
|
21
42
|
|
|
22
43
|
Mine = Data.define(:sha, :had_own_build)
|
|
23
44
|
end
|