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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1eb5f2eb79c1eb2da95ef69cb65f8bef60b72dc3d98563a6235573b4ae139fd6
4
- data.tar.gz: f5a38c5ef53340a88e41c9c007e68c34d1035d0427b953ac7b4193421201dde7
3
+ metadata.gz: 863a5cc9f6796e525c125ed320542a3e4d8b076b5a6a5660744c9136ead84cc2
4
+ data.tar.gz: c771ccb2bd0c72fcf075e48c829556de327ad4dc3b2527db4ca0b5b0ee763e34
5
5
  SHA512:
6
- metadata.gz: 133e4480b8e8c22835294d0c2b18e79f9ee6ba065ee080c7c5f0e0c61a1a8bf92aecc6ee2e6092bce8ba095ffe7937ce8b6cb6526ca20938ae3d3d54dc39062e
7
- data.tar.gz: b4e08f469025a62273c6c848b4d790e8b9a1520a6ce6ab24f0b845bcd7453cfa2040b1d40b3d0a29fe73c13e9c3d4242779022b0b2c43ba11c968f0e5ae74be5
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. Auth reuses the Heroku CLI's token from `~/.netrc`
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
- - `pending`: `{ "sha", "started_at", "subject", "author" }` when a build is
153
- in flight.
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: config.app, platform_label: adapter.display_name,
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
- attr_reader :platform, :app, :repo, :branch, :fetch, :task
11
+ DEFAULT_ENVIRONMENT = "production"
12
12
 
13
- def initialize(platform:, app:, repo:, branch:, fetch:, task: nil)
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
- app = flags[:app] || env["HEROKU_APP"] || file["app"]
25
- raise Error, "no app configured - pass --app NAME, set HEROKU_APP, or run `onair init`" if app.nil?
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: file["platform"] || "heroku",
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
 
@@ -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
- def initialize(config)
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)
@@ -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)}.merge(commit_fields(pending.sha))
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
@@ -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
- detail = "v#{release.version}"
62
- detail = "#{detail}: #{release.description}" if release.sha == snapshot.deployed&.sha
63
- label, color, note = if release.status == :failed
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
- row_lines(label, color, release.sha, age(release.started_at), extra: paint(note, color)) + [""]
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&.sha
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
- [" #{paint("Could not resolve the running commit for v#{deployed&.version} " \
81
- "(#{deployed&.description}).", :yellow)}"]
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 v#{rollout.version}: #{process_counts(rollout)}", :dim]
119
+ ["✓ rolled out#{target}: #{process_counts(rollout)}", :dim]
91
120
  elsif rollout.processes.all?(&:complete?)
92
- ["⟳ preboot: previous web dynos may still serve for " \
121
+ ["⟳ #{rollout.overlap_note || "previous release may still serve"} for " \
93
122
  "~#{remaining(rollout.overlap_until)} (estimate)", :yellow]
94
123
  else
95
- ["⟳ rolling out v#{rollout.version}: #{process_counts(rollout)}", :yellow]
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
- " #{paint("⏸ v#{deployed.version} (#{deployed.description}) - newer build " \
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
- !snapshot.release.nil? && snapshot.release.sha != snapshot.deployed&.sha
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Onair
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
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/description still render.
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
- Pending = Data.define(:sha, :started_at)
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
- Rollout = Data.define(:version, :processes, :overlap_until) do
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.3.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