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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7340207635a0bb6db21f36e716000082d7d67fa3d62603388656bfe6d829c81
4
- data.tar.gz: fbf68dbb0590ce6d05c3f75f6f879bb392dc45dcca488503b3c19ee57b823cb2
3
+ metadata.gz: c55fceb9d85a211c8e28903ae67f1b8b98a9114c8ab403c63af68721bef76df4
4
+ data.tar.gz: 2b0684c399abf35c57bb6aac6def417f6996126b5afec576295d5d57cd5f72d2
5
5
  SHA512:
6
- metadata.gz: 1d07bb66ff2c4c7bf8c5f243fc01afe244e83d1bc53e9734ac22ea7b41990104c538ac53520cf47f4aa55195499ee6a9e0ed81c06fec2afad33b0f4076990bb5
7
- data.tar.gz: 46bb9c90c94d5f5e2a3501693e2802ae6dfb84ccc4c5c68c5c99ee34e9c2b86203f20867a94251801f9d28e6d66387779e5f5a48ba3fb25439b257d208b3bbb0
6
+ metadata.gz: 46728839d509e41136a4a87f774c8f343055b0fd219d45e04732c6ef95ba635c5f13715e52b7f6dfd700ffe149e59debce03b77bf748e2c03b94f837540d24ff
7
+ data.tar.gz: 3bcd0432f0eab0d5bba7d0f7e8da7d1f29b50a083631cfe357c00eaf1d6403ac526dcb9c3ca6ecd68d26b4459d64d7407aa3a2b926bf9585f433e316369cdcbc
data/.version CHANGED
@@ -1 +1 @@
1
- 1.12.0
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.12.0)
4
+ atlas_rb (1.13.0)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
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
@@ -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.12.0
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-24 00:00:00.000000000 Z
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