@homeflare/alchemy 0.51.0 → 0.52.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/linux/container-env-quote.d.ts +39 -0
- package/dist/linux/container-env-quote.d.ts.map +1 -0
- package/dist/linux/container-form.d.ts +14 -13
- package/dist/linux/container-form.d.ts.map +1 -1
- package/dist/linux/container-paths.d.ts +17 -0
- package/dist/linux/container-paths.d.ts.map +1 -0
- package/dist/linux/index.js +41 -8
- package/dist/linux/index.js.map +6 -4
- package/dist/unifi/index.js.map +2 -2
- package/dist/unifi/network.d.ts +2 -1
- package/dist/unifi/network.d.ts.map +1 -1
- package/docs/unifi.md +11 -10
- package/package.json +1 -1
- package/src/linux/container-env-chain.test.ts +135 -0
- package/src/linux/container-env-live.test.ts +115 -0
- package/src/linux/container-env-oracle.ts +109 -0
- package/src/linux/container-env-quadlet-oracle.ts +174 -0
- package/src/linux/container-env-quote.test.ts +131 -0
- package/src/linux/container-env-quote.ts +126 -0
- package/src/linux/container-fixture.test.ts +3 -1
- package/src/linux/container-form.ts +32 -28
- package/src/linux/container-paths.ts +21 -0
- package/src/unifi/network-scope.test.ts +21 -0
- package/src/unifi/network.ts +2 -1
- package/src/unifi/write-op-reference.test.ts +3 -2
|
@@ -16,6 +16,15 @@
|
|
|
16
16
|
* `containerProblems` below. A container that needs a secret takes `EnvironmentFile=` pointing at
|
|
17
17
|
* a host path an out-of-band secret renderer (openbao-agent) maintains; this family never reads
|
|
18
18
|
* or renders that file's contents.
|
|
19
|
+
* ⛔ `Environment=` VALUES ARE ALWAYS QUOTED, VIA container-env-quote.ts — measured 2026-10-11:
|
|
20
|
+
* unquoted, a spaced value splits at its first space, a quoted fragment re-parses into a bogus
|
|
21
|
+
* variable, and an unknown C escape drops the line's assignments silently (ice2/grafana-oidc.md;
|
|
22
|
+
* the live podman 5.4.2 generator reproduced all three). Quoting costs the PARSE nothing — the
|
|
23
|
+
* generator reads a quoted line as exactly one assignment — and it also stops systemd expanding
|
|
24
|
+
* `%` specifiers in values. It is NOT free at deploy: every Environment line's BYTES change, so
|
|
25
|
+
* the first deploy after this bump rewrites the file and RESTARTS every container declaring
|
|
26
|
+
* `environment` (all 13 on ct100; only grafana's values change meaning) — plan it as one window.
|
|
27
|
+
* Newlines are refused, never encoded (same module).
|
|
19
28
|
* ⛔ `[Install]` SUPPORTS ONLY `Alias=`/`WantedBy=`/`RequiredBy=` FOR A `.container` FILE (same doc,
|
|
20
29
|
* "Currently, only the Alias, WantedBy and RequiredBy keys are supported") — NOT `Also=`, which
|
|
21
30
|
* plain `systemd.unit(5)` allows and unit-text.ts's `INSTALL_KEYS` therefore lists. See
|
|
@@ -23,9 +32,14 @@
|
|
|
23
32
|
*/
|
|
24
33
|
import { type UnitSection, configDigest, digestOf, renderUnit } from './unit-form.ts';
|
|
25
34
|
import { podmanArgsProblems, secretLikeEnvironment, secretLikeLines } from './container-secrets.ts';
|
|
35
|
+
import { environmentLines, environmentValueProblems } from './container-env-quote.ts';
|
|
36
|
+
import { QUADLET_DEFAULT_DIRECTORY, QUADLET_SEARCH_DIRECTORIES } from './container-paths.ts';
|
|
26
37
|
|
|
27
38
|
/** ★ Reused, not redefined: same digest and `restartOn`-join contract as Systemd.Unit. */
|
|
28
39
|
export { configDigest, digestOf };
|
|
40
|
+
// ★ The search path lives in container-paths.ts (file-length cap) and is re-exported here so every
|
|
41
|
+
// importer and the barrel keep one import site — the API does not move.
|
|
42
|
+
export { QUADLET_DEFAULT_DIRECTORY, QUADLET_SEARCH_DIRECTORIES };
|
|
29
43
|
|
|
30
44
|
export type UnitLine = readonly [key: string, value: string];
|
|
31
45
|
|
|
@@ -64,7 +78,8 @@ export type ContainerSection = {
|
|
|
64
78
|
export type ContainerProps = {
|
|
65
79
|
/** The Quadlet file's basename, WITHOUT `.container`. Written as `<name>.container`. */
|
|
66
80
|
name: string;
|
|
67
|
-
/** @default QUADLET_DEFAULT_DIRECTORY. Must be one of QUADLET_SEARCH_DIRECTORIES
|
|
81
|
+
/** @default QUADLET_DEFAULT_DIRECTORY. Must be one of QUADLET_SEARCH_DIRECTORIES
|
|
82
|
+
* (container-paths.ts, where the search path now lives). */
|
|
68
83
|
directory?: string;
|
|
69
84
|
/** `[Unit]` — `Description=`/`Documentation=` typed, everything else verbatim (see file header). */
|
|
70
85
|
unit?: { description?: string; documentation?: string; lines?: readonly UnitLine[] };
|
|
@@ -103,25 +118,8 @@ export type ContainerAttributes = {
|
|
|
103
118
|
};
|
|
104
119
|
|
|
105
120
|
/**
|
|
106
|
-
*
|
|
107
|
-
* Search Path", Podman 5.4, read 2026-09-24: "Quadlet files for the root user can be placed in the
|
|
108
|
-
* following directories ordered in precedence" (`/run` over `/etc` over `/usr/share`).
|
|
109
|
-
* ★ CT100's `caddy.container` lives in the middle one — measured live, `cat` over `ssh ct100`,
|
|
110
|
-
* 2026-09-24: `/etc/containers/systemd/caddy.container`.
|
|
111
|
-
* ⛔ A directory outside this list is invisible to Quadlet: the generator only reads these three, so
|
|
112
|
-
* a file elsewhere is a `.container` file with no `.service` ever generated from it — silently,
|
|
113
|
-
* unlike `Systemd.Unit`'s directory check, which at least writes a unit systemd could load if only
|
|
114
|
-
* it were enabled. See `containerProblems`.
|
|
121
|
+
* ★ A conservative subset of what Quadlet itself accepts as a unit name: no template `@`, no `/`.
|
|
115
122
|
*/
|
|
116
|
-
export const QUADLET_SEARCH_DIRECTORIES = [
|
|
117
|
-
'/run/containers/systemd',
|
|
118
|
-
'/etc/containers/systemd',
|
|
119
|
-
'/usr/share/containers/systemd',
|
|
120
|
-
] as const;
|
|
121
|
-
|
|
122
|
-
export const QUADLET_DEFAULT_DIRECTORY = '/etc/containers/systemd';
|
|
123
|
-
|
|
124
|
-
/** ★ A conservative subset of what Quadlet itself accepts as a unit name: no template `@`, no `/`. */
|
|
125
123
|
export const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
126
124
|
|
|
127
125
|
export const containerPathFor = (props: Pick<ContainerProps, 'name' | 'directory'>): string =>
|
|
@@ -149,13 +147,8 @@ const containerLines = (c: ContainerSection): UnitLine[] => [
|
|
|
149
147
|
...(c.exec === undefined ? [] : [['Exec', c.exec] as UnitLine]),
|
|
150
148
|
...(c.autoUpdate === undefined ? [] : [['AutoUpdate', c.autoUpdate] as UnitLine]),
|
|
151
149
|
...(c.environmentFile ?? []).map((file): UnitLine => ['EnvironmentFile', file]),
|
|
152
|
-
//
|
|
153
|
-
...
|
|
154
|
-
.sort()
|
|
155
|
-
.map((key): UnitLine => [
|
|
156
|
-
'Environment',
|
|
157
|
-
`${key}=${(c.environment as Record<string, string>)[key]}`,
|
|
158
|
-
]),
|
|
150
|
+
// ⛔ Always quoted, via container-env-quote.ts (see this file's header) — sorted there too.
|
|
151
|
+
...environmentLines(c.environment ?? {}),
|
|
159
152
|
...(c.volume ?? []).map((volume): UnitLine => ['Volume', volume]),
|
|
160
153
|
...(c.publishPort ?? []).map((port): UnitLine => ['PublishPort', port]),
|
|
161
154
|
...(c.podmanArgs ?? []).map((arg): UnitLine => ['PodmanArgs', arg]),
|
|
@@ -225,19 +218,30 @@ export const containerProblems = (props: ContainerProps): string[] => {
|
|
|
225
218
|
(problem) => `container.${problem}`,
|
|
226
219
|
),
|
|
227
220
|
);
|
|
221
|
+
// ⛔ Newlines in a key or value are refused before anything renders: a .container file is read
|
|
222
|
+
// line by line, so a raw newline ends the assignment mid-value and whatever follows parses as a
|
|
223
|
+
// NEW directive (key injection when a value comes from external config). Strings here so one
|
|
224
|
+
// plan shows every refusal at once; the typed error stays the contract for direct renderers.
|
|
225
|
+
const envProblems = environmentValueProblems(props.container.environment ?? {});
|
|
226
|
+
found.push(...envProblems);
|
|
228
227
|
// ★ Every OTHER rendered line — `Exec=`/`PodmanArgs=` and the `unit`/`service`/`install` escape
|
|
229
228
|
// hatches — checked too, so a secret cannot reach the file through a field this typed check does
|
|
230
229
|
// not otherwise inspect. `container.environment`'s OWN lines are excluded: `secretLikeEnvironment`
|
|
231
230
|
// above already checked them, with a better error message (the actual env var name).
|
|
231
|
+
// ⛔ The sweep renders WITHOUT the environment (a newline value cannot throw here); the NUL
|
|
232
|
+
// check is skipped when the environment could not render — the refusals above are its result.
|
|
233
|
+
const { environment: _env, ...withoutEnvironment } = props.container;
|
|
232
234
|
found.push(
|
|
233
235
|
...secretLikeLines([
|
|
234
236
|
...unitLines(props),
|
|
235
|
-
|
|
237
|
+
// ⛔ No `Environment` filter needed: the destructure above removed the only field whose
|
|
238
|
+
// lines would carry it, so `containerLines` here emits none — a filter would be dead code.
|
|
239
|
+
...containerLines(withoutEnvironment),
|
|
236
240
|
...serviceLines(props),
|
|
237
241
|
...installLines(props),
|
|
238
242
|
]),
|
|
239
243
|
);
|
|
240
|
-
if (renderContainerFile(props).includes('\u0000'))
|
|
244
|
+
if (envProblems.length === 0 && renderContainerFile(props).includes('\u0000'))
|
|
241
245
|
found.push('the rendered .container file contains NUL');
|
|
242
246
|
return found;
|
|
243
247
|
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Quadlet looks for `.container` files — split out of container-form.ts under the file-length
|
|
3
|
+
* cap, with the measured context that justifies every entry kept intact.
|
|
4
|
+
*
|
|
5
|
+
* Podman's OWN rootful search path, most specific first — `podman-systemd.unit(5)`, "Podman Unit
|
|
6
|
+
* Search Path", Podman 5.4, read 2026-09-24: "Quadlet files for the root user can be placed in the
|
|
7
|
+
* following directories ordered in precedence" (`/run` over `/etc` over `/usr/share`).
|
|
8
|
+
* ★ CT100's `caddy.container` lives in the middle one — measured live, `cat` over `ssh ct100`,
|
|
9
|
+
* 2026-09-24: `/etc/containers/systemd/caddy.container`.
|
|
10
|
+
* ⛔ A directory outside this list is invisible to Quadlet: the generator only reads these three, so
|
|
11
|
+
* a file elsewhere is a `.container` file with no `.service` ever generated from it — silently,
|
|
12
|
+
* unlike `Systemd.Unit`'s directory check, which at least writes a unit systemd could load if only
|
|
13
|
+
* it were enabled. See `containerProblems`.
|
|
14
|
+
*/
|
|
15
|
+
export const QUADLET_SEARCH_DIRECTORIES = [
|
|
16
|
+
'/run/containers/systemd',
|
|
17
|
+
'/etc/containers/systemd',
|
|
18
|
+
'/usr/share/containers/systemd',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export const QUADLET_DEFAULT_DIRECTORY = '/etc/containers/systemd';
|
|
@@ -9,6 +9,7 @@ import * as Effect from 'effect/Effect';
|
|
|
9
9
|
import * as Exit from 'effect/Exit';
|
|
10
10
|
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
11
11
|
import { type NetworkProps, spec } from './network.ts';
|
|
12
|
+
import { IMMUTABLE_KEYS, REMOVABLE_KEYS, SETTABLE_KEYS } from './network-scope.ts';
|
|
12
13
|
import { UnifiLiveDriftedSinceDeploy, UnifiUpdateDidNotConverge } from './policy.ts';
|
|
13
14
|
import { unifiOperations } from './resource.ts';
|
|
14
15
|
|
|
@@ -71,6 +72,26 @@ const tagOf = (exit: Exit.Exit<unknown, unknown>) => {
|
|
|
71
72
|
const puts = (fake: { seen: ReadonlyArray<{ method: string }> }) =>
|
|
72
73
|
fake.seen.filter((s) => s.method === 'PUT');
|
|
73
74
|
|
|
75
|
+
// ⛔ EXACT-CONTENTS PIN. The three allowlists are the whole write scope: a widened SETTABLE_KEYS or
|
|
76
|
+
// REMOVABLE_KEYS would let a whole-object PUT reach a key the controller reads "off" when omitted,
|
|
77
|
+
// and a narrowed IMMUTABLE_KEYS would let a patch move the network (`zoneId`/`vlanId`/`management`/
|
|
78
|
+
// `deviceId`) or the management LAN (`default` is refused separately, not listed here). A widening
|
|
79
|
+
// with a key no behaviour test below names would keep them all green, so each list is pinned to
|
|
80
|
+
// its EXACT contents — a mutation fails this test even when nothing else does.
|
|
81
|
+
describe('the three key sets are pinned to their exact contents', () => {
|
|
82
|
+
test('SETTABLE_KEYS is exactly ipv6Configuration', () => {
|
|
83
|
+
expect([...SETTABLE_KEYS]).toEqual(['ipv6Configuration']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('REMOVABLE_KEYS is exactly ipv6Configuration', () => {
|
|
87
|
+
expect([...REMOVABLE_KEYS]).toEqual(['ipv6Configuration']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('IMMUTABLE_KEYS is exactly zoneId/vlanId/management/deviceId', () => {
|
|
91
|
+
expect([...IMMUTABLE_KEYS]).toEqual(['zoneId', 'vlanId', 'management', 'deviceId']);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
74
95
|
describe('removable-field allowlist (omission would turn the block off)', () => {
|
|
75
96
|
test.each([
|
|
76
97
|
['ipv4Configuration', 'ipv4Configuration'],
|
package/src/unifi/network.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `Unifi.Network` — one network on a UniFi Network site,
|
|
2
|
+
* `Unifi.Network` — one network on a UniFi Network site, the ONE family with a write path
|
|
3
|
+
* (`policy.ts` lifts Tim's read-only rule for exactly this update; see `network-update.ts`).
|
|
3
4
|
*
|
|
4
5
|
* ★ WHY NETWORK IS IN THE FIRST IMPORT SET. `getNetworkDetails`/`getNetworksOverviewPage`
|
|
5
6
|
* (`../../distilled-unifi-network/src/services/networks.ts`) is a list+get pair over
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* what those operations are named. `resource.ts`'s engine and `policy.ts`'s refusal never call one
|
|
5
5
|
* today; nothing in the TYPE SYSTEM stops a future family file from importing, say,
|
|
6
6
|
* `unifi_devices.adoptDevice` and calling it directly, bypassing `unifiOperations` altogether.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* The wire guard (`wire-guard.ts`'s `guardedHttpClient`, `GetOnlyHttpClient` = empty allow
|
|
8
|
+
* list) is the runtime backstop if one ever did; this is the static one, so the mistake is
|
|
9
|
+
* caught at test time instead of by a wire-level defect.
|
|
9
10
|
*
|
|
10
11
|
* ⚠️ REWRITTEN (2026-09-26, red team, IMPORTANT-2). The previous version parsed import statements
|
|
11
12
|
* structurally (namespace alias + `.op`, or a named import's LOCAL name) and missed: the SDK's
|