onair-cli 0.3.0 → 0.5.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 +21 -0
- data/README.md +96 -6
- data/lib/onair/cli.rb +3 -3
- data/lib/onair/config.rb +15 -6
- data/lib/onair/platform/base.rb +34 -4
- data/lib/onair/platform/dokku.rb +214 -0
- data/lib/onair/platform/github_deployments.rb +157 -0
- data/lib/onair/platform/heroku.rb +7 -8
- data/lib/onair/renderer/json.rb +5 -2
- data/lib/onair/renderer/tty.rb +52 -11
- data/lib/onair/report.rb +4 -1
- data/lib/onair/version.rb +1 -1
- data/lib/onair.rb +15 -4
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 863a5cc9f6796e525c125ed320542a3e4d8b076b5a6a5660744c9136ead84cc2
|
|
4
|
+
data.tar.gz: c771ccb2bd0c72fcf075e48c829556de327ad4dc3b2527db4ca0b5b0ee763e34
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e8016ad8033b28ec15e73cdae96b1b0e94562a4bc28226dfd5b556c8a3a24dc713106b9f8fe5b3c3881c330e71f9a297c768c0a2c9175114eccfdd4cc4eae76d
|
|
7
|
+
data.tar.gz: f78628cf04574876bc46322b9eba02d8f252152c90d7bc75bd032041be4a9f3741f6f3619d01693a3f9f098e18fcd64c6e14e722c27e65d51640a1bf5e7660e7
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.5.0] - 2026-11-02
|
|
6
|
+
|
|
7
|
+
- GitHub Deployments: `platform: github_deployments` reads one environment's
|
|
8
|
+
deployments (`environment:`, default `production`) through the GitHub API
|
|
9
|
+
with the `gh` token, so anyone who can read the repo sees what is deployed
|
|
10
|
+
without host access. The newest successful deployment is Deployed, a newer
|
|
11
|
+
in-flight one is Pending with its commit, and a newer failed one is Failed.
|
|
12
|
+
It reports what CI recorded, not what the host runs.
|
|
13
|
+
- `app:` is required only by the platforms that use it. `--json` gains an
|
|
14
|
+
`environment` field, and `deployed` is `null` when nothing successful is deployed yet.
|
|
15
|
+
|
|
16
|
+
## [0.4.0] - 2026-11-02
|
|
17
|
+
|
|
18
|
+
- Dokku: `platform: dokku` with a `host:` reads the running commit from the
|
|
19
|
+
last `git push` over SSH. A running build shows as `Pending` and a build
|
|
20
|
+
that failed after the last success as `Failed`, both without a commit. The
|
|
21
|
+
rollout line counts containers per process type, and the window in which
|
|
22
|
+
the previous containers keep serving shows as an estimate.
|
|
23
|
+
- Rows drop `v<version>` when the platform numbers no releases. `--json`
|
|
24
|
+
carries `null` versions and shas there, and `pending` gains `build_id`.
|
|
25
|
+
|
|
5
26
|
## [0.3.0] - 2026-10-08
|
|
6
27
|
|
|
7
28
|
- Heroku: a rollout line under Deployed counts the dynos of each process type
|
data/README.md
CHANGED
|
@@ -28,6 +28,8 @@ case.
|
|
|
28
28
|
## Principles
|
|
29
29
|
|
|
30
30
|
1. **Truth over convenience.** What's *running*, not what was built last.
|
|
31
|
+
The one exception is opt-in: `platform: github_deployments` reports what
|
|
32
|
+
CI recorded, not what the host runs (see [GitHub Deployments](#github-deployments)).
|
|
31
33
|
2. **Zero runtime dependencies.** Ruby stdlib only.
|
|
32
34
|
3. **Read-only.** Never deploys, never rolls back, never writes to git except
|
|
33
35
|
an explicit lazy `git fetch`.
|
|
@@ -42,8 +44,9 @@ case.
|
|
|
42
44
|
gem install onair-cli
|
|
43
45
|
```
|
|
44
46
|
|
|
45
|
-
Requires Ruby >= 3.2.
|
|
46
|
-
(written by `heroku login`), falling back to `heroku auth:token`.
|
|
47
|
+
Requires Ruby >= 3.2. On Heroku, auth reuses the Heroku CLI's token from
|
|
48
|
+
`~/.netrc` (written by `heroku login`), falling back to `heroku auth:token`.
|
|
49
|
+
On Dokku, it reuses your SSH setup (see [Dokku](#dokku)).
|
|
47
50
|
|
|
48
51
|
## Usage
|
|
49
52
|
|
|
@@ -76,7 +79,8 @@ the API is a faster transport, never a cache.
|
|
|
76
79
|
run that release: `⟳ rolling out v1234: web 1/3, 2 starting` while dynos
|
|
77
80
|
restart, `✓ rolled out` once every dyno is up on it. With preboot on, the
|
|
78
81
|
previous web dynos keep serving for about three minutes after the new ones
|
|
79
|
-
are up; that window shows as an estimate.
|
|
82
|
+
are up; that window shows as an estimate. On Dokku it counts containers,
|
|
83
|
+
and the estimate is the app's wait-to-retire after the newest build.
|
|
80
84
|
- **Pending** - an in-flight build that's about to replace the deploy.
|
|
81
85
|
- **Releasing** - a newer release whose release phase (e.g. migrations) is
|
|
82
86
|
still running; production still serves the Deployed row until it finishes.
|
|
@@ -99,7 +103,86 @@ branch: main # comparison branch; default: main
|
|
|
99
103
|
```
|
|
100
104
|
|
|
101
105
|
Resolution order: CLI flags → env vars (`HEROKU_APP`, `GITHUB_REPO`) →
|
|
102
|
-
`.onair.yml`.
|
|
106
|
+
`.onair.yml`. `HEROKU_APP` applies only with `platform: heroku`.
|
|
107
|
+
|
|
108
|
+
### Dokku
|
|
109
|
+
|
|
110
|
+
```yaml
|
|
111
|
+
platform: dokku
|
|
112
|
+
host: my-dokku # any SSH destination: dokku@host, or a ~/.ssh/config alias
|
|
113
|
+
app: myapp
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
onair runs `ssh -o BatchMode=yes <host> <command> <app> --format json` for
|
|
117
|
+
Dokku's report commands, so it needs Dokku 0.38 or newer and an `ssh` client.
|
|
118
|
+
The host must log in as Dokku's `dokku` user, directly or through an alias
|
|
119
|
+
with `User dokku`. Keys, addresses and users come from your SSH config;
|
|
120
|
+
`BatchMode` never prompts, so the key must be loadable without a passphrase
|
|
121
|
+
prompt (`ssh-add`).
|
|
122
|
+
|
|
123
|
+
The deployed commit is the one the last `git push` deployed. An app deployed
|
|
124
|
+
another way (`git:from-image`, `git:sync`) shows the deploy source instead
|
|
125
|
+
of a commit. Dokku builds record no commit, so `Pending` and `Failed` name
|
|
126
|
+
the build, and pinned detection stays silent.
|
|
127
|
+
|
|
128
|
+
### GitHub Deployments
|
|
129
|
+
|
|
130
|
+
For teams whose CI deploys to a host most developers cannot reach (a Dokku
|
|
131
|
+
box behind a firewall, a VPS where only admins hold SSH keys). If CI records
|
|
132
|
+
each deploy as a GitHub Deployment, anyone who can read the repo sees what is
|
|
133
|
+
deployed, with no access to the host.
|
|
134
|
+
|
|
135
|
+
```yaml
|
|
136
|
+
platform: github_deployments
|
|
137
|
+
repo: acme/widgets # default: inferred from origin
|
|
138
|
+
environment: production # default: production
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
No `app:` is needed. onair reads the environment's last 10 deployments and
|
|
142
|
+
the newest status of each through the GitHub API, with the token from
|
|
143
|
+
`GH_TOKEN`, `GITHUB_TOKEN` or `gh auth token`; without one it asks you to run
|
|
144
|
+
`gh auth login`. The token needs read access to the repo's deployments.
|
|
145
|
+
|
|
146
|
+
- **Deployed** is the newest deployment whose status is `success`.
|
|
147
|
+
- **Pending** is a newer deployment that is `queued`, `pending` or
|
|
148
|
+
`in_progress`, with its commit.
|
|
149
|
+
- **Failed** is a newer deployment whose status is `failure` or `error`.
|
|
150
|
+
- A deployment with no status yet is ignored.
|
|
151
|
+
- With no successful deployment among the last 10, the report says so in one
|
|
152
|
+
line, under any Pending or Failed row.
|
|
153
|
+
- If the status of a deployment newer than the deployed one cannot be read,
|
|
154
|
+
the report fails rather than show an older commit as running.
|
|
155
|
+
|
|
156
|
+
**This adapter reports what CI recorded, not what the host runs.** A deploy
|
|
157
|
+
that bypassed CI stays invisible until the next recorded one, and a CI job
|
|
158
|
+
that records the sha it pushed reports that sha even if the host ended up
|
|
159
|
+
running something else. So have CI record the sha it reads back from the host
|
|
160
|
+
after the deploy, not the sha it pushed. For example, in GitHub Actions:
|
|
161
|
+
|
|
162
|
+
```yaml
|
|
163
|
+
permissions:
|
|
164
|
+
deployments: write
|
|
165
|
+
steps:
|
|
166
|
+
- uses: actions/checkout@v4
|
|
167
|
+
- name: Deploy
|
|
168
|
+
env:
|
|
169
|
+
GH_TOKEN: ${{ github.token }}
|
|
170
|
+
run: |
|
|
171
|
+
id=$(gh api repos/${{ github.repository }}/deployments \
|
|
172
|
+
-f ref=${{ github.sha }} -f environment=production \
|
|
173
|
+
-F auto_merge=false -f 'required_contexts[]' --jq .id)
|
|
174
|
+
gh api repos/${{ github.repository }}/deployments/$id/statuses -f state=in_progress
|
|
175
|
+
if ./deploy.sh && [ "$(./running-sha.sh)" = "${{ github.sha }}" ]; then
|
|
176
|
+
state=success
|
|
177
|
+
else
|
|
178
|
+
state=failure
|
|
179
|
+
fi
|
|
180
|
+
gh api repos/${{ github.repository }}/deployments/$id/statuses -f state=$state
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Here `deploy.sh` deploys and `running-sha.sh` asks the host which commit it
|
|
184
|
+
runs. When the host runs a different commit than the one pushed, the job
|
|
185
|
+
records a failure rather than a false success.
|
|
103
186
|
|
|
104
187
|
### Task links (optional)
|
|
105
188
|
|
|
@@ -127,6 +210,7 @@ additively:
|
|
|
127
210
|
"platform": "heroku",
|
|
128
211
|
"branch": "main",
|
|
129
212
|
"repo": "acme/widgets",
|
|
213
|
+
"environment": null,
|
|
130
214
|
"remote_head": "<sha>",
|
|
131
215
|
"deployed": {
|
|
132
216
|
"sha": "<sha>", "version": 1234, "description": "Deploy a1b2c3d4",
|
|
@@ -149,8 +233,14 @@ additively:
|
|
|
149
233
|
```
|
|
150
234
|
|
|
151
235
|
- `delta.status` is `"current"`, `"behind"`, or `"unknown"`.
|
|
152
|
-
- `
|
|
153
|
-
|
|
236
|
+
- `environment` is the GitHub Deployments environment read, `null` on other
|
|
237
|
+
platforms. With `platform: github_deployments`, `app` is `null`, and
|
|
238
|
+
`deployed` is `null` when no deployment among the last 10 succeeded.
|
|
239
|
+
- `pending`: `{ "sha", "build_id", "started_at", "subject", "author" }` when
|
|
240
|
+
a build is in flight. On Dokku `sha` is `null` and `build_id` names the
|
|
241
|
+
build; on Heroku `build_id` is `null`.
|
|
242
|
+
- `version` is `null` on platforms that number no releases (Dokku), and a
|
|
243
|
+
Dokku `release` is a failed build with a `null` sha.
|
|
154
244
|
- `release`: `{ "sha", "version", "description", "status", "started_at",
|
|
155
245
|
"subject", "author" }` when a release newer than the running one is not
|
|
156
246
|
live; `status` is `"pending"` (release phase running) or `"failed"`. While
|
data/lib/onair/cli.rb
CHANGED
|
@@ -62,17 +62,17 @@ module Onair
|
|
|
62
62
|
def status(flags)
|
|
63
63
|
config = Config.resolve(flags)
|
|
64
64
|
git = Git.new(fetch_allowed: config.fetch)
|
|
65
|
-
adapter = Platform.build(config)
|
|
66
65
|
repo = config.repo || git.origin_repo
|
|
66
|
+
adapter = Platform.build(config, repo: repo)
|
|
67
67
|
now = Time.now
|
|
68
68
|
report = Orchestrator.new(config: config, adapter: adapter, git: git, repo: repo, now: now).run
|
|
69
69
|
|
|
70
70
|
if flags[:json]
|
|
71
71
|
puts Renderer::Json.new(report: report, app: config.app, platform: config.platform,
|
|
72
|
-
branch: config.branch, repo: repo, task: config.task).render
|
|
72
|
+
branch: config.branch, repo: repo, environment: adapter.environment, task: config.task).render
|
|
73
73
|
else
|
|
74
74
|
tty = $stdout.tty?
|
|
75
|
-
print Renderer::Tty.new(report: report, app:
|
|
75
|
+
print Renderer::Tty.new(report: report, app: adapter.target_label, platform_label: adapter.display_name,
|
|
76
76
|
branch: config.branch, repo: repo, task: config.task,
|
|
77
77
|
color: tty && !no_color?, hyperlinks: tty, now: now).render
|
|
78
78
|
end
|
data/lib/onair/config.rb
CHANGED
|
@@ -8,10 +8,16 @@ module Onair
|
|
|
8
8
|
class Config
|
|
9
9
|
FILENAME = ".onair.yml"
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
DEFAULT_ENVIRONMENT = "production"
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
attr_reader :platform, :app, :repo, :branch, :fetch, :task, :host, :environment
|
|
14
|
+
|
|
15
|
+
# `app`, `host` and `environment` are each read by the adapters that need
|
|
16
|
+
# them; those adapters check them, so any may be nil elsewhere.
|
|
17
|
+
def initialize(platform:, app:, repo:, branch:, fetch:, task: nil, host: nil, environment: nil)
|
|
14
18
|
@platform = platform
|
|
19
|
+
@host = host
|
|
20
|
+
@environment = environment
|
|
15
21
|
@app = app
|
|
16
22
|
@repo = repo
|
|
17
23
|
@branch = branch
|
|
@@ -21,16 +27,19 @@ module Onair
|
|
|
21
27
|
|
|
22
28
|
def self.resolve(flags = {}, env: ENV, dir: Dir.pwd)
|
|
23
29
|
file = find_file(dir) || {}
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
platform = file["platform"] || "heroku"
|
|
31
|
+
# HEROKU_APP names a Heroku app, so it must not redirect another platform.
|
|
32
|
+
app = flags[:app] || ((platform == "heroku") ? env["HEROKU_APP"] : nil) || file["app"]
|
|
26
33
|
|
|
27
34
|
new(
|
|
28
|
-
platform:
|
|
35
|
+
platform: platform,
|
|
29
36
|
app: app,
|
|
30
37
|
repo: env["GITHUB_REPO"] || file["repo"],
|
|
31
38
|
branch: file["branch"] || "main",
|
|
32
39
|
fetch: !flags[:no_fetch],
|
|
33
|
-
task: TaskLink.from_config(file["task"])
|
|
40
|
+
task: TaskLink.from_config(file["task"]),
|
|
41
|
+
host: file["host"],
|
|
42
|
+
environment: file["environment"] || DEFAULT_ENVIRONMENT
|
|
34
43
|
)
|
|
35
44
|
end
|
|
36
45
|
|
data/lib/onair/platform/base.rb
CHANGED
|
@@ -9,21 +9,23 @@ module Onair
|
|
|
9
9
|
@registry[name] = klass
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def build(config)
|
|
12
|
+
def build(config, repo: nil)
|
|
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
|
-
klass.new(config)
|
|
19
|
+
klass.new(config, repo: repo)
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# Adapter contract: implement #snapshot returning a Snapshot.
|
|
24
24
|
#
|
|
25
25
|
# deployed: the release CURRENTLY RUNNING (nil sha only if
|
|
26
|
-
# truly unresolvable)
|
|
26
|
+
# truly unresolvable), or nil when the platform
|
|
27
|
+
# records no successful deploy; renderers then show
|
|
28
|
+
# one line in its place
|
|
27
29
|
# pending: newest in-flight build, or nil
|
|
28
30
|
# release: newest release above the running one that is not
|
|
29
31
|
# running (release phase pending or failed), or nil
|
|
@@ -33,12 +35,19 @@ module Onair
|
|
|
33
35
|
# release, plus an optional estimate of when the
|
|
34
36
|
# previous release stops serving; nil when unknown
|
|
35
37
|
#
|
|
38
|
+
# Versions may be nil on every value, and pending/release shas may be nil
|
|
39
|
+
# when the platform's builds carry no commit; renderers drop what is absent.
|
|
40
|
+
#
|
|
36
41
|
# Adapters own their internal concurrency and auth. Everything above
|
|
37
42
|
# (delta, pinned, mine, rendering) is platform-agnostic and must not
|
|
38
43
|
# reference any specific platform.
|
|
39
44
|
class Base
|
|
40
|
-
|
|
45
|
+
NO_APP = "no app configured - pass --app NAME, set HEROKU_APP, or run `onair init`"
|
|
46
|
+
|
|
47
|
+
# `repo` is the GitHub repo (config or origin remote), or nil.
|
|
48
|
+
def initialize(config, repo: nil)
|
|
41
49
|
@config = config
|
|
50
|
+
@repo = repo
|
|
42
51
|
end
|
|
43
52
|
|
|
44
53
|
def snapshot
|
|
@@ -48,6 +57,27 @@ module Onair
|
|
|
48
57
|
def display_name
|
|
49
58
|
self.class.name.split("::").last
|
|
50
59
|
end
|
|
60
|
+
|
|
61
|
+
# What the report's header names next to the platform.
|
|
62
|
+
def target_label
|
|
63
|
+
@config.app
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The deploy environment the adapter reads, for platforms that have one.
|
|
67
|
+
def environment
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Exceptions surface via #value; without this the dying thread also
|
|
74
|
+
# prints its own backtrace to stderr.
|
|
75
|
+
def quiet_thread(&block)
|
|
76
|
+
Thread.new do
|
|
77
|
+
Thread.current.report_on_exception = false
|
|
78
|
+
block.call
|
|
79
|
+
end
|
|
80
|
+
end
|
|
51
81
|
end
|
|
52
82
|
end
|
|
53
83
|
end
|
|
@@ -0,0 +1,214 @@
|
|
|
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, NO_APP if app.nil?
|
|
62
|
+
raise Error, "invalid Dokku app name #{app.inspect}" unless APP_NAME.match?(app)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Dokku strips the plugin prefix from report JSON keys, so git:report's
|
|
66
|
+
# `--git-last-updated-at` arrives as `last-updated-at`.
|
|
67
|
+
# Only a git push records the pushed commit. git:report's sha is not
|
|
68
|
+
# used: it can read the repo's HEAD rather than what was deployed.
|
|
69
|
+
def deployed(apps, git)
|
|
70
|
+
source = apps["deploy-source"].to_s
|
|
71
|
+
raise Error, "nothing deployed yet for app #{app}" if source.empty?
|
|
72
|
+
|
|
73
|
+
metadata = apps["deploy-source-metadata"].to_s
|
|
74
|
+
sha = (source == GIT_PUSH && FULL_SHA.match?(metadata)) ? metadata : nil
|
|
75
|
+
Deployed.new(sha: sha, version: nil, description: "deploy source: #{source}",
|
|
76
|
+
deployed_at: unix_time(git&.dig("last-updated-at")))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Builds carry no commit, so the sha stays unknown until the build is
|
|
80
|
+
# correlated with the CI run that pushed it.
|
|
81
|
+
def pending(builds)
|
|
82
|
+
running = builds.find { |build| status(build) == "running" }
|
|
83
|
+
running && Pending.new(sha: nil, build_id: running["id"], started_at: parse_time(running["started_at"]))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Only a failure newer than the last success matters; older failed
|
|
87
|
+
# records were superseded and are stale.
|
|
88
|
+
def failed(builds)
|
|
89
|
+
failure = newest(builds, "failed")
|
|
90
|
+
success = newest(builds, "succeeded")
|
|
91
|
+
return nil if failure.nil? || (success && started(success) >= started(failure))
|
|
92
|
+
|
|
93
|
+
Release.new(sha: nil, version: nil, description: "build #{failure["id"]}", status: :failed,
|
|
94
|
+
started_at: parse_time(failure["finished_at"]) || started(failure))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def newest(builds, wanted)
|
|
98
|
+
builds.select { |build| status(build) == wanted && started(build) }.max_by { |build| started(build) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# display_status turns a running record whose process died into
|
|
102
|
+
# "abandoned"; older Dokku versions only report status.
|
|
103
|
+
def status(build)
|
|
104
|
+
build["display_status"] || build["status"]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def started(build)
|
|
108
|
+
parse_time(build["started_at"])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def rollout(ps, success, checks)
|
|
112
|
+
return nil unless ps.is_a?(Hash)
|
|
113
|
+
|
|
114
|
+
containers = ps.filter_map do |key, value|
|
|
115
|
+
match = CONTAINER_STATUS.match(key)
|
|
116
|
+
match && [match[:type], value.to_s.split(" ", 2).first.to_s]
|
|
117
|
+
end
|
|
118
|
+
return nil if containers.empty?
|
|
119
|
+
|
|
120
|
+
processes = containers.group_by(&:first)
|
|
121
|
+
.sort_by { |type, _| [(type == "web") ? 0 : 1, type] }
|
|
122
|
+
.map { |type, rows| process_rollout(type, rows.map(&:last)) }
|
|
123
|
+
Rollout.new(version: nil, processes: processes, overlap_note: RETIRE_NOTE,
|
|
124
|
+
overlap_until: retire_deadline(processes, success, checks))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# ps:report lists only the current deploy's containers, so nothing is
|
|
128
|
+
# ever counted as previous.
|
|
129
|
+
def process_rollout(type, states)
|
|
130
|
+
waiting = states.reject { |state| state == SERVING_STATE }.tally
|
|
131
|
+
ProcessRollout.new(type: type, total: states.size, up: states.size - waiting.values.sum,
|
|
132
|
+
waiting: waiting, previous: 0)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def retire_deadline(processes, success, checks)
|
|
136
|
+
finished = success && parse_time(success["finished_at"])
|
|
137
|
+
return nil if finished.nil? || !processes.all?(&:complete?)
|
|
138
|
+
|
|
139
|
+
finished + wait_to_retire(checks)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def wait_to_retire(checks)
|
|
143
|
+
Integer(checks&.dig("computed-wait-to-retire"), 10)
|
|
144
|
+
rescue ArgumentError, TypeError
|
|
145
|
+
DEFAULT_WAIT_TO_RETIRE
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def optional
|
|
149
|
+
yield
|
|
150
|
+
rescue Error
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Dokku prints warnings on stderr (e.g. "Skipping unreadable build
|
|
155
|
+
# record"), so only stdout is parsed.
|
|
156
|
+
def dokku(subcommand)
|
|
157
|
+
stdout, stderr, status = run(["ssh", *SSH_OPTIONS, host, subcommand, app, "--format", "json"])
|
|
158
|
+
raise Error, "dokku #{subcommand} timed out on #{host}" if status.nil?
|
|
159
|
+
raise Error, ssh_error(subcommand, stderr, status) unless status.success?
|
|
160
|
+
|
|
161
|
+
JSON.parse(stdout)
|
|
162
|
+
rescue Errno::ENOENT
|
|
163
|
+
raise Error, "ssh not found - the Dokku platform needs an ssh client on PATH"
|
|
164
|
+
rescue JSON::ParserError
|
|
165
|
+
raise Error, "Dokku returned invalid JSON for #{subcommand}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Returns [stdout, stderr, status], with a nil status when the command
|
|
169
|
+
# outlived the timeout and was killed.
|
|
170
|
+
def run(argv, timeout: COMMAND_TIMEOUT)
|
|
171
|
+
Open3.popen3(*argv) do |stdin, stdout, stderr, wait|
|
|
172
|
+
stdin.close
|
|
173
|
+
out = Thread.new { stdout.read }
|
|
174
|
+
err = Thread.new { stderr.read }
|
|
175
|
+
unless wait.join(timeout)
|
|
176
|
+
begin
|
|
177
|
+
Process.kill("KILL", wait.pid)
|
|
178
|
+
rescue Errno::ESRCH
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
return [out.value, err.value, nil]
|
|
182
|
+
end
|
|
183
|
+
[out.value, err.value, wait.value]
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# ssh itself exits 255; anything else is Dokku's own exit code.
|
|
188
|
+
def ssh_error(subcommand, stderr, status)
|
|
189
|
+
line = stderr.lines.map { |text| text.sub(/\A\s*!\s*/, "").strip }.reject(&:empty?).last
|
|
190
|
+
if status.exitstatus == 255 && stderr.include?("Permission denied")
|
|
191
|
+
"SSH to #{host} was refused - load the key with `ssh-add`"
|
|
192
|
+
elsif status.exitstatus == 255
|
|
193
|
+
"could not reach #{host} over SSH#{line && ": #{line}"}"
|
|
194
|
+
else
|
|
195
|
+
"dokku #{subcommand} failed on #{host}#{line && ": #{line}"}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def unix_time(value)
|
|
200
|
+
Time.at(Integer(value, 10)).utc
|
|
201
|
+
rescue ArgumentError, TypeError
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def parse_time(value)
|
|
206
|
+
value && Time.iso8601(value)
|
|
207
|
+
rescue ArgumentError
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
Platform.register("dokku", self)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Onair
|
|
9
|
+
module Platform
|
|
10
|
+
# Reads what CI recorded as GitHub Deployments for one environment, so
|
|
11
|
+
# anyone who can read the repo sees production without host access. It
|
|
12
|
+
# reports CI's record, not what the host runs.
|
|
13
|
+
class GithubDeployments < Base
|
|
14
|
+
HOST = "api.github.com"
|
|
15
|
+
API_VERSION = "2022-11-28"
|
|
16
|
+
WINDOW = 10
|
|
17
|
+
# owner/name, with no all-dot segment, since the repo goes into a URL path.
|
|
18
|
+
REPO_NAME = %r{\A(?!\.+/)[\w.-]+/(?!\.+\z)[\w.-]+\z}
|
|
19
|
+
SUCCESS = "success"
|
|
20
|
+
# GitHub marks an older successful deployment inactive once a newer one
|
|
21
|
+
# in the environment succeeds, so both states count as deployed once.
|
|
22
|
+
SUCCEEDED_STATES = %w[success inactive].freeze
|
|
23
|
+
IN_FLIGHT_STATES = %w[queued pending in_progress].freeze
|
|
24
|
+
FAILED_STATES = %w[failure error].freeze
|
|
25
|
+
|
|
26
|
+
def initialize(config, repo: nil)
|
|
27
|
+
super
|
|
28
|
+
raise Error, "no GitHub repo - add `repo: owner/name` to #{Config::FILENAME}" if @repo.to_s.empty?
|
|
29
|
+
raise Error, "invalid GitHub repo #{@repo.inspect} - expected owner/name" unless REPO_NAME.match?(@repo)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def snapshot
|
|
33
|
+
token = resolve_token
|
|
34
|
+
rows = with_statuses(token, deployments(token))
|
|
35
|
+
# A deployment whose status could not be read may be the one running,
|
|
36
|
+
# so any such row above the newest success fails the report.
|
|
37
|
+
index = rows.index { |row| row[:error] || row[:state] == SUCCESS }
|
|
38
|
+
raise rows[index][:error] if index && rows[index][:error]
|
|
39
|
+
|
|
40
|
+
newer = index ? rows.first(index) : rows
|
|
41
|
+
succeeded = rows.select { |row| SUCCEEDED_STATES.include?(row[:state]) }.map { |row| row[:sha] }.uniq
|
|
42
|
+
Snapshot.new(deployed: index && deployed(rows[index]), pending: pending(newer), release: failed(newer),
|
|
43
|
+
latest_built_sha: succeeded.first, succeeded_shas: succeeded, rollout: nil)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def display_name
|
|
47
|
+
"GitHub"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def target_label
|
|
51
|
+
"#{@repo} (#{environment})"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def environment
|
|
55
|
+
@config.environment || Config::DEFAULT_ENVIRONMENT
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def resolve_token
|
|
61
|
+
token = Auth::GithubToken.token
|
|
62
|
+
raise Error, "no GitHub token found - run `gh auth login`" if token.nil? || token.empty?
|
|
63
|
+
|
|
64
|
+
token
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def deployments(token)
|
|
68
|
+
query = URI.encode_www_form(environment: environment, per_page: WINDOW)
|
|
69
|
+
with_http { |http| get(http, token, "/repos/#{@repo}/deployments?#{query}") }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Pairs each deployment with its newest status, newest deployment first.
|
|
73
|
+
# A failed statuses call keeps its row with the error, for #snapshot to
|
|
74
|
+
# judge; a deployment with no status yet says nothing, so it is skipped.
|
|
75
|
+
def with_statuses(token, deployments)
|
|
76
|
+
threads = deployments.map do |deployment|
|
|
77
|
+
quiet_thread do
|
|
78
|
+
newest_status(token, deployment["id"])
|
|
79
|
+
rescue Error => e
|
|
80
|
+
e
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
deployments.zip(threads.map(&:value)).filter_map do |deployment, status|
|
|
84
|
+
next if status.nil?
|
|
85
|
+
|
|
86
|
+
row = {id: deployment["id"], sha: deployment["sha"], created_at: parse_time(deployment["created_at"])}
|
|
87
|
+
next row.merge(error: status) if status.is_a?(Error)
|
|
88
|
+
|
|
89
|
+
row.merge(state: status["state"], status_at: parse_time(status["created_at"]))
|
|
90
|
+
end.sort_by { |row| row[:created_at] || Time.at(0) }.reverse
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def newest_status(token, id)
|
|
94
|
+
with_http { |http| get(http, token, "/repos/#{@repo}/deployments/#{id}/statuses?per_page=1") }.first
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def deployed(row)
|
|
98
|
+
Deployed.new(sha: row[:sha], version: nil, description: "deployment #{row[:id]}",
|
|
99
|
+
deployed_at: row[:status_at])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def pending(newer)
|
|
103
|
+
row = newer.find { |candidate| IN_FLIGHT_STATES.include?(candidate[:state]) }
|
|
104
|
+
row && Pending.new(sha: row[:sha], started_at: row[:created_at])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def failed(newer)
|
|
108
|
+
row = newer.find { |candidate| FAILED_STATES.include?(candidate[:state]) }
|
|
109
|
+
row && Release.new(sha: row[:sha], version: nil, description: "deployment #{row[:id]}",
|
|
110
|
+
status: :failed, started_at: row[:status_at])
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def with_http(&)
|
|
114
|
+
Net::HTTP.start(HOST, 443, use_ssl: true, open_timeout: 5, read_timeout: 15, &)
|
|
115
|
+
rescue Net::OpenTimeout, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
116
|
+
raise Error, "GitHub API request failed: #{e.message}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def get(http, token, path)
|
|
120
|
+
request = Net::HTTP::Get.new(path)
|
|
121
|
+
request["Authorization"] = "Bearer #{token}"
|
|
122
|
+
request["Accept"] = "application/vnd.github+json"
|
|
123
|
+
request["X-GitHub-Api-Version"] = API_VERSION
|
|
124
|
+
response = http.request(request)
|
|
125
|
+
raise Error, error_message(response, path) unless response.is_a?(Net::HTTPSuccess)
|
|
126
|
+
|
|
127
|
+
JSON.parse(response.body)
|
|
128
|
+
rescue JSON::ParserError
|
|
129
|
+
raise Error, "GitHub API returned invalid JSON for #{path}"
|
|
130
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
131
|
+
raise Error, "GitHub API request failed: #{e.message}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def error_message(response, path)
|
|
135
|
+
case response.code.to_i
|
|
136
|
+
when 401 then "GitHub rejected the token (401) - run `gh auth login`"
|
|
137
|
+
when 404 then "GitHub repo not found or not visible to this token: #{@repo}"
|
|
138
|
+
when 403, 429
|
|
139
|
+
if response["x-ratelimit-remaining"] == "0"
|
|
140
|
+
"GitHub API rate limit reached - try again later"
|
|
141
|
+
else
|
|
142
|
+
"GitHub API returned #{response.code} for #{path}"
|
|
143
|
+
end
|
|
144
|
+
else "GitHub API returned #{response.code} for #{path}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def parse_time(value)
|
|
149
|
+
value && Time.iso8601(value)
|
|
150
|
+
rescue ArgumentError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
Platform.register("github_deployments", self)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -16,6 +16,12 @@ 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"
|
|
20
|
+
|
|
21
|
+
def initialize(config, repo: nil)
|
|
22
|
+
super
|
|
23
|
+
raise Error, NO_APP if app.nil?
|
|
24
|
+
end
|
|
19
25
|
|
|
20
26
|
def snapshot
|
|
21
27
|
token = resolve_token
|
|
@@ -36,13 +42,6 @@ module Onair
|
|
|
36
42
|
@config.app
|
|
37
43
|
end
|
|
38
44
|
|
|
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
45
|
def resolve_token
|
|
47
46
|
token = Auth::Netrc.token(HOST)
|
|
48
47
|
token = Auth::HerokuCli.token if token.nil? || token.empty?
|
|
@@ -138,7 +137,7 @@ module Onair
|
|
|
138
137
|
.sort_by { |type, _| [(type == "web") ? 0 : 1, type] }
|
|
139
138
|
.map { |type, dynos| process_rollout(type, dynos, version) }
|
|
140
139
|
Rollout.new(version: version, processes: processes,
|
|
141
|
-
overlap_until: preboot ? preboot_handoff(formation, version) : nil)
|
|
140
|
+
overlap_until: preboot ? preboot_handoff(formation, version) : nil, overlap_note: PREBOOT_NOTE)
|
|
142
141
|
end
|
|
143
142
|
|
|
144
143
|
def process_rollout(type, dynos, version)
|
data/lib/onair/renderer/json.rb
CHANGED
|
@@ -8,8 +8,9 @@ module Onair
|
|
|
8
8
|
# Machine-readable status. The schema is a public API documented in the
|
|
9
9
|
# README — additive changes only.
|
|
10
10
|
class Json
|
|
11
|
-
def initialize(report:, app:, platform:, branch:, repo:, task: nil)
|
|
11
|
+
def initialize(report:, app:, platform:, branch:, repo:, environment: nil, task: nil)
|
|
12
12
|
@report = report
|
|
13
|
+
@environment = environment
|
|
13
14
|
@app = app
|
|
14
15
|
@platform = platform
|
|
15
16
|
@branch = branch
|
|
@@ -27,6 +28,7 @@ module Onair
|
|
|
27
28
|
platform: @platform,
|
|
28
29
|
branch: @branch,
|
|
29
30
|
repo: @repo,
|
|
31
|
+
environment: @environment,
|
|
30
32
|
remote_head: @report.remote_head,
|
|
31
33
|
deployed: deployed_payload,
|
|
32
34
|
pending: pending_payload,
|
|
@@ -60,7 +62,8 @@ module Onair
|
|
|
60
62
|
pending = snapshot.pending
|
|
61
63
|
return nil if pending.nil?
|
|
62
64
|
|
|
63
|
-
{sha: pending.sha, started_at: iso(pending.started_at)}
|
|
65
|
+
{sha: pending.sha, build_id: pending.build_id, started_at: iso(pending.started_at)}
|
|
66
|
+
.merge(commit_fields(pending.sha))
|
|
64
67
|
end
|
|
65
68
|
|
|
66
69
|
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,28 +63,50 @@ 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?
|
|
67
|
+
|
|
68
|
+
label, color, note = release.version.nil? ? versionless_release(release) : versioned_release(release)
|
|
69
|
+
row_lines(label, color, release.sha, age(release.started_at), extra: paint(note, color)) + [""]
|
|
70
|
+
end
|
|
60
71
|
|
|
61
|
-
|
|
62
|
-
detail =
|
|
63
|
-
|
|
72
|
+
def versioned_release(release)
|
|
73
|
+
detail = version_label(release.version)
|
|
74
|
+
detail = [detail, release.description].compact.join(": ") if release.sha == snapshot.deployed&.sha
|
|
75
|
+
if release.status == :failed
|
|
64
76
|
["Failed:", :red, "✗ release phase failed (#{detail})"]
|
|
65
77
|
else
|
|
66
78
|
["Releasing:", :yellow, "⟳ release phase (#{detail})"]
|
|
67
79
|
end
|
|
68
|
-
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A platform that numbers no releases names the deploy by its description.
|
|
83
|
+
def versionless_release(release)
|
|
84
|
+
if release.status == :failed
|
|
85
|
+
["Failed:", :red, "✗ deploy failed (#{release.description})"]
|
|
86
|
+
else
|
|
87
|
+
["Releasing:", :yellow, "⟳ deploying (#{release.description})"]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A failed build whose commit the platform does not record.
|
|
92
|
+
def build_failed_lines(release)
|
|
93
|
+
commitless_row("Failed:", :red, release.description, age(release.started_at),
|
|
94
|
+
paint("✗ build failed", :red)) + [""]
|
|
69
95
|
end
|
|
70
96
|
|
|
71
97
|
def deployed_lines
|
|
72
98
|
deployed = snapshot.deployed
|
|
73
|
-
if deployed
|
|
99
|
+
return [" #{paint("No successful deploy recorded.", :yellow)}"] if deployed.nil?
|
|
100
|
+
|
|
101
|
+
if deployed.sha
|
|
74
102
|
lines = row_lines("Deployed:", :green, deployed.sha, age(deployed.deployed_at), extra: delta_text)
|
|
75
103
|
lines << rollout_line if @report.rollout
|
|
76
104
|
lines << pinned_line if @report.pinned
|
|
77
105
|
lines.concat(yours_lines)
|
|
78
106
|
lines
|
|
79
107
|
else
|
|
80
|
-
["
|
|
81
|
-
|
|
108
|
+
subject = ["the running commit", version_label(deployed.version)].compact.join(" for ")
|
|
109
|
+
[" #{paint("Could not resolve #{subject} (#{deployed.description}).", :yellow)}"]
|
|
82
110
|
end
|
|
83
111
|
end
|
|
84
112
|
|
|
@@ -86,13 +114,14 @@ module Onair
|
|
|
86
114
|
# traffic to the previous release, so that window gets its own line.
|
|
87
115
|
def rollout_line
|
|
88
116
|
rollout = @report.rollout
|
|
117
|
+
target = [version_label(rollout.version)].compact.map { |label| " #{label}" }.join
|
|
89
118
|
text, color = if rollout.complete?
|
|
90
|
-
["✓ rolled out
|
|
119
|
+
["✓ rolled out#{target}: #{process_counts(rollout)}", :dim]
|
|
91
120
|
elsif rollout.processes.all?(&:complete?)
|
|
92
|
-
["⟳
|
|
121
|
+
["⟳ #{rollout.overlap_note || "previous release may still serve"} for " \
|
|
93
122
|
"~#{remaining(rollout.overlap_until)} (estimate)", :yellow]
|
|
94
123
|
else
|
|
95
|
-
["⟳ rolling out
|
|
124
|
+
["⟳ rolling out#{target}: #{process_counts(rollout)}", :yellow]
|
|
96
125
|
end
|
|
97
126
|
" #{paint(text, color)}"
|
|
98
127
|
end
|
|
@@ -131,6 +160,17 @@ module Onair
|
|
|
131
160
|
[first, " #{paint("→", :dim)} #{linkify(subject, info ? sha : nil)}"]
|
|
132
161
|
end
|
|
133
162
|
|
|
163
|
+
# A row for a build with no commit: its name stands where the sha would,
|
|
164
|
+
# and there is no subject line to show.
|
|
165
|
+
def commitless_row(label, color_key, name, age, extra)
|
|
166
|
+
age_blurb = age ? " #{paint("(#{age})", :dim)}" : ""
|
|
167
|
+
[" #{paint(label.ljust(LABEL_WIDTH), color_key)} #{paint(name, :bold)}#{age_blurb} #{extra}"]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def version_label(version)
|
|
171
|
+
version && "v#{version}"
|
|
172
|
+
end
|
|
173
|
+
|
|
134
174
|
def delta_text
|
|
135
175
|
case @report.delta
|
|
136
176
|
when :current
|
|
@@ -143,7 +183,8 @@ module Onair
|
|
|
143
183
|
|
|
144
184
|
def pinned_line
|
|
145
185
|
deployed = snapshot.deployed
|
|
146
|
-
|
|
186
|
+
running = [version_label(deployed.version), "(#{deployed.description})"].compact.join(" ")
|
|
187
|
+
" #{paint("⏸ #{running} - newer build " \
|
|
147
188
|
"#{snapshot.latest_built_sha[0, 9]} succeeded but is not running", :yellow)}"
|
|
148
189
|
end
|
|
149
190
|
|
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,8 @@ 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"
|
|
67
|
+
require_relative "onair/platform/github_deployments"
|
|
57
68
|
require_relative "onair/renderer/tty"
|
|
58
69
|
require_relative "onair/renderer/json"
|
|
59
70
|
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eugene
|
|
@@ -31,6 +31,8 @@ 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
|
|
35
|
+
- lib/onair/platform/github_deployments.rb
|
|
34
36
|
- lib/onair/platform/heroku.rb
|
|
35
37
|
- lib/onair/remote_head.rb
|
|
36
38
|
- lib/onair/renderer/json.rb
|