@homeflare/alchemy 0.33.0 → 0.34.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.
- package/dist/grafana/alerting-provenance.d.ts +80 -0
- package/dist/grafana/alerting-provenance.d.ts.map +1 -0
- package/dist/grafana/contact-point-fixtures.d.ts +28 -0
- package/dist/grafana/contact-point-fixtures.d.ts.map +1 -0
- package/dist/grafana/contact-point.d.ts +99 -0
- package/dist/grafana/contact-point.d.ts.map +1 -0
- package/dist/grafana/dashboard-model.d.ts +8 -38
- package/dist/grafana/dashboard-model.d.ts.map +1 -1
- package/dist/grafana/datasource.d.ts +2 -9
- package/dist/grafana/datasource.d.ts.map +1 -1
- package/dist/grafana/index.d.ts +10 -4
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +198 -41
- package/dist/grafana/index.js.map +12 -6
- package/dist/grafana/message-template.d.ts +70 -0
- package/dist/grafana/message-template.d.ts.map +1 -0
- package/dist/grafana/mute-timing.d.ts +112 -0
- package/dist/grafana/mute-timing.d.ts.map +1 -0
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/grafana/secret-refs.d.ts +19 -0
- package/dist/grafana/secret-refs.d.ts.map +1 -0
- package/dist/grafana/subset-match.d.ts +2 -0
- package/dist/grafana/subset-match.d.ts.map +1 -0
- package/dist/linux/index.d.ts +1 -1
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +3 -1
- package/dist/linux/index.js.map +1 -1
- package/docs/grafana-alerting.md +145 -0
- package/docs/grafana.md +4 -3
- package/docs/upstream-conformance.md +26 -4
- package/package.json +1 -1
- package/src/grafana/alerting-provenance.ts +109 -0
- package/src/grafana/contact-point-fixtures.ts +25 -0
- package/src/grafana/contact-point-provenance.test.ts +113 -0
- package/src/grafana/contact-point-secrets.test.ts +117 -0
- package/src/grafana/contact-point.test.ts +123 -0
- package/src/grafana/contact-point.ts +179 -0
- package/src/grafana/dashboard-model.ts +8 -57
- package/src/grafana/datasource.ts +5 -27
- package/src/grafana/index.ts +24 -4
- package/src/grafana/message-template.test.ts +212 -0
- package/src/grafana/message-template.ts +114 -0
- package/src/grafana/mute-timing.test.ts +227 -0
- package/src/grafana/mute-timing.ts +160 -0
- package/src/grafana/providers.ts +6 -0
- package/src/grafana/secret-refs.ts +48 -0
- package/src/grafana/subset-match.ts +71 -0
- package/src/linux/index-export.test.ts +25 -0
- package/src/linux/index.ts +15 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.MuteTiming` — one Grafana alerting mute timing, keyed by `name` (there is no separate
|
|
3
|
+
* `uid` — `routeGetMuteTiming`/`routePutMuteTiming`/`routeDeleteMuteTiming` are all name-keyed on
|
|
4
|
+
* the wire, and `name` is the field a notification policy's own `mute_time_intervals` references).
|
|
5
|
+
*
|
|
6
|
+
* ★ `provenance`/`version` ARE READ THROUGH A WIDENED TYPE, NOT THE SDK's OWN `MuteTimeInterval` —
|
|
7
|
+
* MEASURED, and a genuinely surprising result worth recording in full. The SDK's generated
|
|
8
|
+
* `MuteTimeInterval` interface (`packages/distilled-grafana/src/services/grafana.ts`) declares
|
|
9
|
+
* only `name`/`time_intervals` — no `provenance`, no `version` — unlike `EmbeddedContactPoint`/
|
|
10
|
+
* `NotificationTemplate`/`ProvisionedAlertRule`/`Route`, which all carry `provenance`. A first
|
|
11
|
+
* pass at this file assumed that meant the data was gone: decoding a raw payload containing both
|
|
12
|
+
* fields through `S.decodeUnknownSync(MuteTimeInterval)` directly DOES strip them. But that is
|
|
13
|
+
* the wrong code path — it is not what `routeGetMuteTiming` itself does. Proven empirically by
|
|
14
|
+
* calling the REAL operation (through `fake-grafana.ts`'s harness, the exact path this file's own
|
|
15
|
+
* `fetchLive` uses) with a response body containing `provenance`/`version`: both come through on
|
|
16
|
+
* the decoded value intact. The mechanism, read from `@distilled.cloud/core@1.0.0-rc.12`'s
|
|
17
|
+
* `protocol-rest.ts`/`protocol-http.ts` (the runtime this SDK's every operation shares): response
|
|
18
|
+
* decoding is `JSON.parse` + `mapKeys` (renames only the field names a schema DOES declare) +
|
|
19
|
+
* `wrapSensitive` (wraps only fields a schema marks sensitive in `Redacted`) — never a strict
|
|
20
|
+
* `Schema.decode`. `protocol-http.ts`'s `mapKeys`, object branch: every response key NOT among a
|
|
21
|
+
* type's declared `props` is copied to the output verbatim in a second pass
|
|
22
|
+
* (`for (const [k, v] of Object.entries(value)) { if (consumed.has(k)) continue; out[k] = v; }`,
|
|
23
|
+
* line ~297-311) — the doc comment on the sibling `wrapSensitive` states the same rule plainly:
|
|
24
|
+
* "Keys the schema doesn't model pass through verbatim." So the TS type is simply narrower than
|
|
25
|
+
* the runtime value; `MuteTimeIntervalWire` below is that same runtime value, typed honestly.
|
|
26
|
+
* This is a real, worth-fixing SDK type-generation gap (tracked in `docs/upstream-conformance.md`,
|
|
27
|
+
* same S22 route as the other named gaps) but NOT a data-loss gap — unlike, say, a field the wire
|
|
28
|
+
* protocol itself never sent, this one is fully recoverable without touching vendored `src/`.
|
|
29
|
+
*
|
|
30
|
+
* The same test proved the outbound direction too: an object with extra keys (`weekdays`, `times`
|
|
31
|
+
* — the real Alertmanager time-interval fields Grafana's OpenAPI's own `TimeInterval` schema
|
|
32
|
+
* apparently fails to model either, for the same reason) passed to `routePutMuteTiming` reaches
|
|
33
|
+
* the wire body unchanged — `mapKeys`'s encode direction is the same verbatim-passthrough rule.
|
|
34
|
+
* `MuteTimingProps.timeIntervals` is therefore typed as the opaque `Record<string, unknown>`
|
|
35
|
+
* shape actually needed to declare a real mute timing (weekdays/times/months/etc.), not the
|
|
36
|
+
* SDK's own too-narrow `TimeInterval`, and cast at the two call sites below.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ THE EVIDENCE ABOVE IS A HAND-AUTHORED FIXTURE, NOT A CAPTURED REAL RESPONSE — flagged by an
|
|
39
|
+
* adversarial review of this PR. The estate has zero live mute timings to observe (grafana.md's
|
|
40
|
+
* census), so nothing confirms Grafana always includes `provenance` on this route in practice,
|
|
41
|
+
* or that some other SDK/version edge case never drops it. That is exactly why `attributes()`
|
|
42
|
+
* below never coerces a missing `live.provenance` to `''` — see alerting-provenance.ts's "fails
|
|
43
|
+
* open" note — an absent field refuses a write rather than being trusted as `ProvenanceNone`.
|
|
44
|
+
*
|
|
45
|
+
* ⛔ A FOREIGN-PROVENANCE MUTE TIMING REFUSES EVERY UPDATE AND DESTROY — `alerting-provenance.ts`,
|
|
46
|
+
* shared with `Grafana.ContactPoint`/`MessageTemplate`/`AlertRuleGroup`/`NotificationPolicy`; see
|
|
47
|
+
* that file's header. `create` is never at risk: it only runs when `fetchLive` already found
|
|
48
|
+
* nothing with this `name`.
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ `version` IS SENT ON DESTROY, NOT ON UPDATE — MEASURED: `RouteDeleteMuteTimingRequest.version`
|
|
51
|
+
* ("Version of mute timing to use for optimistic concurrency. Leave empty to disable validation")
|
|
52
|
+
* exists; `RoutePutMuteTimingRequest` has no `version` field at all, so an update has nothing to
|
|
53
|
+
* send for it — a real Grafana API asymmetry between the two write routes, not an omission here.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ `X-Disable-Provenance` IS NEVER SET — same reasoning as `contact-point.ts`'s file header.
|
|
56
|
+
*/
|
|
57
|
+
import { Resource } from 'alchemy';
|
|
58
|
+
import * as Provider from 'alchemy/Provider';
|
|
59
|
+
import * as grafana from '@distilled.cloud/grafana';
|
|
60
|
+
import * as Effect from 'effect/Effect';
|
|
61
|
+
import {
|
|
62
|
+
type GrafanaProvisionedObjectError,
|
|
63
|
+
isForeignProvenance,
|
|
64
|
+
refuseIfForeignProvenance,
|
|
65
|
+
} from './alerting-provenance.ts';
|
|
66
|
+
import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
|
|
67
|
+
import { declaredContentMatches } from './subset-match.ts';
|
|
68
|
+
|
|
69
|
+
export interface MuteTimingProps {
|
|
70
|
+
name: string;
|
|
71
|
+
/** Grafana's Alertmanager-style time-interval entries (`weekdays`/`times`/`months`/…), passed
|
|
72
|
+
* through opaquely — see the file header for why this is not the SDK's own `TimeInterval`. */
|
|
73
|
+
timeIntervals: ReadonlyArray<Record<string, unknown>>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface MuteTimingAttributes {
|
|
77
|
+
name: string;
|
|
78
|
+
timeIntervals: ReadonlyArray<Record<string, unknown>>;
|
|
79
|
+
/** `""`/`"api"` (this family may write); anything else, INCLUDING `undefined` (Grafana didn't
|
|
80
|
+
* report the field, or the widened-type passthrough this file depends on turns out not to hold
|
|
81
|
+
* for some response shape not yet observed), refuses — see alerting-provenance.ts's "fails
|
|
82
|
+
* open" note. This is the field where that default matters most: unlike the other resources in
|
|
83
|
+
* this family, `provenance` here is not even in the SDK's declared type. */
|
|
84
|
+
provenance: string | undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface GrafanaMuteTiming extends Resource<
|
|
88
|
+
'Grafana.MuteTiming',
|
|
89
|
+
MuteTimingProps,
|
|
90
|
+
MuteTimingAttributes,
|
|
91
|
+
never
|
|
92
|
+
> {}
|
|
93
|
+
|
|
94
|
+
export const GrafanaMuteTiming = Resource<GrafanaMuteTiming>('Grafana.MuteTiming');
|
|
95
|
+
|
|
96
|
+
/** The real runtime shape of a `GET`/list response — see the file header's "measured" note. */
|
|
97
|
+
type MuteTimeIntervalWire = grafana.MuteTimeInterval & {
|
|
98
|
+
provenance?: string;
|
|
99
|
+
version?: string;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const spec: GrafanaSpec<
|
|
103
|
+
MuteTimingProps,
|
|
104
|
+
MuteTimeIntervalWire,
|
|
105
|
+
MuteTimingAttributes,
|
|
106
|
+
| grafana.RouteGetMuteTimingError
|
|
107
|
+
| grafana.RoutePostMuteTimingError
|
|
108
|
+
| grafana.RoutePutMuteTimingError
|
|
109
|
+
| grafana.RouteDeleteMuteTimingError
|
|
110
|
+
| GrafanaProvisionedObjectError
|
|
111
|
+
> = {
|
|
112
|
+
attributes: (live) => ({
|
|
113
|
+
name: live.name ?? '',
|
|
114
|
+
// ⛔ NEVER `?? ''` — see alerting-provenance.ts's "fails open" note, and this file's own
|
|
115
|
+
// Attributes doc comment on why it matters most here.
|
|
116
|
+
provenance: live.provenance,
|
|
117
|
+
timeIntervals: (live.time_intervals ?? []) as unknown as ReadonlyArray<Record<string, unknown>>,
|
|
118
|
+
}),
|
|
119
|
+
create: (props) =>
|
|
120
|
+
grafana.routePostMuteTiming({
|
|
121
|
+
name: props.name,
|
|
122
|
+
time_intervals:
|
|
123
|
+
props.timeIntervals as unknown as grafana.RoutePostMuteTimingRequestTimeIntervalsList,
|
|
124
|
+
}),
|
|
125
|
+
destroy: (props, live) =>
|
|
126
|
+
Effect.gen(function* () {
|
|
127
|
+
if (isForeignProvenance(live.provenance)) {
|
|
128
|
+
return yield* refuseIfForeignProvenance('Grafana.MuteTiming', props.name, live.provenance);
|
|
129
|
+
}
|
|
130
|
+
yield* grafana.routeDeleteMuteTiming({
|
|
131
|
+
name: props.name,
|
|
132
|
+
...(live.version === undefined ? {} : { version: live.version }),
|
|
133
|
+
});
|
|
134
|
+
}),
|
|
135
|
+
fetchLive: (props) =>
|
|
136
|
+
grafana.routeGetMuteTiming({ name: props.name }).pipe(
|
|
137
|
+
Effect.map((live) => live as MuteTimeIntervalWire),
|
|
138
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
139
|
+
),
|
|
140
|
+
/** Subset match (subset-match.ts), not plain equality — tolerates a default Grafana might
|
|
141
|
+
* inject into a time-interval entry the declaration never set. */
|
|
142
|
+
matches: (attributes, props) =>
|
|
143
|
+
declaredContentMatches(props.timeIntervals, attributes.timeIntervals),
|
|
144
|
+
update: (props, live) =>
|
|
145
|
+
Effect.gen(function* () {
|
|
146
|
+
if (isForeignProvenance(live.provenance)) {
|
|
147
|
+
return yield* refuseIfForeignProvenance('Grafana.MuteTiming', props.name, live.provenance);
|
|
148
|
+
}
|
|
149
|
+
yield* grafana.routePutMuteTiming({
|
|
150
|
+
name: props.name,
|
|
151
|
+
time_intervals:
|
|
152
|
+
props.timeIntervals as unknown as grafana.RoutePutMuteTimingRequestTimeIntervalsList,
|
|
153
|
+
});
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const handlers = grafanaHandlers(spec);
|
|
158
|
+
|
|
159
|
+
export const GrafanaMuteTimingProvider = () =>
|
|
160
|
+
Provider.effect(GrafanaMuteTiming, Effect.succeed(GrafanaMuteTiming.Provider.of(handlers)));
|
package/src/grafana/providers.ts
CHANGED
|
@@ -12,14 +12,20 @@
|
|
|
12
12
|
* deploys anything over HTTP already has one.
|
|
13
13
|
*/
|
|
14
14
|
import * as Layer from 'effect/Layer';
|
|
15
|
+
import { GrafanaContactPointProvider } from './contact-point.ts';
|
|
15
16
|
import { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
|
|
16
17
|
import { GrafanaDashboardProvider } from './dashboard.ts';
|
|
17
18
|
import { GrafanaDatasourceProvider } from './datasource.ts';
|
|
18
19
|
import { GrafanaFolderProvider } from './folder.ts';
|
|
20
|
+
import { GrafanaMessageTemplateProvider } from './message-template.ts';
|
|
21
|
+
import { GrafanaMuteTimingProvider } from './mute-timing.ts';
|
|
19
22
|
|
|
20
23
|
export const grafanaProviders = (target: GrafanaTarget) =>
|
|
21
24
|
Layer.mergeAll(
|
|
22
25
|
GrafanaDatasourceProvider(),
|
|
23
26
|
GrafanaFolderProvider(),
|
|
24
27
|
GrafanaDashboardProvider(),
|
|
28
|
+
GrafanaContactPointProvider(),
|
|
29
|
+
GrafanaMuteTimingProvider(),
|
|
30
|
+
GrafanaMessageTemplateProvider(),
|
|
25
31
|
).pipe(Layer.provide(grafanaCredentials(target)));
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "secret stays in env, never in state" resolution (S25) for this family —
|
|
3
|
+
* `Grafana.Datasource`'s `secureJsonDataRefs` and `Grafana.ContactPoint`'s `secureSettingsRefs`
|
|
4
|
+
* are the identical shape: a map of field name to an env var NAME, resolved fresh from
|
|
5
|
+
* `process.env` inside the caller's own create/update effect (S24), never at plan time and never
|
|
6
|
+
* returned in `attributes`. Extracted here once a second family needed the exact same loop
|
|
7
|
+
* `datasource.ts` already had — see that file's original note for why this exists at all:
|
|
8
|
+
* Grafana's own read APIs never return a secret value, only whether one is set
|
|
9
|
+
* (`DataSource.secureJsonFields`'s booleans; a contact point's `settings` comes back with secure
|
|
10
|
+
* keys replaced by a literal redaction placeholder instead — see contact-point.ts).
|
|
11
|
+
*/
|
|
12
|
+
import * as Data from 'effect/Data';
|
|
13
|
+
import * as Effect from 'effect/Effect';
|
|
14
|
+
|
|
15
|
+
/** A named secure-field env var is unset — a domain refusal, not a distilled error. */
|
|
16
|
+
export class GrafanaSecretRefUnsetError extends Data.TaggedError('GrafanaSecretRefUnsetError')<{
|
|
17
|
+
readonly message: string;
|
|
18
|
+
}> {}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reads every named env var fresh, inside the caller's effect (S24) — never at plan time.
|
|
22
|
+
* `describeField` builds the refusal message's own field-specific phrase — each caller names its
|
|
23
|
+
* declaration path differently (`secureJsonData.<field>` for a datasource,
|
|
24
|
+
* `settings.<field>` for a contact point) — so the message stays accurate to what the caller
|
|
25
|
+
* itself declares.
|
|
26
|
+
*/
|
|
27
|
+
export const resolveSecretRefs = (
|
|
28
|
+
refs: Record<string, string> | undefined,
|
|
29
|
+
describeField: (field: string) => string,
|
|
30
|
+
): Effect.Effect<Record<string, string> | undefined, GrafanaSecretRefUnsetError> =>
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
if (refs === undefined) return undefined;
|
|
33
|
+
const out: Record<string, string> = {};
|
|
34
|
+
for (const [field, envVar] of Object.entries(refs)) {
|
|
35
|
+
const raw = process.env[envVar];
|
|
36
|
+
if (raw === undefined || raw.trim() === '') {
|
|
37
|
+
return yield* Effect.fail(
|
|
38
|
+
new GrafanaSecretRefUnsetError({
|
|
39
|
+
message:
|
|
40
|
+
`${envVar} is unset. Export the value of ${describeField(field)} there — ` +
|
|
41
|
+
'never as an Alchemy prop.',
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
out[field] = raw.trim();
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `declaredContentMatches` — "is the declaration a subset of what is live", not plain equality.
|
|
3
|
+
* Extracted out of `dashboard-model.ts` (where it was introduced, after an adversarial review of
|
|
4
|
+
* that PR found plain structural equality made an ordinary, never-edited dashboard show `update`
|
|
5
|
+
* FOREVER) once a second shape needed the identical rule: `Grafana.ContactPoint`'s `settings` is
|
|
6
|
+
* the same story — Grafana can inject default fields into a contact point's settings that the
|
|
7
|
+
* declaration never mentioned, and `Grafana.AlertRuleGroup`'s per-rule `data`/`annotations`/
|
|
8
|
+
* `labels` maps carry the same risk. Nothing about the reasoning below is dashboard-specific;
|
|
9
|
+
* `dashboard-model.ts` now just re-exports this.
|
|
10
|
+
*
|
|
11
|
+
* ★ `declared` NEED ONLY BE A SUBSET OF `live`. Grafana's own save/decode path can decorate an
|
|
12
|
+
* object with fields the declaration never set (a dashboard panel's auto-resolved `datasource`
|
|
13
|
+
* reference is the documented example dashboard-model.ts found — Grafana's schema migration
|
|
14
|
+
* decorates any panel/target whose `datasource` is absent with an explicit `{type, uid}`
|
|
15
|
+
* reference on save). This function instead asks only "does every field the DECLARATION
|
|
16
|
+
* mentions, at any depth, still match what is live" — `live` may carry additional fields, at any
|
|
17
|
+
* depth, that `declared` never mentioned, without being compared at all. A field `declared` DOES
|
|
18
|
+
* mention that is missing, or different, in `live` still fails the match — real content drift is
|
|
19
|
+
* still caught exactly as before; only fields the declaration is silent about are now tolerated.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ THE TRADE-OFF THIS ACCEPTS, DELIBERATELY: removing a field from the declaration (rather than
|
|
22
|
+
* changing its value) can no longer force Grafana to drop it. If a previous declaration set a
|
|
23
|
+
* field and a later one deletes that key entirely, `live` keeps whatever the last write set — the
|
|
24
|
+
* declaration is silent about that key now, so nothing compares it, so no `update` ever fires to
|
|
25
|
+
* change it. This is the same "undeclared means don't care, never means force-to-default"
|
|
26
|
+
* convention already used everywhere else in this kit for a plain optional prop; it now also
|
|
27
|
+
* applies inside a nested JSON value, not just at the top-level props. Declare an explicit
|
|
28
|
+
* replacement value to change a field; omission never clears one that was set by a previous
|
|
29
|
+
* declaration or by Grafana itself.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ ARRAYS COMPARE BY LENGTH, THEN ELEMENT-WISE SUBSET — never a looser "declared has fewer
|
|
32
|
+
* items" rule. A caller whose array IS order-significant (dashboard `panels`/`targets`) or
|
|
33
|
+
* position-keyed (an alert rule group's `rules`) gets that for free; a caller with a genuinely
|
|
34
|
+
* unordered array would need its own normalization first (this file has no `sortedSet`, and
|
|
35
|
+
* nothing in this kit's Grafana family needs one — see dashboard-model.ts's own note).
|
|
36
|
+
*
|
|
37
|
+
* ⛔ NOT RE-MEASURED FOR EVERY CALLER — flagged by an adversarial review of this PR. The exact
|
|
38
|
+
* length rule is confirmed for dashboards (the original, documented case), but whether Grafana
|
|
39
|
+
* ever PADS or APPENDS a default entry to an array the way it decorates an object field (the
|
|
40
|
+
* `datasource` auto-decoration above) is unverified for `Grafana.MuteTiming`'s `time_intervals`
|
|
41
|
+
* specifically — no live mute timing exists in this house to observe (grafana.md's census), and
|
|
42
|
+
* `mute-timing.test.ts`'s own fixtures are hand-authored, not captured. If a future live
|
|
43
|
+
* observation finds Grafana appending an entry (e.g. a normalized default time range) to a
|
|
44
|
+
* declared `time_intervals` array, the length check here would show a permanent false `update` —
|
|
45
|
+
* the exact regression this whole file exists to prevent for OBJECT fields already. `mute-timing.
|
|
46
|
+
* test.ts`'s "array length is exact, not tolerant" test pins today's behavior so that fix has an
|
|
47
|
+
* obvious, single place to change (relax this rule, or give the mute-timing caller its own
|
|
48
|
+
* length-tolerant wrapper) rather than being rediscovered from scratch.
|
|
49
|
+
* ⚠️ `null` ON EITHER SIDE OF A LEAF NEVER MISMATCHES (mirrors `alchemy/Diff`'s `deepEqual` with
|
|
50
|
+
* `stripNullish: true`, which this function replaces wherever a caller needs subset semantics).
|
|
51
|
+
*/
|
|
52
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
53
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
54
|
+
|
|
55
|
+
export const declaredContentMatches = (declared: unknown, live: unknown): boolean => {
|
|
56
|
+
if (declared == null) return true;
|
|
57
|
+
if (Array.isArray(declared)) {
|
|
58
|
+
return (
|
|
59
|
+
Array.isArray(live) &&
|
|
60
|
+
declared.length === live.length &&
|
|
61
|
+
declared.every((item, index) => declaredContentMatches(item, live[index]))
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (isPlainObject(declared)) {
|
|
65
|
+
return (
|
|
66
|
+
isPlainObject(live) &&
|
|
67
|
+
Object.entries(declared).every(([key, value]) => declaredContentMatches(value, live[key]))
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return declared === live;
|
|
71
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `renderContainerFile`/`containerPathFor` are reachable from the package entry — the same pure
|
|
3
|
+
* `.container` renderer `container-fixture.test.ts` (container-form.ts) already proves internally,
|
|
4
|
+
* now callable by a CONSUMER without a deep import.
|
|
5
|
+
*
|
|
6
|
+
* ★ WHY THIS WAS MISSING. `index.ts`'s barrel forwarded `QUADLET_DEFAULT_DIRECTORY`/
|
|
7
|
+
* `QUADLET_SEARCH_DIRECTORIES` (constants) but not the renderer or the path rule, unlike
|
|
8
|
+
* `Systemd.Unit`'s own pure helpers (`renderUnit`/`DEFAULT_UNIT_DIRECTORY`), which ARE both on
|
|
9
|
+
* the barrel. A consumer (homeflare-ct100 PR #1) writing an equivalence test against a live
|
|
10
|
+
* host — the same kind of test this package's own fixture tests are — had no import path to
|
|
11
|
+
* the real renderer and had to reimplement the render contract by hand to write one at all.
|
|
12
|
+
*/
|
|
13
|
+
import { expect, test } from 'bun:test';
|
|
14
|
+
import { containerPathFor, renderContainerFile } from './index.ts';
|
|
15
|
+
|
|
16
|
+
test('renderContainerFile and containerPathFor are reachable from the package entry', () => {
|
|
17
|
+
const rendered = renderContainerFile({
|
|
18
|
+
container: { image: 'docker.io/library/busybox:1', network: 'host' },
|
|
19
|
+
name: 'smoke',
|
|
20
|
+
});
|
|
21
|
+
expect(rendered).toContain('[Container]');
|
|
22
|
+
expect(rendered).toContain('Image=docker.io/library/busybox:1');
|
|
23
|
+
expect(rendered).toContain('Network=host');
|
|
24
|
+
expect(containerPathFor({ name: 'smoke' })).toBe('/etc/containers/systemd/smoke.container');
|
|
25
|
+
});
|
package/src/linux/index.ts
CHANGED
|
@@ -38,5 +38,19 @@ export type {
|
|
|
38
38
|
PodmanContainerProps,
|
|
39
39
|
PodmanUnitLine,
|
|
40
40
|
} from './container.ts';
|
|
41
|
-
|
|
41
|
+
// ★ `renderContainerFile`/`containerPathFor` are the pure `.container` file renderer and its path
|
|
42
|
+
// rule — the same kind of pure helper `renderUnit`/`DEFAULT_UNIT_DIRECTORY` above already are
|
|
43
|
+
// for `Systemd.Unit`. A consumer needs these to prove ITS OWN declared props render into the
|
|
44
|
+
// directive set it expects (an equivalence/fixture test against a live host), the same way this
|
|
45
|
+
// package's own container-fixture.test.ts does internally — without them, a consuming repo has
|
|
46
|
+
// no import path to the real renderer and has to reimplement it to write that test at all
|
|
47
|
+
// (found: homeflare-ct100 PR #1). `containerProblems`/`refuseContainer`/`serviceNameFor` stay
|
|
48
|
+
// internal — no consumer need for those was raised, and validation/naming internals are a wider
|
|
49
|
+
// surface to commit to than one pure render function.
|
|
50
|
+
export {
|
|
51
|
+
containerPathFor,
|
|
52
|
+
QUADLET_DEFAULT_DIRECTORY,
|
|
53
|
+
QUADLET_SEARCH_DIRECTORIES,
|
|
54
|
+
renderContainerFile,
|
|
55
|
+
} from './container-form.ts';
|
|
42
56
|
export { PodmanContainer, PodmanContainerProvider } from './container.ts';
|