cru-flags 0.1.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 +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +30 -0
- data/README.md +35 -0
- data/docs/design.md +535 -0
- data/lib/cru_flags/client.rb +308 -0
- data/lib/cru_flags/document.rb +41 -0
- data/lib/cru_flags/errors.rb +21 -0
- data/lib/cru_flags/fetcher.rb +92 -0
- data/lib/cru_flags/flipper_adapter.rb +62 -0
- data/lib/cru_flags/railtie.rb +42 -0
- data/lib/cru_flags/version.rb +5 -0
- data/lib/cru_flags.rb +65 -0
- metadata +88 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ddbe75a3b8dd51184b5c884571040346e4f33e96bd0301e7dc845a290b8bb10b
|
|
4
|
+
data.tar.gz: c3bf794d63aaae3a521f9916bd5c00d0c620bdc363b1aa08fd850a3b31a1c136
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9415e6972df83cb2c45d4a7b7de065ad99a47f884f507a057cc7550c96b1c9f4143be31eca001826d34393e0304ebaea4808a39580ec924df6f337a466296f82
|
|
7
|
+
data.tar.gz: f2f12796650212518e932577bc789f1bbf25746fbe2aea574654e7ddad8e37f91badfdb73879f46d9dd871a1a556bc477b364d20aa54b195ba749112224277a3
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-08-24)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* the cru-flags client — sibling contract + Flipper adapter ([#1](https://github.com/CruGlobal/cru-flags-ruby/issues/1)) ([02dfa26](https://github.com/CruGlobal/cru-flags-ruby/commit/02dfa264a68c48ccdce6055893aef5fe50fe8473))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Miscellaneous Chores
|
|
12
|
+
|
|
13
|
+
* release 0.1.0 ([2ed0e67](https://github.com/CruGlobal/cru-flags-ruby/commit/2ed0e67e0ce0441d80ba8d1dc6559a9d8995de17))
|
|
14
|
+
|
|
15
|
+
## Changelog
|
data/LICENSE
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Cru
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
10
|
+
this list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from this
|
|
18
|
+
software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
23
|
+
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
24
|
+
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
25
|
+
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
26
|
+
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
27
|
+
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
28
|
+
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
29
|
+
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
30
|
+
POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# cru-flags
|
|
2
|
+
|
|
3
|
+
> **Status: AI-generated, not actively maintained by a human team.** This
|
|
4
|
+
> library was authored primarily by an AI assistant against the specification
|
|
5
|
+
> in [docs/design.md](docs/design.md), reviewed and operated by the Cru Rails
|
|
6
|
+
> team. Read the design doc before changing behavior — drift between the
|
|
7
|
+
> design doc and the code is a bug, and the doc wins.
|
|
8
|
+
|
|
9
|
+
The official Ruby client for Cru's pipeline feature-flag service, and the
|
|
10
|
+
third sibling of [cru-flags-node](https://github.com/CruGlobal/cru-flags-node)
|
|
11
|
+
and [cru-flags-python](https://github.com/CruGlobal/cru-flags-python) — plus a
|
|
12
|
+
read-only Flipper adapter so the Rails fleet's existing `Flipper.enabled?`
|
|
13
|
+
call sites work unchanged.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# In a Rails app: add the gem, done. The Railtie wires Flipper for you.
|
|
17
|
+
Flipper.enabled?(:checkout_v2)
|
|
18
|
+
|
|
19
|
+
# Anywhere else:
|
|
20
|
+
require "cru_flags"
|
|
21
|
+
CruFlags.enabled?("checkout_v2")
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Configuration is one environment variable: `CRU_FLAGS_URL`, the full document
|
|
25
|
+
URL (`https://deploys.cru.org/flags/<project>/<environment>`). Unset means
|
|
26
|
+
every flag is off and nothing starts — no thread, no socket, no warning.
|
|
27
|
+
|
|
28
|
+
Semantics in one paragraph: the client polls the document in a background
|
|
29
|
+
thread (30s ± 20% jitter, 2s timeouts, conditional GETs via ETag) and answers
|
|
30
|
+
reads from a frozen in-memory snapshot. **Fail-static**: all flags are off
|
|
31
|
+
until the first successful fetch; after that, an outage means flags stop
|
|
32
|
+
changing — never that they turn off. Reads never raise and never do I/O.
|
|
33
|
+
Health transitions (and only transitions) are logged.
|
|
34
|
+
|
|
35
|
+
CRuby >= 3.2 only. See [docs/design.md](docs/design.md) for everything else.
|
data/docs/design.md
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
# cru-flags-ruby — Design Document
|
|
2
|
+
|
|
3
|
+
> ADL-35. Drafted 2026-08-21 against cru-flags-python's docs/design.md (the
|
|
4
|
+
> most complete sibling spec) and the recon in adl-35-cru-flags-ruby-recon.md.
|
|
5
|
+
> When the CruGlobal/cru-flags-ruby repo is created this file moves in as
|
|
6
|
+
> `docs/design.md` and inherits the siblings' rule: drift between the design
|
|
7
|
+
> doc and the code is a bug, and the doc wins.
|
|
8
|
+
|
|
9
|
+
## 1. Purpose & Scope
|
|
10
|
+
|
|
11
|
+
`cru-flags` is the official Ruby client for Cru's pipeline feature-flag
|
|
12
|
+
service, and the third sibling of `@cruglobal/flags` (Node) and `cru-flags`
|
|
13
|
+
(Python). It answers exactly one question, as cheaply and as safely as
|
|
14
|
+
possible:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
Flipper.enabled?(:checkout_v2) # the fleet's existing call sites, unchanged
|
|
18
|
+
CruFlags.enabled?("checkout_v2") # the sibling-parity API, same answer
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The flag service publishes one JSON document per (project, environment) at a
|
|
22
|
+
public HTTP endpoint. This library polls that document in the background and
|
|
23
|
+
answers reads from an in-memory snapshot. It is a **read-only client**: flags
|
|
24
|
+
are authored elsewhere (cru-cli / the deploys.cru.org dashboard), never by
|
|
25
|
+
this library.
|
|
26
|
+
|
|
27
|
+
The Ruby client differs from its siblings in one deliberate way: it ships a
|
|
28
|
+
**Flipper adapter** that answers Flipper's read API from the same in-memory
|
|
29
|
+
snapshot. The ~20 Rails apps already call `Flipper.enabled?` (ADL-9's
|
|
30
|
+
ratified standard; ararat's hand-rolled `flipper.rb` is the current
|
|
31
|
+
reference), so the gem keeps that API working while replacing everything
|
|
32
|
+
underneath it. What it replaces, and why, is §4.
|
|
33
|
+
|
|
34
|
+
### In scope for v0.x
|
|
35
|
+
|
|
36
|
+
- Read `CRU_FLAGS_URL` from the environment; poll that URL in a background
|
|
37
|
+
thread; answer `enabled?(name)` from the last document received.
|
|
38
|
+
- Conditional requests (`If-None-Match` / `ETag`) so steady-state polling
|
|
39
|
+
costs one 304 per interval.
|
|
40
|
+
- An opt-in `refresh_mode: "on-demand"` that drops the thread and refreshes
|
|
41
|
+
synchronously on the reading thread, for runtimes that freeze between
|
|
42
|
+
requests (§7.1) — included for contract parity with the siblings.
|
|
43
|
+
- **Fail-static** semantics: a flag lookup never raises, never blocks (in
|
|
44
|
+
background mode), and never changes answer because the network broke.
|
|
45
|
+
- A read-only `CruFlags::FlipperAdapter` over the snapshot, plus a Railtie
|
|
46
|
+
that wires it into Flipper with zero app code.
|
|
47
|
+
- Stdlib-only client core (`net/http`, `json`, `thread`); Ruby >= 3.2.
|
|
48
|
+
|
|
49
|
+
### Out of scope
|
|
50
|
+
|
|
51
|
+
- **Writing** flags, or any authentication. The flag documents are public,
|
|
52
|
+
read-only URLs.
|
|
53
|
+
- Per-actor / group / percentage targeting, variants, or non-boolean flag
|
|
54
|
+
values. The service models a flag as a boolean with metadata; so does this
|
|
55
|
+
client. The Flipper adapter exposes **only the boolean gate**.
|
|
56
|
+
- Local flag overrides, file-based sources, streaming, or a daemon/sidecar.
|
|
57
|
+
- The service's `/flipper/{project}/{env}/features` protocol endpoints. Stock
|
|
58
|
+
Flipper's HTTP adapter needed them; ours reads the snapshot, so the
|
|
59
|
+
collection/single-feature asymmetries documented in the recon simply never
|
|
60
|
+
come into play.
|
|
61
|
+
|
|
62
|
+
### Non-goals
|
|
63
|
+
|
|
64
|
+
- We do **not** try to be a general feature-flag SDK. There is one document
|
|
65
|
+
shape, defined by our own service, and this client is allowed to know it.
|
|
66
|
+
- We do **not** raise on misconfiguration. An application missing
|
|
67
|
+
`CRU_FLAGS_URL` must still boot and serve traffic, with every flag off —
|
|
68
|
+
exactly ararat's current Memory-adapter fallback posture.
|
|
69
|
+
- We do **not** cache in `Rails.cache`. The snapshot is process-local memory;
|
|
70
|
+
there is no TTL, no Marshal round-trip, and therefore no TTL cliff and no
|
|
71
|
+
default_proc trap (§4).
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 2. Wire contract
|
|
76
|
+
|
|
77
|
+
Identical to the siblings'. The live document (a real response from
|
|
78
|
+
`https://deploys.cru.org/flags/ararat/release-candidate`):
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"Project": "ararat",
|
|
83
|
+
"Environment": "release-candidate",
|
|
84
|
+
"Version": 3,
|
|
85
|
+
"NotifySlack": true,
|
|
86
|
+
"Flags": {
|
|
87
|
+
"pilot_banner": {
|
|
88
|
+
"Enabled": true,
|
|
89
|
+
"Description": "Pilot: flag-gated banner proving the flag service end-to-end (ararat#198)",
|
|
90
|
+
"CreatedAt": "2026-07-31T14:09:01.119Z",
|
|
91
|
+
"UpdatedAt": "2026-07-31T14:09:08.777Z",
|
|
92
|
+
"UpdatedBy": "Omicron7"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Load-bearing properties:
|
|
99
|
+
|
|
100
|
+
| Property | Contract |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| Top level | JSON object with `Project`, `Environment`, `Version`, `NotifySlack`, `Flags`. PascalCase keys. |
|
|
103
|
+
| `Flags` | Object keyed by flag name. Each value is an object with at least `Enabled: bool`; other keys are metadata. |
|
|
104
|
+
| Unknown keys | **Additive.** New top-level and per-flag keys may appear at any time; the client ignores what it does not understand and never validates beyond "is a JSON object". |
|
|
105
|
+
| `ETag` | Present on `200`, currently the stringified `Version`. Opaque to the client — store and echo it, never parse it. |
|
|
106
|
+
| `304 Not Modified` | Returned when `If-None-Match` matches. Body empty; the previous snapshot stays in force. |
|
|
107
|
+
| `404 Not Found` | A *valid* answer meaning "no document exists yet", not a failure. |
|
|
108
|
+
| `400 Bad Request` | The URL names an environment that does not exist (only `release-candidate` and `production` are real). This *is* a failure: the caller's configuration is wrong and someone should see it. |
|
|
109
|
+
|
|
110
|
+
`enabled?(name)` is defined precisely as:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
document.dig("Flags", name, "Enabled") == true
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The `== true` identity check (not truthiness) is deliberate: a flag whose
|
|
117
|
+
`Enabled` is the *string* `"true"`, or `1`, or `null` is a malformed
|
|
118
|
+
document, and the safe reading of a malformed document is "off".
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## 3. Public API
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
CruFlags.enabled?("checkout_v2") # -> bool, never raises
|
|
126
|
+
CruFlags.ready(timeout: 3.0) # -> bool, never raises
|
|
127
|
+
CruFlags.snapshot # -> Hash, plain JSON-serializable deep copy
|
|
128
|
+
CruFlags.refresh(force: false) # -> bool, refresh now on this thread
|
|
129
|
+
CruFlags.close # -> nil, stop refreshing (mostly for tests)
|
|
130
|
+
CruFlags.flipper_adapter # -> the read-only Flipper adapter (§5)
|
|
131
|
+
|
|
132
|
+
CruFlags::Client.new(
|
|
133
|
+
url: nil, # nil -> read CRU_FLAGS_URL on first use
|
|
134
|
+
poll_seconds: 30.0, # refresh interval, ±20% jitter
|
|
135
|
+
fetch_timeout: 2.0, # per-request open AND read timeout
|
|
136
|
+
on_error: nil, # nil -> log to the "cru_flags" logger (§3.6)
|
|
137
|
+
refresh_mode: nil # nil -> read CRU_FLAGS_REFRESH_MODE, else "background"
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### 3.1 `CruFlags` module methods
|
|
142
|
+
|
|
143
|
+
The module methods delegate to a lazily-constructed process-wide
|
|
144
|
+
`CruFlags::Client` singleton — the 99% path, mirroring the siblings'
|
|
145
|
+
module-level `flags`. Requiring the gem does **nothing**: no environment
|
|
146
|
+
read, no socket, no thread (§7). The singleton is constructed on first use,
|
|
147
|
+
so tests and dotenv can set `CRU_FLAGS_URL` any time before the first lookup.
|
|
148
|
+
|
|
149
|
+
### 3.2 `enabled?(name) -> bool`
|
|
150
|
+
|
|
151
|
+
Non-blocking read of the current snapshot (in on-demand mode it may refresh
|
|
152
|
+
first; §7.1). **Never raises** — the entire body is rescued so no bug in this
|
|
153
|
+
library, no malformed document, and no shutdown race can take down a caller's
|
|
154
|
+
request path. Anything unexpected answers `false`. Accepts a String or
|
|
155
|
+
Symbol; Symbols are converted with `to_s` (the document's keys are strings;
|
|
156
|
+
Flipper call sites pass symbols).
|
|
157
|
+
|
|
158
|
+
Unknown flag, unset flag, no document yet, no `CRU_FLAGS_URL`, service down
|
|
159
|
+
before the first success — all `false`.
|
|
160
|
+
|
|
161
|
+
### 3.3 `ready(timeout: nil) -> bool`
|
|
162
|
+
|
|
163
|
+
Blocks until the **first fetch attempt completes** — success *or* failure —
|
|
164
|
+
and returns whether it completed within `timeout`. Returns `true` after a
|
|
165
|
+
failed first attempt (the attempt did complete, and "all off" is the final
|
|
166
|
+
answer until the next poll). Returns `false` immediately, without blocking,
|
|
167
|
+
when the client is inert (no URL) — there is no attempt to wait for and never
|
|
168
|
+
will be. In on-demand mode `ready` *performs* the first attempt like any
|
|
169
|
+
other read, so `timeout:` is unused there — the call is bounded by the fetch
|
|
170
|
+
timeouts (up to ~2s by default) regardless of a smaller `timeout:` value,
|
|
171
|
+
exactly as the python client documents. Never raises.
|
|
172
|
+
|
|
173
|
+
### 3.4 `snapshot -> Hash`
|
|
174
|
+
|
|
175
|
+
A plain, deep-copied, JSON-serializable Hash of the last document received
|
|
176
|
+
(`{}` before the first success). `JSON.generate(CruFlags.snapshot)`
|
|
177
|
+
round-trips to exactly what the service sent. The copy exists so a caller
|
|
178
|
+
cannot mutate library state; the internally stored snapshot is genuinely
|
|
179
|
+
frozen (§6).
|
|
180
|
+
|
|
181
|
+
### 3.5 `refresh(force: false) -> bool`
|
|
182
|
+
|
|
183
|
+
Fetches on the *calling* thread and returns whether the snapshot is fresh (an
|
|
184
|
+
attempt has completed and the most recent one succeeded). Without `force` it
|
|
185
|
+
is a no-op while the last attempt is younger than `poll_seconds`. Blocks for
|
|
186
|
+
at most the fetch timeouts. Never raises. This is the refresh primitive in
|
|
187
|
+
on-demand mode, and an out-of-band poke in background mode — where, per the
|
|
188
|
+
Node contract, concurrent `refresh` calls and a concurrent poller tick all
|
|
189
|
+
**share one in-flight request** rather than stacking simultaneous fetches.
|
|
190
|
+
|
|
191
|
+
### 3.6 `on_error`
|
|
192
|
+
|
|
193
|
+
A callable invoked **only on health transitions**:
|
|
194
|
+
|
|
195
|
+
- ok → failing: called with the exception that broke the poll.
|
|
196
|
+
- failing → ok: called with `nil` (recovery).
|
|
197
|
+
|
|
198
|
+
Never called per-poll while a failure persists, and never for `304` or `404`.
|
|
199
|
+
The default logs both transitions at WARN — to `Rails.logger` when Rails is
|
|
200
|
+
loaded, else to a `Logger` on `$stderr` tagged `cru_flags`. This is the
|
|
201
|
+
structural replacement for ararat's `LoggedFlagFetches` delegator: the two
|
|
202
|
+
2026-07-31 outages that motivated it were invisible precisely because
|
|
203
|
+
failures were swallowed silently; transition logging makes an outage exactly
|
|
204
|
+
two log lines (one down, one up) instead of zero or thousands. Exceptions
|
|
205
|
+
raised *by* `on_error` are swallowed: a broken error handler must not kill
|
|
206
|
+
the poller.
|
|
207
|
+
|
|
208
|
+
### 3.7 `close`
|
|
209
|
+
|
|
210
|
+
Stops refreshing, in either mode. Terminal: a closed client never refreshes
|
|
211
|
+
again, and closing before the first lookup leaves the client permanently
|
|
212
|
+
inert. The last snapshot stays readable. Mostly useful in tests.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## 4. Why fail-static (and what this replaces)
|
|
217
|
+
|
|
218
|
+
The design constraint is inverted relative to a normal HTTP client: **the
|
|
219
|
+
flag client is allowed to be wrong, but never allowed to be loud, slow, or
|
|
220
|
+
fatal.**
|
|
221
|
+
|
|
222
|
+
1. **`enabled?` performs no I/O** in the default background mode: one ivar
|
|
223
|
+
read and two Hash lookups. No lock on the read path, so a slow network can
|
|
224
|
+
never become a slow request. It also performs the fork/liveness guard (a
|
|
225
|
+
`Process.pid` check plus `Thread#alive?`) that lazily re-arms a dead or
|
|
226
|
+
post-fork poller, still lock-free and network-free — the guard only takes
|
|
227
|
+
a lock on the (rare) path where it actually needs to respawn.
|
|
228
|
+
2. **All flags are `false` until the first successful fetch.** A flag guards
|
|
229
|
+
*new* behaviour; the safe answer while ignorant is the old behaviour.
|
|
230
|
+
3. **Last-known-good persists indefinitely. There is no TTL.** This is the
|
|
231
|
+
failure-mode semantic §6 of the fleet's original flags design doc (the
|
|
232
|
+
Google-Doc-era spec, snapshotted as cru-flags-design-doc.md) promised and
|
|
233
|
+
the current fleet recipe does not deliver: ararat composes `Flipper::Adapters::Failsafe` outside a
|
|
234
|
+
30-second `ActiveSupportCacheStore`, so 30 seconds into any flag-service
|
|
235
|
+
outage every flag snaps to `false` — a synchronised, fleet-wide behaviour
|
|
236
|
+
change caused by exactly the outage a flag client should ride through.
|
|
237
|
+
Holding the last deliberately-published configuration is the whole reason
|
|
238
|
+
this gem exists. Freshness is a monitoring question (the transition log,
|
|
239
|
+
`ready`, `snapshot["Version"]`), never a silent behaviour flip.
|
|
240
|
+
4. **404 is data, not an error.** "This project has no flags yet" is the
|
|
241
|
+
normal state of every project on day one.
|
|
242
|
+
5. **Nothing raises.** `enabled?` and `ready` rescue `StandardError` —
|
|
243
|
+
`Interrupt`, `SystemExit`, and other non-StandardError exceptions still
|
|
244
|
+
propagate.
|
|
245
|
+
|
|
246
|
+
The stock-Flipper traps this architecture retires by construction (all
|
|
247
|
+
verified live in the recon):
|
|
248
|
+
|
|
249
|
+
- Flipper's Rails engine installs a **Memoizer middleware that preloads
|
|
250
|
+
`get_all` on every request** — with a network adapter underneath, Failsafe
|
|
251
|
+
is load-bearing and any gap 500s the whole app. Our adapter's `get_all` is
|
|
252
|
+
a frozen-Hash read; the preload becomes free and Failsafe has nothing to
|
|
253
|
+
guard.
|
|
254
|
+
- `Flipper::Adapters::Http` must be given `max_retries: 0` **explicitly**
|
|
255
|
+
(its internal default never applies) and leaves open/read timeouts at
|
|
256
|
+
Net::HTTP's ~60s unless set. Our HTTP layer sets 2s/2s explicitly and never
|
|
257
|
+
sits on a request path.
|
|
258
|
+
- The `default_proc` that Marshal — and therefore any Marshal-backed
|
|
259
|
+
`Rails.cache` — cannot serialize (ararat's `MarshalableFlipperReads`
|
|
260
|
+
incident) is added by `Flipper::Adapters::Memoizable#get_all`
|
|
261
|
+
(memoizable.rb:103 in 1.4.2), **which wraps ANY adapter the DSL is given,
|
|
262
|
+
including ours** — not by the Http adapter as ararat's comments suggest.
|
|
263
|
+
With no `Rails.cache` layer nothing ever Marshals the result, so the trap
|
|
264
|
+
is gone — but Memoizable's mutation constrains our adapter's return values;
|
|
265
|
+
see the hard rule in §5.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## 5. The Flipper adapter
|
|
270
|
+
|
|
271
|
+
`CruFlags::FlipperAdapter` — a read-only adapter over the client's frozen
|
|
272
|
+
snapshot, so every existing `Flipper.enabled?(:name)` call site works
|
|
273
|
+
unchanged.
|
|
274
|
+
|
|
275
|
+
- `features` → the Set of flag names in the current document.
|
|
276
|
+
- `get(feature)` / `get_multi` / `get_all` → gate-value hashes shaped by
|
|
277
|
+
`Flipper::Adapter.default_config` (which in 1.4 includes the `expression`
|
|
278
|
+
gate key alongside boolean/actors/groups/percentages), with **only the
|
|
279
|
+
boolean gate** populated: `true` when the flag's `Enabled == true`, off
|
|
280
|
+
otherwise. The service is boolean-only.
|
|
281
|
+
- **HARD RULE: `get`, `get_multi`, and `get_all` must return freshly-built,
|
|
282
|
+
UNFROZEN top-level Hashes on every call** (their nested gate values may
|
|
283
|
+
reference frozen snapshot data; the top-level containers may not be
|
|
284
|
+
frozen and may not be memoized/shared). Flipper's DSL wraps every adapter
|
|
285
|
+
in `Adapters::Memoizable` by default, and its `get_all` **mutates the
|
|
286
|
+
returned Hash in place** (`response.default_proc = ...`,
|
|
287
|
+
memoizable.rb:103) on every Memoizer-preloaded request — a frozen or
|
|
288
|
+
shared return value raises `FrozenError` on every request in production,
|
|
289
|
+
a stricter recurrence of the very failure mode §4 retires. The suite
|
|
290
|
+
pins this with a test that calls the adapter through
|
|
291
|
+
`Flipper::Adapters::Memoizable` under memoization.
|
|
292
|
+
- `add` / `remove` / `clear` / `enable` / `disable` →
|
|
293
|
+
raise `CruFlags::ReadOnlyError`. The CLI and dashboard are the only
|
|
294
|
+
writers; this preserves the behavior of ararat's
|
|
295
|
+
`Flipper::Adapters::ReadOnly` wrapper (whose own error class is
|
|
296
|
+
`ReadOnly::WriteAttempted`) without another wrapper layer.
|
|
297
|
+
- The adapter holds no state of its own and takes no locks — it reads
|
|
298
|
+
whatever frozen document the client currently publishes, so a Memoizer
|
|
299
|
+
preload, a console `Flipper.enabled?`, and a background job all see the
|
|
300
|
+
same atomically-swapped snapshot.
|
|
301
|
+
|
|
302
|
+
### 5.1 Railtie
|
|
303
|
+
|
|
304
|
+
`require "cru_flags"` in a Rails app (via the Gemfile) installs a Railtie
|
|
305
|
+
that, in an initializer:
|
|
306
|
+
|
|
307
|
+
- registers the adapter via `Flipper.configure { |c| c.adapter { CruFlags.flipper_adapter } }`
|
|
308
|
+
in an ordinary initializer — safe against ordering because Flipper's engine
|
|
309
|
+
resolves `config.adapter` LAZILY on first `Flipper.instance` access (its
|
|
310
|
+
`flipper.default` block calls it at invocation time, not at boot), and the
|
|
311
|
+
last `AdapterBuilder#store` wins, so an app's own `Flipper.configure` in
|
|
312
|
+
`config/initializers` still overrides ours;
|
|
313
|
+
- quiets strict mode for the no-flag-document case, **but not with a naive
|
|
314
|
+
"unless the app set it" check** — Flipper's engine populates
|
|
315
|
+
`config.flipper.strict` in `config.before_configuration` (`:warn` in
|
|
316
|
+
development, `false` in production), which runs before ANY gem initializer,
|
|
317
|
+
so the value is always already set when we look. Instead the Railtie sets
|
|
318
|
+
`config.flipper.strict = false` only when `ENV["FLIPPER_STRICT"].nil?` AND
|
|
319
|
+
the current value equals Flipper's own computed default for this Rails env —
|
|
320
|
+
i.e. only when we can prove nobody chose it. `FLIPPER_STRICT=warn` (or any
|
|
321
|
+
app-set value) is always honored. Rationale: local dev and brand-new
|
|
322
|
+
projects legitimately have no flag document, and `Flipper.add` is never
|
|
323
|
+
called by app code (flags are born in the service), so dev-mode `:warn`
|
|
324
|
+
would fire on every check.
|
|
325
|
+
|
|
326
|
+
With `CRU_FLAGS_URL` unset the client is inert, the snapshot is empty, and
|
|
327
|
+
every flag reads `false` — byte-for-byte ararat's current Memory-adapter
|
|
328
|
+
fallback, with zero configuration branching. An app that wants different
|
|
329
|
+
wiring (a test adapter, Flipper UI in development) configures Flipper itself;
|
|
330
|
+
the Railtie only supplies the default.
|
|
331
|
+
|
|
332
|
+
Non-Rails Ruby (plain scripts, sidekiq-less workers) skips the Railtie and
|
|
333
|
+
uses `CruFlags.enabled?` directly.
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## 6. Snapshot storage & atomicity
|
|
338
|
+
|
|
339
|
+
The parsed document is **deep-frozen** before it is published — via
|
|
340
|
+
`JSON.parse(body, freeze: true)` (json >= 2.4, which also interns repeated
|
|
341
|
+
strings) rather than a hand-rolled recursive walk. The frozen tree is then
|
|
342
|
+
assigned to a single instance variable — one atomic reference swap — while
|
|
343
|
+
holding a mutex that only *writers* contend for. Readers never take the lock.
|
|
344
|
+
|
|
345
|
+
The lock-free read depends on CRuby's GVL making a single reference store
|
|
346
|
+
observable atomically; **the gem supports CRuby only** (the entire fleet is
|
|
347
|
+
CRuby; JRuby/TruffleRuby have no GVL and would need a read barrier we have no
|
|
348
|
+
consumer for). The gemspec/README say so.
|
|
349
|
+
|
|
350
|
+
Why frozen rather than "a hash we promise not to mutate": the snapshot is
|
|
351
|
+
shared across every thread in the process, and one accidental
|
|
352
|
+
`snapshot["Flags"]["x"]["Enabled"] = true` in caller code would be an
|
|
353
|
+
un-debuggable, cross-thread, non-reproducible bug. Freezing makes it a
|
|
354
|
+
`FrozenError` at the point of the mistake. `CruFlags.snapshot` hands back a
|
|
355
|
+
thawed deep copy so the public API stays ordinary Ruby.
|
|
356
|
+
|
|
357
|
+
Consistency guarantee: a reader sees either the whole old document or the
|
|
358
|
+
whole new one, never a half-applied update.
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## 7. Threading model
|
|
363
|
+
|
|
364
|
+
- **One background Thread per active client**, `Thread#name` set to
|
|
365
|
+
`"cru-flags-poller"`, `abort_on_exception` left false, `report_on_exception`
|
|
366
|
+
set false (the loop rescues everything; a teardown race must not spam
|
|
367
|
+
stderr).
|
|
368
|
+
- **Started lazily** on the first `enabled?` / `ready` call, never at
|
|
369
|
+
require. Requiring a gem must not start threads: it breaks preloading
|
|
370
|
+
forking servers (Puma workers, Spring) that require before forking, and
|
|
371
|
+
makes `require "cru_flags"` in a test suite a side-effecting act. **After
|
|
372
|
+
`fork`, the child re-starts its own poller lazily on first read** — the
|
|
373
|
+
client detects a PID change and discards the parent's dead thread state
|
|
374
|
+
(Puma's `fork_worker` and Spring both make this a first-class case in
|
|
375
|
+
Rails, where the siblings could treat forking as exotic).
|
|
376
|
+
- **Never delays exit.** Ruby threads other than the main thread die with
|
|
377
|
+
the process — no daemon flag needed — but the loop also sleeps
|
|
378
|
+
interruptibly (a `Queue#pop` with timeout / `ConditionVariable#wait`), so
|
|
379
|
+
`close` returns promptly instead of waiting out a poll interval.
|
|
380
|
+
- **Jitter**: each sleep is `poll_seconds * rand(0.8..1.2)`. Fleet pods boot
|
|
381
|
+
together; without jitter they synchronise into a thundering herd against
|
|
382
|
+
the flag service. ±20% de-phases them within a couple of intervals.
|
|
383
|
+
- **Inert clients start nothing.** No `CRU_FLAGS_URL` and no explicit `url:`
|
|
384
|
+
means no thread, no socket, no warning — just `false`.
|
|
385
|
+
|
|
386
|
+
### 7.1 On-demand refresh (`refresh_mode: "on-demand"`)
|
|
387
|
+
|
|
388
|
+
Contract-identical to the siblings' mode (python design §5.1): no poller
|
|
389
|
+
thread at all; every read fetches first when the last **attempt** is
|
|
390
|
+
`poll_seconds` or older, else answers from cache. Concurrent readers coalesce
|
|
391
|
+
on one mutex — a burst of N readers is one request. Staleness anchors on the
|
|
392
|
+
last attempt, not the last success, so a dead service costs one failed
|
|
393
|
+
request per interval, not one per read. No jitter (real traffic de-phases
|
|
394
|
+
itself). The cost is the read-path guarantee: `enabled?` can block up to the
|
|
395
|
+
fetch timeouts, once per interval.
|
|
396
|
+
|
|
397
|
+
Selecting it: `Client.new(refresh_mode: "on-demand")` or
|
|
398
|
+
`CRU_FLAGS_REFRESH_MODE=on-demand`. The constructor argument wins. An
|
|
399
|
+
unrecognised *environment* value warns through `on_error` and falls back to
|
|
400
|
+
background (misconfiguration must never stop boot); an unrecognised
|
|
401
|
+
*constructor* argument raises `ArgumentError` (that is a typo in code).
|
|
402
|
+
|
|
403
|
+
Why it ships at all: the Rails fleet is long-running ECS and will use
|
|
404
|
+
background mode everywhere — but the three clients advertise one contract,
|
|
405
|
+
the mode is ~100 lines plus ported tests, and the first Cloud-Run-shaped
|
|
406
|
+
Ruby consumer gets it for free instead of forking the gem.
|
|
407
|
+
|
|
408
|
+
### 7.2 Environment resolution
|
|
409
|
+
|
|
410
|
+
`CRU_FLAGS_URL` and `CRU_FLAGS_REFRESH_MODE` are read once, at first use, and
|
|
411
|
+
cached. Empty or whitespace-only counts as unset. Only `http`/`https` URLs
|
|
412
|
+
are accepted; anything else makes the client inert with one warning through
|
|
413
|
+
`on_error`.
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## 8. Fetch algorithm
|
|
418
|
+
|
|
419
|
+
One tick:
|
|
420
|
+
|
|
421
|
+
1. Build a `Net::HTTP::Get` with `Accept: application/json`, a `User-Agent`
|
|
422
|
+
of `cru-flags-ruby/<version>`, and `If-None-Match: <etag>` when an ETag is
|
|
423
|
+
stored.
|
|
424
|
+
2. One-shot `Net::HTTP.start(host, port, use_ssl:, open_timeout: t,
|
|
425
|
+
read_timeout: t, write_timeout: t)` — no keep-alive, no connection
|
|
426
|
+
pooling; the right trade for one request per 30 seconds. **No retries
|
|
427
|
+
within a tick** — the next tick is the retry. Redirects are followed to a
|
|
428
|
+
fixed limit of **3** (Net::HTTP does not follow them itself; the siblings
|
|
429
|
+
inherit auto-follow from `fetch`/`urllib`). Ruby-first hardening the
|
|
430
|
+
siblings may want to adopt: each redirect hop's merged URI is
|
|
431
|
+
re-validated (http/https scheme, non-empty host) before it is followed,
|
|
432
|
+
and the response body is capped at `MAX_BODY_BYTES` (1 MiB) before
|
|
433
|
+
parsing, both failing the tick rather than handing an attacker-controlled
|
|
434
|
+
value to `Net::HTTP` or `JSON.parse`.
|
|
435
|
+
3. Outcomes:
|
|
436
|
+
|
|
437
|
+
| Outcome | Action | Health |
|
|
438
|
+
| --- | --- | --- |
|
|
439
|
+
| `200` + JSON object | Freeze and publish; **then** store `ETag` | ok |
|
|
440
|
+
| `304` | Keep snapshot and ETag | ok |
|
|
441
|
+
| `404` | Publish empty snapshot; clear ETag | ok |
|
|
442
|
+
| Other status (`400`, `5xx`, …) | Keep snapshot | failing |
|
|
443
|
+
| Timeout / DNS / connection error | Keep snapshot | failing |
|
|
444
|
+
| Body not JSON, or not a JSON object | Keep snapshot | failing |
|
|
445
|
+
|
|
446
|
+
Document-before-ETag ordering is load-bearing (inherited from the Node
|
|
447
|
+
client): a body that fails to parse must never get its ETag stored, or
|
|
448
|
+
the client would pin itself to the bad response via endless 304s.
|
|
449
|
+
|
|
450
|
+
4. Report the health transition (§3.6), if any.
|
|
451
|
+
|
|
452
|
+
Parsing is lenient exactly like the siblings, with the Node client's §6.1
|
|
453
|
+
rules as the reference: missing/`null` `Flags` becomes `{}`, but `Flags`
|
|
454
|
+
**present with the wrong type** (a string, number, or array) fails the whole
|
|
455
|
+
document (health → failing) — leniency is for absence, not for malformation.
|
|
456
|
+
Individual non-Hash flag *entries* are dropped without failing the document;
|
|
457
|
+
all unknown keys are preserved verbatim. (§2's "never validates beyond is-a-
|
|
458
|
+
JSON-object" is scoped by these rules, same as the siblings' docs.)
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
## 9. Testing strategy
|
|
463
|
+
|
|
464
|
+
Every line of the behavioural contract is a test, in the python client's
|
|
465
|
+
organisation: contract tests grouped by design-doc section, against **a real
|
|
466
|
+
local HTTP server** (a minimal programmable responder on a stdlib
|
|
467
|
+
`TCPServer` at `127.0.0.1:0` — WEBrick is no longer a default gem and the
|
|
468
|
+
responder needs ~60 lines), recording inbound headers so the ETag echo, 304,
|
|
469
|
+
404, 500, slow-response, and malformed-body paths exercise real `Net::HTTP`
|
|
470
|
+
behaviour rather than a mock's idea of it.
|
|
471
|
+
|
|
472
|
+
Plus, ported from the siblings and ararat:
|
|
473
|
+
|
|
474
|
+
- Subprocess tests: `require "cru_flags"` starts no threads even with
|
|
475
|
+
`CRU_FLAGS_URL` set; a running poller never delays interpreter exit.
|
|
476
|
+
- A fork test: parent warms the client, child forks, child's reads work and
|
|
477
|
+
spawn the child's own poller (§7's PID-change case).
|
|
478
|
+
- Jitter distribution; a concurrency hammer on `enabled?` across snapshot
|
|
479
|
+
swaps; `JSON.generate`/`parse` round-trip of `snapshot`.
|
|
480
|
+
- Flipper conformance: `Flipper.new(CruFlags.flipper_adapter)` answering
|
|
481
|
+
`enabled?` end-to-end against the local server, the Memoizer preload path,
|
|
482
|
+
writes raising `ReadOnlyError`, and — the regression ararat paid for —
|
|
483
|
+
`Marshal.dump(Rails.cache-shaped round trip)` of everything `get_all`
|
|
484
|
+
returns.
|
|
485
|
+
- On-demand mode: its own section (coalescing, one-fetch-per-interval while
|
|
486
|
+
down, no thread).
|
|
487
|
+
- `test/verify_live.rb`, opt-in and networked, against the real public
|
|
488
|
+
endpoint (document parses; second conditional fetch is 304). Not in CI.
|
|
489
|
+
|
|
490
|
+
Framework: minitest (the fleet standard), `filterwarnings`-equivalent
|
|
491
|
+
strictness via `Warning[:deprecated] = true` and a test that the suite emits
|
|
492
|
+
no warnings. CI: one required check running lint (standardrb, the fleet
|
|
493
|
+
direction per AP-7) + the suite on the `.tool-versions` Ruby, plus a matrix
|
|
494
|
+
re-running the suite on 3.2 / 3.3 / 3.4.
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
## 10. Packaging & distribution
|
|
499
|
+
|
|
500
|
+
- Gem `cru-flags`, require path `cru_flags`, module `CruFlags`, repo
|
|
501
|
+
`CruGlobal/cru-flags-ruby`. Version lives in `lib/cru_flags/version.rb`,
|
|
502
|
+
bumped by release-please (`release-type: ruby` **with an explicit
|
|
503
|
+
`"version-file": "lib/cru_flags/version.rb"`** — release-please's default
|
|
504
|
+
path derivation turns the hyphenated name into `lib/cru/flags/version.rb`
|
|
505
|
+
and would silently bump a file that doesn't exist).
|
|
506
|
+
- `required_ruby_version = ">= 3.2"` — the fleet floor.
|
|
507
|
+
- Runtime dependency: **`flipper` (`~> 1.4`) only.** The client core uses
|
|
508
|
+
stdlib exclusively; the Flipper adapter and Railtie are the gem's reason to
|
|
509
|
+
have its one dependency, and every consumer already carries it. (If a
|
|
510
|
+
flipper-less consumer ever appears, splitting a zero-dep core gem out is a
|
|
511
|
+
follow-up, not a v0 concern.)
|
|
512
|
+
- Publishing via **RubyGems Trusted Publishing** (OIDC) from
|
|
513
|
+
`.github/workflows/release.yml` — the PyPI pattern; no API key in the
|
|
514
|
+
repo. First publish uses RubyGems' pending-publisher flow since the gem
|
|
515
|
+
name is unclaimed.
|
|
516
|
+
- Repo onboarding matches the fleet standard from day one: security-only
|
|
517
|
+
`dependabot.yml` (bundler + github-actions lanes per the ADL-32 template),
|
|
518
|
+
a `dependabot-auto-merge` gate is NOT installed (review-tier repo), README
|
|
519
|
+
status banner and CLAUDE.md same-PR-doc rule copied from the siblings.
|
|
520
|
+
|
|
521
|
+
---
|
|
522
|
+
|
|
523
|
+
## 11. Rollout (out of scope for the gem, recorded for sequencing)
|
|
524
|
+
|
|
525
|
+
1. Gem ships and publishes v0.1.0.
|
|
526
|
+
2. ararat cuts over: Gemfile swap, delete `config/initializers/flipper.rb`
|
|
527
|
+
and its two regression tests (their scenarios live in the gem's suite
|
|
528
|
+
now). ararat is the proving ground exactly as it was for the hand-rolled
|
|
529
|
+
version.
|
|
530
|
+
3. devops-claude-skills onboarding skill Stage 4 changes from "copy ararat's
|
|
531
|
+
flipper.rb" to "add the cru-flags gem".
|
|
532
|
+
4. The remaining fleet picks it up as apps onboard to v2 (no mass retrofit —
|
|
533
|
+
only ararat and clm are on v2 today).
|
|
534
|
+
|
|
535
|
+
Each step is its own PR with its own review; none blocks the gem.
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "logger"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module CruFlags
|
|
8
|
+
# The polling flag client (design doc §3–§8). One instance per process in
|
|
9
|
+
# normal use, via the CruFlags module singleton.
|
|
10
|
+
class Client
|
|
11
|
+
VALID_SCHEMES = %w[http https].freeze
|
|
12
|
+
MODES = %w[background on-demand].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(url: nil, poll_seconds: 30.0, fetch_timeout: 2.0,
|
|
15
|
+
on_error: nil, refresh_mode: nil)
|
|
16
|
+
@poll_seconds = positive(poll_seconds, 30.0)
|
|
17
|
+
@fetch_timeout = positive(fetch_timeout, 2.0)
|
|
18
|
+
@on_error = on_error || default_on_error
|
|
19
|
+
@refresh_mode = resolve_mode(refresh_mode)
|
|
20
|
+
@url = url
|
|
21
|
+
@document = nil
|
|
22
|
+
@etag = nil
|
|
23
|
+
@healthy = true
|
|
24
|
+
@attempted = false
|
|
25
|
+
@last_attempt_at = nil
|
|
26
|
+
@last_attempt_ok = false
|
|
27
|
+
@closed = false
|
|
28
|
+
@write_mutex = Mutex.new
|
|
29
|
+
@fetch_mutex = Mutex.new
|
|
30
|
+
@ready_mutex = Mutex.new
|
|
31
|
+
@ready_cv = ConditionVariable.new
|
|
32
|
+
@wake = Queue.new
|
|
33
|
+
@thread = nil
|
|
34
|
+
@pid = nil
|
|
35
|
+
validate_url!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inert? = @url.nil?
|
|
39
|
+
|
|
40
|
+
def enabled?(name)
|
|
41
|
+
read_path_touch
|
|
42
|
+
doc = @document
|
|
43
|
+
doc&.dig("Flags", name.to_s, "Enabled") == true
|
|
44
|
+
rescue
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def snapshot
|
|
49
|
+
read_path_touch
|
|
50
|
+
doc = @document
|
|
51
|
+
doc ? JSON.parse(JSON.generate(doc)) : {}
|
|
52
|
+
rescue
|
|
53
|
+
{}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The Flipper adapter's read primitive: the frozen Flags hash (or {}),
|
|
57
|
+
# through the same read path as enabled? (lazy start / on-demand
|
|
58
|
+
# refresh).
|
|
59
|
+
def flags_for_adapter
|
|
60
|
+
read_path_touch
|
|
61
|
+
@document&.fetch("Flags", nil) || {}
|
|
62
|
+
rescue
|
|
63
|
+
{}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Blocks until the first fetch attempt completes (success or failure) and
|
|
67
|
+
# returns whether it did within timeout. Immediately false for inert
|
|
68
|
+
# clients (design doc §3.3, §7).
|
|
69
|
+
def ready(timeout: nil)
|
|
70
|
+
return false if inert? || (@closed && !@attempted)
|
|
71
|
+
ensure_started
|
|
72
|
+
return on_demand_ready if on_demand?
|
|
73
|
+
@ready_mutex.synchronize do
|
|
74
|
+
deadline = timeout && Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
75
|
+
until @attempted
|
|
76
|
+
ensure_started # self-heals a poller that died mid-wait, rather than waiting on it forever
|
|
77
|
+
remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
78
|
+
return false if remaining && remaining <= 0
|
|
79
|
+
@ready_cv.wait(@ready_mutex, remaining || 1.0)
|
|
80
|
+
return false if @closed && !@attempted
|
|
81
|
+
end
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
rescue
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def refresh(force: false)
|
|
89
|
+
return false if inert? || @closed
|
|
90
|
+
waited_from = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
91
|
+
begin
|
|
92
|
+
@fetch_mutex.synchronize do
|
|
93
|
+
if force
|
|
94
|
+
attempt_fetch unless @last_attempt_at && @last_attempt_at >= waited_from
|
|
95
|
+
elsif stale?
|
|
96
|
+
attempt_fetch
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
ensure
|
|
100
|
+
signal_ready
|
|
101
|
+
end
|
|
102
|
+
@attempted && @last_attempt_ok
|
|
103
|
+
rescue
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def close
|
|
108
|
+
@closed = true
|
|
109
|
+
@wake.push(:stop)
|
|
110
|
+
@ready_mutex.synchronize { @ready_cv.broadcast }
|
|
111
|
+
begin
|
|
112
|
+
@thread&.join(0.1)
|
|
113
|
+
rescue
|
|
114
|
+
nil # close called from the poller thread itself must not raise ThreadError
|
|
115
|
+
end
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# The pre-read hook shared by enabled?/snapshot/flags_for_adapter: lazily
|
|
122
|
+
# arms the background poller (a no-op once running) and, in on-demand
|
|
123
|
+
# mode, performs the coalesced fetch before the read.
|
|
124
|
+
def read_path_touch
|
|
125
|
+
ensure_started
|
|
126
|
+
refresh_on_demand
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# refresh_mode resolution (design doc §7.1, §7.2): constructor argument
|
|
130
|
+
# wins over ENV["CRU_FLAGS_REFRESH_MODE"], which wins over the
|
|
131
|
+
# "background" default. An unrecognized constructor value is a caller
|
|
132
|
+
# typo -> ArgumentError. An unrecognized ENV value is misconfiguration
|
|
133
|
+
# that must never stop boot -> one on_error warning, then background.
|
|
134
|
+
# @on_error is already assigned by the time this runs, so the warning
|
|
135
|
+
# can be reported inline.
|
|
136
|
+
def resolve_mode(arg)
|
|
137
|
+
if arg
|
|
138
|
+
raise ArgumentError, "refresh_mode must be one of #{MODES.join(", ")}" unless MODES.include?(arg)
|
|
139
|
+
return arg
|
|
140
|
+
end
|
|
141
|
+
env = ENV["CRU_FLAGS_REFRESH_MODE"].to_s.strip
|
|
142
|
+
return "background" if env.empty?
|
|
143
|
+
return env if MODES.include?(env)
|
|
144
|
+
report(FetchError.new("CRU_FLAGS_REFRESH_MODE=#{env.inspect} is not recognized; using background",
|
|
145
|
+
code: :network))
|
|
146
|
+
"background"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# On-demand mode has no poller thread at all (design doc §7.1).
|
|
150
|
+
def on_demand?
|
|
151
|
+
@refresh_mode == "on-demand"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The on-demand read-path hook: every enabled?/snapshot/ready call
|
|
155
|
+
# attempts a coalesced, attempt-anchored fetch before reading. A no-op
|
|
156
|
+
# in background mode, for inert clients, and once closed.
|
|
157
|
+
def refresh_on_demand
|
|
158
|
+
return unless on_demand?
|
|
159
|
+
return if inert? || @closed
|
|
160
|
+
begin
|
|
161
|
+
attempt_fetch_coalesced(force: false)
|
|
162
|
+
ensure
|
|
163
|
+
signal_ready # deliberate: kept for symmetry with the background poller's signal, even though on-demand's own ready() never waits on this CV
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# ready in on-demand mode performs the read-path refresh itself rather
|
|
168
|
+
# than waiting on the ready CV (there is no poller to signal it); the
|
|
169
|
+
# timeout: argument is unused there, bounded instead by the fetch
|
|
170
|
+
# timeouts (design doc §3.3, §7.1).
|
|
171
|
+
def on_demand_ready
|
|
172
|
+
refresh_on_demand
|
|
173
|
+
@attempted
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Lazily arms the background poller on the first enabled?/ready/snapshot
|
|
177
|
+
# call. A no-op for inert, closed, or on-demand clients. Self-healing: a
|
|
178
|
+
# dead poller (killed by an unanticipated bug) is detected via
|
|
179
|
+
# Thread#alive? and respawned rather than leaving flags frozen forever.
|
|
180
|
+
# Fork-safe: a PID change discards the dead parent thread's state
|
|
181
|
+
# (including the ready CV, whose wait queue may reference parent
|
|
182
|
+
# threads) so the child re-arms its own poller on its own next read
|
|
183
|
+
# (design doc §7).
|
|
184
|
+
def ensure_started
|
|
185
|
+
return if inert? || @closed || on_demand?
|
|
186
|
+
return if @thread&.alive? && Process.pid == @pid
|
|
187
|
+
@write_mutex.synchronize do
|
|
188
|
+
return if @closed
|
|
189
|
+
if @thread && Process.pid != @pid
|
|
190
|
+
@thread = nil
|
|
191
|
+
@wake = Queue.new
|
|
192
|
+
@ready_mutex = Mutex.new
|
|
193
|
+
@ready_cv = ConditionVariable.new
|
|
194
|
+
end
|
|
195
|
+
spawn_poller unless @thread&.alive?
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def spawn_poller
|
|
200
|
+
@pid = Process.pid
|
|
201
|
+
@thread = Thread.new do
|
|
202
|
+
Thread.current.name = "cru-flags-poller"
|
|
203
|
+
Thread.current.report_on_exception = false
|
|
204
|
+
until @closed
|
|
205
|
+
begin
|
|
206
|
+
attempt_fetch_coalesced(force: true)
|
|
207
|
+
rescue
|
|
208
|
+
nil # a bug here must not silently kill the poller (design doc §7)
|
|
209
|
+
ensure
|
|
210
|
+
signal_ready
|
|
211
|
+
end
|
|
212
|
+
begin
|
|
213
|
+
@wake.pop(timeout: @poll_seconds * rand(0.8..1.2))
|
|
214
|
+
rescue
|
|
215
|
+
# Non-spinning: a broken wake queue must not turn into a busy
|
|
216
|
+
# loop hammering the flag service.
|
|
217
|
+
sleep(@poll_seconds)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def attempt_fetch_coalesced(force:)
|
|
224
|
+
@fetch_mutex.synchronize do
|
|
225
|
+
attempt_fetch if force || stale?
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def signal_ready
|
|
230
|
+
return unless @attempted
|
|
231
|
+
@ready_mutex.synchronize { @ready_cv.broadcast }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def stale?
|
|
235
|
+
@last_attempt_at.nil? ||
|
|
236
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_attempt_at) >= @poll_seconds
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def attempt_fetch
|
|
240
|
+
outcome = Fetcher.call(url: @url, etag: @etag, timeout: @fetch_timeout)
|
|
241
|
+
apply(outcome)
|
|
242
|
+
ensure
|
|
243
|
+
@attempted = true
|
|
244
|
+
@last_attempt_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def apply(outcome)
|
|
248
|
+
case outcome.kind
|
|
249
|
+
when :document
|
|
250
|
+
@document = outcome.document # document BEFORE etag (design doc §8)
|
|
251
|
+
@etag = outcome.etag
|
|
252
|
+
became(healthy: true)
|
|
253
|
+
when :not_modified
|
|
254
|
+
became(healthy: true)
|
|
255
|
+
when :missing
|
|
256
|
+
@document = Document::EMPTY
|
|
257
|
+
@etag = nil
|
|
258
|
+
became(healthy: true)
|
|
259
|
+
when :failed
|
|
260
|
+
became(healthy: false, error: outcome.error)
|
|
261
|
+
end
|
|
262
|
+
@last_attempt_ok = outcome.kind != :failed
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def became(healthy:, error: nil)
|
|
266
|
+
return if healthy == @healthy
|
|
267
|
+
@healthy = healthy
|
|
268
|
+
report(healthy ? nil : error)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def report(error)
|
|
272
|
+
@on_error.call(error)
|
|
273
|
+
rescue
|
|
274
|
+
nil # a broken error handler must not kill the poller
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def default_on_error
|
|
278
|
+
lambda do |error|
|
|
279
|
+
logger = defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger ||
|
|
280
|
+
(@fallback_logger ||= Logger.new($stderr, progname: "cru_flags"))
|
|
281
|
+
if error
|
|
282
|
+
logger.warn("flag fetches failing: #{error.class}: #{error.message}")
|
|
283
|
+
else
|
|
284
|
+
logger.warn("flag fetches recovered")
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def positive(value, fallback)
|
|
290
|
+
(value.is_a?(Numeric) && value.finite? && value.positive?) ? value.to_f : fallback
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def validate_url!
|
|
294
|
+
return if @url.nil?
|
|
295
|
+
uri = URI(@url.to_s)
|
|
296
|
+
invalid_scheme = !VALID_SCHEMES.include?(uri.scheme)
|
|
297
|
+
no_host = uri.host.to_s.empty?
|
|
298
|
+
if invalid_scheme || no_host
|
|
299
|
+
reason = invalid_scheme ? "scheme #{uri.scheme.inspect} is not http(s)" : "has no host"
|
|
300
|
+
@url = nil
|
|
301
|
+
report(FetchError.new("CRU_FLAGS_URL #{reason}; client is inert", code: :network))
|
|
302
|
+
end
|
|
303
|
+
rescue URI::InvalidURIError
|
|
304
|
+
@url = nil
|
|
305
|
+
report(FetchError.new("CRU_FLAGS_URL is not a valid URL; client is inert", code: :network))
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module CruFlags
|
|
6
|
+
module Document
|
|
7
|
+
EMPTY = {"Flags" => {}.freeze}.freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Parse and deep-freeze one flag document. Leniency is for ABSENCE, not
|
|
12
|
+
# malformation: a missing Flags key is an empty document, a wrong-typed
|
|
13
|
+
# one is a broken document (design doc §8).
|
|
14
|
+
def parse(body)
|
|
15
|
+
parsed = JSON.parse(body, freeze: true)
|
|
16
|
+
raise ParseError, "document is not a JSON object" unless parsed.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
normalize_flags(parsed)
|
|
19
|
+
rescue JSON::ParserError => e
|
|
20
|
+
raise ParseError, "document is not JSON: #{e.message}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def normalize_flags(parsed)
|
|
24
|
+
flags = parsed["Flags"]
|
|
25
|
+
case flags
|
|
26
|
+
when nil
|
|
27
|
+
rebuild(parsed, {}.freeze)
|
|
28
|
+
when Hash
|
|
29
|
+
clean = flags.select { |_name, entry| entry.is_a?(Hash) }
|
|
30
|
+
(clean.size == flags.size) ? parsed : rebuild(parsed, clean.freeze)
|
|
31
|
+
else
|
|
32
|
+
raise ParseError, "Flags is #{flags.class}, expected an object"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def rebuild(parsed, flags)
|
|
37
|
+
parsed.except("Flags").merge("Flags" => flags).freeze
|
|
38
|
+
end
|
|
39
|
+
private_class_method :normalize_flags, :rebuild
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CruFlags
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ParseError < Error; end
|
|
7
|
+
|
|
8
|
+
# Normalized fetch failure. code is :http, :network, :timeout, or :parse;
|
|
9
|
+
# status is set only for :http.
|
|
10
|
+
class FetchError < Error
|
|
11
|
+
attr_reader :code, :status
|
|
12
|
+
|
|
13
|
+
def initialize(message, code:, status: nil)
|
|
14
|
+
super(message)
|
|
15
|
+
@code = code
|
|
16
|
+
@status = status
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class ReadOnlyError < Error; end
|
|
21
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module CruFlags
|
|
7
|
+
# One conditional GET of the flag document. Stateless; the Client owns the
|
|
8
|
+
# etag and the snapshot. Never raises — every failure is an Outcome.
|
|
9
|
+
module Fetcher
|
|
10
|
+
Outcome = Struct.new(:kind, :document, :etag, :error)
|
|
11
|
+
|
|
12
|
+
REDIRECT_LIMIT = 3
|
|
13
|
+
REDIRECT_CODES = %w[301 302 303 307 308].freeze
|
|
14
|
+
VALID_SCHEMES = %w[http https].freeze
|
|
15
|
+
BODY_EXCERPT = 200
|
|
16
|
+
MAX_BODY_BYTES = 1_048_576
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def call(url:, etag: nil, timeout: 2.0)
|
|
21
|
+
response = get_following_redirects(URI(url), etag:, timeout:)
|
|
22
|
+
case response.code
|
|
23
|
+
when "200"
|
|
24
|
+
body = response.body.to_s
|
|
25
|
+
if body.bytesize > MAX_BODY_BYTES
|
|
26
|
+
failed(FetchError.new("flag fetch body exceeds #{MAX_BODY_BYTES} bytes (#{body.bytesize})",
|
|
27
|
+
code: :parse))
|
|
28
|
+
else
|
|
29
|
+
document = Document.parse(body)
|
|
30
|
+
Outcome.new(kind: :document, document:, etag: response["etag"])
|
|
31
|
+
end
|
|
32
|
+
when "304" then Outcome.new(kind: :not_modified)
|
|
33
|
+
when "404" then Outcome.new(kind: :missing)
|
|
34
|
+
else
|
|
35
|
+
excerpt = response.body.to_s.strip.gsub(/\s+/, " ")[0, BODY_EXCERPT]
|
|
36
|
+
failed(FetchError.new("flag fetch failed with HTTP #{response.code}: #{excerpt}",
|
|
37
|
+
code: :http, status: Integer(response.code)))
|
|
38
|
+
end
|
|
39
|
+
rescue FetchError => e
|
|
40
|
+
failed(e)
|
|
41
|
+
rescue ParseError => e
|
|
42
|
+
failed(FetchError.new(e.message, code: :parse))
|
|
43
|
+
rescue Timeout::Error => e
|
|
44
|
+
failed(FetchError.new("flag fetch timed out: #{e.message}", code: :timeout))
|
|
45
|
+
rescue SystemCallError, SocketError, IOError, OpenSSL::SSL::SSLError,
|
|
46
|
+
URI::Error, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError => e
|
|
47
|
+
failed(FetchError.new("flag fetch failed: #{e.message}", code: :network))
|
|
48
|
+
rescue => e
|
|
49
|
+
# Floor, not enumeration: "call never raises" must hold by construction.
|
|
50
|
+
# Mirrors the sibling clients' contract (node's toFlagsError) — any
|
|
51
|
+
# exception type we didn't anticipate still normalizes to :network
|
|
52
|
+
# rather than escaping as a raise.
|
|
53
|
+
failed(FetchError.new("flag fetch failed: #{e.message}", code: :network))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def get_following_redirects(uri, etag:, timeout:)
|
|
57
|
+
REDIRECT_LIMIT.downto(0) do |hops_left|
|
|
58
|
+
response = get(uri, etag:, timeout:)
|
|
59
|
+
return response unless REDIRECT_CODES.include?(response.code)
|
|
60
|
+
if hops_left.zero?
|
|
61
|
+
raise FetchError.new("flag fetch exceeded #{REDIRECT_LIMIT} redirects",
|
|
62
|
+
code: :http, status: Integer(response.code))
|
|
63
|
+
end
|
|
64
|
+
uri = uri.merge(response["location"].to_s)
|
|
65
|
+
validate_redirect_uri!(uri)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Guards each redirect hop (design doc §8): a Location header pointing at
|
|
70
|
+
# a non-http(s) scheme or a hostless URI must not be handed to Net::HTTP,
|
|
71
|
+
# which would otherwise raise something less legible than a plain :failed
|
|
72
|
+
# :network outcome.
|
|
73
|
+
def validate_redirect_uri!(uri)
|
|
74
|
+
return if VALID_SCHEMES.include?(uri.scheme) && !uri.host.to_s.empty?
|
|
75
|
+
raise FetchError.new("flag fetch redirected to an invalid URI (scheme #{uri.scheme.inspect}, host #{uri.host.inspect})",
|
|
76
|
+
code: :network)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def get(uri, etag:, timeout:)
|
|
80
|
+
headers = {"Accept" => "application/json",
|
|
81
|
+
"User-Agent" => "cru-flags-ruby/#{VERSION}"}
|
|
82
|
+
headers["If-None-Match"] = etag if etag
|
|
83
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
84
|
+
open_timeout: timeout, read_timeout: timeout, write_timeout: timeout) do |http|
|
|
85
|
+
http.get(uri.request_uri, headers)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def failed(error) = Outcome.new(kind: :failed, error:)
|
|
90
|
+
private_class_method :get_following_redirects, :validate_redirect_uri!, :get, :failed
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "flipper"
|
|
4
|
+
|
|
5
|
+
module CruFlags
|
|
6
|
+
# Read-only Flipper adapter over the client's frozen snapshot (design doc
|
|
7
|
+
# §5). HARD RULE: get/get_multi/get_all build fresh, UNFROZEN top-level
|
|
8
|
+
# hashes on every call — Flipper::Adapters::Memoizable mutates the Hash we
|
|
9
|
+
# return (response.default_proc =, memoizable.rb:103), so a frozen or
|
|
10
|
+
# shared return value would raise FrozenError on every memoized request.
|
|
11
|
+
class FlipperAdapter
|
|
12
|
+
include Flipper::Adapter
|
|
13
|
+
|
|
14
|
+
def initialize(client)
|
|
15
|
+
@client = client
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def name = :cru_flags
|
|
19
|
+
|
|
20
|
+
def read_only? = true
|
|
21
|
+
|
|
22
|
+
def features
|
|
23
|
+
@client.flags_for_adapter.keys.to_set
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get(feature)
|
|
27
|
+
gates_for(feature.key)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def get_multi(features)
|
|
31
|
+
flags = @client.flags_for_adapter
|
|
32
|
+
features.to_h { |feature| [feature.key, gates_for(feature.key, flags)] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def get_all
|
|
36
|
+
flags = @client.flags_for_adapter
|
|
37
|
+
flags.keys.to_h { |name| [name, gates_for(name, flags)] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def add(_feature) = write_refused
|
|
41
|
+
|
|
42
|
+
def remove(_feature) = write_refused
|
|
43
|
+
|
|
44
|
+
def clear(_feature) = write_refused
|
|
45
|
+
|
|
46
|
+
def enable(_feature, _gate, _thing) = write_refused
|
|
47
|
+
|
|
48
|
+
def disable(_feature, _gate, _thing) = write_refused
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def gates_for(name, flags = @client.flags_for_adapter)
|
|
53
|
+
gates = self.class.default_config.dup
|
|
54
|
+
gates[:boolean] = true if flags.dig(name, "Enabled") == true
|
|
55
|
+
gates
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def write_refused
|
|
59
|
+
raise ReadOnlyError, "flags are written by cru-cli and the deploys.cru.org dashboard, never by the app"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded at file scope, not inside the initializer: Rails::Application
|
|
4
|
+
# computes its initializer collection ONCE, by walking Rails::Railtie's
|
|
5
|
+
# currently-defined subclasses, before running any of them. If "flipper"
|
|
6
|
+
# were required for the first time from inside our own initializer's body,
|
|
7
|
+
# Flipper::Engine would not exist yet when that walk happens, so its own
|
|
8
|
+
# initializers (Strict wrapping, ActorLimit, the Memoizer middleware) would
|
|
9
|
+
# never join the collection at all — not run late, just silently absent.
|
|
10
|
+
# This file only loads under Rails (see the guarded require in
|
|
11
|
+
# lib/cru_flags.rb), so requiring "flipper" here does not touch the
|
|
12
|
+
# no-bundler / non-Rails path.
|
|
13
|
+
require "flipper"
|
|
14
|
+
|
|
15
|
+
module CruFlags
|
|
16
|
+
# Zero-config Rails wiring (design doc §5.1). Registration is ordering-safe
|
|
17
|
+
# because Flipper's engine resolves config.adapter LAZILY on first
|
|
18
|
+
# Flipper.instance access, and the last-registered adapter block wins — an
|
|
19
|
+
# app's own Flipper.configure in config/initializers still overrides ours.
|
|
20
|
+
class Railtie < Rails::Railtie
|
|
21
|
+
# Flipper's engine assigns config.flipper.strict in before_configuration
|
|
22
|
+
# (:warn in development, false elsewhere) — BEFORE any gem initializer —
|
|
23
|
+
# so "did the app set it" cannot be read off the config value alone. We
|
|
24
|
+
# quiet strict only when the env var is unset AND the value still equals
|
|
25
|
+
# Flipper's own computed default, i.e. only when nobody chose it.
|
|
26
|
+
def self.quiet_strict?(current:, env_var:, rails_env:)
|
|
27
|
+
return false unless env_var.nil?
|
|
28
|
+
flipper_default = (rails_env == "development") ? :warn : false
|
|
29
|
+
current == flipper_default
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
initializer "cru_flags.flipper" do |app|
|
|
33
|
+
Flipper.configure { |config| config.adapter { CruFlags.flipper_adapter } }
|
|
34
|
+
|
|
35
|
+
if app.config.respond_to?(:flipper) &&
|
|
36
|
+
Railtie.quiet_strict?(current: app.config.flipper.strict,
|
|
37
|
+
env_var: ENV["FLIPPER_STRICT"], rails_env: Rails.env.to_s)
|
|
38
|
+
app.config.flipper.strict = false
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/cru_flags.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cru_flags/version"
|
|
4
|
+
require_relative "cru_flags/errors"
|
|
5
|
+
require_relative "cru_flags/document"
|
|
6
|
+
require_relative "cru_flags/fetcher"
|
|
7
|
+
require_relative "cru_flags/client"
|
|
8
|
+
|
|
9
|
+
# Module-level singleton — the 99% path (design doc §3.1). Requiring this
|
|
10
|
+
# file does nothing: no env read, no socket, no thread.
|
|
11
|
+
module CruFlags
|
|
12
|
+
ENV_VAR = "CRU_FLAGS_URL"
|
|
13
|
+
|
|
14
|
+
@client_mutex = Mutex.new
|
|
15
|
+
@client = nil
|
|
16
|
+
@flipper_adapter_mutex = Mutex.new
|
|
17
|
+
@flipper_adapter = nil
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def client
|
|
21
|
+
@client || @client_mutex.synchronize { @client ||= Client.new(url: url_from_env) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def enabled?(name) = client.enabled?(name)
|
|
25
|
+
|
|
26
|
+
def ready(timeout: nil) = client.ready(timeout:)
|
|
27
|
+
|
|
28
|
+
def snapshot = client.snapshot
|
|
29
|
+
|
|
30
|
+
def refresh(force: false) = client.refresh(force:)
|
|
31
|
+
|
|
32
|
+
def close = client.close
|
|
33
|
+
|
|
34
|
+
# The read-only Flipper adapter (design doc §5), memoized and bound to
|
|
35
|
+
# the singleton client. `flipper` is required lazily here (not at the
|
|
36
|
+
# top of this file) so requiring "cru_flags" alone stays flipper-free.
|
|
37
|
+
def flipper_adapter
|
|
38
|
+
@flipper_adapter || @flipper_adapter_mutex.synchronize do
|
|
39
|
+
@flipper_adapter ||= begin
|
|
40
|
+
require_relative "cru_flags/flipper_adapter"
|
|
41
|
+
FlipperAdapter.new(client)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Test hook, not public API: closes and discards the singleton so the
|
|
47
|
+
# next call re-reads the environment.
|
|
48
|
+
def reset!
|
|
49
|
+
@client_mutex.synchronize do
|
|
50
|
+
@client&.close
|
|
51
|
+
@client = nil
|
|
52
|
+
end
|
|
53
|
+
@flipper_adapter_mutex.synchronize { @flipper_adapter = nil }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def url_from_env
|
|
59
|
+
value = ENV[ENV_VAR].to_s.strip
|
|
60
|
+
value.empty? ? nil : value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
require_relative "cru_flags/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: cru-flags
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Cru
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-24 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: flipper
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.4'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.4'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: json
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.4'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '2.4'
|
|
41
|
+
description: Polls the flag service's per-(project, environment) JSON document, answers
|
|
42
|
+
reads from a frozen in-memory snapshot with fail-static semantics, and ships a read-only
|
|
43
|
+
Flipper adapter for the Rails fleet. CRuby only. See docs/design.md.
|
|
44
|
+
email:
|
|
45
|
+
- apps@cru.org
|
|
46
|
+
executables: []
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- CHANGELOG.md
|
|
51
|
+
- LICENSE
|
|
52
|
+
- README.md
|
|
53
|
+
- docs/design.md
|
|
54
|
+
- lib/cru_flags.rb
|
|
55
|
+
- lib/cru_flags/client.rb
|
|
56
|
+
- lib/cru_flags/document.rb
|
|
57
|
+
- lib/cru_flags/errors.rb
|
|
58
|
+
- lib/cru_flags/fetcher.rb
|
|
59
|
+
- lib/cru_flags/flipper_adapter.rb
|
|
60
|
+
- lib/cru_flags/railtie.rb
|
|
61
|
+
- lib/cru_flags/version.rb
|
|
62
|
+
homepage: https://github.com/CruGlobal/cru-flags-ruby
|
|
63
|
+
licenses:
|
|
64
|
+
- BSD-3-Clause
|
|
65
|
+
metadata:
|
|
66
|
+
rubygems_mfa_required: 'true'
|
|
67
|
+
source_code_uri: https://github.com/CruGlobal/cru-flags-ruby
|
|
68
|
+
changelog_uri: https://github.com/CruGlobal/cru-flags-ruby/blob/main/CHANGELOG.md
|
|
69
|
+
post_install_message:
|
|
70
|
+
rdoc_options: []
|
|
71
|
+
require_paths:
|
|
72
|
+
- lib
|
|
73
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
- - ">="
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: '3.2'
|
|
78
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0'
|
|
83
|
+
requirements: []
|
|
84
|
+
rubygems_version: 3.5.22
|
|
85
|
+
signing_key:
|
|
86
|
+
specification_version: 4
|
|
87
|
+
summary: Official Ruby client for Cru's pipeline feature-flag service
|
|
88
|
+
test_files: []
|