standard_health 0.1.0 → 0.3.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/CHANGELOG.md +24 -0
- data/README.md +146 -2
- data/app/controllers/standard_health/application_controller.rb +7 -17
- data/app/controllers/standard_health/diagnostics_application_controller.rb +11 -0
- data/app/controllers/standard_health/diagnostics_controller.rb +9 -3
- data/lib/standard_health/configuration.rb +13 -0
- data/lib/standard_health/env_spec.rb +154 -31
- data/lib/standard_health/version.rb +1 -1
- data/lib/standard_health.rb +36 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4bc8cdc021df7485cb1443cf6902b6fee31627ad6135d9eef6f284cd5eed47e0
|
|
4
|
+
data.tar.gz: bfd4e8a50e602685430481b85a0ebf604d1e930db71ce47492036d772f4aa13c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 32ec724c45eb9899e4d241d5681d296e1ac5745c3ff1852b5a187dea1fcc2a6b7ff7a922c08f925f0b59189ca8523d11fd034a5743f394186d91c4c1f2d8596d
|
|
7
|
+
data.tar.gz: 4711ceae81d4b9d0c19b440cc62fe8b2677ce569661adf741775ecc7933ee93c68402c8141ca830e76a6a75aa023c3f7759ef4d68a175d38280a2d971e83b964
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-04-29
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `if:` and `unless:` Proc predicates on `required` and `recommended`. Evaluated at audit time; when `unless:` returns truthy or `if:` returns falsy the entry is reported with `status: :not_applicable` and a `reason` field. Solves the MyInfo mock-mode case where `MYINFO_PRIVATE_JWKS` is only required outside mock mode.
|
|
15
|
+
- `mode_alias` top-level DSL inside `EnvSpec.define`. Maps a Symbol to an `Array<String>` of `APP_ENVIRONMENT` values. `in:` now accepts a Symbol that is resolved against declared aliases at audit time; an undefined Symbol raises `StandardHealth::EnvSpec::UnknownModeAlias`.
|
|
16
|
+
- `consumed_by:` keyword on `required`/`recommended`. Accepts a String or `Array<String>` pointing at where the env var is read in the host app; flows through to audit rows verbatim.
|
|
17
|
+
- `group "Label" do ... end` block inside `EnvSpec.define`. Tags enclosed entries with a `group` field in their audit rows. Pure metadata for ops UX.
|
|
18
|
+
- New `:not_applicable` value for the `status` field, used when an `if:`/`unless:` predicate suppresses an entry.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- `EnvSpec` internals refactored: `Entry` struct extended with `consumed_by`, `if_predicate`, `unless_predicate`, and `group`. Backward-compatible — every 0.2.0 spec produces identical audit output (modulo `description` now appearing where it was previously suppressed and `group`/`consumed_by` appearing only when set).
|
|
23
|
+
|
|
24
|
+
## [0.2.0] - 2026-04-29
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- `c.diagnostics_parent_controller` config option. When set, only `StandardHealth::DiagnosticsController` inherits from it; `HealthController` still uses `parent_controller`. Lets consuming apps put auth (e.g. HTTP Basic) on the diagnostics endpoint without needing `raise_on_missing_callback_actions = false` — the workaround three rarebit-one web apps applied when adopting v0.1.0.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- `StandardHealth::DiagnosticsController` now inherits from a new `StandardHealth::DiagnosticsApplicationController` that resolves to `diagnostics_parent_controller || parent_controller`. Backward-compatible — when the new option isn't set, behavior is identical to v0.1.0.
|
|
33
|
+
|
|
10
34
|
## [0.1.0] - 2026-04-28
|
|
11
35
|
|
|
12
36
|
### Added
|
data/README.md
CHANGED
|
@@ -69,8 +69,10 @@ The DSL has two declarations:
|
|
|
69
69
|
|
|
70
70
|
Both accept:
|
|
71
71
|
|
|
72
|
-
- `in: %w[staging production]` — restricts the entry to those `APP_ENVIRONMENT` values; ignored otherwise
|
|
72
|
+
- `in: %w[staging production]` — restricts the entry to those `APP_ENVIRONMENT` values; ignored otherwise. May also be a Symbol resolved via `mode_alias` (see below).
|
|
73
73
|
- `description: "..."` — surfaced verbatim in the audit JSON
|
|
74
|
+
- `consumed_by: "config/initializers/sentry.rb"` — pointer (or `Array<String>`) to where the value is read; surfaced verbatim
|
|
75
|
+
- `if: -> { ... }` / `unless: -> { ... }` — Proc predicates evaluated at audit time. When `unless:` returns truthy or `if:` returns falsy, the entry is reported with `status: :not_applicable`
|
|
74
76
|
|
|
75
77
|
Audit output (one row per applicable entry):
|
|
76
78
|
|
|
@@ -83,7 +85,108 @@ Audit output (one row per applicable entry):
|
|
|
83
85
|
}
|
|
84
86
|
```
|
|
85
87
|
|
|
86
|
-
Possible `status` values are `ok`, `missing` (required + absent),
|
|
88
|
+
Possible `status` values are `ok`, `missing` (required + absent), `should_set` (recommended + absent), and `not_applicable` (suppressed by an `if:`/`unless:` predicate).
|
|
89
|
+
|
|
90
|
+
### Predicates: `if:` and `unless:`
|
|
91
|
+
|
|
92
|
+
Use predicates when an env var is only meaningful under runtime conditions that aren't expressible as a fixed list of `APP_ENVIRONMENT` values — e.g. when a host app supports a "mock mode" toggle.
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
required :MYINFO_PRIVATE_JWKS,
|
|
96
|
+
in: %w[production],
|
|
97
|
+
unless: -> { ENV["MYINFO_MOCK_MODE"].present? }
|
|
98
|
+
|
|
99
|
+
recommended :SENTRY_DSN,
|
|
100
|
+
if: -> { ENV["SENTRY_DISABLED"].blank? }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A suppressed entry surfaces as:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"name": "MYINFO_PRIVATE_JWKS",
|
|
108
|
+
"level": "required",
|
|
109
|
+
"status": "not_applicable",
|
|
110
|
+
"reason": "unless predicate matched",
|
|
111
|
+
"mode": "production"
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The `reason` is `"unless predicate matched"` or `"if predicate did not match"`. Both predicates may be combined; the entry only evaluates when `if:` is truthy and `unless:` is falsy.
|
|
116
|
+
|
|
117
|
+
### Mode aliases: `mode_alias`
|
|
118
|
+
|
|
119
|
+
Declare reusable groupings of `APP_ENVIRONMENT` values inside the `define` block, then reference them as Symbols in `in:`. Common patterns ship as conventions (not built-ins): `:deployed` for staging-and-up, `:live` for production-only.
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
StandardHealth::EnvSpec.define do
|
|
123
|
+
mode_alias :deployed, %w[staging preview production]
|
|
124
|
+
mode_alias :live, %w[production]
|
|
125
|
+
|
|
126
|
+
required :APP_ENVIRONMENT
|
|
127
|
+
required :SENTRY_DSN, in: :deployed
|
|
128
|
+
required :STRIPE_LIVE_KEY, in: :live
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`in:` accepts:
|
|
133
|
+
|
|
134
|
+
- `nil` (omitted) — entry always applies
|
|
135
|
+
- `Array<String>` — literal mode list (existing behaviour)
|
|
136
|
+
- `Symbol` — resolved against `mode_alias` at audit time. An undeclared Symbol raises `StandardHealth::EnvSpec::UnknownModeAlias`.
|
|
137
|
+
|
|
138
|
+
### `description:` and `consumed_by:`
|
|
139
|
+
|
|
140
|
+
Both flow through to audit rows verbatim. `description:` is a human hint; `consumed_by:` points at the file(s) that read the value, which makes "what does this env var actually do" much faster to answer in incident response.
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
required :APP_HOST,
|
|
144
|
+
in: :deployed,
|
|
145
|
+
description: "Canonical web host",
|
|
146
|
+
consumed_by: "config/initializers/sentry.rb"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{
|
|
151
|
+
"name": "APP_HOST",
|
|
152
|
+
"level": "required",
|
|
153
|
+
"status": "ok",
|
|
154
|
+
"mode": "production",
|
|
155
|
+
"description": "Canonical web host",
|
|
156
|
+
"consumed_by": "config/initializers/sentry.rb"
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`consumed_by:` may be a String or `Array<String>`; an Array is preserved as a JSON array.
|
|
161
|
+
|
|
162
|
+
### Groups
|
|
163
|
+
|
|
164
|
+
Wrap related declarations in a `group "Label" do ... end` block to tag them with a category. Groups are pure metadata — they don't affect applicability, status, or evaluation order. Nested `group` blocks are supported; the innermost label propagates to enclosed entries. Calling `group` without a block raises `ArgumentError`.
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
StandardHealth::EnvSpec.define do
|
|
168
|
+
group "Singpass / MyInfo" do
|
|
169
|
+
required :MYINFO_CLIENT_ID
|
|
170
|
+
required :MYINFO_PRIVATE_JWKS, unless: -> { ENV["MYINFO_MOCK_MODE"].present? }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
group "Database" do
|
|
174
|
+
required :DATABASE_URL, in: :deployed
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Audit rows for entries declared inside a `group` block carry a `group` key:
|
|
180
|
+
|
|
181
|
+
```json
|
|
182
|
+
{ "name": "MYINFO_CLIENT_ID", "level": "required", "status": "ok", "mode": "production", "group": "Singpass / MyInfo" }
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Entries declared outside any `group` block omit the `group` key entirely.
|
|
186
|
+
|
|
187
|
+
### Backward compatibility
|
|
188
|
+
|
|
189
|
+
All v0.2.0 specs continue to produce identical audit output in v0.3.0. The new fields (`description`, `consumed_by`, `group`, `reason`) appear only when the corresponding feature is used; the new `:not_applicable` status only appears when a predicate suppresses an entry.
|
|
87
190
|
|
|
88
191
|
## Custom checks
|
|
89
192
|
|
|
@@ -125,8 +228,49 @@ StandardHealth.configure do |c|
|
|
|
125
228
|
end
|
|
126
229
|
```
|
|
127
230
|
|
|
231
|
+
> **Note (Rails 7.1+):** the `only: :env` filter above raises `AbstractController::ActionNotFound` because `HealthController` (alive/ready) shares this parent and has no `:env` action. Use [Splitting auth between health and diagnostics](#splitting-auth-between-health-and-diagnostics) instead — that's why v0.2.0 added `diagnostics_parent_controller`.
|
|
232
|
+
|
|
128
233
|
For a more granular setup, mount the engine inside an authenticated route block in your host app's `routes.rb`.
|
|
129
234
|
|
|
235
|
+
### Splitting auth between health and diagnostics
|
|
236
|
+
|
|
237
|
+
The pattern above hits a snag on Rails 7.1+ when you want to protect *only* `/diagnostics/env`. Both `HealthController` and `DiagnosticsController` inherit from `parent_controller`, so a `before_action :authenticate, only: :env` on that single parent applies to both — and Rails raises `AbstractController::ActionNotFound` because `:env` doesn't exist on `HealthController`.
|
|
238
|
+
|
|
239
|
+
Pre-v0.2.0 the workaround was to disable the check on the host controller:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
class StandardHealthHostController < ActionController::API
|
|
243
|
+
self.raise_on_missing_callback_actions = false # workaround
|
|
244
|
+
http_basic_authenticate_with(name: ..., password: ..., only: :env)
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
From v0.2.0 onwards, point `diagnostics_parent_controller` at a separate base class instead. Only `DiagnosticsController` inherits from it, so the `only: :env` callback no longer leaks onto `HealthController`:
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
# app/controllers/health_base_controller.rb
|
|
252
|
+
class HealthBaseController < ActionController::API
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# app/controllers/diagnostics_base_controller.rb
|
|
256
|
+
class DiagnosticsBaseController < ActionController::API
|
|
257
|
+
http_basic_authenticate_with(
|
|
258
|
+
name: ENV.fetch("HEALTH_USER"),
|
|
259
|
+
password: ENV.fetch("HEALTH_PASS")
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# config/initializers/standard_health.rb
|
|
264
|
+
StandardHealth.configure do |c|
|
|
265
|
+
c.parent_controller = "HealthBaseController"
|
|
266
|
+
c.diagnostics_parent_controller = "DiagnosticsBaseController"
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Now `/health/alive` and `/health/ready` are unauthenticated (probe-friendly) while `/health/diagnostics/env` requires HTTP Basic — no `raise_on_missing_callback_actions` flag needed.
|
|
271
|
+
|
|
272
|
+
When `diagnostics_parent_controller` is unset, `DiagnosticsController` falls back to `parent_controller`, matching v0.1.0 behavior exactly.
|
|
273
|
+
|
|
130
274
|
## Status semantics
|
|
131
275
|
|
|
132
276
|
`/ready` returns:
|
|
@@ -3,26 +3,16 @@
|
|
|
3
3
|
module StandardHealth
|
|
4
4
|
# Base controller for all StandardHealth endpoints.
|
|
5
5
|
#
|
|
6
|
-
# Lazily inherits from `StandardHealth.config.parent_controller`
|
|
6
|
+
# Lazily inherits from `StandardHealth.config.parent_controller` (resolved
|
|
7
|
+
# by `StandardHealth.parent_controller` in lib/standard_health.rb) so host
|
|
7
8
|
# apps can wire their own auth/rate-limiting/before_actions in once and
|
|
8
|
-
# have them apply to /alive
|
|
9
|
-
#
|
|
9
|
+
# have them apply to /alive and /ready. The default is
|
|
10
|
+
# `ActionController::API` so the engine works in API-only host apps
|
|
10
11
|
# without configuration.
|
|
11
12
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# singleton avoids re-running `constantize` per request.
|
|
16
|
-
def self.parent_controller
|
|
17
|
-
@parent_controller ||= config.parent_controller.constantize
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# Allow tests/host apps to bust the cached parent controller (e.g. when
|
|
21
|
-
# toggling config between examples).
|
|
22
|
-
def self.reset_parent_controller!
|
|
23
|
-
@parent_controller = nil
|
|
24
|
-
end
|
|
25
|
-
|
|
13
|
+
# `DiagnosticsController` has its own base class
|
|
14
|
+
# (`DiagnosticsApplicationController`) with its own resolver — see
|
|
15
|
+
# `config.diagnostics_parent_controller`.
|
|
26
16
|
class ApplicationController < parent_controller
|
|
27
17
|
end
|
|
28
18
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StandardHealth
|
|
4
|
+
# Separate base class for `DiagnosticsController` so host apps can target
|
|
5
|
+
# diagnostics-only auth at this class via `diagnostics_parent_controller`.
|
|
6
|
+
# When `diagnostics_parent_controller` is unset, this resolves to the same
|
|
7
|
+
# class as `parent_controller`, so the inheritance chain is behaviorally
|
|
8
|
+
# identical to v0.1.0.
|
|
9
|
+
class DiagnosticsApplicationController < diagnostics_parent_controller
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
module StandardHealth
|
|
4
4
|
# Diagnostics endpoints. Output here is potentially sensitive (it
|
|
5
5
|
# enumerates which env vars are missing), so host apps are responsible
|
|
6
|
-
# for wrapping these routes with authentication
|
|
7
|
-
#
|
|
8
|
-
|
|
6
|
+
# for wrapping these routes with authentication.
|
|
7
|
+
#
|
|
8
|
+
# Inherits from `DiagnosticsApplicationController`, which resolves to
|
|
9
|
+
# `config.diagnostics_parent_controller || config.parent_controller`.
|
|
10
|
+
# This lets host apps put auth on diagnostics only — e.g. an HTTP Basic
|
|
11
|
+
# `before_action :auth, only: :env` on a dedicated diagnostics parent —
|
|
12
|
+
# without that callback leaking onto `HealthController` and tripping
|
|
13
|
+
# Rails 7.1's `raise_on_missing_callback_actions`.
|
|
14
|
+
class DiagnosticsController < DiagnosticsApplicationController
|
|
9
15
|
# Audits the configured EnvSpec against the current process ENV and
|
|
10
16
|
# returns the result as JSON. When no EnvSpec is configured the
|
|
11
17
|
# endpoint returns an empty audit rather than a 404 so callers don't
|
|
@@ -25,6 +25,18 @@ module StandardHealth
|
|
|
25
25
|
# engine works in API-only host apps without configuration.
|
|
26
26
|
attr_accessor :parent_controller
|
|
27
27
|
|
|
28
|
+
# Optional class name of a controller that ONLY `DiagnosticsController`
|
|
29
|
+
# should inherit from. When set, `HealthController` continues to use
|
|
30
|
+
# `parent_controller` while `DiagnosticsController` uses this one. Lets
|
|
31
|
+
# host apps put auth (e.g. HTTP Basic) on the diagnostics endpoint
|
|
32
|
+
# without needing to set `raise_on_missing_callback_actions = false`
|
|
33
|
+
# to suppress Rails 7.1's missing-action error caused by a single
|
|
34
|
+
# parent declaring `before_action :auth, only: :env` for both controllers.
|
|
35
|
+
#
|
|
36
|
+
# When unset (the default), `DiagnosticsController` falls back to
|
|
37
|
+
# `parent_controller` — fully backward-compatible with v0.1.0.
|
|
38
|
+
attr_accessor :diagnostics_parent_controller
|
|
39
|
+
|
|
28
40
|
# An optional `StandardHealth::EnvSpec` instance describing required and
|
|
29
41
|
# recommended environment variables for the host app. Audited via the
|
|
30
42
|
# /diagnostics/env endpoint.
|
|
@@ -32,6 +44,7 @@ module StandardHealth
|
|
|
32
44
|
|
|
33
45
|
def initialize
|
|
34
46
|
@parent_controller = "ActionController::API"
|
|
47
|
+
@diagnostics_parent_controller = nil
|
|
35
48
|
@env_spec = nil
|
|
36
49
|
@checks = []
|
|
37
50
|
end
|
|
@@ -6,32 +6,62 @@ module StandardHealth
|
|
|
6
6
|
# Example:
|
|
7
7
|
#
|
|
8
8
|
# StandardHealth::EnvSpec.define do
|
|
9
|
+
# mode_alias :deployed, %w[staging preview production]
|
|
10
|
+
# mode_alias :live, %w[production]
|
|
11
|
+
#
|
|
9
12
|
# required :SECRET_KEY_BASE
|
|
10
13
|
# required :APP_ENVIRONMENT, in: %w[staging production]
|
|
11
14
|
# recommended :SENTRY_DSN, description: "Error tracking DSN"
|
|
15
|
+
#
|
|
16
|
+
# group "Singpass / MyInfo" do
|
|
17
|
+
# required :MYINFO_CLIENT_ID
|
|
18
|
+
# required :MYINFO_PRIVATE_JWKS,
|
|
19
|
+
# in: :live,
|
|
20
|
+
# unless: -> { ENV["MYINFO_MOCK_MODE"].present? }
|
|
21
|
+
# end
|
|
12
22
|
# end
|
|
13
23
|
#
|
|
14
24
|
# Each entry has:
|
|
15
25
|
# - `name` (Symbol)
|
|
16
26
|
# - `level` (:required | :recommended)
|
|
17
|
-
# - `
|
|
18
|
-
# while `APP_ENVIRONMENT` matches one of these modes.
|
|
19
|
-
#
|
|
20
|
-
#
|
|
27
|
+
# - `in:` (Array<String>, Symbol, or nil) — when an Array, the entry only
|
|
28
|
+
# applies while `APP_ENVIRONMENT` matches one of these modes. A Symbol
|
|
29
|
+
# is resolved against `mode_alias` declarations at audit time. When
|
|
30
|
+
# omitted, the entry applies to every mode.
|
|
31
|
+
# - `description:` (String, optional) — human-readable hint surfaced
|
|
21
32
|
# verbatim by `/diagnostics/env`.
|
|
33
|
+
# - `consumed_by:` (String or Array<String>, optional) — pointer(s) to
|
|
34
|
+
# where the value is read in the host app. Surfaced verbatim.
|
|
35
|
+
# - `if:` / `unless:` (Proc, optional) — predicates evaluated at audit
|
|
36
|
+
# time. When `unless:` returns truthy or `if:` returns falsy the entry
|
|
37
|
+
# is reported with `status: :not_applicable`.
|
|
38
|
+
# - `group` (String, optional) — set implicitly by enclosing `group`
|
|
39
|
+
# block; surfaced verbatim.
|
|
22
40
|
class EnvSpec
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
41
|
+
# Raised when `in:` references a Symbol that hasn't been declared via
|
|
42
|
+
# `mode_alias`.
|
|
43
|
+
class UnknownModeAlias < ArgumentError; end
|
|
44
|
+
|
|
45
|
+
# Internal entry record. `modes` may be an Array<String> or a Symbol
|
|
46
|
+
# alias that gets resolved at audit time.
|
|
47
|
+
Entry = Struct.new(
|
|
48
|
+
:name,
|
|
49
|
+
:level,
|
|
50
|
+
:modes,
|
|
51
|
+
:description,
|
|
52
|
+
:consumed_by,
|
|
53
|
+
:if_predicate,
|
|
54
|
+
:unless_predicate,
|
|
55
|
+
:group,
|
|
56
|
+
keyword_init: true
|
|
57
|
+
)
|
|
31
58
|
|
|
32
59
|
# @return [Array<Entry>]
|
|
33
60
|
attr_reader :entries
|
|
34
61
|
|
|
62
|
+
# @return [Hash{Symbol => Array<String>}]
|
|
63
|
+
attr_reader :mode_aliases
|
|
64
|
+
|
|
35
65
|
# Build a spec via the DSL.
|
|
36
66
|
def self.define(&block)
|
|
37
67
|
new.tap { |spec| spec.instance_eval(&block) if block }
|
|
@@ -39,13 +69,42 @@ module StandardHealth
|
|
|
39
69
|
|
|
40
70
|
def initialize
|
|
41
71
|
@entries = []
|
|
72
|
+
@mode_aliases = {}
|
|
73
|
+
@group_stack = []
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Declare a mode alias usable in `in:`. Re-declaring overrides the
|
|
77
|
+
# previous value (last writer wins) so layered specs are easy to compose.
|
|
78
|
+
#
|
|
79
|
+
# @param name [Symbol]
|
|
80
|
+
# @param modes [Array<String>]
|
|
81
|
+
def mode_alias(name, modes)
|
|
82
|
+
@mode_aliases[name.to_sym] = Array(modes).map(&:to_s)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Group subsequent declarations under a label. Pure metadata —
|
|
86
|
+
# propagated to audit rows as `group:` and otherwise inert. Nested
|
|
87
|
+
# `group` blocks are supported; the innermost label is the one that
|
|
88
|
+
# propagates to enclosed entries.
|
|
89
|
+
#
|
|
90
|
+
# @param label [String]
|
|
91
|
+
def group(label, &block)
|
|
92
|
+
raise ArgumentError, "group requires a block" unless block
|
|
93
|
+
|
|
94
|
+
@group_stack.push(label.to_s)
|
|
95
|
+
block.call
|
|
96
|
+
ensure
|
|
97
|
+
@group_stack.pop
|
|
42
98
|
end
|
|
43
99
|
|
|
44
100
|
# Declare a required env var.
|
|
45
101
|
#
|
|
46
102
|
# @param name [Symbol, String]
|
|
47
|
-
# @param in [Array<String>, nil] limit applicability to these modes
|
|
103
|
+
# @param in [Array<String>, Symbol, nil] limit applicability to these modes
|
|
48
104
|
# @param description [String, nil]
|
|
105
|
+
# @param consumed_by [String, Array<String>, nil]
|
|
106
|
+
# @param if [Proc, nil]
|
|
107
|
+
# @param unless [Proc, nil]
|
|
49
108
|
def required(name, **opts)
|
|
50
109
|
add(:required, name, **opts)
|
|
51
110
|
end
|
|
@@ -60,41 +119,105 @@ module StandardHealth
|
|
|
60
119
|
#
|
|
61
120
|
# @param env_hash [Hash{String, Symbol => String}] e.g. ENV.to_h
|
|
62
121
|
# @param mode [String, Symbol] current APP_ENVIRONMENT value
|
|
63
|
-
# @return [Array<Hash>] one row per applicable entry
|
|
64
|
-
#
|
|
122
|
+
# @return [Array<Hash>] one row per applicable entry. Each row has at
|
|
123
|
+
# least `name`, `level`, `status`, `mode`. When an entry is suppressed
|
|
124
|
+
# by an `if:`/`unless:` predicate, `status` is `:not_applicable` and a
|
|
125
|
+
# `reason` field explains why. `description`, `consumed_by`, and
|
|
126
|
+
# `group` are included when set on the entry.
|
|
65
127
|
def audit(env_hash, mode:)
|
|
66
128
|
mode_str = mode.to_s
|
|
67
129
|
env = stringify(env_hash)
|
|
68
130
|
|
|
69
131
|
@entries.each_with_object([]) do |entry, out|
|
|
70
|
-
next unless
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
132
|
+
next unless mode_applies?(entry, mode_str)
|
|
133
|
+
|
|
134
|
+
row = base_row(entry, mode_str)
|
|
135
|
+
|
|
136
|
+
suppression = predicate_suppression(entry)
|
|
137
|
+
if suppression
|
|
138
|
+
row[:status] = :not_applicable
|
|
139
|
+
row[:reason] = suppression
|
|
140
|
+
else
|
|
141
|
+
value = env[entry.name.to_s]
|
|
142
|
+
row[:status] = classify(entry, value)
|
|
143
|
+
end
|
|
144
|
+
|
|
82
145
|
out << row
|
|
83
146
|
end
|
|
84
147
|
end
|
|
85
148
|
|
|
86
149
|
private
|
|
87
150
|
|
|
88
|
-
def add(level, name,
|
|
89
|
-
|
|
151
|
+
def add(level, name, **opts)
|
|
152
|
+
modes_opt = opts[:in]
|
|
90
153
|
@entries << Entry.new(
|
|
91
154
|
name: name.to_sym,
|
|
92
155
|
level: level,
|
|
93
|
-
modes:
|
|
94
|
-
description: description
|
|
156
|
+
modes: normalize_modes(modes_opt),
|
|
157
|
+
description: opts[:description],
|
|
158
|
+
consumed_by: normalize_consumed_by(opts[:consumed_by]),
|
|
159
|
+
if_predicate: opts[:if],
|
|
160
|
+
unless_predicate: opts[:unless],
|
|
161
|
+
group: @group_stack.last
|
|
95
162
|
)
|
|
96
163
|
end
|
|
97
164
|
|
|
165
|
+
def normalize_modes(modes_opt)
|
|
166
|
+
return nil if modes_opt.nil?
|
|
167
|
+
return modes_opt if modes_opt.is_a?(Symbol)
|
|
168
|
+
Array(modes_opt).map(&:to_s)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def normalize_consumed_by(value)
|
|
172
|
+
return nil if value.nil?
|
|
173
|
+
array = Array(value).map(&:to_s)
|
|
174
|
+
array.empty? ? nil : array
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def mode_applies?(entry, mode_str)
|
|
178
|
+
modes = resolve_modes(entry.modes)
|
|
179
|
+
return true if modes.nil? || modes.empty?
|
|
180
|
+
modes.include?(mode_str)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def resolve_modes(modes)
|
|
184
|
+
return nil if modes.nil?
|
|
185
|
+
if modes.is_a?(Symbol)
|
|
186
|
+
unless @mode_aliases.key?(modes)
|
|
187
|
+
raise UnknownModeAlias, "Unknown mode alias: #{modes.inspect}"
|
|
188
|
+
end
|
|
189
|
+
@mode_aliases[modes]
|
|
190
|
+
else
|
|
191
|
+
modes
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def predicate_suppression(entry)
|
|
196
|
+
if entry.unless_predicate && entry.unless_predicate.call
|
|
197
|
+
return "unless predicate matched"
|
|
198
|
+
end
|
|
199
|
+
if entry.if_predicate && !entry.if_predicate.call
|
|
200
|
+
return "if predicate did not match"
|
|
201
|
+
end
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Builds the metadata-only portion of an audit row. The caller is
|
|
206
|
+
# responsible for setting `:status` (and optionally `:reason`) — keeping
|
|
207
|
+
# status off the base intentionally so a future code path that forgets
|
|
208
|
+
# to set it produces a missing-key error instead of a silent `nil`.
|
|
209
|
+
def base_row(entry, mode_str)
|
|
210
|
+
row = { name: entry.name, level: entry.level, mode: mode_str }
|
|
211
|
+
row[:description] = entry.description if entry.description
|
|
212
|
+
row[:consumed_by] = serialize_consumed_by(entry.consumed_by) if entry.consumed_by
|
|
213
|
+
row[:group] = entry.group if entry.group
|
|
214
|
+
row
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def serialize_consumed_by(value)
|
|
218
|
+
value.length == 1 ? value.first : value
|
|
219
|
+
end
|
|
220
|
+
|
|
98
221
|
def classify(entry, value)
|
|
99
222
|
present = !value.nil? && !value.to_s.empty?
|
|
100
223
|
return :ok if present
|
data/lib/standard_health.rb
CHANGED
|
@@ -32,5 +32,41 @@ module StandardHealth
|
|
|
32
32
|
def reset_config!
|
|
33
33
|
@config = Configuration.new
|
|
34
34
|
end
|
|
35
|
+
|
|
36
|
+
# Resolves `config.parent_controller` to an actual class lazily — at
|
|
37
|
+
# the moment the engine's `ApplicationController` is first referenced
|
|
38
|
+
# (request time), by which point the host app's controllers are
|
|
39
|
+
# loaded. Caching avoids re-running `constantize` per request, and
|
|
40
|
+
# the name-mismatch reset lets tests swap config between examples.
|
|
41
|
+
def parent_controller
|
|
42
|
+
expected = config.parent_controller
|
|
43
|
+
if @parent_controller && @parent_controller.name != expected
|
|
44
|
+
@parent_controller = nil
|
|
45
|
+
end
|
|
46
|
+
@parent_controller ||= expected.constantize
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def reset_parent_controller!
|
|
50
|
+
@parent_controller = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Resolves the parent class for `DiagnosticsController` only. Falls
|
|
54
|
+
# back to `config.parent_controller` when no diagnostics-specific
|
|
55
|
+
# parent is configured, preserving v0.1.0 behavior. When set, lets
|
|
56
|
+
# host apps apply `before_action :auth, only: :env` to the diagnostics
|
|
57
|
+
# endpoint without that callback leaking onto `HealthController`
|
|
58
|
+
# (which would otherwise trip Rails 7.1's
|
|
59
|
+
# `raise_on_missing_callback_actions`).
|
|
60
|
+
def diagnostics_parent_controller
|
|
61
|
+
expected = config.diagnostics_parent_controller || config.parent_controller
|
|
62
|
+
if @diagnostics_parent_controller && @diagnostics_parent_controller.name != expected
|
|
63
|
+
@diagnostics_parent_controller = nil
|
|
64
|
+
end
|
|
65
|
+
@diagnostics_parent_controller ||= expected.constantize
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reset_diagnostics_parent_controller!
|
|
69
|
+
@diagnostics_parent_controller = nil
|
|
70
|
+
end
|
|
35
71
|
end
|
|
36
72
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: standard_health
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -37,6 +37,7 @@ files:
|
|
|
37
37
|
- README.md
|
|
38
38
|
- Rakefile
|
|
39
39
|
- app/controllers/standard_health/application_controller.rb
|
|
40
|
+
- app/controllers/standard_health/diagnostics_application_controller.rb
|
|
40
41
|
- app/controllers/standard_health/diagnostics_controller.rb
|
|
41
42
|
- app/controllers/standard_health/health_controller.rb
|
|
42
43
|
- config/routes.rb
|