@homeflare/alchemy 0.31.2 → 0.33.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/README.md +39 -3
- package/dist/grafana/dashboard-model.d.ts +77 -0
- package/dist/grafana/dashboard-model.d.ts.map +1 -0
- package/dist/grafana/dashboard.d.ts +98 -0
- package/dist/grafana/dashboard.d.ts.map +1 -0
- package/dist/grafana/folder.d.ts +100 -0
- package/dist/grafana/folder.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +9 -8
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +158 -6
- package/dist/grafana/index.js.map +8 -4
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/grafana/provisioned.d.ts +11 -0
- package/dist/grafana/provisioned.d.ts.map +1 -0
- package/dist/linux/container-form.d.ts +129 -0
- package/dist/linux/container-form.d.ts.map +1 -0
- package/dist/linux/container-generator.d.ts +74 -0
- package/dist/linux/container-generator.d.ts.map +1 -0
- package/dist/linux/container-handlers.d.ts +15 -0
- package/dist/linux/container-handlers.d.ts.map +1 -0
- package/dist/linux/container-lifecycle.d.ts +37 -0
- package/dist/linux/container-lifecycle.d.ts.map +1 -0
- package/dist/linux/container-preflight.d.ts +34 -0
- package/dist/linux/container-preflight.d.ts.map +1 -0
- package/dist/linux/container-secrets.d.ts +37 -0
- package/dist/linux/container-secrets.d.ts.map +1 -0
- package/dist/linux/container-settle.d.ts +34 -0
- package/dist/linux/container-settle.d.ts.map +1 -0
- package/dist/linux/container.d.ts +30 -0
- package/dist/linux/container.d.ts.map +1 -0
- package/dist/linux/fake-quadlet.d.ts +25 -0
- package/dist/linux/fake-quadlet.d.ts.map +1 -0
- package/dist/linux/index.d.ts +6 -3
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +641 -268
- package/dist/linux/index.js.map +15 -7
- package/dist/linux/providers.d.ts +1 -1
- package/dist/linux/providers.d.ts.map +1 -1
- package/dist/linux/systemctl.d.ts +7 -1
- package/dist/linux/systemctl.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +29 -0
- package/dist/opnsense/alias-form.d.ts.map +1 -0
- package/dist/opnsense/alias.d.ts +81 -0
- package/dist/opnsense/alias.d.ts.map +1 -0
- package/dist/opnsense/category-form.d.ts +10 -0
- package/dist/opnsense/category-form.d.ts.map +1 -0
- package/dist/opnsense/category.d.ts +58 -0
- package/dist/opnsense/category.d.ts.map +1 -0
- package/dist/opnsense/credentials.d.ts +36 -0
- package/dist/opnsense/credentials.d.ts.map +1 -0
- package/dist/opnsense/fake-opnsense.d.ts +51 -0
- package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
- package/dist/opnsense/group-form.d.ts +11 -0
- package/dist/opnsense/group-form.d.ts.map +1 -0
- package/dist/opnsense/group.d.ts +62 -0
- package/dist/opnsense/group.d.ts.map +1 -0
- package/dist/opnsense/index.d.ts +16 -0
- package/dist/opnsense/index.d.ts.map +1 -0
- package/dist/opnsense/index.js +211 -0
- package/dist/opnsense/index.js.map +20 -0
- package/dist/opnsense/policy.d.ts +20 -0
- package/dist/opnsense/policy.d.ts.map +1 -0
- package/dist/opnsense/providers.d.ts +13 -0
- package/dist/opnsense/providers.d.ts.map +1 -0
- package/dist/opnsense/resource.d.ts +74 -0
- package/dist/opnsense/resource.d.ts.map +1 -0
- package/dist/opnsense/wire.d.ts +39 -0
- package/dist/opnsense/wire.d.ts.map +1 -0
- package/dist/proxmox/index.js +170 -80
- package/dist/proxmox/index.js.map +9 -7
- package/dist/proxmox/storage-form.d.ts +54 -0
- package/dist/proxmox/storage-form.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +56 -0
- package/dist/proxmox/storage-wire.d.ts.map +1 -0
- package/dist/proxmox/storage.d.ts +55 -60
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-form.d.ts +124 -0
- package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
- package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool.d.ts +27 -52
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +48 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -0
- package/dist/unifi/firewall-zone-form.d.ts +40 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
- package/dist/unifi/firewall-zone.d.ts +50 -0
- package/dist/unifi/firewall-zone.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +13 -0
- package/dist/unifi/index.d.ts.map +1 -0
- package/dist/unifi/index.js +190 -0
- package/dist/unifi/index.js.map +16 -0
- package/dist/unifi/network-form.d.ts +67 -0
- package/dist/unifi/network-form.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +67 -0
- package/dist/unifi/network.d.ts.map +1 -0
- package/dist/unifi/policy.d.ts +20 -0
- package/dist/unifi/policy.d.ts.map +1 -0
- package/dist/unifi/providers.d.ts +13 -0
- package/dist/unifi/providers.d.ts.map +1 -0
- package/dist/unifi/resource.d.ts +64 -0
- package/dist/unifi/resource.d.ts.map +1 -0
- package/docs/grafana-folder-dashboard.md +145 -0
- package/docs/grafana.md +11 -10
- package/docs/opnsense.md +146 -0
- package/docs/provider-standard.md +1 -1
- package/docs/quadlet-container.md +132 -0
- package/docs/unifi.md +93 -0
- package/docs/upstream-conformance.md +9 -4
- package/package.json +12 -2
- package/src/grafana/dashboard-model.test.ts +111 -0
- package/src/grafana/dashboard-model.ts +111 -0
- package/src/grafana/dashboard.test.ts +232 -0
- package/src/grafana/dashboard.ts +161 -0
- package/src/grafana/folder.test.ts +212 -0
- package/src/grafana/folder.ts +161 -0
- package/src/grafana/index.ts +21 -8
- package/src/grafana/providers.ts +7 -1
- package/src/grafana/provisioned.ts +49 -0
- package/src/linux/container-fixture.test.ts +92 -0
- package/src/linux/container-form.ts +243 -0
- package/src/linux/container-generator.test.ts +195 -0
- package/src/linux/container-generator.ts +113 -0
- package/src/linux/container-handlers.ts +73 -0
- package/src/linux/container-lifecycle.test.ts +209 -0
- package/src/linux/container-lifecycle.ts +217 -0
- package/src/linux/container-preflight.ts +97 -0
- package/src/linux/container-secrets.test.ts +171 -0
- package/src/linux/container-secrets.ts +112 -0
- package/src/linux/container-settle.ts +97 -0
- package/src/linux/container.ts +60 -0
- package/src/linux/fake-quadlet.ts +127 -0
- package/src/linux/index.ts +11 -3
- package/src/linux/providers.ts +2 -0
- package/src/linux/systemctl.ts +9 -0
- package/src/opnsense/alias-form.test.ts +89 -0
- package/src/opnsense/alias-form.ts +53 -0
- package/src/opnsense/alias.test.ts +203 -0
- package/src/opnsense/alias.ts +117 -0
- package/src/opnsense/category-form.test.ts +54 -0
- package/src/opnsense/category-form.ts +20 -0
- package/src/opnsense/category.test.ts +138 -0
- package/src/opnsense/category.ts +82 -0
- package/src/opnsense/credentials.ts +41 -0
- package/src/opnsense/fake-opnsense.ts +79 -0
- package/src/opnsense/group-form.test.ts +75 -0
- package/src/opnsense/group-form.ts +25 -0
- package/src/opnsense/group.test.ts +141 -0
- package/src/opnsense/group.ts +78 -0
- package/src/opnsense/index.ts +45 -0
- package/src/opnsense/policy.ts +49 -0
- package/src/opnsense/providers.ts +21 -0
- package/src/opnsense/resource.ts +146 -0
- package/src/opnsense/wire.ts +56 -0
- package/src/opnsense/write-refusal.test.ts +148 -0
- package/src/proxmox/constraints-host-forms.test.ts +1 -1
- package/src/proxmox/storage-form.ts +144 -0
- package/src/proxmox/storage-read-failure.test.ts +214 -0
- package/src/proxmox/storage-wire.ts +124 -0
- package/src/proxmox/storage.ts +165 -175
- package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
- package/src/proxmox/zfs-pool-form.ts +236 -0
- package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
- package/src/proxmox/zfs-pool-wire.ts +132 -0
- package/src/proxmox/zfs-pool.ts +106 -76
- package/src/unifi/fake-unifi.ts +60 -0
- package/src/unifi/firewall-zone-form.ts +65 -0
- package/src/unifi/firewall-zone.test.ts +88 -0
- package/src/unifi/firewall-zone.ts +66 -0
- package/src/unifi/index.ts +26 -0
- package/src/unifi/network-form.ts +207 -0
- package/src/unifi/network.test.ts +209 -0
- package/src/unifi/network.ts +83 -0
- package/src/unifi/policy.ts +37 -0
- package/src/unifi/providers.ts +15 -0
- package/src/unifi/resource.test.ts +133 -0
- package/src/unifi/resource.ts +131 -0
- package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
- package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
- package/dist/proxmox/zfs-pool-write.d.ts +0 -128
- package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
- package/src/proxmox/zfs-pool-spec.ts +0 -76
- package/src/proxmox/zfs-pool-write.ts +0 -196
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Podman.Container`'s four provider handlers, as plain functions over a HostRunner — the same
|
|
3
|
+
* split as `unit-handlers.ts`, so `container.ts`'s `Provider.of` stays a thin wire-up.
|
|
4
|
+
*/
|
|
5
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
6
|
+
import type { Diff } from 'alchemy/Diff';
|
|
7
|
+
import { isResolved } from 'alchemy/Diff';
|
|
8
|
+
import type { Input } from 'alchemy/Input';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import { lift, resolvedString } from '../launchd/host-effect.ts';
|
|
11
|
+
import type { HostRunner } from '../launchd/runner.ts';
|
|
12
|
+
import { adoptsAtApply } from '../ownership/adopt.ts';
|
|
13
|
+
import { noteUnfinished } from '../ownership/resume.ts';
|
|
14
|
+
import type { ContainerAttributes, ContainerProps } from './container-form.ts';
|
|
15
|
+
import {
|
|
16
|
+
deleteContainer,
|
|
17
|
+
diffContainer,
|
|
18
|
+
readContainer,
|
|
19
|
+
reconcileContainer,
|
|
20
|
+
} from './container-lifecycle.ts';
|
|
21
|
+
import { assertRenameTarget } from './container-preflight.ts';
|
|
22
|
+
|
|
23
|
+
export const readHandler = (
|
|
24
|
+
runner: HostRunner,
|
|
25
|
+
olds: ContainerProps,
|
|
26
|
+
output: ContainerAttributes | undefined,
|
|
27
|
+
): Effect.Effect<ContainerAttributes | undefined, Error> =>
|
|
28
|
+
lift(async () => {
|
|
29
|
+
const found = await readContainer(runner, olds);
|
|
30
|
+
if (found === undefined) return undefined;
|
|
31
|
+
return output === undefined ? Unowned(found) : found;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const diffHandler = (
|
|
35
|
+
runner: HostRunner,
|
|
36
|
+
instanceId: string,
|
|
37
|
+
news: Input<ContainerProps>,
|
|
38
|
+
output: ContainerAttributes | undefined,
|
|
39
|
+
): Effect.Effect<Diff | undefined, Error> => {
|
|
40
|
+
// ★ An unfinished generation of our own: `--adopt` may resume it (ownership/resume.ts).
|
|
41
|
+
if (output === undefined) return noteUnfinished(instanceId);
|
|
42
|
+
if (isResolved(news)) return lift(() => diffContainer(runner, news, output));
|
|
43
|
+
/**
|
|
44
|
+
* ⛔ A RENAME MUST BE SEEN EVEN WHILE OTHER PROPS ARE UNRESOLVED — same reason as
|
|
45
|
+
* `unit-handlers.ts`'s `diffHandler`: the engine's default for a prop change is `update`, and
|
|
46
|
+
* an update under a new name writes the new `.container` file and leaves the old generated
|
|
47
|
+
* unit running with nothing in state to ever remove it.
|
|
48
|
+
*/
|
|
49
|
+
const name = resolvedString(news, 'name');
|
|
50
|
+
if (name === undefined || name === output.name) return Effect.succeed(undefined);
|
|
51
|
+
return lift(async () => {
|
|
52
|
+
await assertRenameTarget(runner, output, name);
|
|
53
|
+
return { action: 'replace' as const, deleteFirst: true };
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const reconcileHandler = (
|
|
58
|
+
runner: HostRunner,
|
|
59
|
+
args: {
|
|
60
|
+
fqn: string;
|
|
61
|
+
instanceId: string;
|
|
62
|
+
news: ContainerProps;
|
|
63
|
+
output: ContainerAttributes | undefined;
|
|
64
|
+
},
|
|
65
|
+
): Effect.Effect<ContainerAttributes, Error> =>
|
|
66
|
+
Effect.flatMap(adoptsAtApply(args), (adopt) =>
|
|
67
|
+
lift(() => reconcileContainer(runner, args.news, args.output, adopt)),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
export const deleteHandler = (
|
|
71
|
+
runner: HostRunner,
|
|
72
|
+
output: ContainerAttributes,
|
|
73
|
+
): Effect.Effect<void, Error> => lift(() => deleteContainer(runner, output));
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Podman.Container` against the fake Quadlet host — mirrors `unit-lifecycle.test.ts`'s shape and,
|
|
3
|
+
* like it, cares most about the NEGATIVE assertions: an adopted container that already matches is
|
|
4
|
+
* not restarted, a re-run restarts nothing, and a read that fails writes nothing. Generator-failure
|
|
5
|
+
* rollback and the never-enable guarantee are in container-generator.test.ts; secret refusal is in
|
|
6
|
+
* container-secrets.test.ts; the CT100 fixture is in container-fixture.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { fakeQuadletHost } from './fake-quadlet.ts';
|
|
10
|
+
import { type ContainerProps, containerProblems } from './container-form.ts';
|
|
11
|
+
import {
|
|
12
|
+
deleteContainer,
|
|
13
|
+
diffContainer,
|
|
14
|
+
readContainer,
|
|
15
|
+
reconcileContainer,
|
|
16
|
+
} from './container-lifecycle.ts';
|
|
17
|
+
|
|
18
|
+
const NAME = 'hf-example';
|
|
19
|
+
const PATH = `/etc/containers/systemd/${NAME}.container`;
|
|
20
|
+
const SERVICE = `${NAME}.service`;
|
|
21
|
+
|
|
22
|
+
const props = (image: string): ContainerProps => ({
|
|
23
|
+
container: { image, network: 'host' },
|
|
24
|
+
install: { wantedBy: ['multi-user.target'] },
|
|
25
|
+
name: NAME,
|
|
26
|
+
service: { restart: 'always', restartSec: '5s' },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const host = () => fakeQuadletHost();
|
|
30
|
+
const verbs = (calls: string[][]) =>
|
|
31
|
+
calls.filter((call) => call[0] === 'systemctl').map((call) => call[1]);
|
|
32
|
+
|
|
33
|
+
/** `Map#get`, refused instead of `!`-asserted — a missing fixture is a test bug, not a null we hide. */
|
|
34
|
+
const must = <V>(map: Map<string, V>, key: string): V => {
|
|
35
|
+
const value = map.get(key);
|
|
36
|
+
if (value === undefined) throw new Error(`test fixture: nothing at ${key}`);
|
|
37
|
+
return value;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe('create', () => {
|
|
41
|
+
test('writes, reloads, verifies the generator and starts — never enables', async () => {
|
|
42
|
+
const fake = host();
|
|
43
|
+
const attrs = await reconcileContainer(fake.runner, props('example/one:1'), undefined);
|
|
44
|
+
expect(attrs).toMatchObject({
|
|
45
|
+
active: true,
|
|
46
|
+
containerPath: PATH,
|
|
47
|
+
name: NAME,
|
|
48
|
+
serviceName: SERVICE,
|
|
49
|
+
});
|
|
50
|
+
expect(verbs(fake.calls)).toEqual(['show', 'daemon-reload', 'show', 'start', 'show']);
|
|
51
|
+
expect(fake.files.get(PATH)?.mode).toBe(0o644);
|
|
52
|
+
expect(fake.files.get(PATH)?.uid).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a start that fails removes the file this deploy wrote and says the unit is down', async () => {
|
|
56
|
+
const fake = host();
|
|
57
|
+
const broken = {
|
|
58
|
+
...fake.runner,
|
|
59
|
+
exec: async (argv: readonly string[]) =>
|
|
60
|
+
argv[1] === 'start'
|
|
61
|
+
? { exitCode: 1, stderr: 'Job failed', stdout: '' }
|
|
62
|
+
: fake.runner.exec(argv),
|
|
63
|
+
};
|
|
64
|
+
await expect(reconcileContainer(broken, props('example/one:1'), undefined)).rejects.toThrow(
|
|
65
|
+
/NOT running/,
|
|
66
|
+
);
|
|
67
|
+
expect(fake.files.has(PATH)).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('adoption', () => {
|
|
72
|
+
/** A fresh fake for the "adopt" half: nothing in state, the file already on disk and generated —
|
|
73
|
+
* exactly what a container Podman or another deploy started before this stack existed looks like. */
|
|
74
|
+
const adoptedHost = async () => {
|
|
75
|
+
const fake = host();
|
|
76
|
+
await reconcileContainer(fake.runner, props('example/one:1'), undefined);
|
|
77
|
+
const adopting = host();
|
|
78
|
+
adopting.files.set(PATH, must(fake.files, PATH));
|
|
79
|
+
await adopting.runner.exec(['systemctl', 'daemon-reload']); // the fake generator, as on a real host
|
|
80
|
+
must(adopting.units, SERVICE).active = true;
|
|
81
|
+
return adopting;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
test('a live file that already matches is adopted without a restart', async () => {
|
|
85
|
+
const adopting = await adoptedHost();
|
|
86
|
+
const before = adopting.calls.length;
|
|
87
|
+
const attrs = await reconcileContainer(
|
|
88
|
+
adopting.runner,
|
|
89
|
+
props('example/one:1'),
|
|
90
|
+
undefined,
|
|
91
|
+
true,
|
|
92
|
+
);
|
|
93
|
+
expect(attrs).toMatchObject({ active: true, sourcePath: PATH });
|
|
94
|
+
const after = verbs(adopting.calls.slice(before));
|
|
95
|
+
expect(after).not.toContain('restart');
|
|
96
|
+
expect(after).not.toContain('daemon-reload');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('and its diff is a noop, so a plan shows nothing to do', async () => {
|
|
100
|
+
const adopting = await adoptedHost();
|
|
101
|
+
const output = await reconcileContainer(
|
|
102
|
+
adopting.runner,
|
|
103
|
+
props('example/one:1'),
|
|
104
|
+
undefined,
|
|
105
|
+
true,
|
|
106
|
+
);
|
|
107
|
+
expect(await diffContainer(adopting.runner, props('example/one:1'), output)).toEqual({
|
|
108
|
+
action: 'noop',
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('a live file with other content is refused without --adopt', async () => {
|
|
113
|
+
const fake = host();
|
|
114
|
+
await reconcileContainer(fake.runner, props('example/one:1'), undefined);
|
|
115
|
+
const other = host();
|
|
116
|
+
other.files.set(PATH, must(fake.files, PATH));
|
|
117
|
+
await expect(
|
|
118
|
+
reconcileContainer(other.runner, props('example/two:2'), undefined),
|
|
119
|
+
).rejects.toThrow(/--adopt/);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('restart happens exactly when something changed', () => {
|
|
124
|
+
test('a changed image restarts once', async () => {
|
|
125
|
+
const fake = host();
|
|
126
|
+
const output = await reconcileContainer(fake.runner, props('example/one:1'), undefined);
|
|
127
|
+
const before = fake.calls.length;
|
|
128
|
+
const next = props('example/two:2');
|
|
129
|
+
expect(await diffContainer(fake.runner, next, output)).toEqual({ action: 'update' });
|
|
130
|
+
const updated = await reconcileContainer(fake.runner, next, output);
|
|
131
|
+
const after = verbs(fake.calls.slice(before));
|
|
132
|
+
expect(after.filter((verb) => verb === 'restart')).toHaveLength(1);
|
|
133
|
+
expect(after).not.toContain('stop');
|
|
134
|
+
expect(updated.active).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a tracked config digest restarts the container without rewriting its file', async () => {
|
|
138
|
+
const fake = host();
|
|
139
|
+
const first = await reconcileContainer(
|
|
140
|
+
fake.runner,
|
|
141
|
+
{ ...props('example/one:1'), restartOn: ['aaa'] },
|
|
142
|
+
undefined,
|
|
143
|
+
);
|
|
144
|
+
const next = { ...props('example/one:1'), restartOn: ['bbb'] };
|
|
145
|
+
expect(await diffContainer(fake.runner, next, first)).toEqual({ action: 'update' });
|
|
146
|
+
const before = fake.calls.length;
|
|
147
|
+
await reconcileContainer(fake.runner, next, first);
|
|
148
|
+
const after = fake.calls.slice(before);
|
|
149
|
+
expect(verbs(after)).toContain('restart');
|
|
150
|
+
expect(after.some((call) => call[0] === 'write')).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a re-run with nothing changed restarts nothing at all', async () => {
|
|
154
|
+
const fake = host();
|
|
155
|
+
const output = await reconcileContainer(fake.runner, props('example/one:1'), undefined);
|
|
156
|
+
const before = fake.calls.length;
|
|
157
|
+
await reconcileContainer(fake.runner, props('example/one:1'), output);
|
|
158
|
+
const after = verbs(fake.calls.slice(before));
|
|
159
|
+
expect(after).toEqual(['show', 'show']);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('started: false stops a running container and does not restart it', async () => {
|
|
163
|
+
const fake = host();
|
|
164
|
+
const output = await reconcileContainer(fake.runner, props('example/one:1'), undefined);
|
|
165
|
+
const stopped = await reconcileContainer(
|
|
166
|
+
fake.runner,
|
|
167
|
+
{ ...props('example/one:1'), started: false },
|
|
168
|
+
output,
|
|
169
|
+
);
|
|
170
|
+
expect(stopped.active).toBe(false);
|
|
171
|
+
expect(verbs(fake.calls)).toContain('stop');
|
|
172
|
+
expect(verbs(fake.calls)).not.toContain('restart');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('refusals', () => {
|
|
177
|
+
test('an unprivileged runner refuses before it touches anything', async () => {
|
|
178
|
+
const fake = fakeQuadletHost({ euid: 1001 });
|
|
179
|
+
await expect(
|
|
180
|
+
reconcileContainer(fake.runner, props('example/one:1'), undefined),
|
|
181
|
+
).rejects.toThrow(/never calls sudo/);
|
|
182
|
+
expect(fake.calls).toHaveLength(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('container.image is required', () => {
|
|
186
|
+
const bad = {
|
|
187
|
+
...props('example/one:1'),
|
|
188
|
+
container: { ...props('example/one:1').container, image: ' ' },
|
|
189
|
+
};
|
|
190
|
+
expect(containerProblems(bad).join(' ')).toMatch(/image is required/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('a directory outside Quadlet’s search path is refused', () => {
|
|
194
|
+
const elsewhere = { ...props('example/one:1'), directory: '/opt/quadlet' };
|
|
195
|
+
expect(containerProblems(elsewhere).join(' ')).toMatch(/rootful search path/);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('delete', () => {
|
|
200
|
+
test('stops, removes the file and reloads — never disables', async () => {
|
|
201
|
+
const fake = host();
|
|
202
|
+
const output = await reconcileContainer(fake.runner, props('example/one:1'), undefined);
|
|
203
|
+
const before = fake.calls.length;
|
|
204
|
+
await deleteContainer(fake.runner, output);
|
|
205
|
+
expect(verbs(fake.calls.slice(before))).toEqual(['show', 'stop', 'daemon-reload']);
|
|
206
|
+
expect(fake.files.has(PATH)).toBe(false);
|
|
207
|
+
expect(await readContainer(fake.runner, props('example/one:1'))).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Podman.Container`'s read / diff / reconcile / delete as plain async functions over a
|
|
3
|
+
* HostRunner — same split as `unit-lifecycle.ts`, so the whole lifecycle runs against a fake host
|
|
4
|
+
* (fake-quadlet.ts) with no real systemd or Podman.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ WHAT A DEPLOY DOES NOT DO — same rule `Systemd.Unit` was built around (unit-lifecycle.ts
|
|
7
|
+
* header): it NEVER mass-restarts. A container is restarted only when THIS resource's rendered
|
|
8
|
+
* `.container` file changed, or a digest the declaration listed in `restartOn` changed.
|
|
9
|
+
* ★ THE ORDER IS WRITE, RELOAD, VERIFY THE GENERATOR, THEN START OR RESTART. `daemon-reload` is
|
|
10
|
+
* where Quadlet turns the `.container` file into `<name>.service` (container-generator.ts) —
|
|
11
|
+
* writing it changes nothing on its own, and a write that fails leaves the old file (and its
|
|
12
|
+
* already-generated unit) exactly as they were.
|
|
13
|
+
* ⛔ A GENERATOR FAILURE IS ROLLED BACK, NOT LEFT FOR THE NEXT DEPLOY. `container-generator.ts`'s
|
|
14
|
+
* header explains why: `daemon-reload` deletes ALL generator output before regenerating, so a
|
|
15
|
+
* `.container` file the generator refuses has NO generated unit at all, even if the file it
|
|
16
|
+
* replaced generated one fine. An UPDATE that fails verification restores the previous file and
|
|
17
|
+
* reloads again, so the container this resource already promised running never loses its unit; a
|
|
18
|
+
* CREATE that fails removes the file it wrote, same as `Systemd.Unit`'s undo.
|
|
19
|
+
* ⛔ NO ENABLE/DISABLE — see container-generator.ts and container-settle.ts headers.
|
|
20
|
+
*/
|
|
21
|
+
import type { Diff } from 'alchemy/Diff';
|
|
22
|
+
import type { HostRunner } from '../launchd/runner.ts';
|
|
23
|
+
import { UNIT_WRITE, isUnitRunning } from './unit-form.ts';
|
|
24
|
+
import { daemonReload, showUnit, stopUnit } from './systemctl.ts';
|
|
25
|
+
import { needsVerificationReload, verifyGenerated } from './container-generator.ts';
|
|
26
|
+
import {
|
|
27
|
+
type ContainerAttributes,
|
|
28
|
+
type ContainerProps,
|
|
29
|
+
configDigest,
|
|
30
|
+
containerPathFor,
|
|
31
|
+
digestOf,
|
|
32
|
+
refuseContainer,
|
|
33
|
+
renderContainerFile,
|
|
34
|
+
serviceNameFor,
|
|
35
|
+
} from './container-form.ts';
|
|
36
|
+
import {
|
|
37
|
+
assertMayWrite,
|
|
38
|
+
assertReplaceable,
|
|
39
|
+
assertUsable,
|
|
40
|
+
assertValid,
|
|
41
|
+
} from './container-preflight.ts';
|
|
42
|
+
import { attributesOf, settle } from './container-settle.ts';
|
|
43
|
+
|
|
44
|
+
const encoder = new TextEncoder();
|
|
45
|
+
const decoder = new TextDecoder();
|
|
46
|
+
|
|
47
|
+
/** What is on the host now: the `.container` file's digest and the GENERATED unit's status. */
|
|
48
|
+
export const readContainer = async (
|
|
49
|
+
runner: HostRunner,
|
|
50
|
+
props: ContainerProps,
|
|
51
|
+
): Promise<ContainerAttributes | undefined> => {
|
|
52
|
+
const path = containerPathFor(props);
|
|
53
|
+
const bytes = await runner.readFile(path);
|
|
54
|
+
const status = await showUnit(runner, serviceNameFor(props));
|
|
55
|
+
if (bytes === undefined && !status.known) return undefined;
|
|
56
|
+
const sha = bytes === undefined ? '' : digestOf(decoder.decode(bytes));
|
|
57
|
+
return attributesOf(
|
|
58
|
+
props,
|
|
59
|
+
configDigest(props.restartOn),
|
|
60
|
+
sha,
|
|
61
|
+
path,
|
|
62
|
+
serviceNameFor(props),
|
|
63
|
+
status,
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const diffContainer = async (
|
|
68
|
+
runner: HostRunner,
|
|
69
|
+
news: ContainerProps,
|
|
70
|
+
output: ContainerAttributes,
|
|
71
|
+
): Promise<Diff> => {
|
|
72
|
+
assertValid(news);
|
|
73
|
+
// ★ The name or directory is the `.container` file's identity: a change is delete-first, same
|
|
74
|
+
// reason as `Systemd.Unit` (unit-lifecycle.ts diffUnit) — two files for one name can never
|
|
75
|
+
// both be the one Quadlet reads, and here also never both generate the same `.service`.
|
|
76
|
+
if (news.name !== output.name || containerPathFor(news) !== output.containerPath) {
|
|
77
|
+
await assertReplaceable(runner, news, output);
|
|
78
|
+
return { action: 'replace', deleteFirst: true };
|
|
79
|
+
}
|
|
80
|
+
const desired = digestOf(renderContainerFile(news));
|
|
81
|
+
const update = async (): Promise<Diff> => {
|
|
82
|
+
assertMayWrite(runner, news.name);
|
|
83
|
+
await runner.checkWrite?.(containerPathFor(news), UNIT_WRITE);
|
|
84
|
+
return { action: 'update' };
|
|
85
|
+
};
|
|
86
|
+
if (desired !== output.containerSha256) return update();
|
|
87
|
+
if (configDigest(news.restartOn) !== output.configSha256) return update();
|
|
88
|
+
const live = await readContainer(runner, news);
|
|
89
|
+
if (live === undefined || live.containerSha256 !== desired) return update();
|
|
90
|
+
const status = await showUnit(runner, serviceNameFor(news));
|
|
91
|
+
const wantStarted = news.started !== false;
|
|
92
|
+
if (isUnitRunning(status.activeState, status.subState, wantStarted) !== wantStarted)
|
|
93
|
+
return update();
|
|
94
|
+
return status.needDaemonReload ? update() : { action: 'noop' };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `adopt` is whether adoption is on AND this apply is a create or an unfinished generation of our
|
|
99
|
+
* own — same meaning as `Systemd.Unit`'s (unit-lifecycle.ts reconcileUnit).
|
|
100
|
+
*/
|
|
101
|
+
export const reconcileContainer = async (
|
|
102
|
+
runner: HostRunner,
|
|
103
|
+
props: ContainerProps,
|
|
104
|
+
output: ContainerAttributes | undefined,
|
|
105
|
+
adopt = false,
|
|
106
|
+
): Promise<ContainerAttributes> => {
|
|
107
|
+
assertValid(props);
|
|
108
|
+
assertMayWrite(runner, props.name);
|
|
109
|
+
const path = containerPathFor(props);
|
|
110
|
+
const serviceName = serviceNameFor(props);
|
|
111
|
+
const text = renderContainerFile(props);
|
|
112
|
+
const desired = digestOf(text);
|
|
113
|
+
const before = await runner.readFile(path);
|
|
114
|
+
const liveSha = before === undefined ? undefined : digestOf(decoder.decode(before));
|
|
115
|
+
const moved =
|
|
116
|
+
output !== undefined && (output.name !== props.name || output.containerPath !== path);
|
|
117
|
+
if (moved && output !== undefined) await assertReplaceable(runner, props, output);
|
|
118
|
+
const prior = moved ? undefined : output;
|
|
119
|
+
if (
|
|
120
|
+
prior === undefined &&
|
|
121
|
+
!(adopt && output === undefined) &&
|
|
122
|
+
liveSha !== undefined &&
|
|
123
|
+
liveSha !== desired
|
|
124
|
+
) {
|
|
125
|
+
throw refuseContainer(
|
|
126
|
+
props.name,
|
|
127
|
+
`${path} is already on this host and is not this resource. Remove it, or declare it as a ` +
|
|
128
|
+
'new resource and deploy with --adopt.',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (moved && output !== undefined) await deleteContainer(runner, output);
|
|
132
|
+
const wrote = liveSha !== desired;
|
|
133
|
+
/**
|
|
134
|
+
* ⛔ MUST GATE THE RELOAD, NOT ONLY THE RESTART — found on adversarial review. `wrote` and
|
|
135
|
+
* `preStatus.needDaemonReload` both describe the GENERATED `<name>.service`'s own staleness;
|
|
136
|
+
* neither can see that the SOURCE `.container` file was written by an apply that then crashed
|
|
137
|
+
* (ssh drop, reboot) before its `daemon-reload` ran. On the retry: the file already holds
|
|
138
|
+
* `desired` (`wrote` is false), and systemd's own flag is unset because the generated unit —
|
|
139
|
+
* still built from the OLD content — hasn't changed either. Without `stale` here, no reload
|
|
140
|
+
* happens, `verifyGenerated` passes against that stale generation, and `settle` (below) would
|
|
141
|
+
* restart the container onto its OLD definition while returning `containerSha256: desired` —
|
|
142
|
+
* state then claims the new declaration took effect, permanently and silently, since every
|
|
143
|
+
* later diff compares state to itself. `stale` is exactly "the file's content no longer matches
|
|
144
|
+
* what THIS resource last recorded as generated", which is the one signal that catches it.
|
|
145
|
+
* Test: container-generator.test.ts, "an interrupted apply that crashed before daemon-reload".
|
|
146
|
+
*/
|
|
147
|
+
const stale = prior !== undefined && prior.containerSha256 !== desired;
|
|
148
|
+
const preStatus = await showUnit(runner, serviceName);
|
|
149
|
+
assertUsable(props, preStatus);
|
|
150
|
+
/**
|
|
151
|
+
* ⛔ `prior === undefined` (a create, or an adopt) AND `needsVerificationReload` — found on
|
|
152
|
+
* adversarial review, the same bug class as `stale` above but for a resource with no prior
|
|
153
|
+
* state at all. An INTERRUPTED CREATE (the write landed, the apply crashed before its
|
|
154
|
+
* `daemon-reload` ran) retries with `wrote: false` (the file already holds `desired`) and
|
|
155
|
+
* `stale: false` (there is no PRIOR state to disagree with) — without this, nothing ever
|
|
156
|
+
* reloaded and `verifyGenerated` threw on the still-missing generated unit FOREVER: loud, but
|
|
157
|
+
* stuck, since a plain retry changes nothing about that. `needsVerificationReload`
|
|
158
|
+
* (container-generator.ts) shares its predicate with `verifyGenerated` itself, so the two can
|
|
159
|
+
* never disagree about what counts as already proven. Cost: one extra, idempotent reload on an
|
|
160
|
+
* ordinary adoption whose generation was already fine — `daemon-reload` restarts nothing, so
|
|
161
|
+
* the never-mass-restart property (`changed`, below) is untouched.
|
|
162
|
+
* Test: container-generator.test.ts, "an interrupted CREATE that crashed before daemon-reload".
|
|
163
|
+
*/
|
|
164
|
+
const needsReload =
|
|
165
|
+
wrote ||
|
|
166
|
+
stale ||
|
|
167
|
+
preStatus.needDaemonReload ||
|
|
168
|
+
(prior === undefined && needsVerificationReload(path, preStatus));
|
|
169
|
+
if (wrote) await runner.writeFileAtomic(path, encoder.encode(text), UNIT_WRITE);
|
|
170
|
+
if (needsReload) await daemonReload(runner);
|
|
171
|
+
const status = needsReload ? await showUnit(runner, serviceName) : preStatus;
|
|
172
|
+
try {
|
|
173
|
+
verifyGenerated(props.name, path, serviceName, status);
|
|
174
|
+
} catch (cause) {
|
|
175
|
+
// ⛔ Only a WRITE this reconcile made is worth rolling back — see file header. A file already
|
|
176
|
+
// on disk before this reconcile ran (wrote === false) generated nothing NEW to restore to.
|
|
177
|
+
if (wrote) {
|
|
178
|
+
if (before === undefined) await runner.removeFile(path).catch(() => undefined);
|
|
179
|
+
else await runner.writeFileAtomic(path, before, UNIT_WRITE).catch(() => undefined);
|
|
180
|
+
await daemonReload(runner).catch(() => undefined);
|
|
181
|
+
}
|
|
182
|
+
throw cause;
|
|
183
|
+
}
|
|
184
|
+
const configChanged = prior !== undefined && prior.configSha256 !== configDigest(props.restartOn);
|
|
185
|
+
const changed = wrote || stale || preStatus.needDaemonReload;
|
|
186
|
+
return settle(runner, props, configDigest(props.restartOn), {
|
|
187
|
+
before,
|
|
188
|
+
changed,
|
|
189
|
+
configChanged,
|
|
190
|
+
desired,
|
|
191
|
+
path,
|
|
192
|
+
serviceName,
|
|
193
|
+
status,
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Stop, remove the `.container` file, reload — clearing the orphaned generated unit. Idempotent.
|
|
199
|
+
* No `disableUnit`: never enabled through systemctl in the first place (container-settle.ts).
|
|
200
|
+
*/
|
|
201
|
+
export const deleteContainer = async (
|
|
202
|
+
runner: HostRunner,
|
|
203
|
+
output: ContainerAttributes,
|
|
204
|
+
): Promise<void> => {
|
|
205
|
+
assertMayWrite(runner, output.name);
|
|
206
|
+
const status = await showUnit(runner, output.serviceName);
|
|
207
|
+
if (status.activeState === 'active' || status.activeState === 'activating') {
|
|
208
|
+
await stopUnit(runner, output.serviceName);
|
|
209
|
+
}
|
|
210
|
+
const stat = await runner.stat(output.containerPath);
|
|
211
|
+
if (stat !== undefined) {
|
|
212
|
+
if (stat.kind !== 'file')
|
|
213
|
+
throw refuseContainer(output.name, `${output.containerPath} is a ${stat.kind}`);
|
|
214
|
+
await runner.removeFile(output.containerPath);
|
|
215
|
+
}
|
|
216
|
+
await daemonReload(runner);
|
|
217
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything `Podman.Container` checks before it touches the host — split from
|
|
3
|
+
* container-lifecycle.ts so a reconcile and a plan-time REPLACE run the very same checks, exactly
|
|
4
|
+
* the reason unit-preflight.ts exists (see that file's header; this mirrors it).
|
|
5
|
+
*
|
|
6
|
+
* ★ EVERY CHECK HERE IS READ-ONLY: validation, `systemctl show`, a file read and
|
|
7
|
+
* `HostRunner.checkWrite`. Nothing here writes, reloads or drives Podman.
|
|
8
|
+
*/
|
|
9
|
+
import { type HostRunner, canActAsRoot } from '../launchd/runner.ts';
|
|
10
|
+
import { UNIT_WRITE, digestOf } from './unit-form.ts';
|
|
11
|
+
import { type UnitStatus, showUnit } from './systemctl.ts';
|
|
12
|
+
import {
|
|
13
|
+
type ContainerAttributes,
|
|
14
|
+
type ContainerProps,
|
|
15
|
+
containerPathFor,
|
|
16
|
+
containerProblems,
|
|
17
|
+
refuseContainer,
|
|
18
|
+
renderContainerFile,
|
|
19
|
+
serviceNameFor,
|
|
20
|
+
} from './container-form.ts';
|
|
21
|
+
|
|
22
|
+
const decoder = new TextDecoder();
|
|
23
|
+
|
|
24
|
+
/** Throw every refusal at once, so one plan shows the whole list. */
|
|
25
|
+
export const assertValid = (props: ContainerProps): void => {
|
|
26
|
+
const found = containerProblems(props);
|
|
27
|
+
if (found.length > 0) throw refuseContainer(props.name, found.join('; '));
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** ⛔ Writing the file and driving systemctl/Podman are root's — refuse up front, not mid-apply. */
|
|
31
|
+
export const assertMayWrite = (runner: HostRunner, name: string): void => {
|
|
32
|
+
if (canActAsRoot(runner)) return;
|
|
33
|
+
throw refuseContainer(
|
|
34
|
+
name,
|
|
35
|
+
'a Quadlet .container file is root-owned and daemon-reload/systemctl need root. Point the ' +
|
|
36
|
+
'runner at a destination whose ssh user is root, or provide a privileged HostRunner. This ' +
|
|
37
|
+
'provider never calls sudo.',
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* ⛔ A MASKED GENERATED UNIT IS SOMEONE'S DECISION, NOT DRIFT — same rule as `Systemd.Unit`
|
|
43
|
+
* (unit-preflight.ts `assertUsable`). Masking a generated unit is done the ordinary way, by
|
|
44
|
+
* symlinking its name to `/dev/null` in a directory Quadlet's generator output does not win
|
|
45
|
+
* against (`/etc/systemd/system` outranks `/run/systemd/generator`); unmasking here would
|
|
46
|
+
* silently overrule that.
|
|
47
|
+
*/
|
|
48
|
+
export const assertUsable = (props: Pick<ContainerProps, 'name'>, status: UnitStatus): void => {
|
|
49
|
+
if (status.loadState === 'masked' || status.unitFileState === 'masked') {
|
|
50
|
+
throw refuseContainer(
|
|
51
|
+
props.name,
|
|
52
|
+
`${serviceNameFor(props)} is masked. If that is stale, \`systemctl unmask\` it deliberately, ` +
|
|
53
|
+
'then redeploy. Nothing was written.',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ⛔ A `.container` FILE THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN — same rule and same
|
|
60
|
+
* exemption as `unit-preflight.ts`'s `assertUnclaimed`: a file byte-identical to our render is
|
|
61
|
+
* this declaration's own leftover, not someone else's.
|
|
62
|
+
*/
|
|
63
|
+
export const assertUnclaimed = async (runner: HostRunner, props: ContainerProps): Promise<void> => {
|
|
64
|
+
const path = containerPathFor(props);
|
|
65
|
+
const bytes = await runner.readFile(path);
|
|
66
|
+
const live = bytes === undefined ? undefined : digestOf(decoder.decode(bytes));
|
|
67
|
+
if (live !== undefined && live !== digestOf(renderContainerFile(props))) {
|
|
68
|
+
throw refuseContainer(
|
|
69
|
+
props.name,
|
|
70
|
+
`${path} is already on this host and is not this resource. Remove it, or declare it as a ` +
|
|
71
|
+
'new resource and deploy with --adopt.',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** The half of a rename check that needs only the NEW NAME — mirrors `assertRenameTarget`. */
|
|
77
|
+
export const assertRenameTarget = async (
|
|
78
|
+
runner: HostRunner,
|
|
79
|
+
old: ContainerAttributes,
|
|
80
|
+
name: string,
|
|
81
|
+
): Promise<void> => {
|
|
82
|
+
assertMayWrite(runner, old.name);
|
|
83
|
+
assertMayWrite(runner, name);
|
|
84
|
+
assertUsable({ name }, await showUnit(runner, serviceNameFor({ name })));
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** A rename, checked at plan time, before the plan may promise a delete-first replace. */
|
|
88
|
+
export const assertReplaceable = async (
|
|
89
|
+
runner: HostRunner,
|
|
90
|
+
next: ContainerProps,
|
|
91
|
+
old: ContainerAttributes,
|
|
92
|
+
): Promise<void> => {
|
|
93
|
+
assertValid(next);
|
|
94
|
+
await assertRenameTarget(runner, old, next.name);
|
|
95
|
+
await assertUnclaimed(runner, next);
|
|
96
|
+
await runner.checkWrite?.(containerPathFor(next), UNIT_WRITE);
|
|
97
|
+
};
|