onair-cli 0.2.0 → 0.4.0

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