@homeflare/alchemy 0.19.1 → 0.21.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 +25 -0
- package/dist/caddy/access-forward-auth.d.ts +56 -0
- package/dist/caddy/access-forward-auth.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +6 -0
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +169 -75
- package/dist/caddy/index.js.map +10 -7
- package/dist/cloudflare/mcp-server-api.d.ts +135 -0
- package/dist/cloudflare/mcp-server-api.d.ts.map +1 -0
- package/dist/cloudflare/mcp-server-form.d.ts +116 -0
- package/dist/cloudflare/mcp-server-form.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/file-converge.d.ts +70 -0
- package/dist/launchd/file-converge.d.ts.map +1 -0
- package/dist/launchd/file-identity.d.ts +32 -0
- package/dist/launchd/file-identity.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +2 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +11 -24
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.js +173 -120
- package/dist/launchd/index.js.map +12 -10
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +17 -2
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +11 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/linux/index.js +37 -11
- package/dist/linux/index.js.map +10 -10
- package/dist/linux/region.d.ts +11 -0
- package/dist/linux/region.d.ts.map +1 -1
- package/dist/linux/remote-file-lifecycle.d.ts.map +1 -1
- package/dist/linux/remote-file-plan.d.ts +22 -0
- package/dist/linux/remote-file-plan.d.ts.map +1 -1
- package/dist/linux/remote-file.d.ts +4 -1
- package/dist/linux/remote-file.d.ts.map +1 -1
- package/dist/linux/sudo-listing.d.ts +47 -0
- package/dist/linux/sudo-listing.d.ts.map +1 -0
- package/dist/linux/unit-handlers.d.ts.map +1 -1
- package/dist/linux/unit-preflight.d.ts +10 -1
- package/dist/linux/unit-preflight.d.ts.map +1 -1
- package/dist/proxmox/credential-literals.d.ts +1 -1
- package/dist/proxmox/credential-literals.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
- package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs.d.ts +7 -0
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +7 -0
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +16 -4
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +5 -2
- package/dist/proxmox/index.js.map +9 -9
- package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
- package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target.d.ts +1 -1
- package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
- package/dist/release/archive.d.ts +11 -0
- package/dist/release/archive.d.ts.map +1 -0
- package/dist/release/binary-claim.d.ts +53 -0
- package/dist/release/binary-claim.d.ts.map +1 -0
- package/dist/release/binary-diff.d.ts +38 -0
- package/dist/release/binary-diff.d.ts.map +1 -0
- package/dist/release/binary-form.d.ts +118 -0
- package/dist/release/binary-form.d.ts.map +1 -0
- package/dist/release/binary-lifecycle.d.ts +55 -0
- package/dist/release/binary-lifecycle.d.ts.map +1 -0
- package/dist/release/binary-read.d.ts +11 -0
- package/dist/release/binary-read.d.ts.map +1 -0
- package/dist/release/binary.d.ts +68 -0
- package/dist/release/binary.d.ts.map +1 -0
- package/dist/release/catalog.d.ts +76 -0
- package/dist/release/catalog.d.ts.map +1 -0
- package/dist/release/checksums.d.ts +22 -0
- package/dist/release/checksums.d.ts.map +1 -0
- package/dist/release/declared-pins.d.ts +4 -0
- package/dist/release/declared-pins.d.ts.map +1 -0
- package/dist/release/download.d.ts +44 -0
- package/dist/release/download.d.ts.map +1 -0
- package/dist/release/fake-deploy.d.ts +48 -0
- package/dist/release/fake-deploy.d.ts.map +1 -0
- package/dist/release/fake-engine.d.ts +8 -0
- package/dist/release/fake-engine.d.ts.map +1 -0
- package/dist/release/fake-install.d.ts +39 -0
- package/dist/release/fake-install.d.ts.map +1 -0
- package/dist/release/fake-provider.d.ts +58 -0
- package/dist/release/fake-provider.d.ts.map +1 -0
- package/dist/release/fake-release.d.ts +95 -0
- package/dist/release/fake-release.d.ts.map +1 -0
- package/dist/release/index.d.ts +24 -0
- package/dist/release/index.d.ts.map +1 -0
- package/dist/release/index.js +1365 -0
- package/dist/release/index.js.map +35 -0
- package/dist/release/providers.d.ts +19 -0
- package/dist/release/providers.d.ts.map +1 -0
- package/dist/release/refused.d.ts +39 -0
- package/dist/release/refused.d.ts.map +1 -0
- package/dist/release/tar.d.ts +14 -0
- package/dist/release/tar.d.ts.map +1 -0
- package/dist/release/victoria.d.ts +36 -0
- package/dist/release/victoria.d.ts.map +1 -0
- package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
- package/docs/adopted-deploys.md +13 -0
- package/docs/linux-host.md +5 -1
- package/docs/provider-standard.md +138 -0
- package/docs/release-binary-catalogs.md +113 -0
- package/docs/release-binary-measured.md +163 -0
- package/docs/release-binary-refusals.md +57 -0
- package/docs/release-binary-upstream.md +163 -0
- package/docs/release-binary.md +154 -0
- package/docs/upstream-conformance.md +164 -0
- package/package.json +5 -1
- package/src/caddy/access-forward-auth.test.ts +63 -0
- package/src/caddy/access-forward-auth.ts +120 -0
- package/src/caddy/index.ts +10 -0
- package/src/caddy/providers.test.ts +4 -0
- package/src/cloudflare/mcp-server-api.ts +294 -0
- package/src/cloudflare/mcp-server-form.ts +219 -0
- package/src/index.ts +1 -0
- package/src/launchd/file-converge.ts +233 -0
- package/src/launchd/file-identity.ts +69 -0
- package/src/launchd/host-file-alias.test.ts +65 -0
- package/src/launchd/host-file-form.ts +2 -1
- package/src/launchd/host-file-lifecycle.ts +25 -144
- package/src/launchd/host-file.ts +8 -2
- package/src/launchd/local-runner.test.ts +12 -1
- package/src/launchd/local-runner.ts +2 -0
- package/src/launchd/runner.ts +17 -2
- package/src/launchd/sudo-runner.ts +11 -0
- package/src/linux/region.ts +15 -0
- package/src/linux/remote-file-lifecycle.test.ts +0 -88
- package/src/linux/remote-file-lifecycle.ts +25 -7
- package/src/linux/remote-file-plan.ts +37 -1
- package/src/linux/remote-file-region.test.ts +181 -0
- package/src/linux/remote-file.ts +4 -1
- package/src/linux/sudo-listing.ts +122 -0
- package/src/linux/unit-handlers.ts +12 -5
- package/src/linux/unit-preflight.ts +20 -4
- package/src/linux/unit-rename.test.ts +76 -0
- package/src/proxmox/ceph-adopt.test.ts +210 -0
- package/src/proxmox/credential-literals.ts +1 -1
- package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
- package/src/proxmox/generated/pbs.ts +7 -0
- package/src/proxmox/generated/pve.ts +7 -0
- package/src/proxmox/index.test.ts +7 -4
- package/src/proxmox/index.ts +16 -4
- package/src/proxmox/pbs-notification-target-form.ts +1 -1
- package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
- package/src/proxmox/pbs-notification-target-wire.ts +1 -1
- package/src/proxmox/pbs-notification-target.ts +1 -1
- package/src/release/adopt-parity.test.ts +106 -0
- package/src/release/apply-pins.test.ts +128 -0
- package/src/release/archive.ts +94 -0
- package/src/release/binary-alias.test.ts +109 -0
- package/src/release/binary-claim.ts +80 -0
- package/src/release/binary-claims.test.ts +47 -0
- package/src/release/binary-diff.ts +73 -0
- package/src/release/binary-form.ts +229 -0
- package/src/release/binary-lifecycle.test.ts +90 -0
- package/src/release/binary-lifecycle.ts +202 -0
- package/src/release/binary-read.ts +55 -0
- package/src/release/binary-refusals.test.ts +192 -0
- package/src/release/binary-sudo.test.ts +93 -0
- package/src/release/binary.ts +181 -0
- package/src/release/catalog.test.ts +96 -0
- package/src/release/catalog.ts +175 -0
- package/src/release/checksums.test.ts +35 -0
- package/src/release/checksums.ts +43 -0
- package/src/release/declared-pins.ts +39 -0
- package/src/release/download.test.ts +138 -0
- package/src/release/download.ts +110 -0
- package/src/release/error-channel.test.ts +69 -0
- package/src/release/fake-deploy.ts +87 -0
- package/src/release/fake-engine.ts +50 -0
- package/src/release/fake-install.ts +59 -0
- package/src/release/fake-provider.ts +68 -0
- package/src/release/fake-release.ts +185 -0
- package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
- package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
- package/src/release/index.ts +41 -0
- package/src/release/plan.test.ts +189 -0
- package/src/release/provider-refusals.test.ts +48 -0
- package/src/release/providers.test.ts +174 -0
- package/src/release/providers.ts +24 -0
- package/src/release/recovery-read.test.ts +44 -0
- package/src/release/refused.ts +40 -0
- package/src/release/tar.test.ts +142 -0
- package/src/release/tar.ts +195 -0
- package/src/release/victoria.test.ts +108 -0
- package/src/release/victoria.ts +154 -0
- package/src/{proxmox → secrets}/write-only.ts +6 -0
- /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
- /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
|
@@ -48,7 +48,7 @@ export const assertMayWrite = (runner: HostRunner, name: string): void => {
|
|
|
48
48
|
* ⛔ A MASKED UNIT IS SOMEONE'S DECISION, NOT DRIFT. `systemctl mask` survives reboots and makes
|
|
49
49
|
* start fail; unmasking here would silently overrule whoever masked it.
|
|
50
50
|
*/
|
|
51
|
-
export const assertUsable = (props: SystemdUnitProps, status: UnitStatus): void => {
|
|
51
|
+
export const assertUsable = (props: Pick<SystemdUnitProps, 'name'>, status: UnitStatus): void => {
|
|
52
52
|
if (status.loadState === 'masked' || status.unitFileState === 'masked') {
|
|
53
53
|
throw refuse(
|
|
54
54
|
props.name,
|
|
@@ -81,6 +81,24 @@ export const assertUnclaimed = async (
|
|
|
81
81
|
}
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The half of a rename check that needs only the NEW NAME: the old unit deletable, the new one
|
|
86
|
+
* writable and not masked.
|
|
87
|
+
* ★ ON ITS OWN it is for the rename unit-handlers.ts `diffHandler` spots while `content` is still
|
|
88
|
+
* an Output. That plan is a delete-first replace too, so a masked target refused only in
|
|
89
|
+
* reconcile would again arrive after the old unit was stopped. The unit file itself cannot be
|
|
90
|
+
* compared until `content` resolves, so `assertUnclaimed` stays reconcile's job there.
|
|
91
|
+
*/
|
|
92
|
+
export const assertRenameTarget = async (
|
|
93
|
+
runner: HostRunner,
|
|
94
|
+
old: SystemdUnitAttributes,
|
|
95
|
+
name: string,
|
|
96
|
+
): Promise<void> => {
|
|
97
|
+
assertMayWrite(runner, old.name);
|
|
98
|
+
assertMayWrite(runner, name);
|
|
99
|
+
assertUsable({ name }, await showUnit(runner, name));
|
|
100
|
+
};
|
|
101
|
+
|
|
84
102
|
/**
|
|
85
103
|
* A rename, checked at plan time: the new identity must be valid, writable, not masked and free,
|
|
86
104
|
* and the old one deletable — before the plan may promise a delete-first replace.
|
|
@@ -92,9 +110,7 @@ export const assertReplaceable = async (
|
|
|
92
110
|
expect?: string,
|
|
93
111
|
): Promise<void> => {
|
|
94
112
|
assertValid(next, expect);
|
|
95
|
-
|
|
96
|
-
assertMayWrite(runner, next.name);
|
|
97
|
-
assertUsable(next, await showUnit(runner, next.name));
|
|
113
|
+
await assertRenameTarget(runner, old, next.name);
|
|
98
114
|
await assertUnclaimed(runner, next);
|
|
99
115
|
await runner.checkWrite?.(unitPathFor(next), UNIT_WRITE);
|
|
100
116
|
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rename a plan sees while `content` is still an Output. `diffHandler` cannot run `diffUnit`
|
|
3
|
+
* there, but it still plans a delete-first replace — so the refusals that need only the new NAME
|
|
4
|
+
* have to fire in the plan, before Alchemy stops and removes the old unit.
|
|
5
|
+
*
|
|
6
|
+
* ★ The fully resolved rename is unit-preflight.test.ts. 🔴 Found by review (2026-09-22): this
|
|
7
|
+
* branch returned the replace with no check at all.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import { fakeLinuxHost } from './fake-linux-host.ts';
|
|
12
|
+
import { renderUnit } from './unit-form.ts';
|
|
13
|
+
import { diffHandler } from './unit-handlers.ts';
|
|
14
|
+
import { diffUnit, reconcileUnit } from './unit-lifecycle.ts';
|
|
15
|
+
|
|
16
|
+
const NAME = 'hf-example.service';
|
|
17
|
+
const PATH = `/etc/systemd/system/${NAME}`;
|
|
18
|
+
const RENAMED = 'hf-renamed.service';
|
|
19
|
+
const RENAMED_PATH = `/etc/systemd/system/${RENAMED}`;
|
|
20
|
+
const body = (exec: string) =>
|
|
21
|
+
renderUnit([
|
|
22
|
+
{ lines: [['Description', 'example']], name: 'Unit' },
|
|
23
|
+
{ lines: [['ExecStart', exec]], name: 'Service' },
|
|
24
|
+
{ lines: [['WantedBy', 'multi-user.target']], name: 'Install' },
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const props = { content: body('/usr/bin/example'), name: NAME };
|
|
28
|
+
// ★ An Effect is what isResolved() calls unresolved — the stand-in for a rendered digest.
|
|
29
|
+
const pending = { content: Effect.succeed(props.content), name: RENAMED } as never;
|
|
30
|
+
|
|
31
|
+
const running = async () => {
|
|
32
|
+
const fake = fakeLinuxHost({ dirs: { '/etc/systemd/system': 0 } });
|
|
33
|
+
const output = await reconcileUnit(fake.runner, props, undefined);
|
|
34
|
+
return { fake, output, writes: () => fake.calls.filter((call) => call[0] === 'write').length };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('a rename while content is still an Output', () => {
|
|
38
|
+
test('a free name plans a delete-first replace and writes nothing', async () => {
|
|
39
|
+
const { fake, output, writes } = await running();
|
|
40
|
+
const before = writes();
|
|
41
|
+
const diff = await Effect.runPromise(diffHandler(fake.runner, 'id', pending, output));
|
|
42
|
+
expect(diff).toEqual({ action: 'replace', deleteFirst: true });
|
|
43
|
+
expect(writes()).toBe(before);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a MASKED target is refused by the plan, with the old unit still running', async () => {
|
|
47
|
+
const { fake, output } = await running();
|
|
48
|
+
fake.placeUnit(RENAMED_PATH, body('/usr/bin/other'), { masked: true });
|
|
49
|
+
await expect(
|
|
50
|
+
Effect.runPromise(diffHandler(fake.runner, 'id', pending, output)),
|
|
51
|
+
).rejects.toThrow(/is masked/);
|
|
52
|
+
expect(fake.units.get(NAME)?.active).toBe(true);
|
|
53
|
+
expect(fake.files.has(PATH)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('an unprivileged runner is refused before anything is removed', async () => {
|
|
57
|
+
const { fake, output } = await running();
|
|
58
|
+
const unprivileged = { ...fake.runner, effectiveUid: () => 1001, privileged: false };
|
|
59
|
+
await expect(
|
|
60
|
+
Effect.runPromise(diffHandler(unprivileged, 'id', pending, output)),
|
|
61
|
+
).rejects.toThrow(/never calls sudo/);
|
|
62
|
+
expect(fake.units.get(NAME)?.active).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('a resolved rename', () => {
|
|
67
|
+
test('is read-only: the plan writes nothing, whatever it decided', async () => {
|
|
68
|
+
const { fake, output, writes } = await running();
|
|
69
|
+
const before = writes();
|
|
70
|
+
expect(await diffUnit(fake.runner, { ...props, name: RENAMED }, output)).toEqual({
|
|
71
|
+
action: 'replace',
|
|
72
|
+
deleteFirst: true,
|
|
73
|
+
});
|
|
74
|
+
expect(writes()).toBe(before);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three Ceph Resources the barrel exports since 2026-09-22 — `CephDaemon`, `CephFs`,
|
|
3
|
+
* `CephOsd` — adopted and then undeclared through Alchemy's own Plan and Apply, over a fake cluster.
|
|
4
|
+
*
|
|
5
|
+
* ★ WHY THESE THREE COULD JOIN THE BARREL AT ALL: each is adopt-only BY SHAPE, and this file is
|
|
6
|
+
* the proof rather than the claim. CephDaemon and CephFs compare nothing (`matches: () => true`),
|
|
7
|
+
* so a present object is always `noop` and never a replace; CephOsd reads the CRUSH tree and
|
|
8
|
+
* POSTs only when `dev` is declared, which nothing here does. All three default to `retain`.
|
|
9
|
+
* ⛔ WHAT A MISTAKE HERE WOULD COST ON A REAL CLUSTER: a mon DELETE loses a quorum member, a CephFS
|
|
10
|
+
* DELETE breaks every mount, an OSD DELETE starts a rebalance. So every test ends on the same
|
|
11
|
+
* assertion — `fake.writes()` is empty — and one row proves the harness can still see a refusal.
|
|
12
|
+
* ★ THE FIXTURE IS C1, THE KIT'S REFERENCE CLUSTER (docs/proxmox.md): three nodes, a mon, mgr and
|
|
13
|
+
* mds on each, one CephFS, two ssd OSDs per node — shapes and counts measured, names and
|
|
14
|
+
* addresses placeholders (RFC 5737). The rows carry only the keys the families read.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
import * as Layer from 'effect/Layer';
|
|
19
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
20
|
+
import {
|
|
21
|
+
type CephDaemonKind,
|
|
22
|
+
ProxmoxCephDaemon,
|
|
23
|
+
ProxmoxCephDaemonProvider,
|
|
24
|
+
} from './ceph-daemon.ts';
|
|
25
|
+
import { ProxmoxCephFs, ProxmoxCephFsProvider } from './ceph-fs.ts';
|
|
26
|
+
import { ProxmoxCephOsd, ProxmoxCephOsdProvider } from './ceph-osd.ts';
|
|
27
|
+
import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
|
|
28
|
+
|
|
29
|
+
const NODES = ['node-b', 'node-c', 'node-d'] as const;
|
|
30
|
+
const KINDS: readonly CephDaemonKind[] = ['mon', 'mgr', 'mds'];
|
|
31
|
+
const ip = (node: string) => `198.51.100.${String(12 + NODES.indexOf(node as never))}`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One collection answer per kind. ⚠️ Every node answers the SAME cluster-wide list — measured on
|
|
35
|
+
* C1, and the reason ceph-daemon-form.ts matches rows by `name` and never by position.
|
|
36
|
+
*/
|
|
37
|
+
const daemons: Record<CephDaemonKind, Record<string, unknown>[]> = {
|
|
38
|
+
mds: NODES.map((node) => ({
|
|
39
|
+
addr: `${ip(node)}:6801/1`,
|
|
40
|
+
ceph_version_short: '20.2.2',
|
|
41
|
+
host: node,
|
|
42
|
+
name: node,
|
|
43
|
+
rank: node === 'node-c' ? 0 : -1,
|
|
44
|
+
service: true,
|
|
45
|
+
standby_replay: false,
|
|
46
|
+
state: node === 'node-c' ? 'up:active' : 'up:standby',
|
|
47
|
+
...(node === 'node-c' ? { fs_name: 'cephfs-c1' } : {}),
|
|
48
|
+
})),
|
|
49
|
+
mgr: NODES.map((node) => ({
|
|
50
|
+
addr: ip(node),
|
|
51
|
+
host: node,
|
|
52
|
+
name: node,
|
|
53
|
+
service: true,
|
|
54
|
+
state: node === 'node-b' ? 'active' : 'standby',
|
|
55
|
+
})),
|
|
56
|
+
mon: NODES.map((node, rank) => ({
|
|
57
|
+
addr: `${ip(node)}:6789/0`,
|
|
58
|
+
host: node,
|
|
59
|
+
name: node,
|
|
60
|
+
quorum: 1,
|
|
61
|
+
rank,
|
|
62
|
+
service: true,
|
|
63
|
+
state: 'running',
|
|
64
|
+
})),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const fs = [
|
|
68
|
+
{
|
|
69
|
+
data_pool: 'cephfs-c1_data',
|
|
70
|
+
data_pools: ['cephfs-c1_data'],
|
|
71
|
+
metadata_pool: 'cephfs-c1_metadata',
|
|
72
|
+
metadata_pool_id: 7,
|
|
73
|
+
name: 'cephfs-c1',
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** osd.{2,3} on node-b, osd.{1,4} on node-c, osd.{0,5} on node-d — C1's placement. */
|
|
78
|
+
const OSDS: readonly (readonly [number, string])[] = [
|
|
79
|
+
[2, 'node-b'],
|
|
80
|
+
[3, 'node-b'],
|
|
81
|
+
[1, 'node-c'],
|
|
82
|
+
[4, 'node-c'],
|
|
83
|
+
[0, 'node-d'],
|
|
84
|
+
[5, 'node-d'],
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/** ⚠️ The tree spells ids as STRINGS (`"2"`), exactly as measured; ceph-osd-tree.ts narrows them. */
|
|
88
|
+
const tree = {
|
|
89
|
+
flags: 'sortbitwise,recovery_deletes,purged_snapdirs,pglog_hardlimit',
|
|
90
|
+
root: {
|
|
91
|
+
children: [
|
|
92
|
+
{
|
|
93
|
+
children: NODES.map((host) => ({
|
|
94
|
+
children: OSDS.filter(([, on]) => on === host).map(([id]) => ({
|
|
95
|
+
device_class: 'ssd',
|
|
96
|
+
host,
|
|
97
|
+
id: String(id),
|
|
98
|
+
in: 1,
|
|
99
|
+
leaf: 1,
|
|
100
|
+
name: `osd.${String(id)}`,
|
|
101
|
+
osdtype: 'bluestore',
|
|
102
|
+
status: 'up',
|
|
103
|
+
type: 'osd',
|
|
104
|
+
})),
|
|
105
|
+
name: host,
|
|
106
|
+
type: 'host',
|
|
107
|
+
})),
|
|
108
|
+
name: 'default',
|
|
109
|
+
type: 'root',
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
leaf: 0,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** GETs answer from the fixture; anything else is recorded and answered like a task that ran. */
|
|
117
|
+
const cluster = () =>
|
|
118
|
+
fakePve((call: PveCall) => {
|
|
119
|
+
if (call.method !== 'GET') return 'UPID:node-b:fake';
|
|
120
|
+
const hit = /^nodes\/[^/]+\/ceph\/(mon|mgr|mds|fs|osd)$/.exec(call.path)?.[1];
|
|
121
|
+
if (hit === 'fs') return fs;
|
|
122
|
+
if (hit === 'osd') return tree;
|
|
123
|
+
return hit === undefined ? undefined : daemons[hit as CephDaemonKind];
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const providers = Layer.mergeAll(
|
|
127
|
+
ProxmoxCephDaemonProvider(),
|
|
128
|
+
ProxmoxCephFsProvider(),
|
|
129
|
+
ProxmoxCephOsdProvider(),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Every live Ceph object of the three families, declared the way a consuming stack would: each
|
|
134
|
+
* daemon on the node it runs on, the filesystem asked of any node, each OSD's failure domain and
|
|
135
|
+
* device class asserted.
|
|
136
|
+
*/
|
|
137
|
+
const everything = (osdHost: (host: string) => string = (host) => host) =>
|
|
138
|
+
Effect.all([
|
|
139
|
+
...KINDS.flatMap((kind) =>
|
|
140
|
+
NODES.map((node) =>
|
|
141
|
+
ProxmoxCephDaemon(`${kind}-${node}`, { kind, node, target: FAKE_TARGET }),
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
ProxmoxCephFs('cephfs-c1', { name: 'cephfs-c1', node: 'node-b', target: FAKE_TARGET }),
|
|
145
|
+
...OSDS.map(([osdid, host]) =>
|
|
146
|
+
ProxmoxCephOsd(`osd-${String(osdid)}`, {
|
|
147
|
+
device_class: 'ssd',
|
|
148
|
+
host: osdHost(host),
|
|
149
|
+
node: 'node-b',
|
|
150
|
+
osdid,
|
|
151
|
+
target: FAKE_TARGET,
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
const engineFor = (fake: ReturnType<typeof cluster>) =>
|
|
157
|
+
engineOver(providers.pipe(Layer.provideMerge(fake.layer)));
|
|
158
|
+
|
|
159
|
+
describe('adopting the live Ceph daemons, filesystem and OSDs', () => {
|
|
160
|
+
test('every row is noop to the verifier, adopted by the deploy, and only read', async () => {
|
|
161
|
+
const fake = cluster();
|
|
162
|
+
await withoutBao(async () => {
|
|
163
|
+
const engine = engineFor(fake);
|
|
164
|
+
const report = await engine.verify(everything());
|
|
165
|
+
expect(report.rows).toHaveLength(16);
|
|
166
|
+
for (const row of report.rows) {
|
|
167
|
+
expect(row).toMatchObject({ changed: [], diff: 'noop', ok: true, planned: 'adopted' });
|
|
168
|
+
}
|
|
169
|
+
const planned = await engine.deploy(everything());
|
|
170
|
+
expect(new Set(Object.values(planned))).toEqual(new Set(['adopted']));
|
|
171
|
+
expect(Object.keys(planned)).toHaveLength(16);
|
|
172
|
+
});
|
|
173
|
+
expect(fake.writes()).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* ⛔ THE ONE THAT MATTERS MOST. `retain` is the whole reason a deleted line is not a lost mon, a
|
|
178
|
+
* destroyed filesystem or a pulled replica; Alchemy plans the removal `orphaned` and must drop
|
|
179
|
+
* only its own state row.
|
|
180
|
+
*/
|
|
181
|
+
test('undeclaring all sixteen drops state rows and sends no DELETE', async () => {
|
|
182
|
+
const fake = cluster();
|
|
183
|
+
await withoutBao(async () => {
|
|
184
|
+
const engine = engineFor(fake);
|
|
185
|
+
await engine.deploy(everything());
|
|
186
|
+
expect(engine.stored()).toContain('mon-node-b');
|
|
187
|
+
// ⚠️ `deploy` reports planned resources only; a removal lives in the plan's `deletions`, so
|
|
188
|
+
// the proof is the state store emptying and the cluster seeing no write.
|
|
189
|
+
expect(await engine.deploy(Effect.void)).toEqual({});
|
|
190
|
+
for (const fqn of ['mon-node-b', 'mds-node-c', 'cephfs-c1', 'osd-0']) {
|
|
191
|
+
expect(engine.stored()).not.toContain(`"${fqn}"`);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
expect(fake.writes()).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('an OSD asserted on the wrong host fails the verifier and the deploy, and writes nothing', async () => {
|
|
198
|
+
const fake = cluster();
|
|
199
|
+
const moved = (host: string) => (host === 'node-b' ? 'node-c' : host);
|
|
200
|
+
await withoutBao(async () => {
|
|
201
|
+
const engine = engineFor(fake);
|
|
202
|
+
const report = await engine.verify(everything(moved));
|
|
203
|
+
const wrong = report.rows.filter((row) => !row.ok).map((row) => row.fqn);
|
|
204
|
+
expect(wrong.toSorted()).toEqual(['osd-2', 'osd-3']);
|
|
205
|
+
expect(report.rows.find((row) => row.fqn === 'osd-2')?.changed).toEqual(['host']);
|
|
206
|
+
await expect(engine.deploy(everything(moved))).rejects.toBeDefined();
|
|
207
|
+
});
|
|
208
|
+
expect(fake.writes()).toEqual([]);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* it from the template. docs/pbs-notifications.md says the same to the reader.
|
|
24
24
|
* ⛔ A REFUSAL NAMES THE FIELD, NEVER THE VALUE — the message lands in a terminal and a CI log.
|
|
25
25
|
*/
|
|
26
|
-
import type { FromEnv } from '
|
|
26
|
+
import type { FromEnv } from '../secrets/write-only.ts';
|
|
27
27
|
|
|
28
28
|
/** `{{ secrets.x }}`, `{{secrets.x}}`, `{{ url-encode secrets.x }}` — a reference, not a value. */
|
|
29
29
|
const READS_SECRET = /\{\{[^}]*\bsecrets\./;
|
|
@@ -241,4 +241,4 @@ export type NodesNodeDisksZfsPostParams = {
|
|
|
241
241
|
export type NodesNodeDisksZfsPostReturn = string;
|
|
242
242
|
|
|
243
243
|
/** GET /nodes/{node}/disks/zfs/{name} — `data` payload after client unwrap. */
|
|
244
|
-
export type NodesNodeDisksZfsNameGetReturn =
|
|
244
|
+
export type NodesNodeDisksZfsNameGetReturn = Record<string, unknown>;
|
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* ⚠️ 151 request parameters are typed `\`${number}\`` because the vendor calls them
|
|
14
14
|
* integer or number. They were `string` before, which accepted 'banana'. A caller holding a
|
|
15
15
|
* number writes \`${n}\` — `String(n)` is a plain `string` and will not typecheck, deliberately.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ 135 OF THESE NAMES ARE ALSO EXPORTED BY `pve.ts`, MEANING SOMETHING ELSE. Both
|
|
18
|
+
* products document a `/nodes/{node}` subtree, so a name like `NodesNodeCertificatesGetReturn`
|
|
19
|
+
* exists on each side — here an array of objects, there `null`. Nothing stops a pve call
|
|
20
|
+
* importing the pbs spelling: it compiles, and the type is simply wrong about the payload.
|
|
21
|
+
* Import from the barrel that names your product. tests/schema-types.test.ts pins this count, so
|
|
22
|
+
* a vendor upgrade that adds a collision fails there rather than at runtime.
|
|
16
23
|
*/
|
|
17
24
|
export * from './pbs/access.ts';
|
|
18
25
|
export * from './pbs/access-users.ts';
|
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* ⚠️ 480 request parameters are typed `\`${number}\`` because the vendor calls them
|
|
14
14
|
* integer or number. They were `string` before, which accepted 'banana'. A caller holding a
|
|
15
15
|
* number writes \`${n}\` — `String(n)` is a plain `string` and will not typecheck, deliberately.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ 135 OF THESE NAMES ARE ALSO EXPORTED BY `pbs.ts`, MEANING SOMETHING ELSE. Both
|
|
18
|
+
* products document a `/nodes/{node}` subtree, so a name like `NodesNodeCertificatesGetReturn`
|
|
19
|
+
* exists on each side — here an array of objects, there `null`. Nothing stops a pbs call
|
|
20
|
+
* importing the pve spelling: it compiles, and the type is simply wrong about the payload.
|
|
21
|
+
* Import from the barrel that names your product. tests/schema-types.test.ts pins this count, so
|
|
22
|
+
* a vendor upgrade that adds a collision fails there rather than at runtime.
|
|
16
23
|
*/
|
|
17
24
|
export * from './pve/access.ts';
|
|
18
25
|
export * from './pve/access-roles.ts';
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* ⛔ Vm stays Provider-only. NodeNetwork/NetworkApply stay off the barrel — they stage
|
|
5
5
|
* interfaces.new. ★ Lxc is a Resource since 2026-09-21: adopt-first, refuse-not-replace,
|
|
6
6
|
* retain on destroy (lxc.ts).
|
|
7
|
+
* ★ CephDaemon, CephFs and CephOsd are Resources since 2026-09-22: adopt-only by shape and retain
|
|
8
|
+
* on destroy (ceph-adopt.test.ts). ⛔ CephFlag stays Provider-only: a declared flag reasserts a
|
|
9
|
+
* maintenance toggle on every deploy (ceph-flag.ts).
|
|
7
10
|
*/
|
|
8
11
|
import { expect, test } from 'bun:test';
|
|
9
12
|
import { PbsDatastore, ProxmoxStorage } from './index.ts';
|
|
@@ -19,6 +22,9 @@ const resourceExports = [
|
|
|
19
22
|
'PbsVerifyJob',
|
|
20
23
|
'ProxmoxAcl',
|
|
21
24
|
'ProxmoxBackupJob',
|
|
25
|
+
'ProxmoxCephDaemon',
|
|
26
|
+
'ProxmoxCephFs',
|
|
27
|
+
'ProxmoxCephOsd',
|
|
22
28
|
'ProxmoxGroup',
|
|
23
29
|
'ProxmoxHaResource',
|
|
24
30
|
'ProxmoxHaRule',
|
|
@@ -50,14 +56,11 @@ test('the provisioning baseline is on the barrel: the list, the declaration, the
|
|
|
50
56
|
expect(barrel.provisionBootstrap()).toStartWith('#!/bin/sh\n');
|
|
51
57
|
});
|
|
52
58
|
|
|
53
|
-
test('QEMU
|
|
59
|
+
test('QEMU, NIC apply and the Ceph flag stay Provider-only', () => {
|
|
54
60
|
expect(src).not.toMatch(/export \{ ProxmoxVm[, }]/);
|
|
55
61
|
expect(src).not.toMatch(/export \{ ProxmoxNodeNetwork[, }]/);
|
|
56
62
|
expect(src).not.toMatch(/export \{ ProxmoxNetworkApply[, }]/);
|
|
57
63
|
expect(src).not.toMatch(/export \{ ProxmoxApiToken[, }]/);
|
|
58
64
|
expect(src).not.toMatch(/export \{ ProxmoxZfsPool[, }]/);
|
|
59
|
-
expect(src).not.toMatch(/export \{ ProxmoxCephOsd[, }]/);
|
|
60
|
-
expect(src).not.toMatch(/export \{ ProxmoxCephFs[, }]/);
|
|
61
|
-
expect(src).not.toMatch(/export \{ ProxmoxCephDaemon[, }]/);
|
|
62
65
|
expect(src).not.toMatch(/export \{ ProxmoxCephFlag[, }]/);
|
|
63
66
|
});
|
package/src/proxmox/index.ts
CHANGED
|
@@ -21,6 +21,18 @@
|
|
|
21
21
|
* ★ THE VENDOR CONSTRAINT READER JOINED 2026-09-22 (codegen/README.md). `constraintsFor` and
|
|
22
22
|
* `violations` are exported so a CONSUMING stack can assert its own declarations offline — the
|
|
23
23
|
* `deploy:pbs` that failed on a 128-character comment had no way to ask that question.
|
|
24
|
+
* ★ THE CEPH DAEMON, FILESYSTEM AND OSD RESOURCES JOINED 2026-09-22, BECAUSE EACH IS ALREADY
|
|
25
|
+
* ADOPT-ONLY BY SHAPE and a live cluster measured that way was waiting on nothing but this line.
|
|
26
|
+
* `ProxmoxCephDaemon` (mon/mgr/mds) and `ProxmoxCephFs` compare nothing, so no edit can plan a
|
|
27
|
+
* replace; `ProxmoxCephOsd` never POSTs unless `dev` is declared. All three default to `retain`,
|
|
28
|
+
* so deleting a declaration drops a state row and leaves the quorum member, the filesystem or
|
|
29
|
+
* the replica alone. ceph-adopt.test.ts pins adopting each as reads only, and removing each as
|
|
30
|
+
* no DELETE, through Alchemy's own Plan and Apply.
|
|
31
|
+
* ⛔ `ProxmoxCephFlag` STAYS PROVIDER-ONLY, AND ON PURPOSE. A flag is a hand on the brake, not
|
|
32
|
+
* configuration: a declared `noout: false` clears an operator's `noout` on EVERY deploy,
|
|
33
|
+
* including one run for an unrelated resource in the middle of a maintenance window
|
|
34
|
+
* (ceph-flag.ts has the whole argument). A stack that wants a flag set must reach for the
|
|
35
|
+
* Provider deliberately; the barrel does not offer the sentence by default.
|
|
24
36
|
* ★ Anything else unlisted is still reachable by path if you genuinely need
|
|
25
37
|
* it — that is a deliberate, visible act rather than an accident of barrelling.
|
|
26
38
|
*/
|
|
@@ -28,10 +40,10 @@ export { ProxmoxAcl, ProxmoxAclProvider } from './acl.ts';
|
|
|
28
40
|
export { type AlertmanagerBodyOptions, alertmanagerAlertBody } from './alertmanager-body.ts';
|
|
29
41
|
export { ProxmoxApiTokenProvider } from './api-token.ts';
|
|
30
42
|
export { ProxmoxBackupJob, ProxmoxBackupJobProvider } from './backup-job.ts';
|
|
31
|
-
export { ProxmoxCephDaemonProvider } from './ceph-daemon.ts';
|
|
43
|
+
export { ProxmoxCephDaemon, ProxmoxCephDaemonProvider } from './ceph-daemon.ts';
|
|
32
44
|
export { ProxmoxCephFlagProvider } from './ceph-flag.ts';
|
|
33
|
-
export { ProxmoxCephFsProvider } from './ceph-fs.ts';
|
|
34
|
-
export { ProxmoxCephOsdProvider } from './ceph-osd.ts';
|
|
45
|
+
export { ProxmoxCephFs, ProxmoxCephFsProvider } from './ceph-fs.ts';
|
|
46
|
+
export { ProxmoxCephOsd, ProxmoxCephOsdProvider } from './ceph-osd.ts';
|
|
35
47
|
export { ProxmoxCephPool, ProxmoxCephPoolProvider } from './ceph-pool.ts';
|
|
36
48
|
export { constraintsFor, formViolations } from './constraint-guard.ts';
|
|
37
49
|
export {
|
|
@@ -92,5 +104,5 @@ export { ProxmoxSdnVnet, ProxmoxSdnVnetProvider } from './sdn-vnet.ts';
|
|
|
92
104
|
export { ProxmoxSdnZone, ProxmoxSdnZoneProvider } from './sdn-zone.ts';
|
|
93
105
|
export { ProxmoxStorage, ProxmoxStorageProvider } from './storage.ts';
|
|
94
106
|
export { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
|
|
95
|
-
export { type FromEnv } from '
|
|
107
|
+
export { type FromEnv } from '../secrets/write-only.ts';
|
|
96
108
|
export { ProxmoxZfsPoolProvider } from './zfs-pool.ts';
|
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
PbsNotificationTargetProps,
|
|
26
26
|
} from './pbs-notification-target.ts';
|
|
27
27
|
import { base64, headerDigest, names, wireItem } from './pbs-notification-target-wire.ts';
|
|
28
|
-
import { type Environment, type FromEnv, resolveAll, sealMatches } from '
|
|
28
|
+
import { type Environment, type FromEnv, resolveAll, sealMatches } from '../secrets/write-only.ts';
|
|
29
29
|
|
|
30
30
|
type Props = PbsNotificationTargetProps;
|
|
31
31
|
type Field = keyof Props;
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
targetForm,
|
|
33
33
|
} from './pbs-notification-target-form.ts';
|
|
34
34
|
import { targetAttributes } from './pbs-notification-target-wire.ts';
|
|
35
|
-
import { seal } from '
|
|
35
|
+
import { seal } from '../secrets/write-only.ts';
|
|
36
36
|
|
|
37
37
|
type Props = PbsNotificationTargetProps;
|
|
38
38
|
type Attributes = PbsNotificationTargetAttributes;
|
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
PbsNotificationTargetProps,
|
|
24
24
|
} from './pbs-notification-target.ts';
|
|
25
25
|
import { bool, text } from './values.ts';
|
|
26
|
-
import { seal } from '
|
|
26
|
+
import { seal } from '../secrets/write-only.ts';
|
|
27
27
|
|
|
28
28
|
export const base64 = (value: string): string => Buffer.from(value, 'utf8').toString('base64');
|
|
29
29
|
|
|
@@ -31,7 +31,7 @@ import * as Provider from 'alchemy/Provider';
|
|
|
31
31
|
import * as Effect from 'effect/Effect';
|
|
32
32
|
import { handlers } from './pbs-notification-target-lifecycle.ts';
|
|
33
33
|
import type { PveRequirements, WithPbsTarget } from './resource.ts';
|
|
34
|
-
import type { FromEnv } from '
|
|
34
|
+
import type { FromEnv } from '../secrets/write-only.ts';
|
|
35
35
|
|
|
36
36
|
export type PbsNotificationTargetType = 'sendmail' | 'smtp' | 'webhook';
|
|
37
37
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adoption through Alchemy's OWN Plan and Apply (openbao/fake-stack.ts): a file already at the path,
|
|
3
|
+
* and a stack holding no state for it. What binary-claim.ts decides, measured end to end — the plan
|
|
4
|
+
* and the apply must give the same answer, and `adopted` must never stand for other bytes.
|
|
5
|
+
*
|
|
6
|
+
* 🔴 EVERY REFUSAL BELOW WAS A SILENT WRITE OR LOSS BEFORE 2026-09-22 (adversarial review of PR 138,
|
|
7
|
+
* lens: adoption and plan parity), each measured with this harness against the code before the
|
|
8
|
+
* fix: other bytes under `--adopt` planned `adopted` (resolved) or `create` (directory an Output)
|
|
9
|
+
* and were downloaded over in place; a symlink planned `adopted` and was refused only at apply;
|
|
10
|
+
* a renamed declaration claimed the identical file at apply and its old name's delete removed it.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, test } from 'bun:test';
|
|
13
|
+
import { BINARY } from './fake-release.ts';
|
|
14
|
+
import { DIR, PATH, THEIRS, deployHarness as harness } from './fake-deploy.ts';
|
|
15
|
+
|
|
16
|
+
describe('a file already at the path, with no state', () => {
|
|
17
|
+
test('OTHER bytes under --adopt, probed: the plan refuses; nothing fetched, their file kept', async () => {
|
|
18
|
+
const h = harness(true);
|
|
19
|
+
h.place(THEIRS);
|
|
20
|
+
await expect(h.stack.deploy(h.literal, { adopt: true })).rejects.toThrow(
|
|
21
|
+
'--adopt takes over only the pinned binary',
|
|
22
|
+
);
|
|
23
|
+
expect(h.events).toEqual([]);
|
|
24
|
+
expect(h.fake.files.get(PATH)?.bytes).toEqual(THEIRS);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('OTHER bytes under --adopt, the directory an Output: refused at apply, before the download', async () => {
|
|
28
|
+
const h = harness(true);
|
|
29
|
+
h.place(THEIRS);
|
|
30
|
+
await expect(h.stack.deploy(h.wired(), { adopt: true })).rejects.toThrow(
|
|
31
|
+
'--adopt takes over only the pinned binary',
|
|
32
|
+
);
|
|
33
|
+
expect(h.events.filter((e) => e.startsWith('GET ') || e.startsWith('write '))).toEqual([]);
|
|
34
|
+
expect(h.fake.files.get(PATH)?.bytes).toEqual(THEIRS);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a symlink under --adopt: refused by the PLAN, not first by the apply', async () => {
|
|
38
|
+
const h = harness(true);
|
|
39
|
+
h.place(new Uint8Array(), 0o755, 'symlink');
|
|
40
|
+
// ★ The plan-side wording; the apply's own would be "is a symlink".
|
|
41
|
+
await expect(h.stack.deploy(h.literal, { adopt: true })).rejects.toThrow(
|
|
42
|
+
'something that is not a regular file',
|
|
43
|
+
);
|
|
44
|
+
expect(h.events).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('the pinned bytes with a wrong mode, under --adopt: adopted, re-written from disk, never fetched', async () => {
|
|
48
|
+
const h = harness(true);
|
|
49
|
+
h.place(BINARY.vmalert, 0o775);
|
|
50
|
+
expect(await h.stack.deploy(h.literal, { adopt: true })).toEqual({ vmalert: 'adopted' });
|
|
51
|
+
expect(h.events).toEqual([`write ${PATH}`]);
|
|
52
|
+
expect(h.fake.files.get(PATH)?.mode).toBe(0o755);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('the pinned bytes, the directory an Output, under --adopt: kept, never fetched', async () => {
|
|
56
|
+
const h = harness(true);
|
|
57
|
+
h.place(BINARY.vmalert);
|
|
58
|
+
const planned = await h.stack.deploy(h.wired(), { adopt: true });
|
|
59
|
+
expect(planned).toEqual({ dir: 'adopted', vmalert: 'create' });
|
|
60
|
+
expect(h.events).toEqual([]);
|
|
61
|
+
expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a renamed declaration while its directory changes: refused at apply, the binary kept', async () => {
|
|
65
|
+
const h = harness(false);
|
|
66
|
+
await h.stack.deploy(h.wired('vmalert'));
|
|
67
|
+
h.events.length = 0;
|
|
68
|
+
// The directory's mode change leaves `dir.path` an Output at plan, so nothing probes the path.
|
|
69
|
+
await expect(h.stack.deploy(h.wired('vmalert-rules', 0o750))).rejects.toThrow(
|
|
70
|
+
'it holds the pinned binary, but this stack holds no state for it',
|
|
71
|
+
);
|
|
72
|
+
// 🔴 Before: planned {dir: update, vmalert-rules: create, vmalert: delete}; the new name took
|
|
73
|
+
// the identical file as its own, the old name's delete removed it, and the deploy succeeded.
|
|
74
|
+
expect(h.events).not.toContain(`remove ${PATH}`);
|
|
75
|
+
expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the same rename declared with renamedFrom (the remedy the refusal names): the binary kept', async () => {
|
|
79
|
+
const h = harness(false);
|
|
80
|
+
await h.stack.deploy(h.wired('vmalert'));
|
|
81
|
+
h.events.length = 0;
|
|
82
|
+
const planned = await h.stack.deploy(h.wired('vmalert-rules', 0o750, 'vmalert'));
|
|
83
|
+
expect(planned).toEqual({ dir: 'update', 'vmalert-rules': 'update' });
|
|
84
|
+
expect(h.events).toEqual([`chmod 750 ${DIR}`]);
|
|
85
|
+
expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('the same rename under --adopt: the new name takes the file, the old name leaves it', async () => {
|
|
89
|
+
const h = harness(false);
|
|
90
|
+
await h.stack.deploy(h.wired('vmalert'));
|
|
91
|
+
h.events.length = 0;
|
|
92
|
+
const planned = await h.stack.deploy(h.wired('vmalert-rules', 0o750), { adopt: true });
|
|
93
|
+
expect(planned).toEqual({ dir: 'update', vmalert: 'delete', 'vmalert-rules': 'create' });
|
|
94
|
+
// 🔴 Before 2026-09-22 (release gate): the old name's orphan delete ran `remove ${PATH}` after
|
|
95
|
+
// the new name had claimed it, and the deploy succeeded with the binary gone.
|
|
96
|
+
expect(h.events).toEqual([`chmod 750 ${DIR}`]);
|
|
97
|
+
expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
|
|
98
|
+
// The new name owns it now: the next deploy of the same declaration changes nothing.
|
|
99
|
+
h.events.length = 0;
|
|
100
|
+
expect(await h.stack.deploy(h.wired('vmalert-rules', 0o750))).toEqual({
|
|
101
|
+
dir: 'noop',
|
|
102
|
+
'vmalert-rules': 'noop',
|
|
103
|
+
});
|
|
104
|
+
expect(h.events).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
});
|