@homeflare/alchemy 0.20.0 → 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.map +3 -3
- package/dist/linux/sudo-listing.d.ts +47 -0
- package/dist/linux/sudo-listing.d.ts.map +1 -0
- 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 +1 -1
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +2 -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/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/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/sudo-listing.ts +122 -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.ts +1 -1
- 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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE FILE UNDER TWO SPELLINGS (launchd/file-identity.ts), on a REAL filesystem: localRunner in a
|
|
3
|
+
* fresh temp directory, through Alchemy's own Plan and Apply where the engine's order matters.
|
|
4
|
+
* Nothing outside `mkdtemp` is touched, and the release is synthetic (fake-release.ts).
|
|
5
|
+
*
|
|
6
|
+
* 🔴 EACH TEST BELOW FAILED BEFORE THE FIX (measured 2026-09-22): the respelled deploy planned a
|
|
7
|
+
* `replace`, reported success, and left no binary on disk; the next deploy put it back.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
10
|
+
import { mkdir, mkdtemp, rm, stat, symlink } from 'node:fs/promises';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { type HostRunner } from '../launchd/runner.ts';
|
|
14
|
+
import { localRunner } from '../launchd/local-runner.ts';
|
|
15
|
+
import { reconcileBinary } from './binary-lifecycle.ts';
|
|
16
|
+
import { VMALERT_TEXT, realEngine } from './fake-engine.ts';
|
|
17
|
+
import { VMUTILS_URL, fakeTransport, syntheticRelease, vmalertProps } from './fake-release.ts';
|
|
18
|
+
|
|
19
|
+
let tmp = '';
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
tmp = await mkdtemp(join(tmpdir(), 'hf-alias-'));
|
|
22
|
+
await mkdir(join(tmp, 'real', 'vmutils-1.151.0'), { recursive: true });
|
|
23
|
+
await symlink(join(tmp, 'real'), join(tmp, 'link'));
|
|
24
|
+
});
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await rm(tmp, { force: true, recursive: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const REAL = () => join(tmp, 'real', 'vmutils-1.151.0');
|
|
30
|
+
const LINKED = () => join(tmp, 'link', 'vmutils-1.151.0');
|
|
31
|
+
/** The installed binary's text, or undefined when the deploy lost it. */
|
|
32
|
+
const onDisk = () =>
|
|
33
|
+
Bun.file(join(REAL(), 'vmalert'))
|
|
34
|
+
.text()
|
|
35
|
+
.catch(() => undefined);
|
|
36
|
+
const engine = () => {
|
|
37
|
+
const e = realEngine();
|
|
38
|
+
return { deploy: (directory: string) => e.deploy({ vmalert: directory }), gets: e.gets };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Whether this temp directory's volume folds case (APFS's default on macOS; not ext4). */
|
|
42
|
+
const foldsCase = async (): Promise<boolean> =>
|
|
43
|
+
stat(join(tmp, 'REAL')).then(
|
|
44
|
+
() => true,
|
|
45
|
+
() => false,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
describe('a respelled directory is an update that keeps the file, never a replace', () => {
|
|
49
|
+
test('through a symlinked parent, via the engine', async () => {
|
|
50
|
+
const e = engine();
|
|
51
|
+
expect(await e.deploy(REAL())).toEqual({ vmalert: 'create' });
|
|
52
|
+
expect(await e.deploy(LINKED())).toEqual({ vmalert: 'update' });
|
|
53
|
+
expect(await onDisk()).toBe(VMALERT_TEXT);
|
|
54
|
+
expect(await e.deploy(LINKED())).toEqual({ vmalert: 'noop' });
|
|
55
|
+
expect(e.gets).toEqual([VMUTILS_URL]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('by case alone, on a volume that folds case', async () => {
|
|
59
|
+
if (!(await foldsCase())) return;
|
|
60
|
+
const e = engine();
|
|
61
|
+
await e.deploy(REAL());
|
|
62
|
+
expect(await e.deploy(join(tmp, 'real', 'VMUTILS-1.151.0'))).toEqual({ vmalert: 'update' });
|
|
63
|
+
expect(await onDisk()).toBe(VMALERT_TEXT);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('an update across the respelling (the path unresolved at plan) keeps the file', async () => {
|
|
67
|
+
const { catalog, vmutils } = syntheticRelease();
|
|
68
|
+
const fetch = fakeTransport({ [VMUTILS_URL]: vmutils }).fetch;
|
|
69
|
+
const olds = vmalertProps(catalog, { directory: REAL() });
|
|
70
|
+
const output = await reconcileBinary(localRunner(), fetch, olds);
|
|
71
|
+
const news = vmalertProps(catalog, { directory: LINKED() });
|
|
72
|
+
const after = await reconcileBinary(localRunner(), fetch, news, { olds, output });
|
|
73
|
+
expect(after.path).toBe(join(LINKED(), 'vmalert'));
|
|
74
|
+
expect(await onDisk()).toBe(VMALERT_TEXT);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('⛔ other bytes at the respelled path are still refused, never written in place', async () => {
|
|
78
|
+
const { catalog, vmutils } = syntheticRelease();
|
|
79
|
+
const transport = fakeTransport({ [VMUTILS_URL]: vmutils });
|
|
80
|
+
const olds = vmalertProps(catalog, { directory: REAL() });
|
|
81
|
+
const output = await reconcileBinary(localRunner(), transport.fetch, olds);
|
|
82
|
+
const news = vmalertProps(catalog, { directory: LINKED(), sha256: 'e'.repeat(64) });
|
|
83
|
+
await expect(
|
|
84
|
+
reconcileBinary(localRunner(), transport.fetch, news, { olds, output }),
|
|
85
|
+
).rejects.toThrow('already exists and is not this resource');
|
|
86
|
+
expect(await onDisk()).toBe(VMALERT_TEXT);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('⚠️ a runner that reports no identity: the loss fails the deploy instead of passing', async () => {
|
|
90
|
+
const real = localRunner();
|
|
91
|
+
const blind: HostRunner = {
|
|
92
|
+
...real,
|
|
93
|
+
stat: async (path) => {
|
|
94
|
+
const found = await real.stat(path);
|
|
95
|
+
if (found === undefined) return undefined;
|
|
96
|
+
const { dev: _dev, ino: _ino, ...rest } = found;
|
|
97
|
+
return rest;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
const { catalog, vmutils } = syntheticRelease();
|
|
101
|
+
const fetch = fakeTransport({ [VMUTILS_URL]: vmutils }).fetch;
|
|
102
|
+
const olds = vmalertProps(catalog, { directory: REAL() });
|
|
103
|
+
const output = await reconcileBinary(blind, fetch, olds);
|
|
104
|
+
const news = vmalertProps(catalog, { directory: LINKED() });
|
|
105
|
+
await expect(reconcileBinary(blind, fetch, news, { olds, output })).rejects.toThrow(
|
|
106
|
+
'they are one file under two spellings',
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Release.Binary may take over at a path it holds no state for: ONLY the pinned binary, and
|
|
3
|
+
* only under `--adopt`. Asked the same way at plan (binary-diff.ts) and at apply
|
|
4
|
+
* (binary-lifecycle.ts), so a plan never says `adopted` or `create` for a file the apply would then
|
|
5
|
+
* overwrite or silently claim.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ OTHER BYTES ARE NEVER ADOPTED, `--adopt` OR NOT. 🔴 MEASURED 2026-09-22 (adopt-parity.test.ts,
|
|
8
|
+
* through Alchemy's own Plan and Apply): under `--adopt`, a file with other bytes at the path
|
|
9
|
+
* planned as `adopted` — beta.79 forces an `adopted` update after every takeover and prints it for
|
|
10
|
+
* drift too — and the apply downloaded the archive and overwrote that file in place. With the
|
|
11
|
+
* directory an Output (the probe skipped) the same file planned as `create`, and was overwritten.
|
|
12
|
+
* ★ Refused rather than converged, which is what Host.File does with a file it adopts: a binary's
|
|
13
|
+
* content is named entirely by its pin, so "adopting" other bytes is replacing them under
|
|
14
|
+
* whatever runs them, with no rollback but a re-download — the very overwrite binary-form.ts
|
|
15
|
+
* inPlaceRefusal refuses for a re-pin. The pinned bytes with another mode or owner ARE adopted;
|
|
16
|
+
* the apply re-writes the bytes already on disk.
|
|
17
|
+
* ⛔ THE PINNED BYTES WITH NO STATE ARE NOT OURS WITHOUT `--adopt` — AT APPLY AS AT PLAN. The probe
|
|
18
|
+
* already says `Unowned` for them (docs/ownership.md: identical is not ours). file-converge.ts
|
|
19
|
+
* accepts them at apply as the resume of an interrupted install, and that gap is where the probe
|
|
20
|
+
* never runs: every first deploy that passes `directory: dir.path`. 🔴 MEASURED 2026-09-22: a
|
|
21
|
+
* declaration renamed (a new logical id, the same path) in a deploy that also changed its
|
|
22
|
+
* HostDirectory's mode claimed the identical file at apply without `--adopt`; the orphan delete of
|
|
23
|
+
* the old name then removed it, and the deploy reported success with the binary gone. With the
|
|
24
|
+
* directory unchanged, the probe refused that same rename.
|
|
25
|
+
* ⚠️ THE COST: an install interrupted between its write and its commit while a prop was an
|
|
26
|
+
* Output now resumes only with `--adopt` — as the same install with every prop resolved already
|
|
27
|
+
* did (its recovery read is `Unowned`), and as ownership.md says a create or replace interrupted
|
|
28
|
+
* while a prop was an Output does. A retained old version is not re-claimed by a rollback either.
|
|
29
|
+
* ★ `renamedFrom()` IS THE REMEDY FOR A RENAME: `.pipe(renamedFrom('old-id'))` migrates the row
|
|
30
|
+
* and keeps the file (adopt-parity.test.ts). 🔴 MEASURED 2026-09-22: the same rename under
|
|
31
|
+
* `--adopt` claimed the file, the old name's delete then removed it, and the deploy succeeded
|
|
32
|
+
* with the binary gone. binary.ts's delete now leaves a path another declaration installed in
|
|
33
|
+
* the same deploy, so that is kept too — as an adoption, not as the old row. ⚠️ That guard sees
|
|
34
|
+
* only this deploy's claims, by spelling; nothing here sees another row's claim in state.
|
|
35
|
+
* ★ HOW DIFF TELLS A TAKEOVER FROM ITS OWN ROW. Every row this resource commits records the digest
|
|
36
|
+
* reconcile READ BACK, which it checked equal to the pin — so `output.sha256 === olds.sha256` for
|
|
37
|
+
* every row of its own, and an update never changes the digest (a new digest is a new path, a
|
|
38
|
+
* `replace`). The engine hands diff anything else only after a read of a path it holds no state
|
|
39
|
+
* for: the adoption probe (`olds` = the declaration) or the recovery read of an interrupted
|
|
40
|
+
* create (`olds` = its row). A non-file reads with digest '' (file-converge.ts).
|
|
41
|
+
*/
|
|
42
|
+
import { type FileTarget, readFileAttributes } from '../launchd/file-converge.ts';
|
|
43
|
+
import type { HostRunner } from '../launchd/runner.ts';
|
|
44
|
+
|
|
45
|
+
const TAKEN = 'already exists and is not this resource';
|
|
46
|
+
|
|
47
|
+
const otherBytes = (found: string, pinned: string): string =>
|
|
48
|
+
`${found === '' ? 'something that is not a regular file' : `a file hashing to ${found}`} is ` +
|
|
49
|
+
`there, not the pinned binary (${pinned}). --adopt takes over only the pinned binary: taking ` +
|
|
50
|
+
'over other bytes would overwrite them in place. Remove it (or move it aside) and deploy again';
|
|
51
|
+
|
|
52
|
+
/** Plan side: why diff must refuse `found` — a digest read at a path with no state — or undefined. */
|
|
53
|
+
export const adoptionProblem = (found: string, pinned: string): string | undefined =>
|
|
54
|
+
found === pinned ? undefined : `${TAKEN}: ${otherBytes(found, pinned)}`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Apply side, for a reconcile with no `output` — a create, a replace's new generation, or the
|
|
58
|
+
* resume of either: why it must not write, or undefined. `adopt` is adoptsAtApply's answer (never
|
|
59
|
+
* true for a fresh replace). A symlink or directory is left to convergeFile, which refuses it by
|
|
60
|
+
* kind.
|
|
61
|
+
*/
|
|
62
|
+
export const claimProblem = async (
|
|
63
|
+
runner: HostRunner,
|
|
64
|
+
want: FileTarget,
|
|
65
|
+
adopt: boolean,
|
|
66
|
+
): Promise<string | undefined> => {
|
|
67
|
+
const stat = await runner.stat(want.path);
|
|
68
|
+
if (stat?.kind !== 'file') return undefined;
|
|
69
|
+
const found = (await readFileAttributes(runner, want.path))?.sha256 ?? '';
|
|
70
|
+
if (found !== want.sha256) return adoptionProblem(found, want.sha256);
|
|
71
|
+
if (adopt) return undefined;
|
|
72
|
+
return (
|
|
73
|
+
`${TAKEN}: it holds the pinned binary, but this stack holds no state for it and the plan ` +
|
|
74
|
+
'could not ask first (a prop was still an Output, or a replace landed on it). ' +
|
|
75
|
+
'Identical is not ours: once claimed, a later delete removes it from whoever put it there. ' +
|
|
76
|
+
'Deploy with --adopt to take it over (a create, or an interrupted install of this one), or ' +
|
|
77
|
+
'remove it. If another declaration in this stack owned it under another name, declare the ' +
|
|
78
|
+
'rename with renamedFrom() instead'
|
|
79
|
+
);
|
|
80
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE PATH, ONE DECLARATION (binary.ts `claim`), through Alchemy's own Plan and Apply over the real
|
|
3
|
+
* localRunner in a fresh temp directory (fake-engine.ts).
|
|
4
|
+
*
|
|
5
|
+
* 🔴 MEASURED 2026-09-22 BEFORE THE FIX: `vmalert` and `vmalert-logs` declared at one path both
|
|
6
|
+
* planned `create` and both succeeded; the next deploy, without `vmalert-logs`, planned
|
|
7
|
+
* `vmalert: noop, vmalert-logs: delete` — and the file `vmalert` still declared was gone.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|
10
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { VMALERT_TEXT, realEngine } from './fake-engine.ts';
|
|
14
|
+
import { VMUTILS_URL } from './fake-release.ts';
|
|
15
|
+
|
|
16
|
+
let dir = '';
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
dir = join(await mkdtemp(join(tmpdir(), 'hf-claims-')), 'vmutils-1.151.0');
|
|
19
|
+
await mkdir(dir);
|
|
20
|
+
});
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await rm(join(dir, '..'), { force: true, recursive: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const onDisk = () =>
|
|
26
|
+
Bun.file(join(dir, 'vmalert'))
|
|
27
|
+
.text()
|
|
28
|
+
.catch(() => undefined);
|
|
29
|
+
|
|
30
|
+
test('⛔ a second resource at the same path is refused in the deploy that declares it', async () => {
|
|
31
|
+
const e = realEngine();
|
|
32
|
+
await expect(e.deploy({ vmalert: dir, 'vmalert-logs': dir })).rejects.toThrow(
|
|
33
|
+
'is already installed by',
|
|
34
|
+
);
|
|
35
|
+
// One of the two installed it; the other touched nothing.
|
|
36
|
+
expect(await onDisk()).toBe(VMALERT_TEXT);
|
|
37
|
+
expect(e.gets).toEqual([VMUTILS_URL]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('declared once, the survivor keeps its file when the stack is corrected', async () => {
|
|
41
|
+
const e = realEngine();
|
|
42
|
+
await e.deploy({ vmalert: dir, 'vmalert-logs': dir }).catch(() => undefined);
|
|
43
|
+
// ★ MEASURED: the first declared won the claim. The refused one left only the engine's
|
|
44
|
+
// unfinished-create row, whose delete touches no file — so the survivor keeps its binary.
|
|
45
|
+
expect(await e.deploy({ vmalert: dir })).toEqual({ vmalert: 'noop', 'vmalert-logs': 'delete' });
|
|
46
|
+
expect(await onDisk()).toBe(VMALERT_TEXT);
|
|
47
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release.Binary's plan-time answer: refuse what the plan can already see is wrong, `replace` when
|
|
3
|
+
* the binary's identity moves, and otherwise compare the live file with the declaration.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THE PINS MUST BE PLAIN VALUES, AND ARE CHECKED EVEN WHILE `directory` IS AN OUTPUT. A pin that
|
|
6
|
+
* is an Output would be computed during the deploy — a fetch by another name — so a malformed or
|
|
7
|
+
* non-literal pin fails the plan here, before a byte is fetched, whatever else is unresolved.
|
|
8
|
+
* ⚠️ A CREATE IS NEVER DIFFED: the engine plans a first deploy without calling diff, and skips
|
|
9
|
+
* the adoption probe while any prop is an Output. A pin wired from another resource's Output on
|
|
10
|
+
* a FIRST deploy therefore reaches no plan-time check; reconcile refuses it before any host call
|
|
11
|
+
* or request, from the props as the stack program declared them (declared-pins.ts). 🔴 Until
|
|
12
|
+
* 2026-09-22 it was checked there for shape only, and a swapped archive with swapped digests
|
|
13
|
+
* installed (apply-pins.test.ts).
|
|
14
|
+
* ⛔ A NEW PIN IS A NEW PATH (binary-form.ts inPlaceRefusal). When the path is resolved and
|
|
15
|
+
* unchanged, a changed pin is refused here; when it is resolved and new, it is a `replace`.
|
|
16
|
+
* ⚠️ WHILE THE DIRECTORY IS STILL AN OUTPUT the path is unknown, and the answer depends on whether
|
|
17
|
+
* the BYTES move, because the path may turn out not to have:
|
|
18
|
+
* - other bytes (or another name) → `replace`. If the path did not move after all, the new
|
|
19
|
+
* generation's create finds the old file, which does not hold its bytes, and refuses
|
|
20
|
+
* (file-converge.ts never overwrites what a generation does not own).
|
|
21
|
+
* - the same bytes from another archive (a re-pin, a repack) → `update`, NEVER `replace`. 🔴 A
|
|
22
|
+
* replace onto the same path would accept the old file as its own (same bytes, same mode) and
|
|
23
|
+
* then Phase 2 would delete the OLD generation — at that same path, taking the new one's file
|
|
24
|
+
* with it. An update reaches reconcile with `olds`, whose in-place guard refuses the same path,
|
|
25
|
+
* and whose move (file-converge.ts) writes the new path before removing the old.
|
|
26
|
+
* ★ Not the engine's default either way: a changed prop is an update by default, which is
|
|
27
|
+
* exactly the in-place overwrite this resource refuses.
|
|
28
|
+
* ★ Create-before-delete: two versions can sit in two directories at once, so nothing forces
|
|
29
|
+
* `deleteFirst`; the old file goes only once the new one is written and verified.
|
|
30
|
+
* ⚠️ HOUSE, NOT UPSTREAM: this reads the live file at plan time and can answer `noop`. Upstream's
|
|
31
|
+
* guidance is a diff that rarely does either (docs/release-binary-upstream.md).
|
|
32
|
+
*/
|
|
33
|
+
import { type Diff, isResolved } from 'alchemy/Diff';
|
|
34
|
+
import type { Input } from 'alchemy/Input';
|
|
35
|
+
import { diffTarget } from '../launchd/file-converge.ts';
|
|
36
|
+
import { resolvedString } from '../launchd/host-effect.ts';
|
|
37
|
+
import type { HostRunner } from '../launchd/runner.ts';
|
|
38
|
+
import { adoptionProblem } from './binary-claim.ts';
|
|
39
|
+
import {
|
|
40
|
+
type ReleaseBinaryAttributes,
|
|
41
|
+
type ReleaseBinaryProps,
|
|
42
|
+
inPlaceRefusal,
|
|
43
|
+
pinProblems,
|
|
44
|
+
pinsMoved,
|
|
45
|
+
} from './binary-form.ts';
|
|
46
|
+
import { NOTHING, desiredBinary, refuse } from './binary-lifecycle.ts';
|
|
47
|
+
|
|
48
|
+
export const diffBinary = async (
|
|
49
|
+
runner: HostRunner,
|
|
50
|
+
news: Input<ReleaseBinaryProps>,
|
|
51
|
+
olds: ReleaseBinaryProps,
|
|
52
|
+
output: ReleaseBinaryAttributes,
|
|
53
|
+
): Promise<Diff | undefined> => {
|
|
54
|
+
const problems = pinProblems((news ?? {}) as Parameters<typeof pinProblems>[0]);
|
|
55
|
+
if (problems.length > 0) throw refuse(output.path, `${problems.join('; ')}. ${NOTHING}`);
|
|
56
|
+
// ⛔ A TAKEOVER IS ONLY EVER OF THE PINNED BINARY (binary-claim.ts): `output` here is a read of a
|
|
57
|
+
// path with no state, so the plan refuses what it would otherwise print as `adopted`.
|
|
58
|
+
const takeover = adoptionProblem(output.sha256, olds.sha256);
|
|
59
|
+
if (takeover !== undefined) throw refuse(output.path, `${takeover}. ${NOTHING}`);
|
|
60
|
+
// ★ pinProblems passed, so every pin — and `name` — is a plain value; only these are read below.
|
|
61
|
+
const pins = news as Pick<ReleaseBinaryProps, 'archive' | 'member' | 'sha256' | 'name'>;
|
|
62
|
+
const directory = resolvedString(news, 'directory');
|
|
63
|
+
const path = directory === undefined ? undefined : `${directory}/${pins.name}`;
|
|
64
|
+
const pinned = pinsMoved(olds, pins);
|
|
65
|
+
if (pinned && path === output.path) {
|
|
66
|
+
throw refuse(output.path, `${inPlaceRefusal(olds, pins)}. ${NOTHING}`);
|
|
67
|
+
}
|
|
68
|
+
// Resolved: a new path is a replace, other bytes or a mode or owner drift an update (diffTarget).
|
|
69
|
+
if (isResolved(news)) return diffTarget(runner, (await desiredBinary(runner, news)).want, output);
|
|
70
|
+
if (path !== undefined) return path === output.path ? undefined : { action: 'replace' };
|
|
71
|
+
if (pins.name !== olds.name || pins.sha256 !== olds.sha256) return { action: 'replace' };
|
|
72
|
+
return pinned ? { action: 'update' } : undefined;
|
|
73
|
+
};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release.Binary's props, attributes and validation — the pure half of the provider.
|
|
3
|
+
*
|
|
4
|
+
* ★ THE PINNED ARCHIVE IS A PROP, NOT A LOOKUP. Repository, tag, exact asset name, size, the
|
|
5
|
+
* archive's SHA-256, the member and the member's SHA-256 all arrive as props — usually spread from
|
|
6
|
+
* a vendor data set (victoria.ts) by `catalogBinary()`. So the resource knows no vendor at all: a
|
|
7
|
+
* second vendor is a second data module, never a change here, and the resource is the shape
|
|
8
|
+
* Alchemy could accept (docs/release-binary-upstream.md).
|
|
9
|
+
* ⛔ NOTHING ESTATE-SPECIFIC IS A CONSTANT HERE. The install directory, owner, group and mode are
|
|
10
|
+
* the consuming stack's props. A stack that keeps binaries under `/opt/<estate>/obs/bin` says so
|
|
11
|
+
* in its own code.
|
|
12
|
+
* ⛔ EVERY PIN IS REQUIRED, AND WELL-FORMED OR REFUSED. There is no "latest", no optional digest and
|
|
13
|
+
* no fallback to a checksum file fetched at apply — see victoria.ts for why a pin beats a fetch.
|
|
14
|
+
*/
|
|
15
|
+
import type { HostFileAttributes } from '../launchd/host-file-form.ts';
|
|
16
|
+
import { identityProblems, pathProblems } from '../launchd/host-file-form.ts';
|
|
17
|
+
|
|
18
|
+
/** One release archive, pinned: where it is published, and the bytes it must be. */
|
|
19
|
+
export interface ReleaseArchive {
|
|
20
|
+
/** The GitHub repository that publishes it, `owner/name`: `'VictoriaMetrics/VictoriaMetrics'`. */
|
|
21
|
+
readonly repo: string;
|
|
22
|
+
/** The release tag, exactly as published: `'v1.151.0'`. */
|
|
23
|
+
readonly tag: string;
|
|
24
|
+
/**
|
|
25
|
+
* The asset's file name, whole: `'vmutils-darwin-arm64-v1.151.0.tar.gz'`. ⛔ Never a prefix or a
|
|
26
|
+
* pattern — `-enterprise` and `-cluster` siblings share every prefix with it.
|
|
27
|
+
*/
|
|
28
|
+
readonly asset: string;
|
|
29
|
+
/** Bytes, per the release API. A download that runs past this is cut off and refused. */
|
|
30
|
+
readonly size: number;
|
|
31
|
+
/** The archive's SHA-256, lower-case hex, as the vendor's checksum file lists it. */
|
|
32
|
+
readonly sha256: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ReleaseBinaryProps {
|
|
36
|
+
/** The pinned archive the binary comes out of. `catalogBinary()` fills it from a data set. */
|
|
37
|
+
archive: ReleaseArchive;
|
|
38
|
+
/** The archive member to extract, by its exact name: `'vmalert-prod'`. Nothing else is unpacked. */
|
|
39
|
+
member: string;
|
|
40
|
+
/** The member's own SHA-256 — what the installed file must hash to. */
|
|
41
|
+
sha256: string;
|
|
42
|
+
/**
|
|
43
|
+
* The installed file name: `'vmalert'`. ★ Separate from `member` on purpose — VictoriaMetrics
|
|
44
|
+
* names every member `<binary>-prod`, and a job's argv[0] should not carry that.
|
|
45
|
+
*/
|
|
46
|
+
name: string;
|
|
47
|
+
/**
|
|
48
|
+
* The directory the binary is written into. ⛔ It must already exist — declare it with
|
|
49
|
+
* `HostDirectory` and pass that resource's `path`, which also orders it first. ★ Give each
|
|
50
|
+
* version its own directory (`catalogDirectory()`): an upgrade is then a new path — a
|
|
51
|
+
* create-before-delete replace, never an overwrite under a running daemon — and a rollback is a
|
|
52
|
+
* path. A new pin at the SAME path is refused (binary-diff.ts).
|
|
53
|
+
*/
|
|
54
|
+
directory: string;
|
|
55
|
+
/**
|
|
56
|
+
* Permission bits. ⛔ Owner-readable and -executable, never group- or world-writable, never
|
|
57
|
+
* setuid, setgid or sticky — a writable binary is whoever-can-write's code, run as whoever runs
|
|
58
|
+
* the daemon. Its directory must not be group- or world-writable either (binary-lifecycle.ts).
|
|
59
|
+
* @default 0o755
|
|
60
|
+
*/
|
|
61
|
+
mode?: number;
|
|
62
|
+
/** User name or numeric uid. Omitted: whoever the runner writes as. */
|
|
63
|
+
owner?: string | number;
|
|
64
|
+
/** Group name or numeric gid. Omitted: the directory's default group. */
|
|
65
|
+
group?: string | number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ReleaseBinaryAttributes extends HostFileAttributes {
|
|
69
|
+
/** The archive the bytes came from, whole. */
|
|
70
|
+
url: string;
|
|
71
|
+
/** The archive member installed, by its exact name: `vmalert-prod`. */
|
|
72
|
+
member: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Everything a download and its two checks need: one archive, one member, both digests. */
|
|
76
|
+
export type PinnedDownload = {
|
|
77
|
+
readonly url: string;
|
|
78
|
+
readonly size: number;
|
|
79
|
+
/** The archive's pinned SHA-256. */
|
|
80
|
+
readonly sha256: string;
|
|
81
|
+
/** The archive member to extract, by exact name. */
|
|
82
|
+
readonly member: string;
|
|
83
|
+
/** The member's pinned SHA-256. */
|
|
84
|
+
readonly memberSha256: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const DEFAULT_BINARY_MODE = 0o755;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The release-asset URL. ★ GITHUB'S DOWNLOAD PATH, NOT THE API: it needs no token and spends no rate
|
|
91
|
+
* limit (it redirects to a signed CDN URL — measured 2026-09-22 with HEAD). Every consumer the
|
|
92
|
+
* estate census found publishes on GitHub except Grafana (grafana.com), which is not a
|
|
93
|
+
* single-binary consumer anyway (docs/release-binary-catalogs.md). A second source kind arrives
|
|
94
|
+
* with its first consumer, not before.
|
|
95
|
+
*/
|
|
96
|
+
export const releaseUrl = (repo: string, tag: string, asset: string): string =>
|
|
97
|
+
`https://github.com/${repo}/releases/download/${tag}/${asset}`;
|
|
98
|
+
|
|
99
|
+
/** Where a declaration puts its binary: `<directory>/<name>`. */
|
|
100
|
+
export const releaseBinaryPath = (props: {
|
|
101
|
+
readonly directory: string;
|
|
102
|
+
readonly name: string;
|
|
103
|
+
}): string => `${props.directory}/${props.name}`;
|
|
104
|
+
|
|
105
|
+
/** The download a declaration means. ⚠️ Validate first (binaryProblems): this trusts its input. */
|
|
106
|
+
export const pinnedDownload = (props: ReleaseBinaryProps): PinnedDownload => ({
|
|
107
|
+
member: props.member,
|
|
108
|
+
memberSha256: props.sha256,
|
|
109
|
+
sha256: props.archive.sha256,
|
|
110
|
+
size: props.archive.size,
|
|
111
|
+
url: releaseUrl(props.archive.repo, props.archive.tag, props.archive.asset),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
export const modeProblems = (mode: number): string[] => {
|
|
115
|
+
if (!Number.isSafeInteger(mode) || mode < 0 || mode > 0o7777) return ['mode must be 0–0o7777'];
|
|
116
|
+
const found: string[] = [];
|
|
117
|
+
if ((mode & 0o7000) !== 0) found.push('mode must not set setuid, setgid or sticky');
|
|
118
|
+
if ((mode & 0o022) !== 0) found.push('mode must not be group- or world-writable');
|
|
119
|
+
if ((mode & 0o100) === 0) found.push('mode must let the owner execute it');
|
|
120
|
+
// ⛔ AND READ IT: every plan re-hashes the binary, and a write is read back. MEASURED 2026-09-22,
|
|
121
|
+
// an execute-only 0111 written by a non-root operator failed its read-back with EACCES.
|
|
122
|
+
if ((mode & 0o400) === 0) found.push('mode must let the owner read it (it is re-hashed)');
|
|
123
|
+
return found;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
127
|
+
export const MAX_ARCHIVE = 1 << 30;
|
|
128
|
+
// ⛔ Strict on purpose: each is written into a URL or a path. GitHub's own owner and name alphabet.
|
|
129
|
+
const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
|
|
130
|
+
const SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
131
|
+
// ★ ONLY WHAT THE READER HANDLES: a gzipped tar. A `.zip` or a bare binary is a new capability
|
|
132
|
+
// with its own walk-down, not a name this should accept and then fail to unpack.
|
|
133
|
+
const TARBALL = /\.(tar\.gz|tgz)$/;
|
|
134
|
+
|
|
135
|
+
/** A single path segment that cannot climb, hide or name the directory itself. */
|
|
136
|
+
const segmentProblem = (what: string, value: string): string | undefined =>
|
|
137
|
+
!SEGMENT.test(value) || value === '.' || value === '..'
|
|
138
|
+
? `${what} must be one path segment of letters, digits, '.', '_' or '-': ${JSON.stringify(value)}`
|
|
139
|
+
: undefined;
|
|
140
|
+
|
|
141
|
+
/** Everything wrong with the pins alone — plain strings in any declaration, resolved or not. */
|
|
142
|
+
export const pinProblems = (props: {
|
|
143
|
+
readonly archive?: Partial<ReleaseArchive>;
|
|
144
|
+
readonly member?: unknown;
|
|
145
|
+
readonly sha256?: unknown;
|
|
146
|
+
readonly name?: unknown;
|
|
147
|
+
}): string[] => {
|
|
148
|
+
const archive = props.archive ?? {};
|
|
149
|
+
const found: string[] = [];
|
|
150
|
+
const text = (what: string, value: unknown): string | undefined => {
|
|
151
|
+
if (typeof value === 'string') return value;
|
|
152
|
+
found.push(`${what} is required, as a plain string`);
|
|
153
|
+
return undefined;
|
|
154
|
+
};
|
|
155
|
+
const repo = text('archive.repo', archive.repo);
|
|
156
|
+
// ⚠️ `owner/..` passes the alphabet, and a URL parser normalises it away: the request would go
|
|
157
|
+
// to `github.com/releases/…`, somewhere nobody pinned. Refused by name.
|
|
158
|
+
const repoName = repo?.slice(repo.indexOf('/') + 1);
|
|
159
|
+
if (repo !== undefined && (!REPO.test(repo) || repoName === '.' || repoName === '..'))
|
|
160
|
+
found.push(`archive.repo must be "owner/name": ${JSON.stringify(repo)}`);
|
|
161
|
+
for (const [what, value] of [
|
|
162
|
+
['archive.tag', archive.tag],
|
|
163
|
+
['name', props.name],
|
|
164
|
+
] as const) {
|
|
165
|
+
const given = text(what, value);
|
|
166
|
+
const problem = given === undefined ? undefined : segmentProblem(what, given);
|
|
167
|
+
if (problem !== undefined) found.push(problem);
|
|
168
|
+
}
|
|
169
|
+
const asset = text('archive.asset', archive.asset);
|
|
170
|
+
if (asset !== undefined) {
|
|
171
|
+
const problem = segmentProblem('archive.asset', asset);
|
|
172
|
+
if (problem !== undefined) found.push(problem);
|
|
173
|
+
else if (!TARBALL.test(asset))
|
|
174
|
+
found.push(`archive.asset must be a .tar.gz or .tgz: "${asset}"`);
|
|
175
|
+
}
|
|
176
|
+
// ⛔ THE SIZE IS ALLOCATED UP FRONT (download.ts fills one buffer of exactly this many bytes), so
|
|
177
|
+
// a typo'd size is a memory bomb in the deploying process. vmutils, the largest archive pinned,
|
|
178
|
+
// is 123.6 MB; a gibibyte is a ceiling nothing single-binary should reach.
|
|
179
|
+
const size = archive.size;
|
|
180
|
+
if (typeof size !== 'number' || !Number.isSafeInteger(size) || size <= 0 || size > MAX_ARCHIVE) {
|
|
181
|
+
found.push(`archive.size must be the asset's byte count, 1 to ${String(MAX_ARCHIVE)}`);
|
|
182
|
+
}
|
|
183
|
+
for (const [what, value] of [
|
|
184
|
+
['archive.sha256', archive.sha256],
|
|
185
|
+
['sha256', props.sha256],
|
|
186
|
+
] as const) {
|
|
187
|
+
const given = text(what, value);
|
|
188
|
+
if (given !== undefined && !HEX64.test(given))
|
|
189
|
+
found.push(`${what} must be 64 lower-case hex digits`);
|
|
190
|
+
}
|
|
191
|
+
const member = text('member', props.member);
|
|
192
|
+
// ⛔ The tar reader refuses these entries anyway; asking for one is a mistake to name at plan.
|
|
193
|
+
if (
|
|
194
|
+
member !== undefined &&
|
|
195
|
+
(member === '' || member.startsWith('/') || member.split('/').includes('..'))
|
|
196
|
+
) {
|
|
197
|
+
found.push(`member must be a relative name inside the archive: ${JSON.stringify(member)}`);
|
|
198
|
+
}
|
|
199
|
+
return found;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const ARCHIVE_KEYS = ['repo', 'tag', 'asset', 'size', 'sha256'] as const;
|
|
203
|
+
|
|
204
|
+
type Pins = Pick<ReleaseBinaryProps, 'archive' | 'member' | 'sha256'>;
|
|
205
|
+
|
|
206
|
+
/** Whether two declarations pin different bytes: another archive, member or member digest. */
|
|
207
|
+
export const pinsMoved = (olds: Pins, news: Pins): boolean =>
|
|
208
|
+
olds.member !== news.member ||
|
|
209
|
+
olds.sha256 !== news.sha256 ||
|
|
210
|
+
ARCHIVE_KEYS.some((key) => olds.archive[key] !== news.archive[key]);
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* ⛔ A NEW PIN IS A NEW PATH. The same path with other bytes would overwrite a binary under the
|
|
214
|
+
* daemon running it, and leave no rollback but a re-download — so it is refused, at plan when the
|
|
215
|
+
* path is resolved (binary-diff.ts) and at apply otherwise (binary-lifecycle.ts).
|
|
216
|
+
*/
|
|
217
|
+
export const inPlaceRefusal = (olds: Pins, news: Pins): string =>
|
|
218
|
+
`a new pin (${news.archive.tag} ${news.member} ${news.sha256}) at the path of the installed ` +
|
|
219
|
+
`one (${olds.archive.tag} ${olds.member} ${olds.sha256}) would overwrite it in place; give each ` +
|
|
220
|
+
'version its own directory (catalogDirectory), so an upgrade is a new path';
|
|
221
|
+
|
|
222
|
+
/** Everything wrong with a declaration, before any host or network is asked anything. */
|
|
223
|
+
export const binaryProblems = (props: ReleaseBinaryProps): string[] => [
|
|
224
|
+
...pinProblems(props),
|
|
225
|
+
...pathProblems(props.directory).map((problem) => `directory: ${problem}`),
|
|
226
|
+
...modeProblems(props.mode ?? DEFAULT_BINARY_MODE),
|
|
227
|
+
...identityProblems('owner', props.owner),
|
|
228
|
+
...identityProblems('group', props.group),
|
|
229
|
+
];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The install lifecycle over a fake host (launchd/fake-runner.ts) and a fake transport — no network,
|
|
3
|
+
* no real filesystem: what an install writes, what a matching host costs (nothing), and read and
|
|
4
|
+
* delete. Every refusal: binary-refusals.test.ts.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { sha256Hex } from '../launchd/job-form.ts';
|
|
8
|
+
import { deleteBinary, readBinary } from './binary-lifecycle.ts';
|
|
9
|
+
import { PATH, setup, writes } from './fake-install.ts';
|
|
10
|
+
import { BINARY, ENTERPRISE_URL, VMUTILS_URL, bytesOf, pathsOn } from './fake-release.ts';
|
|
11
|
+
|
|
12
|
+
describe('installing', () => {
|
|
13
|
+
test('the declared member, from the plain archive only, verified, at <directory>/<name>', async () => {
|
|
14
|
+
const s = setup();
|
|
15
|
+
const notes: string[] = [];
|
|
16
|
+
const attrs = await s.install(s.props(), { note: async (m: string) => void notes.push(m) });
|
|
17
|
+
// ⛔ The enterprise sibling was served too, under its real name; it was never asked for.
|
|
18
|
+
expect(s.transport.requests).toEqual([VMUTILS_URL]);
|
|
19
|
+
expect(s.transport.requests).not.toContain(ENTERPRISE_URL);
|
|
20
|
+
expect(s.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
|
|
21
|
+
expect(attrs).toMatchObject({
|
|
22
|
+
member: 'vmalert-prod',
|
|
23
|
+
mode: 0o755,
|
|
24
|
+
path: PATH,
|
|
25
|
+
url: VMUTILS_URL,
|
|
26
|
+
});
|
|
27
|
+
expect(attrs.sha256).toBe(sha256Hex(BINARY.vmalert));
|
|
28
|
+
expect(notes[0]).toContain('downloading');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('⛔ an undeclared member is never written: vmagent-prod and vmauth-prod stay in the archive', async () => {
|
|
32
|
+
const s = setup();
|
|
33
|
+
await s.install();
|
|
34
|
+
expect(pathsOn(s.fake)).toEqual([PATH]);
|
|
35
|
+
expect(writes(s.fake).map((c) => c[1])).toEqual([PATH]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('already installed and matching: no download, no write', async () => {
|
|
39
|
+
const s = setup();
|
|
40
|
+
const output = await s.install();
|
|
41
|
+
s.transport.requests.length = 0;
|
|
42
|
+
s.fake.calls.length = 0;
|
|
43
|
+
await s.install(s.props(), { olds: s.props(), output });
|
|
44
|
+
expect([s.transport.requests, writes(s.fake)]).toEqual([[], []]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('the right bytes with the wrong mode: re-written from disk, not downloaded', async () => {
|
|
48
|
+
const s = setup();
|
|
49
|
+
const output = await s.install();
|
|
50
|
+
s.transport.requests.length = 0;
|
|
51
|
+
const fixed = await s.install(s.props({ mode: 0o555 }), { olds: s.props(), output });
|
|
52
|
+
expect(fixed.mode).toBe(0o555);
|
|
53
|
+
expect(s.transport.requests).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ⚠️ Only under --adopt (binary-claim.ts); without it, binary-refusals.test.ts refuses the same file.
|
|
57
|
+
test('unprobed, the pinned bytes at the path, under --adopt: kept, not fetched', async () => {
|
|
58
|
+
const s = setup();
|
|
59
|
+
s.fake.files.set(PATH, { bytes: BINARY.vmalert, gid: 0, kind: 'file', mode: 0o755, uid: 0 });
|
|
60
|
+
expect((await s.install(s.props(), { adopt: true })).sha256).toBe(sha256Hex(BINARY.vmalert));
|
|
61
|
+
expect([s.transport.requests, writes(s.fake)]).toEqual([[], []]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a new version is a new path: written, then the old one removed', async () => {
|
|
65
|
+
const s = setup();
|
|
66
|
+
const old = { ...(await s.install()), path: '/opt/example/bin/vmutils-1.150.0/vmalert' };
|
|
67
|
+
s.fake.files.set(old.path, {
|
|
68
|
+
bytes: bytesOf('old'),
|
|
69
|
+
gid: 0,
|
|
70
|
+
kind: 'file',
|
|
71
|
+
mode: 0o755,
|
|
72
|
+
uid: 0,
|
|
73
|
+
});
|
|
74
|
+
await s.install(s.props(), { output: old });
|
|
75
|
+
expect(s.fake.files.has(old.path)).toBe(false);
|
|
76
|
+
expect(s.fake.files.has(PATH)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('read and delete', () => {
|
|
81
|
+
test('read reports the file at the path by its digest; delete removes it, twice', async () => {
|
|
82
|
+
const s = setup();
|
|
83
|
+
expect(await readBinary(s.fake.runner, s.props())).toBeUndefined();
|
|
84
|
+
const output = await s.install();
|
|
85
|
+
expect((await readBinary(s.fake.runner, s.props()))?.sha256).toBe(output.sha256);
|
|
86
|
+
await deleteBinary(s.fake.runner, output);
|
|
87
|
+
await deleteBinary(s.fake.runner, output);
|
|
88
|
+
expect(s.fake.files.has(PATH)).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|