onair-cli 0.2.0 → 0.4.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 +17 -0
- data/README.md +49 -6
- data/lib/onair/cli.rb +5 -4
- data/lib/onair/config.rb +11 -5
- data/lib/onair/git.rb +1 -1
- data/lib/onair/orchestrator.rb +3 -2
- data/lib/onair/platform/base.rb +18 -1
- data/lib/onair/platform/dokku.rb +213 -0
- data/lib/onair/platform/heroku.rb +65 -12
- data/lib/onair/remote_head.rb +1 -1
- data/lib/onair/renderer/json.rb +24 -7
- data/lib/onair/renderer/tty.rb +67 -11
- data/lib/onair/report.rb +18 -6
- data/lib/onair/version.rb +1 -1
- data/lib/onair.rb +30 -3
- 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: e6929334a2f930a5e7e05e43067c8442ae9b85d3dc9967c960c4ff9c0e34ebc7
|
|
4
|
+
data.tar.gz: 4f029777e9e3ee5aabc3977ad930ba2cf35d82a4c5e9c76a0995197d250ae389
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c85de1face07ebb3c04cadc6001ac5e92edff4b3632f87f1f79158f212c3063761ee6efdfdf69709bfe09251b0d0c5271dec5aaa38291b84b3cc523f705d5f3e
|
|
7
|
+
data.tar.gz: 3325a141acd7f90491a2b9c547097acbd68b1eba14d72462d518e61e564923a6c5e332263eab7403f99a751bb7278acf47a0d98d2f010492267796ad797e0538
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.4.0] - 2026-11-02
|
|
6
|
+
|
|
7
|
+
- Dokku: `platform: dokku` with a `host:` reads the running commit from the
|
|
8
|
+
last `git push` over SSH. A running build shows as `Pending` and a build
|
|
9
|
+
that failed after the last success as `Failed`, both without a commit. The
|
|
10
|
+
rollout line counts containers per process type, and the window in which
|
|
11
|
+
the previous containers keep serving shows as an estimate.
|
|
12
|
+
- Rows drop `v<version>` when the platform numbers no releases. `--json`
|
|
13
|
+
carries `null` versions and shas there, and `pending` gains `build_id`.
|
|
14
|
+
|
|
15
|
+
## [0.3.0] - 2026-10-08
|
|
16
|
+
|
|
17
|
+
- Heroku: a rollout line under Deployed counts the dynos of each process type
|
|
18
|
+
running the deployed release (`web 1/3, 2 starting`) and says when every
|
|
19
|
+
dyno is up on it. With preboot on, the handoff from the previous web dynos
|
|
20
|
+
shows as an estimate. `--json` gains a `rollout` object.
|
|
21
|
+
|
|
5
22
|
## [0.2.0] - 2026-10-07
|
|
6
23
|
|
|
7
24
|
- Heroku: Deployed is the release Heroku is serving, not the newest one. A
|
data/README.md
CHANGED
|
@@ -42,8 +42,9 @@ case.
|
|
|
42
42
|
gem install onair-cli
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
Requires Ruby >= 3.2.
|
|
46
|
-
(written by `heroku login`), falling back to `heroku auth:token`.
|
|
45
|
+
Requires Ruby >= 3.2. On Heroku, auth reuses the Heroku CLI's token from
|
|
46
|
+
`~/.netrc` (written by `heroku login`), falling back to `heroku auth:token`.
|
|
47
|
+
On Dokku, it reuses your SSH setup (see [Dokku](#dokku)).
|
|
47
48
|
|
|
48
49
|
## Usage
|
|
49
50
|
|
|
@@ -72,6 +73,12 @@ the API is a faster transport, never a cache.
|
|
|
72
73
|
author, and a delta against `origin/main`: `★ current` or
|
|
73
74
|
`↓ N commits behind`. If the relationship is unknown (diverged history,
|
|
74
75
|
commits unavailable), no marker is shown - silence over speculation.
|
|
76
|
+
- **Rollout** - under the Deployed row, how many dynos of each process type
|
|
77
|
+
run that release: `⟳ rolling out v1234: web 1/3, 2 starting` while dynos
|
|
78
|
+
restart, `✓ rolled out` once every dyno is up on it. With preboot on, the
|
|
79
|
+
previous web dynos keep serving for about three minutes after the new ones
|
|
80
|
+
are up; that window shows as an estimate. On Dokku it counts containers,
|
|
81
|
+
and the estimate is the app's wait-to-retire after the newest build.
|
|
75
82
|
- **Pending** - an in-flight build that's about to replace the deploy.
|
|
76
83
|
- **Releasing** - a newer release whose release phase (e.g. migrations) is
|
|
77
84
|
still running; production still serves the Deployed row until it finishes.
|
|
@@ -94,7 +101,27 @@ branch: main # comparison branch; default: main
|
|
|
94
101
|
```
|
|
95
102
|
|
|
96
103
|
Resolution order: CLI flags → env vars (`HEROKU_APP`, `GITHUB_REPO`) →
|
|
97
|
-
`.onair.yml`.
|
|
104
|
+
`.onair.yml`. `HEROKU_APP` applies only with `platform: heroku`.
|
|
105
|
+
|
|
106
|
+
### Dokku
|
|
107
|
+
|
|
108
|
+
```yaml
|
|
109
|
+
platform: dokku
|
|
110
|
+
host: my-dokku # any SSH destination: dokku@host, or a ~/.ssh/config alias
|
|
111
|
+
app: myapp
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
onair runs `ssh -o BatchMode=yes <host> <command> <app> --format json` for
|
|
115
|
+
Dokku's report commands, so it needs Dokku 0.38 or newer and an `ssh` client.
|
|
116
|
+
The host must log in as Dokku's `dokku` user, directly or through an alias
|
|
117
|
+
with `User dokku`. Keys, addresses and users come from your SSH config;
|
|
118
|
+
`BatchMode` never prompts, so the key must be loadable without a passphrase
|
|
119
|
+
prompt (`ssh-add`).
|
|
120
|
+
|
|
121
|
+
The deployed commit is the one the last `git push` deployed. An app deployed
|
|
122
|
+
another way (`git:from-image`, `git:sync`) shows the deploy source instead
|
|
123
|
+
of a commit. Dokku builds record no commit, so `Pending` and `Failed` name
|
|
124
|
+
the build, and pinned detection stays silent.
|
|
98
125
|
|
|
99
126
|
### Task links (optional)
|
|
100
127
|
|
|
@@ -130,6 +157,13 @@ additively:
|
|
|
130
157
|
},
|
|
131
158
|
"pending": null,
|
|
132
159
|
"release": null,
|
|
160
|
+
"rollout": {
|
|
161
|
+
"version": 1234, "complete": true,
|
|
162
|
+
"processes": [
|
|
163
|
+
{ "type": "web", "up": 3, "total": 3, "waiting": {}, "previous": 0 }
|
|
164
|
+
],
|
|
165
|
+
"overlap_until": null
|
|
166
|
+
},
|
|
133
167
|
"delta": { "status": "current", "behind_by": 0 },
|
|
134
168
|
"pinned": null,
|
|
135
169
|
"yours": null
|
|
@@ -137,12 +171,21 @@ additively:
|
|
|
137
171
|
```
|
|
138
172
|
|
|
139
173
|
- `delta.status` is `"current"`, `"behind"`, or `"unknown"`.
|
|
140
|
-
- `pending`: `{ "sha", "started_at", "subject", "author" }` when
|
|
141
|
-
in flight.
|
|
174
|
+
- `pending`: `{ "sha", "build_id", "started_at", "subject", "author" }` when
|
|
175
|
+
a build is in flight. On Dokku `sha` is `null` and `build_id` names the
|
|
176
|
+
build; on Heroku `build_id` is `null`.
|
|
177
|
+
- `version` is `null` on platforms that number no releases (Dokku), and a
|
|
178
|
+
Dokku `release` is a failed build with a `null` sha.
|
|
142
179
|
- `release`: `{ "sha", "version", "description", "status", "started_at",
|
|
143
180
|
"subject", "author" }` when a release newer than the running one is not
|
|
144
181
|
live; `status` is `"pending"` (release phase running) or `"failed"`. While
|
|
145
182
|
it carries a different sha, `delta.status` is never `"current"`.
|
|
183
|
+
- `rollout`: dynos per process type against the running release. `up`
|
|
184
|
+
counts dynos serving it, `waiting` counts the rest of its dynos by state
|
|
185
|
+
(`starting`, `crashed`, ...), `previous` counts dynos on an older release.
|
|
186
|
+
`overlap_until` is an estimated end of the preboot handoff while it lasts,
|
|
187
|
+
otherwise `null`. `complete` is true once every dyno serves the running
|
|
188
|
+
release and no handoff remains. `null` when the dynos can't be read.
|
|
146
189
|
- `pinned`: `{ "version", "description", "latest_built_sha" }` when a newer
|
|
147
190
|
build succeeded but is not running.
|
|
148
191
|
- `yours`: `{ "sha", "had_own_build", "subject", "author" }` when your commit
|
|
@@ -155,7 +198,7 @@ additively:
|
|
|
155
198
|
|
|
156
199
|
```sh
|
|
157
200
|
bundle install
|
|
158
|
-
bundle exec rake # specs +
|
|
201
|
+
bundle exec rake # specs + standard
|
|
159
202
|
```
|
|
160
203
|
|
|
161
204
|
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
|
@@ -8,10 +8,13 @@ module Onair
|
|
|
8
8
|
class Config
|
|
9
9
|
FILENAME = ".onair.yml"
|
|
10
10
|
|
|
11
|
-
attr_reader :platform, :app, :repo, :branch, :fetch, :task
|
|
11
|
+
attr_reader :platform, :app, :repo, :branch, :fetch, :task, :host
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# `host` is the SSH destination for SSH-driven platforms (Dokku); nil
|
|
14
|
+
# elsewhere, and only the adapter that needs it checks it.
|
|
15
|
+
def initialize(platform:, app:, repo:, branch:, fetch:, task: nil, host: nil)
|
|
14
16
|
@platform = platform
|
|
17
|
+
@host = host
|
|
15
18
|
@app = app
|
|
16
19
|
@repo = repo
|
|
17
20
|
@branch = branch
|
|
@@ -21,16 +24,19 @@ module Onair
|
|
|
21
24
|
|
|
22
25
|
def self.resolve(flags = {}, env: ENV, dir: Dir.pwd)
|
|
23
26
|
file = find_file(dir) || {}
|
|
24
|
-
|
|
27
|
+
platform = file["platform"] || "heroku"
|
|
28
|
+
# HEROKU_APP names a Heroku app, so it must not redirect another platform.
|
|
29
|
+
app = flags[:app] || ((platform == "heroku") ? env["HEROKU_APP"] : nil) || file["app"]
|
|
25
30
|
raise Error, "no app configured - pass --app NAME, set HEROKU_APP, or run `onair init`" if app.nil?
|
|
26
31
|
|
|
27
32
|
new(
|
|
28
|
-
platform:
|
|
33
|
+
platform: platform,
|
|
29
34
|
app: app,
|
|
30
35
|
repo: env["GITHUB_REPO"] || file["repo"],
|
|
31
36
|
branch: file["branch"] || "main",
|
|
32
37
|
fetch: !flags[:no_fetch],
|
|
33
|
-
task: TaskLink.from_config(file["task"])
|
|
38
|
+
task: TaskLink.from_config(file["task"]),
|
|
39
|
+
host: file["host"]
|
|
34
40
|
)
|
|
35
41
|
end
|
|
36
42
|
|
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
|
|
@@ -20,7 +21,7 @@ module Onair
|
|
|
20
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)
|
|
@@ -29,6 +29,12 @@ module Onair
|
|
|
29
29
|
# running (release phase pending or failed), or nil
|
|
30
30
|
# latest_built_sha: newest successfully built sha, or nil
|
|
31
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
|
|
35
|
+
#
|
|
36
|
+
# Versions may be nil on every value, and pending/release shas may be nil
|
|
37
|
+
# when the platform's builds carry no commit; renderers drop what is absent.
|
|
32
38
|
#
|
|
33
39
|
# Adapters own their internal concurrency and auth. Everything above
|
|
34
40
|
# (delta, pinned, mine, rendering) is platform-agnostic and must not
|
|
@@ -45,6 +51,17 @@ module Onair
|
|
|
45
51
|
def display_name
|
|
46
52
|
self.class.name.split("::").last
|
|
47
53
|
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Exceptions surface via #value; without this the dying thread also
|
|
58
|
+
# prints its own backtrace to stderr.
|
|
59
|
+
def quiet_thread(&block)
|
|
60
|
+
Thread.new do
|
|
61
|
+
Thread.current.report_on_exception = false
|
|
62
|
+
block.call
|
|
63
|
+
end
|
|
64
|
+
end
|
|
48
65
|
end
|
|
49
66
|
end
|
|
50
67
|
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Onair
|
|
8
|
+
module Platform
|
|
9
|
+
# Dokku has no HTTP API, so every read is `ssh <host> <subcommand> <app>
|
|
10
|
+
# --format json`, run in parallel. Keys, users and addresses come from the
|
|
11
|
+
# user's SSH setup; the adapter only knows the destination.
|
|
12
|
+
class Dokku < Base
|
|
13
|
+
GIT_PUSH = "git-push"
|
|
14
|
+
FULL_SHA = /\A\h{40}\z/
|
|
15
|
+
# Dokku app names: lowercase letters, digits and dashes.
|
|
16
|
+
APP_NAME = /\A[a-z0-9][a-z0-9-]*\z/
|
|
17
|
+
# ps:report's JSON lists one `status-<process>.<n>` entry per container
|
|
18
|
+
# (named after its CONTAINER.<process>.<n> file), valued like
|
|
19
|
+
# "running (CID: 0123456789a)". The text output's dash form also matches.
|
|
20
|
+
CONTAINER_STATUS = /\Astatus-(?<type>.+)[.-]\d+\z/
|
|
21
|
+
SERVING_STATE = "running"
|
|
22
|
+
# After a deploy, Dokku keeps the previous containers serving for the
|
|
23
|
+
# app's wait-to-retire, 60 seconds unless configured.
|
|
24
|
+
DEFAULT_WAIT_TO_RETIRE = 60
|
|
25
|
+
RETIRE_NOTE = "retiring: previous containers may still serve"
|
|
26
|
+
# Wall-clock limit per command, matching the Heroku adapter's read timeout.
|
|
27
|
+
COMMAND_TIMEOUT = 15
|
|
28
|
+
SSH_OPTIONS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
|
29
|
+
"-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3"].freeze
|
|
30
|
+
|
|
31
|
+
def snapshot
|
|
32
|
+
validate!
|
|
33
|
+
apps_thread = quiet_thread { dokku("apps:report") }
|
|
34
|
+
git_thread = quiet_thread { optional { dokku("git:report") } }
|
|
35
|
+
builds_thread = quiet_thread { optional { dokku("builds:list") } }
|
|
36
|
+
ps_thread = quiet_thread { optional { dokku("ps:report") } }
|
|
37
|
+
checks_thread = quiet_thread { optional { dokku("checks:report") } }
|
|
38
|
+
|
|
39
|
+
deployed = deployed(apps_thread.value, git_thread.value)
|
|
40
|
+
builds = Array(builds_thread.value).grep(Hash)
|
|
41
|
+
Snapshot.new(deployed: deployed, pending: pending(builds), release: failed(builds),
|
|
42
|
+
latest_built_sha: nil, succeeded_shas: [],
|
|
43
|
+
rollout: rollout(ps_thread.value, newest(builds, "succeeded"), checks_thread.value))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def host
|
|
49
|
+
@config.host
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def app
|
|
53
|
+
@config.app
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The host and app go on the ssh command line, so a value starting with
|
|
57
|
+
# a dash would be read as an ssh option.
|
|
58
|
+
def validate!
|
|
59
|
+
raise Error, "no host configured - add `host:` to #{Config::FILENAME}" if host.to_s.empty?
|
|
60
|
+
raise Error, "invalid Dokku host #{host.inspect}" if host.start_with?("-")
|
|
61
|
+
raise Error, "invalid Dokku app name #{app.inspect}" unless APP_NAME.match?(app)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Dokku strips the plugin prefix from report JSON keys, so git:report's
|
|
65
|
+
# `--git-last-updated-at` arrives as `last-updated-at`.
|
|
66
|
+
# Only a git push records the pushed commit. git:report's sha is not
|
|
67
|
+
# used: it can read the repo's HEAD rather than what was deployed.
|
|
68
|
+
def deployed(apps, git)
|
|
69
|
+
source = apps["deploy-source"].to_s
|
|
70
|
+
raise Error, "nothing deployed yet for app #{app}" if source.empty?
|
|
71
|
+
|
|
72
|
+
metadata = apps["deploy-source-metadata"].to_s
|
|
73
|
+
sha = (source == GIT_PUSH && FULL_SHA.match?(metadata)) ? metadata : nil
|
|
74
|
+
Deployed.new(sha: sha, version: nil, description: "deploy source: #{source}",
|
|
75
|
+
deployed_at: unix_time(git&.dig("last-updated-at")))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Builds carry no commit, so the sha stays unknown until the build is
|
|
79
|
+
# correlated with the CI run that pushed it.
|
|
80
|
+
def pending(builds)
|
|
81
|
+
running = builds.find { |build| status(build) == "running" }
|
|
82
|
+
running && Pending.new(sha: nil, build_id: running["id"], started_at: parse_time(running["started_at"]))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Only a failure newer than the last success matters; older failed
|
|
86
|
+
# records were superseded and are stale.
|
|
87
|
+
def failed(builds)
|
|
88
|
+
failure = newest(builds, "failed")
|
|
89
|
+
success = newest(builds, "succeeded")
|
|
90
|
+
return nil if failure.nil? || (success && started(success) >= started(failure))
|
|
91
|
+
|
|
92
|
+
Release.new(sha: nil, version: nil, description: "build #{failure["id"]}", status: :failed,
|
|
93
|
+
started_at: parse_time(failure["finished_at"]) || started(failure))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def newest(builds, wanted)
|
|
97
|
+
builds.select { |build| status(build) == wanted && started(build) }.max_by { |build| started(build) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# display_status turns a running record whose process died into
|
|
101
|
+
# "abandoned"; older Dokku versions only report status.
|
|
102
|
+
def status(build)
|
|
103
|
+
build["display_status"] || build["status"]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def started(build)
|
|
107
|
+
parse_time(build["started_at"])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def rollout(ps, success, checks)
|
|
111
|
+
return nil unless ps.is_a?(Hash)
|
|
112
|
+
|
|
113
|
+
containers = ps.filter_map do |key, value|
|
|
114
|
+
match = CONTAINER_STATUS.match(key)
|
|
115
|
+
match && [match[:type], value.to_s.split(" ", 2).first.to_s]
|
|
116
|
+
end
|
|
117
|
+
return nil if containers.empty?
|
|
118
|
+
|
|
119
|
+
processes = containers.group_by(&:first)
|
|
120
|
+
.sort_by { |type, _| [(type == "web") ? 0 : 1, type] }
|
|
121
|
+
.map { |type, rows| process_rollout(type, rows.map(&:last)) }
|
|
122
|
+
Rollout.new(version: nil, processes: processes, overlap_note: RETIRE_NOTE,
|
|
123
|
+
overlap_until: retire_deadline(processes, success, checks))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# ps:report lists only the current deploy's containers, so nothing is
|
|
127
|
+
# ever counted as previous.
|
|
128
|
+
def process_rollout(type, states)
|
|
129
|
+
waiting = states.reject { |state| state == SERVING_STATE }.tally
|
|
130
|
+
ProcessRollout.new(type: type, total: states.size, up: states.size - waiting.values.sum,
|
|
131
|
+
waiting: waiting, previous: 0)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def retire_deadline(processes, success, checks)
|
|
135
|
+
finished = success && parse_time(success["finished_at"])
|
|
136
|
+
return nil if finished.nil? || !processes.all?(&:complete?)
|
|
137
|
+
|
|
138
|
+
finished + wait_to_retire(checks)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def wait_to_retire(checks)
|
|
142
|
+
Integer(checks&.dig("computed-wait-to-retire"), 10)
|
|
143
|
+
rescue ArgumentError, TypeError
|
|
144
|
+
DEFAULT_WAIT_TO_RETIRE
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def optional
|
|
148
|
+
yield
|
|
149
|
+
rescue Error
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Dokku prints warnings on stderr (e.g. "Skipping unreadable build
|
|
154
|
+
# record"), so only stdout is parsed.
|
|
155
|
+
def dokku(subcommand)
|
|
156
|
+
stdout, stderr, status = run(["ssh", *SSH_OPTIONS, host, subcommand, app, "--format", "json"])
|
|
157
|
+
raise Error, "dokku #{subcommand} timed out on #{host}" if status.nil?
|
|
158
|
+
raise Error, ssh_error(subcommand, stderr, status) unless status.success?
|
|
159
|
+
|
|
160
|
+
JSON.parse(stdout)
|
|
161
|
+
rescue Errno::ENOENT
|
|
162
|
+
raise Error, "ssh not found - the Dokku platform needs an ssh client on PATH"
|
|
163
|
+
rescue JSON::ParserError
|
|
164
|
+
raise Error, "Dokku returned invalid JSON for #{subcommand}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Returns [stdout, stderr, status], with a nil status when the command
|
|
168
|
+
# outlived the timeout and was killed.
|
|
169
|
+
def run(argv, timeout: COMMAND_TIMEOUT)
|
|
170
|
+
Open3.popen3(*argv) do |stdin, stdout, stderr, wait|
|
|
171
|
+
stdin.close
|
|
172
|
+
out = Thread.new { stdout.read }
|
|
173
|
+
err = Thread.new { stderr.read }
|
|
174
|
+
unless wait.join(timeout)
|
|
175
|
+
begin
|
|
176
|
+
Process.kill("KILL", wait.pid)
|
|
177
|
+
rescue Errno::ESRCH
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
return [out.value, err.value, nil]
|
|
181
|
+
end
|
|
182
|
+
[out.value, err.value, wait.value]
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# ssh itself exits 255; anything else is Dokku's own exit code.
|
|
187
|
+
def ssh_error(subcommand, stderr, status)
|
|
188
|
+
line = stderr.lines.map { |text| text.sub(/\A\s*!\s*/, "").strip }.reject(&:empty?).last
|
|
189
|
+
if status.exitstatus == 255 && stderr.include?("Permission denied")
|
|
190
|
+
"SSH to #{host} was refused - load the key with `ssh-add`"
|
|
191
|
+
elsif status.exitstatus == 255
|
|
192
|
+
"could not reach #{host} over SSH#{line && ": #{line}"}"
|
|
193
|
+
else
|
|
194
|
+
"dokku #{subcommand} failed on #{host}#{line && ": #{line}"}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def unix_time(value)
|
|
199
|
+
Time.at(Integer(value, 10)).utc
|
|
200
|
+
rescue ArgumentError, TypeError
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def parse_time(value)
|
|
205
|
+
value && Time.iso8601(value)
|
|
206
|
+
rescue ArgumentError
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
Platform.register("dokku", self)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -10,15 +10,25 @@ module Onair
|
|
|
10
10
|
HOST = "api.heroku.com"
|
|
11
11
|
RELEASE_WINDOW = 10
|
|
12
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
|
|
19
|
+
PREBOOT_NOTE = "preboot: previous web dynos may still serve"
|
|
13
20
|
|
|
14
21
|
def snapshot
|
|
15
22
|
token = resolve_token
|
|
16
23
|
releases_thread = quiet_thread { releases(token) }
|
|
17
24
|
builds_thread = quiet_thread { builds(token) }
|
|
25
|
+
dynos_thread = quiet_thread { dynos(token) }
|
|
18
26
|
deployed, release = releases_thread.value
|
|
19
27
|
pending, succeeded_shas = builds_thread.value
|
|
28
|
+
dyno_rows, preboot = dynos_thread.value
|
|
20
29
|
Snapshot.new(deployed: deployed, pending: pending, release: release,
|
|
21
|
-
|
|
30
|
+
latest_built_sha: succeeded_shas.first, succeeded_shas: succeeded_shas,
|
|
31
|
+
rollout: rollout(dyno_rows, preboot, deployed.version))
|
|
22
32
|
end
|
|
23
33
|
|
|
24
34
|
private
|
|
@@ -27,13 +37,6 @@ module Onair
|
|
|
27
37
|
@config.app
|
|
28
38
|
end
|
|
29
39
|
|
|
30
|
-
def quiet_thread(&block)
|
|
31
|
-
Thread.new do
|
|
32
|
-
Thread.current.report_on_exception = false
|
|
33
|
-
block.call
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
|
|
37
40
|
def resolve_token
|
|
38
41
|
token = Auth::Netrc.token(HOST)
|
|
39
42
|
token = Auth::HerokuCli.token if token.nil? || token.empty?
|
|
@@ -62,7 +65,7 @@ module Onair
|
|
|
62
65
|
|
|
63
66
|
def deployed(row, sha)
|
|
64
67
|
Deployed.new(sha: sha, version: row["version"], description: row["description"],
|
|
65
|
-
|
|
68
|
+
deployed_at: parse_time(row["created_at"]))
|
|
66
69
|
end
|
|
67
70
|
|
|
68
71
|
# Only the newest release counts: an older failed one was superseded by
|
|
@@ -71,11 +74,11 @@ module Onair
|
|
|
71
74
|
return nil if newest.equal?(running) || !IN_FLIGHT_STATUSES.include?(newest["status"])
|
|
72
75
|
|
|
73
76
|
slug_id = newest.dig("slug", "id")
|
|
74
|
-
sha = slug_id == running.dig("slug", "id") ? running_sha : slug_commit(http, token, slug_id)
|
|
77
|
+
sha = (slug_id == running.dig("slug", "id")) ? running_sha : slug_commit(http, token, slug_id)
|
|
75
78
|
return nil if sha.nil?
|
|
76
79
|
|
|
77
80
|
Release.new(sha: sha, version: newest["version"], description: newest["description"],
|
|
78
|
-
|
|
81
|
+
status: newest["status"].to_sym, started_at: parse_time(newest["created_at"]))
|
|
79
82
|
end
|
|
80
83
|
|
|
81
84
|
def slug_commit(http, token, slug_id)
|
|
@@ -96,12 +99,62 @@ module Onair
|
|
|
96
99
|
pending_sha = pending_build&.dig("source_blob", "version")
|
|
97
100
|
pending = pending_sha && Pending.new(sha: pending_sha, started_at: parse_time(pending_build["created_at"]))
|
|
98
101
|
succeeded = builds.select { |build| build["status"] == "succeeded" }
|
|
99
|
-
|
|
102
|
+
.filter_map { |build| build.dig("source_blob", "version") }
|
|
100
103
|
[pending, succeeded]
|
|
101
104
|
rescue Error
|
|
102
105
|
[nil, []]
|
|
103
106
|
end
|
|
104
107
|
|
|
108
|
+
# A failed dynos call drops the rollout and nothing else; a failed
|
|
109
|
+
# preboot lookup only drops the handoff estimate.
|
|
110
|
+
def dynos(token)
|
|
111
|
+
with_http do |http|
|
|
112
|
+
rows = get(http, token, "/apps/#{app}/dynos")
|
|
113
|
+
[rows, preboot?(http, token)]
|
|
114
|
+
end
|
|
115
|
+
rescue Error
|
|
116
|
+
[nil, false]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def preboot?(http, token)
|
|
120
|
+
get(http, token, "/apps/#{app}/features/preboot")["enabled"] == true
|
|
121
|
+
rescue Error
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def rollout(rows, preboot, version)
|
|
126
|
+
return nil if rows.nil? || version.nil?
|
|
127
|
+
|
|
128
|
+
formation = rows.reject { |dyno| ONE_OFF_TYPES.include?(dyno["type"]) }
|
|
129
|
+
return nil if formation.empty?
|
|
130
|
+
|
|
131
|
+
processes = formation.group_by { |dyno| dyno["type"] }
|
|
132
|
+
.sort_by { |type, _| [(type == "web") ? 0 : 1, type] }
|
|
133
|
+
.map { |type, dynos| process_rollout(type, dynos, version) }
|
|
134
|
+
Rollout.new(version: version, processes: processes,
|
|
135
|
+
overlap_until: preboot ? preboot_handoff(formation, version) : nil, overlap_note: PREBOOT_NOTE)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def process_rollout(type, dynos, version)
|
|
139
|
+
current, previous = dynos.partition { |dyno| dyno.dig("release", "version") == version }
|
|
140
|
+
waiting = current.map { |dyno| dyno["state"] }.reject { |state| SERVING_STATES.include?(state) }.tally
|
|
141
|
+
ProcessRollout.new(type: type, total: dynos.size, up: current.size - waiting.values.sum,
|
|
142
|
+
waiting: waiting, previous: previous.size)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Old web dynos still listed are already counted as previous; the
|
|
146
|
+
# estimate covers only the handoff the dynos list does not show.
|
|
147
|
+
def preboot_handoff(formation, version)
|
|
148
|
+
web = formation.select { |dyno| dyno["type"] == "web" }
|
|
149
|
+
return nil if web.empty?
|
|
150
|
+
return nil unless web.all? do |dyno|
|
|
151
|
+
dyno.dig("release", "version") == version && SERVING_STATES.include?(dyno["state"])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
last_up = web.filter_map { |dyno| parse_time(dyno["updated_at"]) }.max
|
|
155
|
+
last_up && (last_up + PREBOOT_HANDOFF)
|
|
156
|
+
end
|
|
157
|
+
|
|
105
158
|
# One connection per thread — the dependent releases → slug pair must
|
|
106
159
|
# not pay a second TLS handshake.
|
|
107
160
|
def with_http(&)
|
data/lib/onair/remote_head.rb
CHANGED
data/lib/onair/renderer/json.rb
CHANGED
|
@@ -31,6 +31,7 @@ module Onair
|
|
|
31
31
|
deployed: deployed_payload,
|
|
32
32
|
pending: pending_payload,
|
|
33
33
|
release: release_payload,
|
|
34
|
+
rollout: rollout_payload,
|
|
34
35
|
delta: delta_payload,
|
|
35
36
|
pinned: pinned_payload,
|
|
36
37
|
yours: yours_payload
|
|
@@ -59,7 +60,8 @@ module Onair
|
|
|
59
60
|
pending = snapshot.pending
|
|
60
61
|
return nil if pending.nil?
|
|
61
62
|
|
|
62
|
-
{
|
|
63
|
+
{sha: pending.sha, build_id: pending.build_id, started_at: iso(pending.started_at)}
|
|
64
|
+
.merge(commit_fields(pending.sha))
|
|
63
65
|
end
|
|
64
66
|
|
|
65
67
|
def release_payload
|
|
@@ -75,11 +77,26 @@ module Onair
|
|
|
75
77
|
}.merge(commit_fields(release.sha))
|
|
76
78
|
end
|
|
77
79
|
|
|
80
|
+
def rollout_payload
|
|
81
|
+
rollout = @report.rollout
|
|
82
|
+
return nil if rollout.nil?
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
version: rollout.version,
|
|
86
|
+
complete: rollout.complete?,
|
|
87
|
+
processes: rollout.processes.map do |process|
|
|
88
|
+
{type: process.type, up: process.up, total: process.total,
|
|
89
|
+
waiting: process.waiting, previous: process.previous}
|
|
90
|
+
end,
|
|
91
|
+
overlap_until: iso(rollout.overlap_until)
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
78
95
|
def delta_payload
|
|
79
96
|
case @report.delta
|
|
80
|
-
when :current then {
|
|
81
|
-
when Integer then {
|
|
82
|
-
else {
|
|
97
|
+
when :current then {status: "current", behind_by: 0}
|
|
98
|
+
when Integer then {status: "behind", behind_by: @report.delta}
|
|
99
|
+
else {status: "unknown", behind_by: nil}
|
|
83
100
|
end
|
|
84
101
|
end
|
|
85
102
|
|
|
@@ -98,19 +115,19 @@ module Onair
|
|
|
98
115
|
mine = @report.mine
|
|
99
116
|
return nil if mine.nil?
|
|
100
117
|
|
|
101
|
-
{
|
|
118
|
+
{sha: mine.sha, had_own_build: mine.had_own_build}.merge(commit_fields(mine.sha))
|
|
102
119
|
end
|
|
103
120
|
|
|
104
121
|
def commit_fields(sha)
|
|
105
122
|
info = sha && @report.commits[sha]
|
|
106
|
-
{
|
|
123
|
+
{subject: info&.subject, author: info&.author_name, task: task_fields(info&.subject)}
|
|
107
124
|
end
|
|
108
125
|
|
|
109
126
|
def task_fields(subject)
|
|
110
127
|
task_id = @task&.find(subject)
|
|
111
128
|
return nil if task_id.nil?
|
|
112
129
|
|
|
113
|
-
{
|
|
130
|
+
{id: task_id, url: @task.url_for(task_id)}
|
|
114
131
|
end
|
|
115
132
|
|
|
116
133
|
def iso(time)
|
data/lib/onair/renderer/tty.rb
CHANGED
|
@@ -49,6 +49,12 @@ module Onair
|
|
|
49
49
|
pending = snapshot.pending
|
|
50
50
|
return [] if pending.nil?
|
|
51
51
|
|
|
52
|
+
if pending.sha.nil?
|
|
53
|
+
started = pending.started_at && "started #{age(pending.started_at)}"
|
|
54
|
+
return commitless_row("Pending:", :yellow, "build #{pending.build_id}", started,
|
|
55
|
+
paint("⟳ deploying", :yellow)) + [""]
|
|
56
|
+
end
|
|
57
|
+
|
|
52
58
|
row_lines("Pending:", :yellow, pending.sha, age(pending.started_at)) + [""]
|
|
53
59
|
end
|
|
54
60
|
|
|
@@ -57,28 +63,66 @@ module Onair
|
|
|
57
63
|
def release_lines
|
|
58
64
|
release = snapshot.release
|
|
59
65
|
return [] if release.nil?
|
|
66
|
+
return build_failed_lines(release) if release.sha.nil?
|
|
60
67
|
|
|
61
|
-
detail =
|
|
62
|
-
detail =
|
|
68
|
+
detail = version_label(release.version)
|
|
69
|
+
detail = [detail, release.description].compact.join(": ") if release.sha == snapshot.deployed&.sha
|
|
63
70
|
label, color, note = if release.status == :failed
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
["Failed:", :red, "✗ release phase failed (#{detail})"]
|
|
72
|
+
else
|
|
73
|
+
["Releasing:", :yellow, "⟳ release phase (#{detail})"]
|
|
74
|
+
end
|
|
68
75
|
row_lines(label, color, release.sha, age(release.started_at), extra: paint(note, color)) + [""]
|
|
69
76
|
end
|
|
70
77
|
|
|
78
|
+
# A failed build whose commit the platform does not record.
|
|
79
|
+
def build_failed_lines(release)
|
|
80
|
+
commitless_row("Failed:", :red, release.description, age(release.started_at),
|
|
81
|
+
paint("✗ build failed", :red)) + [""]
|
|
82
|
+
end
|
|
83
|
+
|
|
71
84
|
def deployed_lines
|
|
72
85
|
deployed = snapshot.deployed
|
|
73
86
|
if deployed&.sha
|
|
74
87
|
lines = row_lines("Deployed:", :green, deployed.sha, age(deployed.deployed_at), extra: delta_text)
|
|
88
|
+
lines << rollout_line if @report.rollout
|
|
75
89
|
lines << pinned_line if @report.pinned
|
|
76
90
|
lines.concat(yours_lines)
|
|
77
91
|
lines
|
|
78
92
|
else
|
|
79
|
-
["
|
|
80
|
-
|
|
93
|
+
subject = ["the running commit", version_label(deployed&.version)].compact.join(" for ")
|
|
94
|
+
[" #{paint("Could not resolve #{subject} (#{deployed&.description}).", :yellow)}"]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The dynos list can look finished while preboot still routes some
|
|
99
|
+
# traffic to the previous release, so that window gets its own line.
|
|
100
|
+
def rollout_line
|
|
101
|
+
rollout = @report.rollout
|
|
102
|
+
target = [version_label(rollout.version)].compact.map { |label| " #{label}" }.join
|
|
103
|
+
text, color = if rollout.complete?
|
|
104
|
+
["✓ rolled out#{target}: #{process_counts(rollout)}", :dim]
|
|
105
|
+
elsif rollout.processes.all?(&:complete?)
|
|
106
|
+
["⟳ #{rollout.overlap_note || "previous release may still serve"} for " \
|
|
107
|
+
"~#{remaining(rollout.overlap_until)} (estimate)", :yellow]
|
|
108
|
+
else
|
|
109
|
+
["⟳ rolling out#{target}: #{process_counts(rollout)}", :yellow]
|
|
81
110
|
end
|
|
111
|
+
" #{paint(text, color)}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def process_counts(rollout)
|
|
115
|
+
rollout.processes.map do |process|
|
|
116
|
+
parts = ["#{process.type} #{process.up}/#{process.total}"]
|
|
117
|
+
parts.concat(process.waiting.map { |state, count| "#{count} #{state}" })
|
|
118
|
+
parts << "#{process.previous} on older release" if process.previous.positive?
|
|
119
|
+
parts.join(", ")
|
|
120
|
+
end.join(" · ")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def remaining(time)
|
|
124
|
+
seconds = (time - @now).ceil.clamp(0..)
|
|
125
|
+
(seconds < 60) ? "#{seconds}s" : "#{(seconds / 60.0).ceil}m"
|
|
82
126
|
end
|
|
83
127
|
|
|
84
128
|
def yours_lines
|
|
@@ -98,7 +142,18 @@ module Onair
|
|
|
98
142
|
padded = label.ljust(LABEL_WIDTH)
|
|
99
143
|
first = " #{paint(padded, color_key)} #{paint(sha[0, 9], :bold)} #{paint("#{age_blurb}by #{author}", :dim)}"
|
|
100
144
|
first = "#{first} #{extra}" if extra
|
|
101
|
-
[first, " #{paint(
|
|
145
|
+
[first, " #{paint("→", :dim)} #{linkify(subject, info ? sha : nil)}"]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# A row for a build with no commit: its name stands where the sha would,
|
|
149
|
+
# and there is no subject line to show.
|
|
150
|
+
def commitless_row(label, color_key, name, age, extra)
|
|
151
|
+
age_blurb = age ? " #{paint("(#{age})", :dim)}" : ""
|
|
152
|
+
[" #{paint(label.ljust(LABEL_WIDTH), color_key)} #{paint(name, :bold)}#{age_blurb} #{extra}"]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def version_label(version)
|
|
156
|
+
version && "v#{version}"
|
|
102
157
|
end
|
|
103
158
|
|
|
104
159
|
def delta_text
|
|
@@ -107,13 +162,14 @@ module Onair
|
|
|
107
162
|
paint("★ current", :green)
|
|
108
163
|
when Integer
|
|
109
164
|
count = @report.delta
|
|
110
|
-
paint("↓ #{count} #{count == 1 ?
|
|
165
|
+
paint("↓ #{count} #{(count == 1) ? "commit" : "commits"} behind origin/#{@branch}", :yellow)
|
|
111
166
|
end
|
|
112
167
|
end
|
|
113
168
|
|
|
114
169
|
def pinned_line
|
|
115
170
|
deployed = snapshot.deployed
|
|
116
|
-
|
|
171
|
+
running = [version_label(deployed.version), "(#{deployed.description})"].compact.join(" ")
|
|
172
|
+
" #{paint("⏸ #{running} - newer build " \
|
|
117
173
|
"#{snapshot.latest_built_sha[0, 9]} succeeded but is not running", :yellow)}"
|
|
118
174
|
end
|
|
119
175
|
|
data/lib/onair/report.rb
CHANGED
|
@@ -10,8 +10,10 @@ 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.
|
|
@@ -20,17 +22,24 @@ module Onair
|
|
|
20
22
|
deployed_sha = snapshot.deployed&.sha
|
|
21
23
|
mine = compute_mine(snapshot, git)
|
|
22
24
|
commits = [deployed_sha, snapshot.pending&.sha, snapshot.release&.sha, mine&.sha]
|
|
23
|
-
|
|
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
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
|
|
@@ -64,8 +73,11 @@ module Onair
|
|
|
64
73
|
!sha.nil? && !latest.nil? && latest != sha && snapshot.pending.nil? && !release_of_other_commit?(snapshot)
|
|
65
74
|
end
|
|
66
75
|
|
|
76
|
+
# A release with no known commit (a failed build on a platform whose
|
|
77
|
+
# builds carry none) says nothing about commits, so it never counts.
|
|
67
78
|
def self.release_of_other_commit?(snapshot)
|
|
68
|
-
|
|
79
|
+
sha = snapshot.release&.sha
|
|
80
|
+
!sha.nil? && sha != snapshot.deployed&.sha
|
|
69
81
|
end
|
|
70
82
|
|
|
71
83
|
# "Did my merge just ship?" — deliberately a 2-commit first-parent window
|
|
@@ -91,6 +103,6 @@ module Onair
|
|
|
91
103
|
end
|
|
92
104
|
|
|
93
105
|
private_class_method :drop_superseded_pending, :compute_delta, :pinned?, :release_of_other_commit?,
|
|
94
|
-
|
|
106
|
+
:compute_mine, :identity_match?, :live_rollout
|
|
95
107
|
end
|
|
96
108
|
end
|
data/lib/onair/version.rb
CHANGED
data/lib/onair.rb
CHANGED
|
@@ -9,19 +9,45 @@ module Onair
|
|
|
9
9
|
CommitInfo = Data.define(:subject, :author_name, :author_email, :committed_at)
|
|
10
10
|
|
|
11
11
|
# The release currently running in production. `sha` may be nil when the
|
|
12
|
-
# platform can't resolve the running commit; version
|
|
12
|
+
# platform can't resolve the running commit; `version` is nil on platforms
|
|
13
|
+
# that number nothing.
|
|
13
14
|
Deployed = Data.define(:sha, :version, :description, :deployed_at)
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
# An in-flight build. `sha` is nil when the platform's builds carry no
|
|
17
|
+
# commit; `build_id` then names the row.
|
|
18
|
+
Pending = Data.define(:sha, :build_id, :started_at) do
|
|
19
|
+
def initialize(sha:, started_at:, build_id: nil) = super
|
|
20
|
+
end
|
|
16
21
|
|
|
17
22
|
# A release newer than the running one that is not serving traffic: its
|
|
18
23
|
# release phase is still running (status :pending) or it failed (:failed).
|
|
24
|
+
# A nil `sha` is a failed build whose commit the platform does not record.
|
|
19
25
|
Release = Data.define(:sha, :version, :description, :status, :started_at)
|
|
20
26
|
|
|
27
|
+
# Dynos of one process type against the running release. `up` counts dynos
|
|
28
|
+
# on the running version that serve (or idle until woken); `waiting` counts
|
|
29
|
+
# the rest of them by state; `previous` counts dynos on an older release.
|
|
30
|
+
ProcessRollout = Data.define(:type, :total, :up, :waiting, :previous) do
|
|
31
|
+
def complete?
|
|
32
|
+
up == total
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# How far the running release has reached its dynos. `overlap_until` is an
|
|
37
|
+
# estimate of when the previous release stops serving traffic, or nil;
|
|
38
|
+
# `overlap_note` is the platform's phrase for that window.
|
|
39
|
+
Rollout = Data.define(:version, :processes, :overlap_until, :overlap_note) do
|
|
40
|
+
def initialize(version:, processes:, overlap_until:, overlap_note: nil) = super
|
|
41
|
+
|
|
42
|
+
def complete?
|
|
43
|
+
processes.all?(&:complete?) && overlap_until.nil?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
21
47
|
# What a platform adapter returns. `latest_built_sha` is the newest
|
|
22
48
|
# successfully built sha (rollback detection); `succeeded_shas` lists all
|
|
23
49
|
# recent succeeded build shas, newest first ("yours had its own deploy").
|
|
24
|
-
Snapshot = Data.define(:deployed, :pending, :release, :latest_built_sha, :succeeded_shas)
|
|
50
|
+
Snapshot = Data.define(:deployed, :pending, :release, :latest_built_sha, :succeeded_shas, :rollout)
|
|
25
51
|
|
|
26
52
|
Mine = Data.define(:sha, :had_own_build)
|
|
27
53
|
end
|
|
@@ -37,6 +63,7 @@ require_relative "onair/auth/github_token"
|
|
|
37
63
|
require_relative "onair/remote_head"
|
|
38
64
|
require_relative "onair/platform/base"
|
|
39
65
|
require_relative "onair/platform/heroku"
|
|
66
|
+
require_relative "onair/platform/dokku"
|
|
40
67
|
require_relative "onair/renderer/tty"
|
|
41
68
|
require_relative "onair/renderer/json"
|
|
42
69
|
require_relative "onair/cli"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: onair-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eugene
|
|
@@ -31,6 +31,7 @@ files:
|
|
|
31
31
|
- lib/onair/git.rb
|
|
32
32
|
- lib/onair/orchestrator.rb
|
|
33
33
|
- lib/onair/platform/base.rb
|
|
34
|
+
- lib/onair/platform/dokku.rb
|
|
34
35
|
- lib/onair/platform/heroku.rb
|
|
35
36
|
- lib/onair/remote_head.rb
|
|
36
37
|
- lib/onair/renderer/json.rb
|