atlas_rb 1.12.0 → 1.13.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/.version +1 -1
- data/CHANGELOG.md +36 -0
- data/Gemfile.lock +1 -1
- data/README.md +50 -0
- data/lib/atlas_rb/errors.rb +42 -0
- data/lib/atlas_rb/faraday_helper.rb +15 -0
- data/lib/atlas_rb/maintenance.rb +68 -0
- data/lib/atlas_rb/middleware/raise_on_read_only_mode.rb +54 -0
- data/lib/atlas_rb.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c55fceb9d85a211c8e28903ae67f1b8b98a9114c8ab403c63af68721bef76df4
|
|
4
|
+
data.tar.gz: 2b0684c399abf35c57bb6aac6def417f6996126b5afec576295d5d57cd5f72d2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 46728839d509e41136a4a87f774c8f343055b0fd219d45e04732c6ef95ba635c5f13715e52b7f6dfd700ffe149e59debce03b77bf748e2c03b94f837540d24ff
|
|
7
|
+
data.tar.gz: 3bcd0432f0eab0d5bba7d0f7e8da7d1f29b50a083631cfe357c00eaf1d6403ac526dcb9c3ca6ecd68d26b4459d64d7407aa3a2b926bf9585f433e316369cdcbc
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.13.0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.13.0
|
|
4
|
+
|
|
5
|
+
### Added — maintenance mode no longer passes through silently
|
|
6
|
+
|
|
7
|
+
Atlas can now hold a repository-wide read-only window, refusing every write
|
|
8
|
+
with `503` + `error: "read_only_mode"`. Before this release a `503` reached
|
|
9
|
+
neither `RaiseOnStaleResource` (409-only) nor `RaiseOnResourceError`
|
|
10
|
+
(403/422-only), and the body carries no `"work"` / `"collection"` key — so the
|
|
11
|
+
binding unwrapped `nil` and returned it. The write silently no-opped and the
|
|
12
|
+
caller's UI reported success. During a window that meant a librarian saving
|
|
13
|
+
metadata, seeing no error, and losing the edit.
|
|
14
|
+
|
|
15
|
+
`AtlasRb::Middleware::RaiseOnReadOnlyMode` now raises
|
|
16
|
+
`AtlasRb::ReadOnlyModeError` on that pair, on every path and all three
|
|
17
|
+
connection builders. It carries `#code` and `#retry_after` (from Atlas's
|
|
18
|
+
`Retry-After` header).
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
rescue AtlasRb::ReadOnlyModeError => e
|
|
22
|
+
e.retry_after # => 900
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
It is keyed on the discriminator as well as the status, so a bodyless `503`
|
|
26
|
+
from a reverse proxy while Atlas restarts still passes through untouched.
|
|
27
|
+
|
|
28
|
+
`AtlasRb::Maintenance.read` / `.write` read and set the flag. `.read` sits on
|
|
29
|
+
the authenticated read floor and is answered while the window is open, so a
|
|
30
|
+
client can always see the flag it is honouring; `.write` is system-gated.
|
|
31
|
+
|
|
32
|
+
A minor bump rather than a patch: a new exception can now raise where callers
|
|
33
|
+
previously got `nil`.
|
|
34
|
+
|
|
35
|
+
**This release requires Atlas 0.6.159 or newer** for the `/maintenance`
|
|
36
|
+
endpoints. The middleware is inert against an older Atlas, which never sends
|
|
37
|
+
the discriminator.
|
|
38
|
+
|
|
3
39
|
## 1.12.0
|
|
4
40
|
|
|
5
41
|
### Changed — index rows arrive flat
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -497,6 +497,56 @@ caller reads `tombstoned`. And the typed `403` / `422` translations above still
|
|
|
497
497
|
fire first, while `tombstone` / `destroy` / `complete` keep returning the raw
|
|
498
498
|
Faraday response for the caller to read.
|
|
499
499
|
|
|
500
|
+
### Maintenance mode (the read-only window)
|
|
501
|
+
|
|
502
|
+
Atlas can be put into a repository-wide read-only window: reads keep being
|
|
503
|
+
served, every write is refused, migrations run, the operator closes it. Three
|
|
504
|
+
doors open the same window — Cerberus's admin hub, Atlas's `maintenance:open`
|
|
505
|
+
rake task, and the deploy orchestrator.
|
|
506
|
+
|
|
507
|
+
Refused writes raise on **every** binding and **every** path:
|
|
508
|
+
|
|
509
|
+
```ruby
|
|
510
|
+
begin
|
|
511
|
+
AtlasRb::Work.update("w-789", metadata)
|
|
512
|
+
rescue AtlasRb::ReadOnlyModeError => e
|
|
513
|
+
e.retry_after # => 900 (seconds, from Atlas's Retry-After header)
|
|
514
|
+
e.message # => "Atlas is in maintenance mode; writes are refused"
|
|
515
|
+
end
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
This is deliberately not `ForbiddenError`. Atlas answers `503`, not `403`,
|
|
519
|
+
because a 403 is a statement about the caller's rights — during a migration
|
|
520
|
+
window the caller's rights are fine, the repository is closed.
|
|
521
|
+
|
|
522
|
+
Read the flag to render a banner before a caller trips it. The read is answered
|
|
523
|
+
even while the window is open, so a client can always see the flag it honours:
|
|
524
|
+
|
|
525
|
+
```ruby
|
|
526
|
+
window = AtlasRb::Maintenance.read
|
|
527
|
+
window["read_only"] # => true
|
|
528
|
+
window["source"] # => "deploy" / "operator"
|
|
529
|
+
window["message"] # => "Scheduled maintenance until 10:00"
|
|
530
|
+
window["retry_after"] # => 900
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Opening and closing is system-gated:
|
|
534
|
+
|
|
535
|
+
```ruby
|
|
536
|
+
AtlasRb::Maintenance.write(read_only: true, source: "deploy")
|
|
537
|
+
AtlasRb::Maintenance.write(read_only: false, source: "deploy")
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
`source` carries one rule: a `"deploy"` close is refused when an `"operator"`
|
|
541
|
+
opened the window, so a deploy that finishes cannot close a window a human
|
|
542
|
+
opened by hand. The refusal is not an error — Atlas answers `200` with the
|
|
543
|
+
*unchanged* state — so read `read_only` off the return value rather than
|
|
544
|
+
assuming the write took. An `"operator"` close clears either.
|
|
545
|
+
|
|
546
|
+
A `503` from a reverse proxy while Atlas restarts carries no JSON body and is
|
|
547
|
+
a different condition with a different remedy, so it passes through as a plain
|
|
548
|
+
response rather than raising.
|
|
549
|
+
|
|
500
550
|
## End-to-end example
|
|
501
551
|
|
|
502
552
|
JSON responses come back as `AtlasRb::Mash` (a `Hashie::Mash` subclass), so
|
data/lib/atlas_rb/errors.rb
CHANGED
|
@@ -378,6 +378,48 @@ module AtlasRb
|
|
|
378
378
|
# a caller that only wants "the write failed" can rescue the parent.
|
|
379
379
|
class NotFoundError < ResourceError; end
|
|
380
380
|
|
|
381
|
+
# Raised when Atlas refuses a write because the repository-wide maintenance
|
|
382
|
+
# window is open: an HTTP `503` whose body carries
|
|
383
|
+
# `error: "read_only_mode"`.
|
|
384
|
+
#
|
|
385
|
+
# Deliberately NOT {ForbiddenError}. Atlas could have refused with a 403, but
|
|
386
|
+
# a 403 is a statement about the caller's rights, and Cerberus renders
|
|
387
|
+
# {ForbiddenError} as a permission-denied page. During a migration window that
|
|
388
|
+
# is a lie — the librarian's rights are fine, the repository is closed.
|
|
389
|
+
#
|
|
390
|
+
# Without this the failure mode is worse than an error: a 503 reaches neither
|
|
391
|
+
# {Middleware::RaiseOnStaleResource} (409-only) nor
|
|
392
|
+
# {Middleware::RaiseOnResourceError} (403/422-only), and the response body
|
|
393
|
+
# carries no `"work"` / `"collection"` key, so the binding unwraps `nil` and
|
|
394
|
+
# returns it. The write silently no-ops and the UI reports success — a
|
|
395
|
+
# librarian saves metadata, sees no error, and loses the edit.
|
|
396
|
+
#
|
|
397
|
+
# rescue AtlasRb::ReadOnlyModeError => e
|
|
398
|
+
# flash.now[:alert] = e.message
|
|
399
|
+
# response.headers["Retry-After"] = e.retry_after.to_s if e.retry_after
|
|
400
|
+
#
|
|
401
|
+
# Read `GET /maintenance` ({AtlasRb::Maintenance.read}) to render a banner
|
|
402
|
+
# before a caller trips this; that endpoint stays answerable while the window
|
|
403
|
+
# is open, precisely so a client can see the flag it is honouring.
|
|
404
|
+
class ReadOnlyModeError < Error
|
|
405
|
+
# @return [String, nil] the envelope's `error` discriminator
|
|
406
|
+
# (`"read_only_mode"`).
|
|
407
|
+
attr_reader :code
|
|
408
|
+
|
|
409
|
+
# @return [Integer, nil] seconds to wait before retrying, from Atlas's
|
|
410
|
+
# `Retry-After` response header.
|
|
411
|
+
attr_reader :retry_after
|
|
412
|
+
|
|
413
|
+
# @param message [String, nil] human-readable refusal description.
|
|
414
|
+
# @param code [String, nil] the envelope's `error` discriminator.
|
|
415
|
+
# @param retry_after [Integer, nil] seconds from the `Retry-After` header.
|
|
416
|
+
def initialize(message=nil, code: nil, retry_after: nil)
|
|
417
|
+
@code = code
|
|
418
|
+
@retry_after = retry_after
|
|
419
|
+
super(message || "Atlas is in maintenance mode; writes are refused")
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
381
423
|
# Raised when the transport has no way to authenticate a relay request:
|
|
382
424
|
# neither `ATLAS_JWT` (BYO-JWT mode) nor a signing key
|
|
383
425
|
# ({AtlasRb.config#assertion_signing_key}, relay-signing mode) is configured.
|
|
@@ -109,6 +109,11 @@ module AtlasRb
|
|
|
109
109
|
instrument(f)
|
|
110
110
|
f.use AtlasRb::Middleware::RaiseOnStaleResource
|
|
111
111
|
f.use AtlasRb::Middleware::RaiseOnResourceError
|
|
112
|
+
# Path-independent, unlike the pair above: a maintenance window refuses
|
|
113
|
+
# writes on EVERY path, and a 503 reaches neither of them. Registered on
|
|
114
|
+
# all three connection builders — a write that slips past it silently
|
|
115
|
+
# unwraps nil and reports success.
|
|
116
|
+
f.use AtlasRb::Middleware::RaiseOnReadOnlyMode
|
|
112
117
|
f.response :follow_redirects
|
|
113
118
|
f.adapter Faraday.default_adapter
|
|
114
119
|
end
|
|
@@ -153,6 +158,11 @@ module AtlasRb
|
|
|
153
158
|
# unsupported_digest_algorithm) into a typed FixityMismatchError —
|
|
154
159
|
# the JSON-connection path already carries this; uploads need it too.
|
|
155
160
|
f.use AtlasRb::Middleware::RaiseOnResourceError
|
|
161
|
+
# Path-independent, unlike the pair above: a maintenance window refuses
|
|
162
|
+
# writes on EVERY path, and a 503 reaches neither of them. Registered on
|
|
163
|
+
# all three connection builders — a write that slips past it silently
|
|
164
|
+
# unwraps nil and reports success.
|
|
165
|
+
f.use AtlasRb::Middleware::RaiseOnReadOnlyMode
|
|
156
166
|
f.request :multipart
|
|
157
167
|
f.request :url_encoded
|
|
158
168
|
end
|
|
@@ -223,6 +233,11 @@ module AtlasRb
|
|
|
223
233
|
# System::Token, and gives System::Work the same typed errors as #connection.
|
|
224
234
|
f.use AtlasRb::Middleware::RaiseOnStaleResource
|
|
225
235
|
f.use AtlasRb::Middleware::RaiseOnResourceError
|
|
236
|
+
# Path-independent, unlike the pair above: a maintenance window refuses
|
|
237
|
+
# writes on EVERY path, and a 503 reaches neither of them. Registered on
|
|
238
|
+
# all three connection builders — a write that slips past it silently
|
|
239
|
+
# unwraps nil and reports success.
|
|
240
|
+
f.use AtlasRb::Middleware::RaiseOnReadOnlyMode
|
|
226
241
|
f.response :follow_redirects
|
|
227
242
|
f.adapter Faraday.default_adapter
|
|
228
243
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtlasRb
|
|
4
|
+
# The repository-wide read-only window (`GET` / `PUT /maintenance`).
|
|
5
|
+
#
|
|
6
|
+
# An operator opens the window, Atlas keeps serving reads and refuses every
|
|
7
|
+
# write with a `503` carrying `error: "read_only_mode"`, migrations run, the
|
|
8
|
+
# operator closes it. Three doors open the same window: Cerberus's admin hub,
|
|
9
|
+
# Atlas's `maintenance:open` rake task, and the deploy orchestrator.
|
|
10
|
+
#
|
|
11
|
+
# ## Why the flag lives in Atlas
|
|
12
|
+
#
|
|
13
|
+
# A window has to hold across both apps. A Cerberus-held flag is bypassed by
|
|
14
|
+
# any direct API caller, including a personal-access token minted by
|
|
15
|
+
# `POST /nuid`, so the flag is a row in Atlas and the floor that enforces it
|
|
16
|
+
# is Atlas's own authorization layer.
|
|
17
|
+
#
|
|
18
|
+
# ## Reading versus writing
|
|
19
|
+
#
|
|
20
|
+
# {.read} sits on Atlas's authenticated read floor and is answered even while
|
|
21
|
+
# the window is open — a client that could not read the flag could not honour
|
|
22
|
+
# it. {.write} is `:system`-gated, matching how the token endpoints gate an
|
|
23
|
+
# operator action, so it goes out over {FaradayHelper#system_connection}.
|
|
24
|
+
#
|
|
25
|
+
# Refused writes surface as {AtlasRb::ReadOnlyModeError} via
|
|
26
|
+
# {Middleware::RaiseOnReadOnlyMode}, on every binding and every path. Poll
|
|
27
|
+
# {.read} to render a banner before a caller trips that.
|
|
28
|
+
class Maintenance
|
|
29
|
+
extend AtlasRb::FaradayHelper
|
|
30
|
+
|
|
31
|
+
# Read the window's state (`GET /maintenance`).
|
|
32
|
+
#
|
|
33
|
+
# Cheap and safe to poll behind a short-TTL cache; Cerberus renders its
|
|
34
|
+
# banner and its client-side write gate from this.
|
|
35
|
+
#
|
|
36
|
+
# @return [AtlasRb::Mash] `read_only` (Boolean), `source`
|
|
37
|
+
# (`"operator"` / `"deploy"` / nil), `since` (ISO-8601 or nil), `message`
|
|
38
|
+
# (String or nil), and `retry_after` (Integer seconds).
|
|
39
|
+
def self.read
|
|
40
|
+
AtlasRb::Mash.new(JSON.parse(connection({}).get("/maintenance").body))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Open or close the window (`PUT /maintenance`). System-gated in Atlas.
|
|
44
|
+
#
|
|
45
|
+
# `source` names which door is acting, and Atlas enforces one rule with it:
|
|
46
|
+
# a `"deploy"` close is refused when an `"operator"` opened the window, so a
|
|
47
|
+
# deploy that finishes cannot close a window a human opened by hand. The
|
|
48
|
+
# refusal is not an error — Atlas answers `200` with the *unchanged* state,
|
|
49
|
+
# so read `read_only` off the return value rather than assuming the write
|
|
50
|
+
# took. An `"operator"` close clears either.
|
|
51
|
+
#
|
|
52
|
+
# @param read_only [Boolean] true to open the window, false to close it.
|
|
53
|
+
# @param source [String] `"operator"` (a human at the hub or the console) or
|
|
54
|
+
# `"deploy"` (the deploy orchestrator).
|
|
55
|
+
# @param message [String, nil] operator note for the client-side banner.
|
|
56
|
+
# @param retry_after [Integer, nil] seconds a refused caller should wait;
|
|
57
|
+
# Atlas mirrors it into the `Retry-After` header on every refusal.
|
|
58
|
+
# @return [AtlasRb::Mash] the window's state after the write — which is the
|
|
59
|
+
# state *before* it when a deploy close was refused.
|
|
60
|
+
def self.write(read_only:, source:, message: nil, retry_after: nil)
|
|
61
|
+
body = { read_only: read_only, source: source }
|
|
62
|
+
body[:message] = message if message
|
|
63
|
+
body[:retry_after] = retry_after if retry_after
|
|
64
|
+
|
|
65
|
+
AtlasRb::Mash.new(JSON.parse(system_connection.put("/maintenance", body.to_json).body))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtlasRb
|
|
4
|
+
module Middleware
|
|
5
|
+
# Translates Atlas's maintenance-window refusal into a typed Ruby exception.
|
|
6
|
+
#
|
|
7
|
+
# Atlas refuses every write-shaped action while the repository-wide
|
|
8
|
+
# read-only window is open, answering `503 Service Unavailable` with the
|
|
9
|
+
# discriminator `error: "read_only_mode"` and a `Retry-After` header. This
|
|
10
|
+
# middleware keys on the **status + discriminator pair** and raises
|
|
11
|
+
# {AtlasRb::ReadOnlyModeError}.
|
|
12
|
+
#
|
|
13
|
+
# ## Why path-independent
|
|
14
|
+
#
|
|
15
|
+
# Modelled on {RaiseOnStaleResource}, not {RaiseOnResourceError}. A
|
|
16
|
+
# maintenance window refuses writes on *every* path, so a path-keyed rule
|
|
17
|
+
# would have to enumerate the whole write surface and would go stale the
|
|
18
|
+
# moment Atlas grows an endpoint.
|
|
19
|
+
#
|
|
20
|
+
# ## Why the discriminator matters
|
|
21
|
+
#
|
|
22
|
+
# A 503 from a reverse proxy while Atlas restarts carries no JSON body. That
|
|
23
|
+
# is a different condition with a different remedy, and it keeps passing
|
|
24
|
+
# through untouched — only Atlas's own envelope raises.
|
|
25
|
+
class RaiseOnReadOnlyMode < Faraday::Middleware
|
|
26
|
+
# @param env [Faraday::Env] the completed response environment.
|
|
27
|
+
# @raise [AtlasRb::ReadOnlyModeError] on a 503 whose body carries
|
|
28
|
+
# `error: "read_only_mode"`.
|
|
29
|
+
# @return [void]
|
|
30
|
+
def on_complete(env)
|
|
31
|
+
return unless env.status == 503
|
|
32
|
+
|
|
33
|
+
body = parse_json(env.body)
|
|
34
|
+
return unless body.is_a?(Hash) && body["error"] == "read_only_mode"
|
|
35
|
+
|
|
36
|
+
raise AtlasRb::ReadOnlyModeError.new(
|
|
37
|
+
body["message"],
|
|
38
|
+
code: body["error"],
|
|
39
|
+
retry_after: env.response_headers&.[]("retry-after")&.to_i
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def parse_json(body)
|
|
46
|
+
return body if body.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
JSON.parse(body.to_s)
|
|
49
|
+
rescue JSON::ParserError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/atlas_rb.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative "atlas_rb/errors"
|
|
|
11
11
|
require_relative "atlas_rb/configuration"
|
|
12
12
|
require_relative "atlas_rb/middleware/raise_on_stale_resource"
|
|
13
13
|
require_relative "atlas_rb/middleware/raise_on_resource_error"
|
|
14
|
+
require_relative "atlas_rb/middleware/raise_on_read_only_mode"
|
|
14
15
|
require_relative "atlas_rb/faraday_helper"
|
|
15
16
|
require_relative "atlas_rb/mash"
|
|
16
17
|
require_relative "atlas_rb/authentication"
|
|
@@ -33,6 +34,7 @@ require_relative "atlas_rb/system/user"
|
|
|
33
34
|
require_relative "atlas_rb/system/token"
|
|
34
35
|
require_relative "atlas_rb/system/work"
|
|
35
36
|
require_relative "atlas_rb/audit_event"
|
|
37
|
+
require_relative "atlas_rb/maintenance"
|
|
36
38
|
|
|
37
39
|
# Ruby client for the Atlas API — Northeastern University's institutional
|
|
38
40
|
# digital repository.
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: atlas_rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.13.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Cliff
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -144,7 +144,9 @@ files:
|
|
|
144
144
|
- lib/atlas_rb/errors.rb
|
|
145
145
|
- lib/atlas_rb/faraday_helper.rb
|
|
146
146
|
- lib/atlas_rb/file_set.rb
|
|
147
|
+
- lib/atlas_rb/maintenance.rb
|
|
147
148
|
- lib/atlas_rb/mash.rb
|
|
149
|
+
- lib/atlas_rb/middleware/raise_on_read_only_mode.rb
|
|
148
150
|
- lib/atlas_rb/middleware/raise_on_resource_error.rb
|
|
149
151
|
- lib/atlas_rb/middleware/raise_on_stale_resource.rb
|
|
150
152
|
- lib/atlas_rb/person.rb
|