@homeflare/alchemy 0.50.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/acl-rule-ordering.d.ts +6 -1
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -1
- package/dist/unifi/acl-rule.d.ts +6 -1
- package/dist/unifi/acl-rule.d.ts.map +1 -1
- package/dist/unifi/dns-policy.d.ts +6 -1
- package/dist/unifi/dns-policy.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +4 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-ordering.d.ts +6 -1
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -1
- package/dist/unifi/firewall-policy.d.ts +6 -1
- package/dist/unifi/firewall-policy.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +6 -1
- package/dist/unifi/firewall-zone.d.ts.map +1 -1
- package/dist/unifi/index.d.ts +5 -3
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +339 -86
- package/dist/unifi/index.js.map +10 -6
- package/dist/unifi/network-scope.d.ts +31 -0
- package/dist/unifi/network-scope.d.ts.map +1 -0
- package/dist/unifi/network-update.d.ts +44 -0
- package/dist/unifi/network-update.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +34 -13
- package/dist/unifi/network.d.ts.map +1 -1
- package/dist/unifi/policy.d.ts +89 -1
- package/dist/unifi/policy.d.ts.map +1 -1
- package/dist/unifi/resource.d.ts +17 -43
- package/dist/unifi/resource.d.ts.map +1 -1
- package/dist/unifi/traffic-matching-list.d.ts +6 -1
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -1
- package/dist/unifi/update-reconcile.d.ts +31 -0
- package/dist/unifi/update-reconcile.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +6 -1
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -1
- package/dist/unifi/wire-guard.d.ts +33 -0
- package/dist/unifi/wire-guard.d.ts.map +1 -0
- package/docs/unifi-network-write.md +90 -0
- package/docs/unifi.md +40 -39
- 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/fake-unifi.ts +15 -2
- package/src/unifi/handler-allow-news.test.ts +80 -0
- package/src/unifi/index.ts +21 -5
- package/src/unifi/network-scope.test.ts +224 -0
- package/src/unifi/network-scope.ts +75 -0
- package/src/unifi/network-update.test.ts +250 -0
- package/src/unifi/network-update.ts +95 -0
- package/src/unifi/network.test.ts +27 -1
- package/src/unifi/network.ts +58 -13
- package/src/unifi/policy.ts +128 -10
- package/src/unifi/resource.test.ts +15 -3
- package/src/unifi/resource.ts +64 -105
- package/src/unifi/update-reconcile.ts +126 -0
- package/src/unifi/wire-guard-anchor.test.ts +198 -0
- package/src/unifi/wire-guard-round2.test.ts +120 -0
- package/src/unifi/wire-guard.test.ts +218 -0
- package/src/unifi/wire-guard.ts +192 -0
- package/src/unifi/write-op-reference.test.ts +41 -4
- package/src/unifi/get-only-guard.test.ts +0 -165
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `[Container]` `Environment=` value renderer — the one place a declared env var crosses THREE
|
|
3
|
+
* parsers between `container.environment` and the container's own environment, each able to mangle
|
|
4
|
+
* a value the props held intact:
|
|
5
|
+
*
|
|
6
|
+
* 1. Quadlet's generator reads the raw line value with systemd's exec-like splitter:
|
|
7
|
+
* `pkg/systemd/parser/unitfile.go` `LookupAllKeyVal` calls `splitString(value,
|
|
8
|
+
* WhitespaceSeparators, SplitRelax | SplitUnquote | SplitCUnescape)` (podman v5.4.0). An
|
|
9
|
+
* unquoted space ENDS the assignment there; a quote starts/ends a quoted region wherever it
|
|
10
|
+
* sits; C-escapes are processed; and an unsupported escape (`\d` in a regex, say) fails the
|
|
11
|
+
* split — `LookupAllKeyVal` then drops EVERY assignment of that line, silently.
|
|
12
|
+
* 2. Quadlet re-emits each assignment as `--env KEY=value` arguments of the generated unit's
|
|
13
|
+
* `ExecStart=` (`podman.addEnv` → `AddCmdline` → `escapeWords`, same source), re-quoting for
|
|
14
|
+
* the next parser but never touching `%` or `$` — both pass through verbatim.
|
|
15
|
+
* 3. systemd parses that `ExecStart=` and expands BOTH syntaxes there. `%`: `%%` folds to one
|
|
16
|
+
* literal `%` and an unknown `%x` makes the setting invalid ("Specifiers must be known and
|
|
17
|
+
* resolvable for the setting to be valid", systemd.unit(5) §Specifiers). `$`
|
|
18
|
+
* (systemd.service(5) §Command lines, v257): the generated ExecStart has no `:` prefix, so
|
|
19
|
+
* variables are always substituted — `${FOO}` is "erased and replaced by the exact value of
|
|
20
|
+
* the environment variable", "to pass a literal dollar sign, use "$$"", and a bare `$` in
|
|
21
|
+
* the middle of a word stays literal (unbraced expansion applies only to a whole `$FOO`
|
|
22
|
+
* word, which a `KEY=value` word never is). So `pa$$word` arrives halved as `pa$word` and
|
|
23
|
+
* `a${HOME}b` arrives as `ab` — the variable is resolved from the UNIT's environment, not
|
|
24
|
+
* the one this file declares.
|
|
25
|
+
*
|
|
26
|
+
* ⛔ MEASURED 2026-10-11 (ice2/grafana-oidc.md; reproduced locally with the real podman 5.4.2
|
|
27
|
+
* generator): the previous renderer emitted `Environment=KEY=value` UNQUOTED, so Grafana's
|
|
28
|
+
* `ROLE_ATTRIBUTE_PATH=contains(groups[*], '…')` reached the container as
|
|
29
|
+
* `ROLE_ATTRIBUTE_PATH=contains(groups[*],` — split at the first space — plus a bogus `--env
|
|
30
|
+
* cn=…` variable, and every login failed `role_attribute_strict_violation`.
|
|
31
|
+
* ⛔ MEASURED 2026-10-12 (same generator dry run, plus a live systemd 257 unit): escaping `\`, `"`,
|
|
32
|
+
* `%` and `$` is what survives all three parsers. `Environment="KEY=a b"` generates `--env
|
|
33
|
+
* "KEY=a b"`; `Environment="D=pa$$$$word"` generates `--env D=pa$$$$word` and
|
|
34
|
+
* `Environment="B=a$${HOME}b"` generates `--env B=a$${HOME}b` — podman passes `$` through
|
|
35
|
+
* untouched — and the live unit folded them back to `pa$$word` and `a${HOME}b`, exactly the
|
|
36
|
+
* declared values, while the same values UNQUOTED arrived as `pa$word` and `ab`.
|
|
37
|
+
* ⛔ NEWLINES ARE REFUSED, NOT ENCODED — a raw newline in a key or value breaks the LINE the whole
|
|
38
|
+
* format is built on: quadlet reads the file line by line, so the assignment ends mid-value and
|
|
39
|
+
* anything after the newline is parsed as a NEW directive (attacker-controllable key injection
|
|
40
|
+
* when a value comes from external config). The refusal is a typed error so a caller can catch
|
|
41
|
+
* it by tag; `containerProblems` (container-form.ts) turns it into a problem string so one plan
|
|
42
|
+
* still shows every refusal at once.
|
|
43
|
+
*/
|
|
44
|
+
import * as Data from 'effect/Data';
|
|
45
|
+
|
|
46
|
+
/** Same shape as container-form.ts's `UnitLine` — structural, so this module imports nothing back. */
|
|
47
|
+
export type EnvironmentLine = readonly [key: string, value: string];
|
|
48
|
+
|
|
49
|
+
/** The shared rule text — the typed error's message and the problem string are the same sentence. */
|
|
50
|
+
const environmentValueProblem = (key: string): string =>
|
|
51
|
+
`environment key ${JSON.stringify(key)} contains a newline; a .container file is line-based, ` +
|
|
52
|
+
'so quadlet would end the assignment mid-value and parse whatever follows as a new directive. ' +
|
|
53
|
+
'Nothing was written.';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* ⛔ CARRIES THE KEY AND THE RULE, NEVER THE VALUE — an error is logged, retried and pasted into a
|
|
57
|
+
* PR, and a newline-containing value may be arbitrarily large or semi-secret; the key name (which
|
|
58
|
+
* `secretLikeEnvironment` already names in its own messages) is enough to locate the prop.
|
|
59
|
+
*/
|
|
60
|
+
export class QuadletEnvironmentValueError extends Data.TaggedError('QuadletEnvironmentValueError')<{
|
|
61
|
+
readonly key: string;
|
|
62
|
+
}> {
|
|
63
|
+
override get message(): string {
|
|
64
|
+
return environmentValueProblem(this.key);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `Environment="KEY=value"` — the whole assignment double-quoted, with `\`, `"`, `%` and `$`
|
|
70
|
+
* escaped (in that order, over the joined `KEY=value`), so the wrapping quotes this function adds
|
|
71
|
+
* can never be confused with quotes the value contained, every `%` reaches systemd's specifier
|
|
72
|
+
* expansion as the documented `%%` literal instead of an unknown `%x` that invalidates the unit,
|
|
73
|
+
* and every `$` reaches it as `$$` — the documented literal-dollar escape (systemd.service(5)
|
|
74
|
+
* §Command lines) — instead of a `${FOO}` systemd would erase and replace with a variable of ITS
|
|
75
|
+
* choosing, or a `$$` it would fold and halve.
|
|
76
|
+
*/
|
|
77
|
+
export const quoteEnvironmentValue = (key: string, value: string): string => {
|
|
78
|
+
if (key.includes('\n') || value.includes('\n')) throw new QuadletEnvironmentValueError({ key });
|
|
79
|
+
// ⛔ THE `$` REPLACEMENT MUST BE A FUNCTION — measured 2026-10-12: `replaceAll`'s replacement
|
|
80
|
+
// STRING runs its own `$`-pattern mini-language (same fold as systemd: `$$` in it IS one
|
|
81
|
+
// literal `$`, and `$&` is the match), so a plain `'$$'` replacement silently no-ops and the
|
|
82
|
+
// value ships bare. A function's return value is inserted verbatim, no patterns read.
|
|
83
|
+
// The earlier `\`, `"` and `%` replacements keep string form: only `$` starts a pattern.
|
|
84
|
+
return `"${`${key}=${value}`
|
|
85
|
+
.replaceAll('\\', '\\\\')
|
|
86
|
+
.replaceAll('"', '\\"')
|
|
87
|
+
.replaceAll('%', '%%')
|
|
88
|
+
.replaceAll('$', () => '$$')}"`;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The sorted, quoted `Environment=` lines of a `[Container]` section — spliced by container-form.ts
|
|
93
|
+
* between `EnvironmentFile=` and `Volume=`, exactly where the unquoted renderer emitted them, so
|
|
94
|
+
* the render ORDER does not change (only the bytes of the Environment lines themselves do).
|
|
95
|
+
*/
|
|
96
|
+
export const environmentLines = (
|
|
97
|
+
environment: Readonly<Record<string, string>>,
|
|
98
|
+
): readonly EnvironmentLine[] =>
|
|
99
|
+
// ★ Object.entries, not keys+index: under noUncheckedIndexedAccess an indexed read is
|
|
100
|
+
// `string | undefined`, and the sort below reproduces the old `keys().sort()` order exactly
|
|
101
|
+
// (both compare UTF-16 code units), so the render order does not change.
|
|
102
|
+
Object.entries(environment)
|
|
103
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
104
|
+
.map(([key, value]): EnvironmentLine => ['Environment', quoteEnvironmentValue(key, value)]);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The newline refusal as problem STRINGS (containerProblems' currency), so one plan still shows the
|
|
108
|
+
* whole list instead of aborting on the first newline — the typed error above stays the contract
|
|
109
|
+
* for anyone rendering directly.
|
|
110
|
+
*/
|
|
111
|
+
export const environmentValueProblems = (
|
|
112
|
+
environment: Readonly<Record<string, string>>,
|
|
113
|
+
): string[] => {
|
|
114
|
+
const found: string[] = [];
|
|
115
|
+
for (const [key, value] of Object.entries(environment).sort(([a], [b]) =>
|
|
116
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
117
|
+
)) {
|
|
118
|
+
try {
|
|
119
|
+
quoteEnvironmentValue(key, value);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (!(error instanceof QuadletEnvironmentValueError)) throw error;
|
|
122
|
+
found.push(environmentValueProblem(key));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return found;
|
|
126
|
+
};
|
|
@@ -66,7 +66,9 @@ test('renders every directive CT100’s live caddy.container carries', () => {
|
|
|
66
66
|
'ContainerName=caddy',
|
|
67
67
|
'Network=host',
|
|
68
68
|
'EnvironmentFile=/etc/caddy/cf-token.env',
|
|
69
|
-
|
|
69
|
+
// ⛔ Quoted, like every Environment= value now (container-env-quote.ts) — the caddy value has
|
|
70
|
+
// no spaces, but uniform quoting is the rule; container-env-quote.test.ts has the story.
|
|
71
|
+
'Environment="CADDY_CONFIG=/etc/caddy/Caddyfile"',
|
|
70
72
|
'Volume=/etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z',
|
|
71
73
|
'Volume=/etc/caddy/rendered:/etc/caddy/rendered:ro,Z',
|
|
72
74
|
'Volume=/etc/caddy/cf-token.env:/run/cf/token.env:ro',
|
|
@@ -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';
|
package/src/unifi/fake-unifi.ts
CHANGED
|
@@ -17,7 +17,14 @@ import * as FetchHttpClient from 'effect/http/FetchHttpClient';
|
|
|
17
17
|
export const FAKE_BASE = 'https://unifi.example.com/proxy/network/integration';
|
|
18
18
|
export const FAKE_KEY = 'placeholder-unifi-key';
|
|
19
19
|
|
|
20
|
-
export type Seen = {
|
|
20
|
+
export type Seen = {
|
|
21
|
+
readonly method: string;
|
|
22
|
+
readonly path: string;
|
|
23
|
+
/** Parsed JSON request body, when one was sent. */
|
|
24
|
+
readonly body?: unknown;
|
|
25
|
+
/** The `redirect` mode fetch was handed (`FetchHttpClient.RequestInit`), when set. */
|
|
26
|
+
readonly redirect?: NonNullable<RequestInit['redirect']>;
|
|
27
|
+
};
|
|
21
28
|
|
|
22
29
|
/**
|
|
23
30
|
* Plain-text failure. `protocol.ts`'s `errorEnvelope` also decodes a documented JSON shape
|
|
@@ -42,7 +49,13 @@ export const fakeUnifi = (route: (method: string, url: URL) => Response) => {
|
|
|
42
49
|
const request =
|
|
43
50
|
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
44
51
|
const url = new URL(request.url);
|
|
45
|
-
|
|
52
|
+
const text = request.method === 'GET' ? '' : await request.clone().text();
|
|
53
|
+
seen.push({
|
|
54
|
+
method: request.method,
|
|
55
|
+
path: `${url.pathname}${url.search}`,
|
|
56
|
+
...(text === '' ? {} : { body: JSON.parse(text) as unknown }),
|
|
57
|
+
...(init?.redirect === undefined ? {} : { redirect: init.redirect }),
|
|
58
|
+
});
|
|
46
59
|
return route(request.method, url);
|
|
47
60
|
}) as typeof globalThis.fetch;
|
|
48
61
|
return { fetch, seen };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `unifiHandlers.reconcile` builds the allowed PUT from `news`, never `olds` (red team round 1,
|
|
3
|
+
* mutation gap), and anchors it to the CONFIGURED base URL read from the credentials.
|
|
4
|
+
*
|
|
5
|
+
* ★ A probe spec whose `describe` is constant, so `updateReconcile`'s identity check passes and the
|
|
6
|
+
* ONLY thing separating `news.id` from `olds.id` is the wire guard's allow entry.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import * as ConfigProvider from 'effect/ConfigProvider';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import * as Exit from 'effect/Exit';
|
|
12
|
+
import * as Layer from 'effect/Layer';
|
|
13
|
+
import * as HttpClient from 'effect/http/HttpClient';
|
|
14
|
+
import * as HttpClientRequest from 'effect/http/HttpClientRequest';
|
|
15
|
+
import { FAKE_BASE, FAKE_KEY, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
16
|
+
import { type UnifiSpec, unifiHandlers } from './resource.ts';
|
|
17
|
+
|
|
18
|
+
interface Probe {
|
|
19
|
+
id: string;
|
|
20
|
+
}
|
|
21
|
+
interface State {
|
|
22
|
+
written: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const probe: UnifiSpec<Probe, State, State, unknown, never> = {
|
|
26
|
+
type: 'Test.Probe',
|
|
27
|
+
describe: () => 'probe',
|
|
28
|
+
fetchLive: () =>
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
const client = yield* HttpClient.HttpClient;
|
|
31
|
+
const res = yield* client.execute(HttpClientRequest.get(`${FAKE_BASE}/probe`));
|
|
32
|
+
return (yield* res.json) as unknown as State;
|
|
33
|
+
}) as never,
|
|
34
|
+
attributes: (live) => live,
|
|
35
|
+
matches: (a) => a.written,
|
|
36
|
+
update: {
|
|
37
|
+
allowedWrite: (props) => ({ method: 'PUT', tail: `/probe/${props.id}` }),
|
|
38
|
+
// `id` differs between news and olds, so the patch is non-empty and the write really runs.
|
|
39
|
+
patchKeys: ['id'],
|
|
40
|
+
driftOf: () => [],
|
|
41
|
+
checkScope: () => Effect.void,
|
|
42
|
+
write: (_live, _patch, props) =>
|
|
43
|
+
Effect.gen(function* () {
|
|
44
|
+
const client = yield* HttpClient.HttpClient;
|
|
45
|
+
const res = yield* client.execute(HttpClientRequest.put(`${FAKE_BASE}/probe/${props.id}`));
|
|
46
|
+
return (yield* res.json) as unknown as State;
|
|
47
|
+
}) as never,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const config = ConfigProvider.layer(
|
|
52
|
+
ConfigProvider.fromUnknown({
|
|
53
|
+
UNIFI_NETWORK_API_BASE_URL: FAKE_BASE,
|
|
54
|
+
UNIFI_NETWORK_API_KEY: FAKE_KEY,
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const reconcile = (news: Probe, olds: Probe) => {
|
|
59
|
+
let written = false;
|
|
60
|
+
const fake = fakeUnifi((method) => {
|
|
61
|
+
if (method === 'PUT') written = true;
|
|
62
|
+
return Response.json({ written });
|
|
63
|
+
});
|
|
64
|
+
const exit = Effect.runPromiseExit(
|
|
65
|
+
unifiHandlers(probe)
|
|
66
|
+
.reconcile({ news, olds, output: { written: false }, session: { note: () => Effect.void } })
|
|
67
|
+
.pipe(Effect.provide(Layer.mergeAll(fakeUnifiLayer(fake.fetch), config))) as never,
|
|
68
|
+
);
|
|
69
|
+
return { fake, exit };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
describe('unifiHandlers.reconcile allow entry', () => {
|
|
73
|
+
test('is built from news: a PUT to news.id passes even though olds.id differs', async () => {
|
|
74
|
+
const { fake, exit } = reconcile({ id: 'NEW' }, { id: 'OLD' });
|
|
75
|
+
expect(Exit.isSuccess(await exit)).toBe(true);
|
|
76
|
+
expect(fake.seen.filter((s) => s.method === 'PUT').map((s) => s.path)).toEqual([
|
|
77
|
+
'/proxy/network/integration/probe/NEW',
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/unifi/index.ts
CHANGED
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* UniFi Network providers for Alchemy —
|
|
2
|
+
* UniFi Network providers for Alchemy — ADOPT-ONLY, except `Unifi.Network` update (Tim's rule of
|
|
3
|
+
* 2026-09-24 lifted for that one case on 2026-10-10; `policy.ts`).
|
|
3
4
|
*
|
|
4
5
|
* ⛔ THIS BARREL IS THE PUBLIC API, SMALLER THAN THE DIRECTORY. `resource.ts`'s generic engine
|
|
5
6
|
* and each `*-form.ts`'s wire helpers are internals a provider needs but a consumer should
|
|
6
7
|
* not depend on — mirrors `../netbox/index.ts`.
|
|
7
8
|
*/
|
|
8
|
-
export {
|
|
9
|
+
export {
|
|
10
|
+
UNIFI_READ_ONLY_POLICY,
|
|
11
|
+
UNIFI_WRITE_POLICY,
|
|
12
|
+
UnifiFieldNotRemovable,
|
|
13
|
+
UnifiFieldNotSettable,
|
|
14
|
+
UnifiIdentityChanged,
|
|
15
|
+
UnifiImmutableFieldChanged,
|
|
16
|
+
UnifiLiveDriftedSinceDeploy,
|
|
17
|
+
UnifiManagementNetworkRefused,
|
|
18
|
+
UnifiUpdateDidNotConverge,
|
|
19
|
+
UnifiUpdateWouldBeNoop,
|
|
20
|
+
UnifiWriteRefused,
|
|
21
|
+
type UnifiWriteAction,
|
|
22
|
+
} from './policy.ts';
|
|
9
23
|
export {
|
|
10
24
|
// Barrel-exported (not just internal to `unifiHandlers`) so `homeflare-network`'s own
|
|
11
25
|
// import-layer guard (plan item C0) can reuse this instead of re-implementing the same
|
|
12
26
|
// `HttpClient` wrap against a second copy of the SDK's `HttpClient.HttpClient` service.
|
|
27
|
+
type AllowedWrite,
|
|
13
28
|
GetOnlyHttpClient,
|
|
14
29
|
UnifiNonGetRequest,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from './
|
|
30
|
+
UnifiRefusedRequest,
|
|
31
|
+
guardedHttpClient,
|
|
32
|
+
} from './wire-guard.ts';
|
|
33
|
+
export { type UnifiRequirements, type UnifiSpec } from './resource.ts';
|
|
18
34
|
export type { FieldDrift } from './drift.ts';
|
|
19
35
|
export {
|
|
20
36
|
UnifiNetwork,
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.Network`'s write SCOPE (`network-scope.ts`), identity check, no-retry PUT and message
|
|
3
|
+
* wording — red team round 1 on PR 377. Drives `unifiOperations(spec).reconcile` against `fakeUnifi`,
|
|
4
|
+
* so the real SDK `updateNetwork` assembles the request and the fake records every one.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import * as Cause from 'effect/Cause';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import * as Exit from 'effect/Exit';
|
|
10
|
+
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
11
|
+
import { type NetworkProps, spec } from './network.ts';
|
|
12
|
+
import { IMMUTABLE_KEYS, REMOVABLE_KEYS, SETTABLE_KEYS } from './network-scope.ts';
|
|
13
|
+
import { UnifiLiveDriftedSinceDeploy, UnifiUpdateDidNotConverge } from './policy.ts';
|
|
14
|
+
import { unifiOperations } from './resource.ts';
|
|
15
|
+
|
|
16
|
+
const PATH = '/proxy/network/integration/v1/sites/site-1/networks/net-1';
|
|
17
|
+
const IPV6 = {
|
|
18
|
+
interfaceType: 'STATIC',
|
|
19
|
+
prefixLength: 64,
|
|
20
|
+
hostIpAddress: '2602:f660:0:16::254',
|
|
21
|
+
clientAddressAssignment: { slaacEnabled: true },
|
|
22
|
+
routerAdvertisement: { priority: 'HIGH' },
|
|
23
|
+
};
|
|
24
|
+
const BASE: NetworkProps = {
|
|
25
|
+
siteId: 'site-1',
|
|
26
|
+
networkId: 'net-1',
|
|
27
|
+
name: 'Compute',
|
|
28
|
+
enabled: true,
|
|
29
|
+
management: 'ADVANCED',
|
|
30
|
+
vlanId: 16,
|
|
31
|
+
dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1'] },
|
|
32
|
+
ipv4Configuration: { dhcpConfiguration: { mode: 'SERVER' }, hostIpAddress: '10.20.16.254' },
|
|
33
|
+
};
|
|
34
|
+
const WITH_V6 = { ...BASE, ipv6Configuration: IPV6 } as NetworkProps;
|
|
35
|
+
|
|
36
|
+
const live = (extra: Record<string, unknown> = {}) => ({
|
|
37
|
+
id: 'net-1',
|
|
38
|
+
default: false,
|
|
39
|
+
metadata: { origin: 'USER' },
|
|
40
|
+
...BASE,
|
|
41
|
+
siteId: undefined,
|
|
42
|
+
networkId: undefined,
|
|
43
|
+
...extra,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const run = (
|
|
47
|
+
liveBody: object,
|
|
48
|
+
news: NetworkProps,
|
|
49
|
+
olds: NetworkProps,
|
|
50
|
+
put: () => Response = () => Response.json(liveBody),
|
|
51
|
+
) => {
|
|
52
|
+
const fake = fakeUnifi((method, url) =>
|
|
53
|
+
url.pathname === PATH
|
|
54
|
+
? method === 'GET'
|
|
55
|
+
? Response.json(liveBody)
|
|
56
|
+
: put()
|
|
57
|
+
: fakeFailure(400, 'unexpected'),
|
|
58
|
+
);
|
|
59
|
+
const exit = Effect.runPromiseExit(
|
|
60
|
+
unifiOperations(spec)
|
|
61
|
+
.reconcile(news, { olds, output: {} })
|
|
62
|
+
.pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
63
|
+
);
|
|
64
|
+
return { fake, exit };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const tagOf = (exit: Exit.Exit<unknown, unknown>) => {
|
|
68
|
+
if (!Exit.isFailure(exit)) return 'Success';
|
|
69
|
+
const error = Cause.squash(exit.cause) as { _tag?: string; message?: string };
|
|
70
|
+
return error._tag ?? 'defect';
|
|
71
|
+
};
|
|
72
|
+
const puts = (fake: { seen: ReadonlyArray<{ method: string }> }) =>
|
|
73
|
+
fake.seen.filter((s) => s.method === 'PUT');
|
|
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
|
+
|
|
95
|
+
describe('removable-field allowlist (omission would turn the block off)', () => {
|
|
96
|
+
test.each([
|
|
97
|
+
['ipv4Configuration', 'ipv4Configuration'],
|
|
98
|
+
['dhcpGuarding', 'dhcpGuarding'],
|
|
99
|
+
['a required key (name)', 'name'],
|
|
100
|
+
])('dropping %s => UnifiFieldNotRemovable, zero PUTs', async (_n, key) => {
|
|
101
|
+
const { [key as 'name']: _gone, ...news } = { ...WITH_V6 } as NetworkProps;
|
|
102
|
+
const { fake, exit } = run(live({ ipv6Configuration: IPV6 }), news as NetworkProps, WITH_V6);
|
|
103
|
+
const result = await exit;
|
|
104
|
+
expect(tagOf(result)).toBe('UnifiFieldNotRemovable');
|
|
105
|
+
expect(JSON.stringify(result)).toContain(key);
|
|
106
|
+
expect(puts(fake)).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ⚠️ Mutation gap: an explicit `null` removes a key just like absence (`== null`, not `=== undefined`).
|
|
110
|
+
test('an explicit null on ipv6Configuration removes it from the body', async () => {
|
|
111
|
+
const news = { ...BASE, ipv6Configuration: null } as unknown as NetworkProps;
|
|
112
|
+
const { fake, exit } = run(live({ ipv6Configuration: IPV6 }), news, WITH_V6, () =>
|
|
113
|
+
Response.json(live()),
|
|
114
|
+
);
|
|
115
|
+
expect(tagOf(await exit)).toBe('Success');
|
|
116
|
+
const body = puts(fake)[0] as { body?: Record<string, unknown> };
|
|
117
|
+
expect(body.body).toBeDefined();
|
|
118
|
+
expect('ipv6Configuration' in (body.body as object)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('settable-field allowlist (only ipv6Configuration may be written)', () => {
|
|
123
|
+
// ⛔ Round-2 BLOCKING finding: the PR claimed this scope but sent these PUTs. Each case is a real
|
|
124
|
+
// change to a non-ipv6 key, with live matching the prior state, so only the allowlist stops it.
|
|
125
|
+
test.each([
|
|
126
|
+
['enabled', { enabled: false }, {}],
|
|
127
|
+
['internetAccessEnabled', { internetAccessEnabled: false }, { internetAccessEnabled: true }],
|
|
128
|
+
[
|
|
129
|
+
'ipv4Configuration',
|
|
130
|
+
{ ipv4Configuration: { dhcpConfiguration: { mode: 'NONE' }, hostIpAddress: '10.9.9.1' } },
|
|
131
|
+
{},
|
|
132
|
+
],
|
|
133
|
+
['dhcpGuarding', { dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.9'] } }, {}],
|
|
134
|
+
])('a patch that sets %s => UnifiFieldNotSettable, zero PUTs', async (field, change, olds) => {
|
|
135
|
+
const oldProps = { ...WITH_V6, ...olds } as NetworkProps;
|
|
136
|
+
const { fake, exit } = run(
|
|
137
|
+
live({ ipv6Configuration: IPV6, ...olds }),
|
|
138
|
+
{ ...oldProps, ...change } as NetworkProps,
|
|
139
|
+
oldProps,
|
|
140
|
+
);
|
|
141
|
+
const result = await exit;
|
|
142
|
+
expect(tagOf(result)).toBe('UnifiFieldNotSettable');
|
|
143
|
+
expect(JSON.stringify(result)).toContain(field);
|
|
144
|
+
expect(puts(fake)).toHaveLength(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('setting ipv6Configuration is still allowed', async () => {
|
|
148
|
+
const { fake, exit } = run(live(), WITH_V6, BASE, () =>
|
|
149
|
+
Response.json(live({ ipv6Configuration: IPV6 })),
|
|
150
|
+
);
|
|
151
|
+
expect(tagOf(await exit)).toBe('Success');
|
|
152
|
+
expect(puts(fake)).toHaveLength(1);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('write scope', () => {
|
|
157
|
+
test('a live default (management) network is refused, zero PUTs', async () => {
|
|
158
|
+
const { fake, exit } = run(live({ default: true }), WITH_V6, BASE);
|
|
159
|
+
expect(tagOf(await exit)).toBe('UnifiManagementNetworkRefused');
|
|
160
|
+
expect(puts(fake)).toHaveLength(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test.each([
|
|
164
|
+
['zoneId', { zoneId: 'zone-2' }, { zoneId: 'zone-1' }],
|
|
165
|
+
['vlanId', { vlanId: 17 }, {}],
|
|
166
|
+
['management', { management: 'GATEWAY' }, {}],
|
|
167
|
+
['deviceId', { deviceId: 'dev-2' }, { deviceId: 'dev-1' }],
|
|
168
|
+
])('a patch that changes %s is refused, zero PUTs', async (field, change, olds) => {
|
|
169
|
+
const oldProps = { ...WITH_V6, ...olds } as NetworkProps;
|
|
170
|
+
const { fake, exit } = run(
|
|
171
|
+
live({ ipv6Configuration: IPV6, ...olds }),
|
|
172
|
+
{ ...oldProps, ...change } as NetworkProps,
|
|
173
|
+
oldProps,
|
|
174
|
+
);
|
|
175
|
+
const result = await exit;
|
|
176
|
+
expect(tagOf(result)).toBe('UnifiImmutableFieldChanged');
|
|
177
|
+
expect(JSON.stringify(result)).toContain(field);
|
|
178
|
+
expect(puts(fake)).toHaveLength(0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('olds naming another network id => UnifiIdentityChanged, zero PUTs', async () => {
|
|
182
|
+
const { fake, exit } = run(live(), WITH_V6, { ...BASE, networkId: 'net-OLD' });
|
|
183
|
+
expect(tagOf(await exit)).toBe('UnifiIdentityChanged');
|
|
184
|
+
expect(puts(fake)).toHaveLength(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('olds naming another site id => UnifiIdentityChanged, zero PUTs', async () => {
|
|
188
|
+
const { fake, exit } = run(live(), WITH_V6, { ...BASE, siteId: 'site-OLD' });
|
|
189
|
+
expect(tagOf(await exit)).toBe('UnifiIdentityChanged');
|
|
190
|
+
expect(puts(fake)).toHaveLength(0);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('the PUT is attempted once', () => {
|
|
195
|
+
test('a 503 on the PUT is not retried', async () => {
|
|
196
|
+
const { fake, exit } = run(live(), WITH_V6, BASE, () => fakeFailure(503, 'busy'));
|
|
197
|
+
expect((await exit)._tag).toBe('Failure');
|
|
198
|
+
expect(puts(fake)).toHaveLength(1);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('error wording', () => {
|
|
203
|
+
test('did-not-converge says the PUT landed and names re-import', () => {
|
|
204
|
+
const message = new UnifiUpdateDidNotConverge({
|
|
205
|
+
type: 'Unifi.Network',
|
|
206
|
+
identity: 'sites/s/networks/n',
|
|
207
|
+
fields: ['ipv6Configuration'],
|
|
208
|
+
}).message;
|
|
209
|
+
expect(message).toContain('LANDED');
|
|
210
|
+
expect(message).toContain('differs from the declaration');
|
|
211
|
+
expect(message).toContain('re-import');
|
|
212
|
+
expect(message).toContain('[ipv6Configuration]');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('live-drifted names both causes, not only a hand edit', () => {
|
|
216
|
+
const message = new UnifiLiveDriftedSinceDeploy({
|
|
217
|
+
type: 'Unifi.Network',
|
|
218
|
+
identity: 'sites/s/networks/n',
|
|
219
|
+
fields: ['name'],
|
|
220
|
+
}).message;
|
|
221
|
+
expect(message).toContain('hand edit');
|
|
222
|
+
expect(message).toContain('UnifiUpdateDidNotConverge');
|
|
223
|
+
});
|
|
224
|
+
});
|