@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,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Environment=` quoting contract, proven against a port of the three parsers a value crosses —
|
|
3
|
+
* container-env-oracle.ts, whose every rule was reproduced with the LIVE podman 5.4.2 system
|
|
4
|
+
* generator and a live systemd 257 unit (2026-10-11/12, `QUADLET_UNIT_DIRS=<dir>
|
|
5
|
+
* /usr/lib/systemd/system-generators/podman-system-generator --dryrun`; artifacts in
|
|
6
|
+
* /tmp/opencode/quadlet-quote/) — not inferred. The port's own header cites each measurement.
|
|
7
|
+
*
|
|
8
|
+
* The TEXT-level contract of the renderer itself (what we emit) is container-env-quote.test.ts;
|
|
9
|
+
* this file is the parser-level proof that what we emit SURVIVES, and that what shipped before
|
|
10
|
+
* did not. container-env-live.test.ts re-validates the port against the real generator wherever
|
|
11
|
+
* one is installed.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import { environmentLines } from './container-env-quote.ts';
|
|
15
|
+
import { renderContainerFile } from './container-form.ts';
|
|
16
|
+
import { deployed } from './container-env-oracle.ts';
|
|
17
|
+
|
|
18
|
+
// ── The real values this row exists for (homeflare-ct100 commit 53b84c1, grafana.container) ──
|
|
19
|
+
|
|
20
|
+
const VAR = 'GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH';
|
|
21
|
+
const ROLE_PATH =
|
|
22
|
+
"(contains(groups[*], 'Admin') || contains(email, 'tim@schenanigans.com')) && 'GrafanaAdmin'";
|
|
23
|
+
const oracleLines = (value: string): string[] => [
|
|
24
|
+
'Environment=SCOPES=openid',
|
|
25
|
+
`Environment=${VAR}=${value}`,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
describe('the unquoted renderer (what shipped before) corrupts values through the real chain', () => {
|
|
29
|
+
test('the deployed Grafana expression is truncated at its first space', () => {
|
|
30
|
+
const { env } = deployed(oracleLines(ROLE_PATH));
|
|
31
|
+
expect(env[VAR]).toBe('(contains(groups[*],');
|
|
32
|
+
// Everything after the first space produced no variable of its own here — it is simply gone.
|
|
33
|
+
expect(Object.keys(env).sort()).toEqual([VAR, 'SCOPES']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a quoted fragment holding `=` becomes a bogus second variable (measured oracle output)', () => {
|
|
37
|
+
const { env } = deployed(
|
|
38
|
+
oracleLines("contains(groups[*], 'cn=admins,ou=people') && is_member('')"),
|
|
39
|
+
);
|
|
40
|
+
expect(env).toEqual({
|
|
41
|
+
[VAR]: 'contains(groups[*],',
|
|
42
|
+
cn: 'admins,ou=people)',
|
|
43
|
+
SCOPES: 'openid',
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('an unknown escape drops every assignment of its line, silently', () => {
|
|
48
|
+
const { env } = deployed(oracleLines('A=1 B=2\\d C=3'));
|
|
49
|
+
expect(env).toEqual({ SCOPES: 'openid' });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a bare % is consumed as a specifier, never literal data', () => {
|
|
53
|
+
const { env, consumed } = deployed(oracleLines('PCT=50%off'));
|
|
54
|
+
expect(consumed).toContain('%o');
|
|
55
|
+
// ⛔ Assert the VALUE the chain produced, not what it is not: `oracleLines` puts the text
|
|
56
|
+
// under VAR, so an `env.PCT` assertion compares `undefined` and can never fail.
|
|
57
|
+
expect(env[VAR]).toBe('PCT=50ff');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('systemd halves $$: the shipped renderer lets `pa$$word` arrive as `pa$word`', () => {
|
|
61
|
+
// Measured 2026-10-12, live systemd 257 unit: the ExecStart word `pa$$word` (no `:` prefix,
|
|
62
|
+
// so variables ARE substituted) folds to `pa$word` — systemd.service(5) §Command lines.
|
|
63
|
+
const { env, consumed } = deployed(oracleLines('pa$$word'));
|
|
64
|
+
expect(env[VAR]).toBe('pa$word');
|
|
65
|
+
expect(consumed).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('systemd erases braced variables: the shipped renderer lets a braced HOME ref arrive as `ab`', () => {
|
|
69
|
+
// oxlint-disable-next-line no-template-curly-in-string -- `${HOME}` is the measured test data, not a placeholder
|
|
70
|
+
const { env, consumed } = deployed(oracleLines('a${HOME}b'));
|
|
71
|
+
expect(env[VAR]).toBe('ab');
|
|
72
|
+
// oxlint-disable-next-line no-template-curly-in-string -- the consumed record is the literal reference systemd erased
|
|
73
|
+
expect(consumed).toEqual(['${HOME}']);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('the quoted renderer survives the same chain intact', () => {
|
|
78
|
+
test('the deployed Grafana expression round-trips exactly', () => {
|
|
79
|
+
const lines = environmentLines({
|
|
80
|
+
[VAR]: ROLE_PATH,
|
|
81
|
+
GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_STRICT: 'true',
|
|
82
|
+
}).map(([key, value]) => `${key}=${value}`);
|
|
83
|
+
const { env, consumed } = deployed(lines);
|
|
84
|
+
expect(env).toEqual({
|
|
85
|
+
[VAR]: ROLE_PATH,
|
|
86
|
+
GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_STRICT: 'true',
|
|
87
|
+
});
|
|
88
|
+
expect(consumed).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('escapes, quotes and percent signs each round-trip to the measured generator output', () => {
|
|
92
|
+
const lines = environmentLines({
|
|
93
|
+
BACKSLASH: 'C:\\path',
|
|
94
|
+
PCT: '50%off',
|
|
95
|
+
QUOTED: 'say "hi"',
|
|
96
|
+
}).map(([key, value]) => `${key}=${value}`);
|
|
97
|
+
const { env, consumed } = deployed(lines);
|
|
98
|
+
expect(env).toEqual({ BACKSLASH: 'C:\\path', PCT: '50%off', QUOTED: 'say "hi"' });
|
|
99
|
+
expect(consumed).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('dollars round-trip exactly: bcrypt hashes, braced references and $$ stay data', () => {
|
|
103
|
+
// The live-systemd counterparts of these three arrived corrupted under the shipped renderer
|
|
104
|
+
// (the two tests above); each `$` doubled to `$$` — systemd's literal-dollar escape — makes
|
|
105
|
+
// the fold restore exactly the declared bytes. `$2y$10$abc` is a bcrypt hash prefix: the
|
|
106
|
+
// common real-world `$` value this round trip must hold.
|
|
107
|
+
// oxlint-disable-next-line no-template-curly-in-string -- `${HOME}` is the measured test data, not a placeholder
|
|
108
|
+
const declared = { B: 'a${HOME}b', C: '$2y$10$abc', D: 'pa$$word' };
|
|
109
|
+
const lines = environmentLines(declared).map(([key, value]) => `${key}=${value}`);
|
|
110
|
+
const { env, consumed } = deployed(lines);
|
|
111
|
+
expect(env).toEqual(declared);
|
|
112
|
+
expect(consumed).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('renderContainerFile wires the quoting in — end to end through the chain', () => {
|
|
117
|
+
const props = {
|
|
118
|
+
name: 'grafana-primary',
|
|
119
|
+
container: {
|
|
120
|
+
environmentFile: ['/etc/grafana-primary/env'],
|
|
121
|
+
environment: { [VAR]: ROLE_PATH, GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_STRICT: 'true' },
|
|
122
|
+
image: 'docker.io/grafana/grafana:12.2.1',
|
|
123
|
+
network: 'host',
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
test('what the container would receive equals what was declared', () => {
|
|
128
|
+
const envLines = renderContainerFile(props)
|
|
129
|
+
.split('\n')
|
|
130
|
+
.filter((line) => line.startsWith('Environment='));
|
|
131
|
+
const { env, consumed } = deployed(envLines);
|
|
132
|
+
expect(env).toEqual(props.container.environment);
|
|
133
|
+
expect(consumed).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The LIVE cross-check: the oracle port (container-env-oracle.ts) against the REAL
|
|
3
|
+
* `podman-system-generator`, wherever one is installed — the same dry run that produced every
|
|
4
|
+
* measured `--env` quote in this family's headers (podman 5.4.2, `QUADLET_UNIT_DIRS=<tmpdir>
|
|
5
|
+
* /usr/lib/systemd/system-generators/podman-system-generator --dryrun`, 2026-10-11/12).
|
|
6
|
+
*
|
|
7
|
+
* ★ WHY A TEST AND NOT A ONE-OFF: the pinned bytes in container-env-chain.test.ts hold the
|
|
8
|
+
* contract between re-measurements; this test RE-MEASURES it, on every `bun test`, on any
|
|
9
|
+
* machine with podman's generator. A podman update that changes what the generator emits for
|
|
10
|
+
* these lines fails here first — and the oracle and its pins get re-measured together, not
|
|
11
|
+
* silently left describing an old generator.
|
|
12
|
+
* ⚠️ SKIPS WHERE THE GENERATOR IS ABSENT (CI, macOS): the pinned tests still hold the contract
|
|
13
|
+
* there; a skip is honest about what was not re-measured on that machine, rather than green
|
|
14
|
+
* by assumption.
|
|
15
|
+
* ⛔ NOTHING AT STAKE: `--dryrun` prints the generated unit to stderr and writes nothing — no
|
|
16
|
+
* daemon-reload, no unit start, no image pull, nothing touches a live host.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, test } from 'bun:test';
|
|
19
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { renderContainerFile } from './container-form.ts';
|
|
23
|
+
import { quadletEnvArgs } from './container-env-quadlet-oracle.ts';
|
|
24
|
+
|
|
25
|
+
const GENERATOR = '/usr/lib/systemd/system-generators/podman-system-generator';
|
|
26
|
+
|
|
27
|
+
/** Every character class this row measured: `$`, `%`, `"`, `\`, a space, and Grafana's own text. */
|
|
28
|
+
const ENVIRONMENT = {
|
|
29
|
+
// oxlint-disable-next-line no-template-curly-in-string -- `${HOME}` is the measured test data, not a placeholder
|
|
30
|
+
B: 'a${HOME}b',
|
|
31
|
+
BACKSLASH: 'C:\\path',
|
|
32
|
+
C: '$2y$10$abc',
|
|
33
|
+
D: 'pa$$word',
|
|
34
|
+
PCT: '50%off',
|
|
35
|
+
QUOTED: 'say "hi"',
|
|
36
|
+
SPACED: 'a b',
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
/** The dry run's printed unit text (both streams — the generator logs to stderr). */
|
|
40
|
+
const generatedExecStart = async (dir: string, unitDirs: string): Promise<string> => {
|
|
41
|
+
const proc = Bun.spawn([GENERATOR, '--dryrun'], {
|
|
42
|
+
cwd: dir,
|
|
43
|
+
env: { ...process.env, QUADLET_UNIT_DIRS: unitDirs },
|
|
44
|
+
stdout: 'pipe',
|
|
45
|
+
stderr: 'pipe',
|
|
46
|
+
});
|
|
47
|
+
const [out, err] = await Promise.all([
|
|
48
|
+
new Response(proc.stdout).text(),
|
|
49
|
+
new Response(proc.stderr).text(),
|
|
50
|
+
]);
|
|
51
|
+
if ((await proc.exited) !== 0) throw new Error(`generator exited non-zero:\n${out}${err}`);
|
|
52
|
+
const line = [...err.split('\n'), ...out.split('\n')].find((l) => l.startsWith('ExecStart='));
|
|
53
|
+
if (line === undefined) throw new Error(`no ExecStart= in generator output:\n${out}${err}`);
|
|
54
|
+
return line.slice('ExecStart='.length);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe('the oracle replays against the live podman generator (when installed)', () => {
|
|
58
|
+
// Quadlet emits the `--env` arguments contiguously, in the .container file's key order, just
|
|
59
|
+
// before the trailing image word (measured, artifacts gen.service / old-gen/gen.out) — so the
|
|
60
|
+
// whole segment is compared at once: content AND order, byte for byte.
|
|
61
|
+
test.skipIf(!existsSync(GENERATOR))(
|
|
62
|
+
'quadlet emits exactly the --env args the oracle models, quoted renderer',
|
|
63
|
+
async () => {
|
|
64
|
+
const image = 'docker.io/library/alpine:3';
|
|
65
|
+
const file = renderContainerFile({
|
|
66
|
+
name: 'dollar',
|
|
67
|
+
container: { image, environment: ENVIRONMENT },
|
|
68
|
+
});
|
|
69
|
+
const envLines = file.split('\n').filter((line) => line.startsWith('Environment='));
|
|
70
|
+
const dir = mkdtempSync(join(tmpdir(), 'hf-quadlet-live-'));
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(join(dir, 'dollar.container'), file);
|
|
73
|
+
const args = await generatedExecStart(dir, dir);
|
|
74
|
+
const segment = args.slice(args.indexOf('--env '), args.lastIndexOf(' '));
|
|
75
|
+
expect(segment).toBe(
|
|
76
|
+
quadletEnvArgs(envLines)
|
|
77
|
+
.map((arg) => `--env ${arg}`)
|
|
78
|
+
.join(' '),
|
|
79
|
+
);
|
|
80
|
+
} finally {
|
|
81
|
+
rmSync(dir, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// The shipped (unquoted) renderer, through the same live run: parser 1's split and skip must
|
|
87
|
+
// reproduce the corruption the chain tests pin — `TRUNC=a b c` yields one variable, not three.
|
|
88
|
+
test.skipIf(!existsSync(GENERATOR))(
|
|
89
|
+
'quadlet emits exactly the --env args the oracle models, unquoted renderer',
|
|
90
|
+
async () => {
|
|
91
|
+
const lines = [
|
|
92
|
+
'Environment=DOLLAR2=pa$$word',
|
|
93
|
+
'Environment=TRUNC=a b c',
|
|
94
|
+
// oxlint-disable-next-line no-template-curly-in-string -- the measured old-renderer line, not a placeholder
|
|
95
|
+
'Environment=DOLLARBRACE=a${HOME}b',
|
|
96
|
+
];
|
|
97
|
+
const dir = mkdtempSync(join(tmpdir(), 'hf-quadlet-live-'));
|
|
98
|
+
try {
|
|
99
|
+
writeFileSync(
|
|
100
|
+
join(dir, 'old.container'),
|
|
101
|
+
['[Container]', 'Image=docker.io/library/alpine:3', ...lines, ''].join('\n'),
|
|
102
|
+
);
|
|
103
|
+
const args = await generatedExecStart(dir, dir);
|
|
104
|
+
const segment = args.slice(args.indexOf('--env '), args.lastIndexOf(' '));
|
|
105
|
+
expect(segment).toBe(
|
|
106
|
+
quadletEnvArgs(lines)
|
|
107
|
+
.map((arg) => `--env ${arg}`)
|
|
108
|
+
.join(' '),
|
|
109
|
+
);
|
|
110
|
+
} finally {
|
|
111
|
+
rmSync(dir, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Environment=` oracle — a PORT of the three parsers a declared env value crosses, from
|
|
3
|
+
* `Environment=` line to what the container actually receives. The quadlet half (read, re-emit)
|
|
4
|
+
* is container-env-quadlet-oracle.ts; this file holds the SYSTEMD half — the `%` and `$` folds —
|
|
5
|
+
* and the composed chain. Every rule was MEASURED, not inferred (live podman 5.4.2
|
|
6
|
+
* `podman-system-generator --dryrun`, and a live systemd 257 unit start, 2026-10-11/12):
|
|
7
|
+
*
|
|
8
|
+
* 3. systemd parses the generated `ExecStart=` with the same splitter, then folds BOTH
|
|
9
|
+
* expansion syntaxes: `%%` → one literal `%`, every other `%x` consumed as a specifier (an
|
|
10
|
+
* unknown one invalidates the unit — systemd.unit(5) §Specifiers); and the `$` rules of
|
|
11
|
+
* systemd.service(5) §Command lines (v257), measured against a live unit: `$$` → one literal
|
|
12
|
+
* `$`; `${NAME}` "erased and replaced by the exact value of the environment variable" —
|
|
13
|
+
* modelled here as UNSET, so recorded in `consumed` and replaced with the empty string — and
|
|
14
|
+
* any other `$` mid-word stays literal, because unbraced expansion applies only to a word
|
|
15
|
+
* that is EXACTLY `$FOO`, which a `KEY=value` word never is. An unterminated `${` stays
|
|
16
|
+
* literal too (measured: `x${UNCLOSED` arrived intact). The generated ExecStart has no `:`
|
|
17
|
+
* prefix, so variable substitution always applies. Measured end to end: the UNQUOTED
|
|
18
|
+
* renderer's `pa$$word` arrives as `pa$word` (halved) and its `a${HOME}b` as `ab` (erased),
|
|
19
|
+
* while the quoted-and-escaped forms of the same values arrive intact.
|
|
20
|
+
*
|
|
21
|
+
* ★ `consumed` IS THE FAILURE SIGNAL — a non-empty list means a `%x` or `${NAME}` the renderer let
|
|
22
|
+
* through as data was eaten downstream, so the declared value did not survive (expanded away, or
|
|
23
|
+
* the unit invalidated). The live unit also logged
|
|
24
|
+
* `Referenced but unset environment variable evaluates to an empty string` for every `${NAME}`
|
|
25
|
+
* — the host-side tell, in `journalctl`, if one ever escapes the renderer at deploy time.
|
|
26
|
+
* ⚠️ WHY A PORT, NOT THE REAL PARSERS: the real generator runs root-side per host, and starting a
|
|
27
|
+
* unit IS a deploy — neither can be a unit test's dependency. The port lets every renderer
|
|
28
|
+
* change replay the measured bytes in CI; container-env-live.test.ts re-validates the port
|
|
29
|
+
* against the REAL generator wherever podman is installed, so port and pins stay honest.
|
|
30
|
+
*/
|
|
31
|
+
import { quadletEnvArgs, splitWords } from './container-env-quadlet-oracle.ts';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parser 3's `%` rules: `%%` folds to one literal `%`; every other `%x` is a specifier systemd
|
|
35
|
+
* consumes, so it is recorded — a non-empty record means the declared value did not survive
|
|
36
|
+
* (expanded away, or the unit invalidated).
|
|
37
|
+
*/
|
|
38
|
+
const foldSpecifiers = (text: string): { text: string; consumed: string[] } => {
|
|
39
|
+
const consumed: string[] = [];
|
|
40
|
+
const folded = text.replace(/%(.)/g, (_all, c: string) => {
|
|
41
|
+
if (c === '%') return '%';
|
|
42
|
+
consumed.push(`%${c}`);
|
|
43
|
+
return '';
|
|
44
|
+
});
|
|
45
|
+
return { text: folded, consumed };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parser 3's `$` rules, left-to-right in ONE scan — a fold that ran two passes could not tell
|
|
50
|
+
* `pa$$$$word` (the escaped `pa$$word`) from anything else. systemd.service(5) §Command lines
|
|
51
|
+
* (v257), measured against a live unit: `$$` → one literal `$`; `${NAME}` → its value (unset →
|
|
52
|
+
* empty, recorded); any other `$` mid-word stays literal — unbraced expansion applies only to a
|
|
53
|
+
* word that is EXACTLY `$FOO`, and this oracle only ever folds `KEY=value` words.
|
|
54
|
+
*/
|
|
55
|
+
const foldDollars = (text: string): { text: string; consumed: string[] } => {
|
|
56
|
+
const consumed: string[] = [];
|
|
57
|
+
let out = '';
|
|
58
|
+
let i = 0;
|
|
59
|
+
while (i < text.length) {
|
|
60
|
+
const c = text.charAt(i);
|
|
61
|
+
if (c !== '$') {
|
|
62
|
+
out += c;
|
|
63
|
+
i++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const next = text.charAt(i + 1);
|
|
67
|
+
if (next === '$') {
|
|
68
|
+
out += '$';
|
|
69
|
+
i += 2;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (next === '{') {
|
|
73
|
+
const end = text.indexOf('}', i + 2);
|
|
74
|
+
if (end === -1) {
|
|
75
|
+
out += text.slice(i);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
consumed.push(text.slice(i, end + 1));
|
|
79
|
+
i = end + 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
out += '$';
|
|
83
|
+
i++;
|
|
84
|
+
}
|
|
85
|
+
return { text: out, consumed };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Parser 3: what a `--env`-shaped ExecStart word becomes at the container — both folds applied. */
|
|
89
|
+
const deployArgs = (
|
|
90
|
+
args: readonly string[],
|
|
91
|
+
): { env: Record<string, string>; consumed: string[] } => {
|
|
92
|
+
const env: Record<string, string> = {};
|
|
93
|
+
const consumed: string[] = [];
|
|
94
|
+
for (const arg of args) {
|
|
95
|
+
const word = splitWords(arg)?.[0];
|
|
96
|
+
if (word === undefined) continue;
|
|
97
|
+
const specifiers = foldSpecifiers(word);
|
|
98
|
+
const dollars = foldDollars(specifiers.text);
|
|
99
|
+
consumed.push(...specifiers.consumed, ...dollars.consumed);
|
|
100
|
+
const eq = dollars.text.indexOf('=');
|
|
101
|
+
if (eq > 0) env[dollars.text.slice(0, eq)] = dollars.text.slice(eq + 1);
|
|
102
|
+
}
|
|
103
|
+
return { env, consumed };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The whole chain over rendered `Environment=` lines → what the container actually receives. */
|
|
107
|
+
export const deployed = (
|
|
108
|
+
lines: readonly string[],
|
|
109
|
+
): { env: Record<string, string>; consumed: string[] } => deployArgs(quadletEnvArgs(lines));
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The QUADLET half of the `Environment=` oracle — how podman reads an `Environment=` line and
|
|
3
|
+
* re-emits it as `--env` arguments of the generated unit's `ExecStart=`. The systemd half (the
|
|
4
|
+
* `$`/`%` folds) and the composed chain live in container-env-oracle.ts, whose header carries
|
|
5
|
+
* the three-parser overview; every rule below was MEASURED against the live podman 5.4.2
|
|
6
|
+
* generator (2026-10-11/12), not inferred.
|
|
7
|
+
*
|
|
8
|
+
* 1. quadlet reads the line's value with systemd's own splitter (pkg/systemd/parser/unitfile.go
|
|
9
|
+
* `LookupAllKeyVal` → `splitString(value, " \t\n\r", SplitRelax | SplitUnquote |
|
|
10
|
+
* SplitCUnescape)`, podman v5.4.0). Measured: an unquoted space ends the assignment there
|
|
11
|
+
* (`ROLE_ATTRIBUTE_PATH` was truncated at its first space); quoted fragments are unquoted
|
|
12
|
+
* and re-parsed (`'cn=admins,ou=people')` became the bogus variable `cn`); words without
|
|
13
|
+
* `=` are silently skipped; an unknown C escape (`\d`) drops EVERY assignment of that line,
|
|
14
|
+
* silently.
|
|
15
|
+
* 2. quadlet re-emits each assignment as one `--env` argument (podman `addEnv` → `AddCmdline` →
|
|
16
|
+
* `escapeWords`, pkg/systemd/parser/split.go v5.4.2): re-escaped, and wrapped in double quotes
|
|
17
|
+
* iff ANY character needs escape — `wordNeedEscape`'s set: whitespace, control characters,
|
|
18
|
+
* `"`, `'` and `\` — never touching `%` or `$`, which pass through verbatim. Measured:
|
|
19
|
+
* `Environment="D=pa$$$$word"` → `--env D=pa$$$$word`, and the single quotes in Grafana's
|
|
20
|
+
* role path stayed literal inside the double-quoted `--env "…'Admin'…"`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parsers 1+2 composed: the assignments quadlet reads out of rendered `Environment=` lines, then
|
|
25
|
+
* the `--env` arguments it emits for them. Exported for container-env-live.test.ts, which replays
|
|
26
|
+
* this against the REAL generator wherever one is installed.
|
|
27
|
+
*/
|
|
28
|
+
export const quadletEnvArgs = (lines: readonly string[]): string[] => {
|
|
29
|
+
const told: Record<string, string> = {};
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
const words = splitWords(line.slice('Environment='.length));
|
|
32
|
+
if (words !== undefined) Object.assign(told, parseAssignments(words));
|
|
33
|
+
}
|
|
34
|
+
return emitPodmanArgs(told);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Podman's own separator set — `WhitespaceSeparators = " \t\n\r"` (split.go v5.4.2), NOT the
|
|
38
|
+
* fuller C-locale whitespace: a raw `\f` or `\v` is a word character to quadlet, not a break. */
|
|
39
|
+
const WHITESPACE = ' \t\n\r';
|
|
40
|
+
|
|
41
|
+
const C_UNESCAPES: Record<string, string> = {
|
|
42
|
+
a: '\x07',
|
|
43
|
+
b: '\b',
|
|
44
|
+
f: '\f',
|
|
45
|
+
n: '\n',
|
|
46
|
+
r: '\r',
|
|
47
|
+
t: '\t',
|
|
48
|
+
v: '\v',
|
|
49
|
+
'\\': '\\',
|
|
50
|
+
'"': '"',
|
|
51
|
+
"'": "'",
|
|
52
|
+
0: '\0',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parser 1: systemd's `splitString(value, WhitespaceSeparators, SplitRelax | SplitUnquote |
|
|
57
|
+
* SplitCUnescape)`. Returns the words, or `undefined` when the split FAILS — which is what turns
|
|
58
|
+
* into "every assignment on this line silently dropped" (measured, see header). Quotes are
|
|
59
|
+
* stripped and their content kept; `\xNN` is unescaped like the simple table.
|
|
60
|
+
*/
|
|
61
|
+
export const splitWords = (value: string): string[] | undefined => {
|
|
62
|
+
const words: string[] = [];
|
|
63
|
+
let word = '';
|
|
64
|
+
let quote: '"' | "'" | undefined;
|
|
65
|
+
let i = 0;
|
|
66
|
+
let started = false;
|
|
67
|
+
while (i < value.length) {
|
|
68
|
+
const c = value.charAt(i);
|
|
69
|
+
if (quote === undefined && WHITESPACE.includes(c)) {
|
|
70
|
+
if (started) words.push(word);
|
|
71
|
+
word = '';
|
|
72
|
+
started = false;
|
|
73
|
+
i++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (c === '\\') {
|
|
77
|
+
// CUNESCAPE applies inside and outside quotes; an unknown escape fails the whole split.
|
|
78
|
+
const next = value[i + 1];
|
|
79
|
+
if (next === undefined) return undefined;
|
|
80
|
+
if (next === 'x' && /^[0-9a-fA-F]{2}/.test(value.slice(i + 2, i + 4))) {
|
|
81
|
+
word += String.fromCharCode(parseInt(value.slice(i + 2, i + 4), 16));
|
|
82
|
+
i += 4;
|
|
83
|
+
started = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const unescaped = C_UNESCAPES[next];
|
|
87
|
+
if (unescaped === undefined) return undefined;
|
|
88
|
+
word += unescaped;
|
|
89
|
+
started = true;
|
|
90
|
+
i += 2;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (c === '"' || c === "'") {
|
|
94
|
+
if (quote === c) quote = undefined;
|
|
95
|
+
else if (quote === undefined) {
|
|
96
|
+
quote = c;
|
|
97
|
+
started = true;
|
|
98
|
+
} else word += c;
|
|
99
|
+
i++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
word += c;
|
|
103
|
+
started = true;
|
|
104
|
+
i++;
|
|
105
|
+
}
|
|
106
|
+
if (started) words.push(word);
|
|
107
|
+
return words;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** A word without `=` is not an assignment — measured: silently skipped, never an error. */
|
|
111
|
+
export const parseAssignments = (words: readonly string[]): Record<string, string> => {
|
|
112
|
+
const env: Record<string, string> = {};
|
|
113
|
+
for (const word of words) {
|
|
114
|
+
const eq = word.indexOf('=');
|
|
115
|
+
if (eq <= 0) continue;
|
|
116
|
+
env[word.slice(0, eq)] = word.slice(eq + 1);
|
|
117
|
+
}
|
|
118
|
+
return env;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `wordNeedEscape`'s set, ported from podman pkg/systemd/parser/split.go (v5.4.2): a character
|
|
123
|
+
* forces quoting iff it is ASCII whitespace or control (`<= 0x20` or `0x7f`), a double or single
|
|
124
|
+
* quote, or a backslash — "unicode is ok" is the source's own comment, so anything above `0x80`
|
|
125
|
+
* passes. ⚠️ Note the SINGLE quote: podman quotes a word containing one even though escaping
|
|
126
|
+
* leaves it literal — a port that only quotes what escaping CHANGES would drift from the real
|
|
127
|
+
* `--env` bytes for values like `it's`.
|
|
128
|
+
*/
|
|
129
|
+
const needEscape = (word: string): boolean => {
|
|
130
|
+
for (const c of word) {
|
|
131
|
+
const code = c.charCodeAt(0);
|
|
132
|
+
if (code > 128) continue;
|
|
133
|
+
if (code <= 0x20 || code === 0x7f || c === '"' || c === "'" || c === '\\') return true;
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** `escapeString`'s table (same source): podman re-escapes the C control codes and `"`/`\`
|
|
139
|
+
* inside the double quotes it adds, and writes a literal space and `'` — safe inside `"`. */
|
|
140
|
+
const REESCAPES: Record<string, string> = {
|
|
141
|
+
'\x07': '\\a',
|
|
142
|
+
'\b': '\\b',
|
|
143
|
+
'\f': '\\f',
|
|
144
|
+
'\n': '\\n',
|
|
145
|
+
'\r': '\\r',
|
|
146
|
+
'\t': '\\t',
|
|
147
|
+
'\v': '\\v',
|
|
148
|
+
'\\': '\\\\',
|
|
149
|
+
'"': '\\"',
|
|
150
|
+
"'": "'",
|
|
151
|
+
' ': ' ',
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Parser 2: re-emit as one `--env` argument — escaped, and quoted iff any character needs it.
|
|
155
|
+
* ⚠️ KEYS SORTED, NOT INSERTION-ORDERED: podman's `addEnv` → `addKeys` collects the map's keys
|
|
156
|
+
* and `sort.Strings`s them before emitting (pkg/systemd/quadlet/podmancmdline.go, v5.4.2), so
|
|
157
|
+
* the generated ExecStart holds the assignments sorted by key whatever order the file declared
|
|
158
|
+
* them in — measured: a file declaring DOLLAR2, TRUNC, DOLLARBRACE emitted DOLLAR2, DOLLARBRACE,
|
|
159
|
+
* TRUNC. The sort here compares UTF-16 code units like the renderer's own; for the ASCII env
|
|
160
|
+
* key names in play it is Go's byte order exactly. */
|
|
161
|
+
export const emitPodmanArgs = (env: Readonly<Record<string, string>>): string[] =>
|
|
162
|
+
Object.entries(env)
|
|
163
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
164
|
+
.map(([key, value]) => {
|
|
165
|
+
const word = `${key}=${value}`;
|
|
166
|
+
if (!needEscape(word)) return word;
|
|
167
|
+
let escaped = '"';
|
|
168
|
+
for (const c of word) {
|
|
169
|
+
const known = c.charCodeAt(0) <= 128 ? REESCAPES[c] : undefined;
|
|
170
|
+
escaped +=
|
|
171
|
+
known ?? (needEscape(c) ? `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}` : c);
|
|
172
|
+
}
|
|
173
|
+
return `${escaped}"`;
|
|
174
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TEXT-level contract of the `Environment=` renderer: what `quoteEnvironmentValue` /
|
|
3
|
+
* `environmentLines` emit, that newlines are refused rather than encoded, and that
|
|
4
|
+
* `renderContainerFile` places the quoted block where the unquoted renderer had it.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ Every expected string below was accepted by the LIVE podman 5.4.2 system generator (measured
|
|
7
|
+
* 2026-10-11/12, artifacts in /tmp/opencode/quadlet-quote/): the generator read each quoted line
|
|
8
|
+
* as exactly ONE assignment. What those bytes BECOME through quadlet's splitter, podman's
|
|
9
|
+
* re-emission and systemd's ExecStart parsing is the parser-level proof —
|
|
10
|
+
* container-env-chain.test.ts, which also shows the old unquoted renderer corrupting the same
|
|
11
|
+
* values through the same chain.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import {
|
|
15
|
+
QuadletEnvironmentValueError,
|
|
16
|
+
environmentLines,
|
|
17
|
+
environmentValueProblems,
|
|
18
|
+
quoteEnvironmentValue,
|
|
19
|
+
} from './container-env-quote.ts';
|
|
20
|
+
import { containerProblems, renderContainerFile } from './container-form.ts';
|
|
21
|
+
|
|
22
|
+
// The real values this row exists for (homeflare-ct100 commit 53b84c1, grafana.container).
|
|
23
|
+
const VAR = 'GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH';
|
|
24
|
+
const ROLE_PATH =
|
|
25
|
+
"(contains(groups[*], 'Admin') || contains(email, 'tim@schenanigans.com')) && 'GrafanaAdmin'";
|
|
26
|
+
|
|
27
|
+
describe('the rendered text', () => {
|
|
28
|
+
test('the deployed Grafana expression, quoted whole', () => {
|
|
29
|
+
expect(environmentLines({ [VAR]: ROLE_PATH })).toEqual([
|
|
30
|
+
[
|
|
31
|
+
'Environment',
|
|
32
|
+
`"${VAR}=(contains(groups[*], 'Admin') || contains(email, ` +
|
|
33
|
+
`'tim@schenanigans.com')) && 'GrafanaAdmin'"`,
|
|
34
|
+
],
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('escapes, quotes and percent signs, exactly as the generator accepted them', () => {
|
|
39
|
+
expect(
|
|
40
|
+
environmentLines({ BACKSLASH: 'C:\\path', PCT: '50%off', QUOTED: 'say "hi"' }).map(
|
|
41
|
+
([key, value]) => `${key}=${value}`,
|
|
42
|
+
),
|
|
43
|
+
).toEqual([
|
|
44
|
+
String.raw`Environment="BACKSLASH=C:\\path"`,
|
|
45
|
+
String.raw`Environment="PCT=50%%off"`,
|
|
46
|
+
String.raw`Environment="QUOTED=say \"hi\""`,
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('dollar signs are doubled for systemd, exactly as the generator accepted them', () => {
|
|
51
|
+
// ⛔ THE PIN THE 2026-10-12 FIX ROUND ADDS: systemd.service(5) §Command lines (v257, measured
|
|
52
|
+
// against a live unit) folds `$$` to one literal `$` and erases `${FOO}`, so a `$` left bare
|
|
53
|
+
// is data the container never receives: `pa$$word` arrived as `pa$word`, `a${HOME}b` as `ab`.
|
|
54
|
+
expect(
|
|
55
|
+
// oxlint-disable-next-line no-template-curly-in-string -- the declared value under test, not a placeholder
|
|
56
|
+
environmentLines({ B: 'a${HOME}b', D: 'pa$$word' }).map(([key, value]) => `${key}=${value}`),
|
|
57
|
+
).toEqual(
|
|
58
|
+
// oxlint-disable-next-line no-template-curly-in-string -- the measured bytes the renderer must emit
|
|
59
|
+
['Environment="B=a$${HOME}b"', 'Environment="D=pa$$$$word"'],
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('keys are sorted, so the render is stable for an unordered plain object', () => {
|
|
64
|
+
expect(environmentLines({ B: '2', A: '1' })).toEqual([
|
|
65
|
+
['Environment', '"A=1"'],
|
|
66
|
+
['Environment', '"B=2"'],
|
|
67
|
+
]);
|
|
68
|
+
expect(environmentLines({})).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('newlines are refused, not encoded', () => {
|
|
73
|
+
test('a newline in a value is a typed error naming the key, never the value', () => {
|
|
74
|
+
expect(() => quoteEnvironmentValue('WEBHOOK_URL', 'https://x\nSECRET-TAIL')).toThrow(
|
|
75
|
+
QuadletEnvironmentValueError,
|
|
76
|
+
);
|
|
77
|
+
try {
|
|
78
|
+
quoteEnvironmentValue('WEBHOOK_URL', 'https://x\nSECRET-TAIL');
|
|
79
|
+
} catch (error) {
|
|
80
|
+
expect((error as QuadletEnvironmentValueError)._tag).toBe('QuadletEnvironmentValueError');
|
|
81
|
+
expect((error as QuadletEnvironmentValueError).key).toBe('WEBHOOK_URL');
|
|
82
|
+
const message = (error as QuadletEnvironmentValueError).message;
|
|
83
|
+
expect(message).toContain('WEBHOOK_URL');
|
|
84
|
+
expect(message).not.toContain('SECRET-TAIL');
|
|
85
|
+
}
|
|
86
|
+
expect(() => quoteEnvironmentValue('BAD\nKEY', 'value')).toThrow(QuadletEnvironmentValueError);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('environmentValueProblems reports every refusal at once, as problem strings', () => {
|
|
90
|
+
expect(environmentValueProblems({ OK: 'fine', BAD: 'a\nb', WORSE: 'c\nd' })).toEqual([
|
|
91
|
+
expect.stringContaining('BAD'),
|
|
92
|
+
expect.stringContaining('WORSE'),
|
|
93
|
+
]);
|
|
94
|
+
expect(environmentValueProblems({ OK: 'fine' })).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('containerProblems reports the refusal, never throwing past the plan', () => {
|
|
98
|
+
// The plan-time surface: a newline value must not abort `containerProblems` (a caller
|
|
99
|
+
// building a plan needs every refusal at once), and the string it gets back is the rule
|
|
100
|
+
// text — enough to locate the prop, never the value.
|
|
101
|
+
const problems = containerProblems({
|
|
102
|
+
name: 'grafana-primary',
|
|
103
|
+
container: { image: 'docker.io/library/alpine:3', environment: { BAD: 'a\nb' } },
|
|
104
|
+
});
|
|
105
|
+
expect(problems).toHaveLength(1);
|
|
106
|
+
expect(problems[0]).toContain('environment key "BAD" contains a newline');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('renderContainerFile wires the quoting in — the whole file', () => {
|
|
111
|
+
const props = {
|
|
112
|
+
name: 'grafana-primary',
|
|
113
|
+
container: {
|
|
114
|
+
environmentFile: ['/etc/grafana-primary/env'],
|
|
115
|
+
environment: { [VAR]: ROLE_PATH, GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_STRICT: 'true' },
|
|
116
|
+
image: 'docker.io/grafana/grafana:12.2.1',
|
|
117
|
+
network: 'host',
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
test('the Environment= block is quoted, still sorted, still after EnvironmentFile=', () => {
|
|
122
|
+
const lines = renderContainerFile(props).split('\n');
|
|
123
|
+
const envBlock = lines.filter((line) => line.startsWith('Environment='));
|
|
124
|
+
expect(envBlock).toEqual(
|
|
125
|
+
environmentLines(props.container.environment).map(([key, value]) => `${key}=${value}`),
|
|
126
|
+
);
|
|
127
|
+
expect(lines.indexOf(envBlock[0] ?? '')).toBeGreaterThan(
|
|
128
|
+
lines.indexOf('EnvironmentFile=/etc/grafana-primary/env'),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|