onair-cli 0.3.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 +10 -0
- data/README.md +31 -6
- data/lib/onair/config.rb +11 -5
- data/lib/onair/platform/base.rb +14 -0
- data/lib/onair/platform/dokku.rb +213 -0
- data/lib/onair/platform/heroku.rb +2 -8
- data/lib/onair/renderer/json.rb +2 -1
- data/lib/onair/renderer/tty.rb +34 -8
- data/lib/onair/report.rb +4 -1
- data/lib/onair/version.rb +1 -1
- data/lib/onair.rb +14 -4
- 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,16 @@
|
|
|
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
|
+
|
|
5
15
|
## [0.3.0] - 2026-10-08
|
|
6
16
|
|
|
7
17
|
- Heroku: a rollout line under Deployed counts the dynos of each process type
|
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
|
|
|
@@ -76,7 +77,8 @@ the API is a faster transport, never a cache.
|
|
|
76
77
|
run that release: `⟳ rolling out v1234: web 1/3, 2 starting` while dynos
|
|
77
78
|
restart, `✓ rolled out` once every dyno is up on it. With preboot on, the
|
|
78
79
|
previous web dynos keep serving for about three minutes after the new ones
|
|
79
|
-
are up; that window shows as an estimate.
|
|
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.
|
|
80
82
|
- **Pending** - an in-flight build that's about to replace the deploy.
|
|
81
83
|
- **Releasing** - a newer release whose release phase (e.g. migrations) is
|
|
82
84
|
still running; production still serves the Deployed row until it finishes.
|
|
@@ -99,7 +101,27 @@ branch: main # comparison branch; default: main
|
|
|
99
101
|
```
|
|
100
102
|
|
|
101
103
|
Resolution order: CLI flags → env vars (`HEROKU_APP`, `GITHUB_REPO`) →
|
|
102
|
-
`.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.
|
|
103
125
|
|
|
104
126
|
### Task links (optional)
|
|
105
127
|
|
|
@@ -149,8 +171,11 @@ additively:
|
|
|
149
171
|
```
|
|
150
172
|
|
|
151
173
|
- `delta.status` is `"current"`, `"behind"`, or `"unknown"`.
|
|
152
|
-
- `pending`: `{ "sha", "started_at", "subject", "author" }` when
|
|
153
|
-
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.
|
|
154
179
|
- `release`: `{ "sha", "version", "description", "status", "started_at",
|
|
155
180
|
"subject", "author" }` when a release newer than the running one is not
|
|
156
181
|
live; `status` is `"pending"` (release phase running) or `"failed"`. While
|
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/platform/base.rb
CHANGED
|
@@ -33,6 +33,9 @@ module Onair
|
|
|
33
33
|
# release, plus an optional estimate of when the
|
|
34
34
|
# previous release stops serving; nil when unknown
|
|
35
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.
|
|
38
|
+
#
|
|
36
39
|
# Adapters own their internal concurrency and auth. Everything above
|
|
37
40
|
# (delta, pinned, mine, rendering) is platform-agnostic and must not
|
|
38
41
|
# reference any specific platform.
|
|
@@ -48,6 +51,17 @@ module Onair
|
|
|
48
51
|
def display_name
|
|
49
52
|
self.class.name.split("::").last
|
|
50
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
|
|
51
65
|
end
|
|
52
66
|
end
|
|
53
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
|
|
@@ -16,6 +16,7 @@ module Onair
|
|
|
16
16
|
# With preboot, Heroku keeps routing to the previous web dynos for about
|
|
17
17
|
# three minutes after the new ones come up.
|
|
18
18
|
PREBOOT_HANDOFF = 180
|
|
19
|
+
PREBOOT_NOTE = "preboot: previous web dynos may still serve"
|
|
19
20
|
|
|
20
21
|
def snapshot
|
|
21
22
|
token = resolve_token
|
|
@@ -36,13 +37,6 @@ module Onair
|
|
|
36
37
|
@config.app
|
|
37
38
|
end
|
|
38
39
|
|
|
39
|
-
def quiet_thread(&block)
|
|
40
|
-
Thread.new do
|
|
41
|
-
Thread.current.report_on_exception = false
|
|
42
|
-
block.call
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
40
|
def resolve_token
|
|
47
41
|
token = Auth::Netrc.token(HOST)
|
|
48
42
|
token = Auth::HerokuCli.token if token.nil? || token.empty?
|
|
@@ -138,7 +132,7 @@ module Onair
|
|
|
138
132
|
.sort_by { |type, _| [(type == "web") ? 0 : 1, type] }
|
|
139
133
|
.map { |type, dynos| process_rollout(type, dynos, version) }
|
|
140
134
|
Rollout.new(version: version, processes: processes,
|
|
141
|
-
overlap_until: preboot ? preboot_handoff(formation, version) : nil)
|
|
135
|
+
overlap_until: preboot ? preboot_handoff(formation, version) : nil, overlap_note: PREBOOT_NOTE)
|
|
142
136
|
end
|
|
143
137
|
|
|
144
138
|
def process_rollout(type, dynos, version)
|
data/lib/onair/renderer/json.rb
CHANGED
|
@@ -60,7 +60,8 @@ module Onair
|
|
|
60
60
|
pending = snapshot.pending
|
|
61
61
|
return nil if pending.nil?
|
|
62
62
|
|
|
63
|
-
{sha: pending.sha, started_at: iso(pending.started_at)}
|
|
63
|
+
{sha: pending.sha, build_id: pending.build_id, started_at: iso(pending.started_at)}
|
|
64
|
+
.merge(commit_fields(pending.sha))
|
|
64
65
|
end
|
|
65
66
|
|
|
66
67
|
def release_payload
|
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,9 +63,10 @@ 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
71
|
["Failed:", :red, "✗ release phase failed (#{detail})"]
|
|
65
72
|
else
|
|
@@ -68,6 +75,12 @@ module Onair
|
|
|
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
|
|
@@ -77,8 +90,8 @@ module Onair
|
|
|
77
90
|
lines.concat(yours_lines)
|
|
78
91
|
lines
|
|
79
92
|
else
|
|
80
|
-
["
|
|
81
|
-
|
|
93
|
+
subject = ["the running commit", version_label(deployed&.version)].compact.join(" for ")
|
|
94
|
+
[" #{paint("Could not resolve #{subject} (#{deployed&.description}).", :yellow)}"]
|
|
82
95
|
end
|
|
83
96
|
end
|
|
84
97
|
|
|
@@ -86,13 +99,14 @@ module Onair
|
|
|
86
99
|
# traffic to the previous release, so that window gets its own line.
|
|
87
100
|
def rollout_line
|
|
88
101
|
rollout = @report.rollout
|
|
102
|
+
target = [version_label(rollout.version)].compact.map { |label| " #{label}" }.join
|
|
89
103
|
text, color = if rollout.complete?
|
|
90
|
-
["✓ rolled out
|
|
104
|
+
["✓ rolled out#{target}: #{process_counts(rollout)}", :dim]
|
|
91
105
|
elsif rollout.processes.all?(&:complete?)
|
|
92
|
-
["⟳
|
|
106
|
+
["⟳ #{rollout.overlap_note || "previous release may still serve"} for " \
|
|
93
107
|
"~#{remaining(rollout.overlap_until)} (estimate)", :yellow]
|
|
94
108
|
else
|
|
95
|
-
["⟳ rolling out
|
|
109
|
+
["⟳ rolling out#{target}: #{process_counts(rollout)}", :yellow]
|
|
96
110
|
end
|
|
97
111
|
" #{paint(text, color)}"
|
|
98
112
|
end
|
|
@@ -131,6 +145,17 @@ module Onair
|
|
|
131
145
|
[first, " #{paint("→", :dim)} #{linkify(subject, info ? sha : nil)}"]
|
|
132
146
|
end
|
|
133
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}"
|
|
157
|
+
end
|
|
158
|
+
|
|
134
159
|
def delta_text
|
|
135
160
|
case @report.delta
|
|
136
161
|
when :current
|
|
@@ -143,7 +168,8 @@ module Onair
|
|
|
143
168
|
|
|
144
169
|
def pinned_line
|
|
145
170
|
deployed = snapshot.deployed
|
|
146
|
-
|
|
171
|
+
running = [version_label(deployed.version), "(#{deployed.description})"].compact.join(" ")
|
|
172
|
+
" #{paint("⏸ #{running} - newer build " \
|
|
147
173
|
"#{snapshot.latest_built_sha[0, 9]} succeeded but is not running", :yellow)}"
|
|
148
174
|
end
|
|
149
175
|
|
data/lib/onair/report.rb
CHANGED
|
@@ -73,8 +73,11 @@ module Onair
|
|
|
73
73
|
!sha.nil? && !latest.nil? && latest != sha && snapshot.pending.nil? && !release_of_other_commit?(snapshot)
|
|
74
74
|
end
|
|
75
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.
|
|
76
78
|
def self.release_of_other_commit?(snapshot)
|
|
77
|
-
|
|
79
|
+
sha = snapshot.release&.sha
|
|
80
|
+
!sha.nil? && sha != snapshot.deployed&.sha
|
|
78
81
|
end
|
|
79
82
|
|
|
80
83
|
# "Did my merge just ship?" — deliberately a 2-commit first-parent window
|
data/lib/onair/version.rb
CHANGED
data/lib/onair.rb
CHANGED
|
@@ -9,13 +9,19 @@ 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
|
|
|
21
27
|
# Dynos of one process type against the running release. `up` counts dynos
|
|
@@ -28,8 +34,11 @@ module Onair
|
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
# 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
|
-
|
|
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
|
+
|
|
33
42
|
def complete?
|
|
34
43
|
processes.all?(&:complete?) && overlap_until.nil?
|
|
35
44
|
end
|
|
@@ -54,6 +63,7 @@ require_relative "onair/auth/github_token"
|
|
|
54
63
|
require_relative "onair/remote_head"
|
|
55
64
|
require_relative "onair/platform/base"
|
|
56
65
|
require_relative "onair/platform/heroku"
|
|
66
|
+
require_relative "onair/platform/dokku"
|
|
57
67
|
require_relative "onair/renderer/tty"
|
|
58
68
|
require_relative "onair/renderer/json"
|
|
59
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
|