@homeflare/alchemy 0.37.8 → 0.39.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 +22 -0
- package/dist/caddy/admin.d.ts +42 -8
- package/dist/caddy/admin.d.ts.map +1 -1
- package/dist/caddy/config.d.ts +9 -3
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +5 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.js +10 -6
- package/dist/caddy/index.js.map +5 -5
- package/dist/caddy/providers.d.ts +19 -12
- package/dist/caddy/providers.d.ts.map +1 -1
- package/dist/forgejo/index.js +4 -2
- package/dist/forgejo/index.js.map +3 -3
- package/dist/forgejo/team-member.d.ts +25 -0
- package/dist/forgejo/team-member.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
- package/dist/proxmox/index.d.ts +14 -3
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +565 -133
- package/dist/proxmox/index.js.map +33 -23
- package/dist/proxmox/qemu-form.d.ts +27 -18
- package/dist/proxmox/qemu-form.d.ts.map +1 -1
- package/dist/proxmox/qemu-judge.d.ts +30 -0
- package/dist/proxmox/qemu-judge.d.ts.map +1 -0
- package/dist/proxmox/qemu-lifecycle.d.ts +16 -2
- package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/qemu-net.d.ts +9 -0
- package/dist/proxmox/qemu-net.d.ts.map +1 -0
- package/dist/proxmox/qemu-props.d.ts +112 -0
- package/dist/proxmox/qemu-props.d.ts.map +1 -0
- package/dist/proxmox/qemu-volume.d.ts +17 -0
- package/dist/proxmox/qemu-volume.d.ts.map +1 -0
- package/dist/proxmox/qemu.d.ts +14 -27
- package/dist/proxmox/qemu.d.ts.map +1 -1
- package/dist/proxmox/storage-download-errors.d.ts +5 -0
- package/dist/proxmox/storage-download-errors.d.ts.map +1 -0
- package/dist/proxmox/storage-download-identity.d.ts +27 -0
- package/dist/proxmox/storage-download-identity.d.ts.map +1 -0
- package/dist/proxmox/storage-download-lifecycle.d.ts +97 -0
- package/dist/proxmox/storage-download-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/storage-download-props.d.ts +36 -0
- package/dist/proxmox/storage-download-props.d.ts.map +1 -0
- package/dist/proxmox/storage-download-task.d.ts +17 -0
- package/dist/proxmox/storage-download-task.d.ts.map +1 -0
- package/dist/proxmox/storage-download.d.ts +44 -0
- package/dist/proxmox/storage-download.d.ts.map +1 -0
- package/dist/telemetry/fake-collector.d.ts +36 -0
- package/dist/telemetry/fake-collector.d.ts.map +1 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +223 -0
- package/dist/telemetry/index.js.map +12 -0
- package/dist/telemetry/layer.d.ts +86 -0
- package/dist/telemetry/layer.d.ts.map +1 -0
- package/dist/telemetry/redact-serialization.d.ts +21 -0
- package/dist/telemetry/redact-serialization.d.ts.map +1 -0
- package/dist/telemetry/redact.d.ts +46 -0
- package/dist/telemetry/redact.d.ts.map +1 -0
- package/dist/unifi/drift.d.ts +27 -0
- package/dist/unifi/drift.d.ts.map +1 -0
- package/dist/unifi/fake-unifi.d.ts +5 -1
- package/dist/unifi/fake-unifi.d.ts.map +1 -1
- package/dist/unifi/firewall-zone-form.d.ts +6 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +1 -1
- package/dist/unifi/firewall-zone.d.ts.map +1 -1
- package/dist/unifi/index.d.ts +4 -3
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +138 -58
- package/dist/unifi/index.js.map +10 -8
- package/dist/unifi/network-drift.d.ts +26 -0
- package/dist/unifi/network-drift.d.ts.map +1 -0
- package/dist/unifi/network-form.d.ts +40 -7
- package/dist/unifi/network-form.d.ts.map +1 -1
- package/dist/unifi/network.d.ts +1 -0
- package/dist/unifi/network.d.ts.map +1 -1
- package/dist/unifi/paginate.d.ts +36 -0
- package/dist/unifi/paginate.d.ts.map +1 -0
- package/dist/unifi/resource.d.ts +36 -1
- package/dist/unifi/resource.d.ts.map +1 -1
- package/docs/telemetry-spike.md +94 -0
- package/docs/telemetry.md +109 -0
- package/docs/unifi.md +56 -0
- package/package.json +6 -2
- package/src/caddy/admin-transport-scope.test.ts +127 -0
- package/src/caddy/admin.ts +45 -9
- package/src/caddy/config-adopt-provider.test.ts +2 -2
- package/src/caddy/config-format-warning.test.ts +9 -3
- package/src/caddy/config.ts +20 -4
- package/src/caddy/fake-caddy.ts +10 -1
- package/src/caddy/providers.test.ts +3 -2
- package/src/caddy/providers.ts +18 -11
- package/src/forgejo/team-member.test.ts +14 -0
- package/src/forgejo/team-member.ts +28 -1
- package/src/proxmox/constraints-required.test.ts +1 -0
- package/src/proxmox/generated/constraints/index.ts +3 -1
- package/src/proxmox/generated/constraints/pve-access.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-storage.ts +30 -0
- package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
- package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
- package/src/proxmox/index.test.ts +11 -6
- package/src/proxmox/index.ts +19 -3
- package/src/proxmox/qemu-form.ts +49 -13
- package/src/proxmox/qemu-identity.test.ts +30 -0
- package/src/proxmox/qemu-judge.test.ts +65 -0
- package/src/proxmox/qemu-judge.ts +84 -0
- package/src/proxmox/qemu-lifecycle.test.ts +78 -19
- package/src/proxmox/qemu-lifecycle.ts +131 -32
- package/src/proxmox/qemu-net.test.ts +48 -0
- package/src/proxmox/qemu-net.ts +78 -0
- package/src/proxmox/qemu-ownership.test.ts +44 -0
- package/src/proxmox/qemu-props.test.ts +87 -0
- package/src/proxmox/qemu-props.ts +157 -0
- package/src/proxmox/qemu-volume.test.ts +80 -0
- package/src/proxmox/qemu-volume.ts +121 -0
- package/src/proxmox/qemu.ts +15 -29
- package/src/proxmox/storage-download-errors.ts +7 -0
- package/src/proxmox/storage-download-identity.test.ts +54 -0
- package/src/proxmox/storage-download-identity.ts +50 -0
- package/src/proxmox/storage-download-lifecycle.ts +226 -0
- package/src/proxmox/storage-download-ownership.test.ts +50 -0
- package/src/proxmox/storage-download-props.ts +38 -0
- package/src/proxmox/storage-download-task.ts +72 -0
- package/src/proxmox/storage-download.test.ts +216 -0
- package/src/proxmox/storage-download.ts +58 -0
- package/src/telemetry/fake-collector.ts +59 -0
- package/src/telemetry/http-client-sealing.test.ts +82 -0
- package/src/telemetry/index.ts +10 -0
- package/src/telemetry/layer.test.ts +204 -0
- package/src/telemetry/layer.ts +152 -0
- package/src/telemetry/redact-serialization.ts +178 -0
- package/src/telemetry/redact.test.ts +124 -0
- package/src/telemetry/redact.ts +147 -0
- package/src/unifi/drift.test.ts +78 -0
- package/src/unifi/drift.ts +56 -0
- package/src/unifi/errors-and-secrets.test.ts +127 -0
- package/src/unifi/fake-unifi.ts +5 -1
- package/src/unifi/firewall-zone-form.ts +31 -2
- package/src/unifi/firewall-zone.test.ts +21 -1
- package/src/unifi/firewall-zone.ts +1 -1
- package/src/unifi/get-only-guard.test.ts +165 -0
- package/src/unifi/index.ts +15 -1
- package/src/unifi/network-drift.test.ts +207 -0
- package/src/unifi/network-drift.ts +80 -0
- package/src/unifi/network-form.ts +11 -39
- package/src/unifi/network.test.ts +7 -82
- package/src/unifi/network.ts +6 -6
- package/src/unifi/paginate.test.ts +228 -0
- package/src/unifi/paginate.ts +129 -0
- package/src/unifi/resource.ts +73 -3
- package/src/unifi/write-op-reference.test.ts +236 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** What a `Proxmox.StorageDownload` declaration says, and what its state keeps. See storage-download.ts's header. */
|
|
2
|
+
import type { WithTarget } from './resource.ts';
|
|
3
|
+
|
|
4
|
+
export type ChecksumAlgorithm = 'md5' | 'sha1' | 'sha224' | 'sha256' | 'sha384' | 'sha512';
|
|
5
|
+
|
|
6
|
+
export interface StorageDownloadProps extends WithTarget {
|
|
7
|
+
/** The node this download runs on. Content lands on `storage`, shared or not — see the header. */
|
|
8
|
+
node: string;
|
|
9
|
+
storage: string;
|
|
10
|
+
/** The name PVE gives the file under `storage`'s `import` content. Normalized by PVE on write. */
|
|
11
|
+
filename: string;
|
|
12
|
+
/** Where to fetch the image from. ⚠️ Not re-verified after create — PVE does not store it. */
|
|
13
|
+
url: string;
|
|
14
|
+
/** ⛔ Required. See storage-download.ts's header on why this resource narrows the vendor's own optional field. */
|
|
15
|
+
checksum: string;
|
|
16
|
+
checksumAlgorithm: ChecksumAlgorithm;
|
|
17
|
+
/** Decompress the downloaded file with this algorithm before it lands on `storage`. */
|
|
18
|
+
compression?: string;
|
|
19
|
+
/** Verify the source's TLS certificate. PVE defaults this on; declare `false` for a self-signed mirror. */
|
|
20
|
+
verifyCertificates?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface StorageDownloadAttributes {
|
|
24
|
+
node: string;
|
|
25
|
+
storage: string;
|
|
26
|
+
filename: string;
|
|
27
|
+
/**
|
|
28
|
+
* ⚠️ REASONED, NOT MEASURED LIVE. PVE's file-based content types spell a volid
|
|
29
|
+
* `<storage>:<segment>/<filename>` — this codebase already relies on that shape for `iso` and
|
|
30
|
+
* `vztmpl` (lxc-create-form.ts's `existingVolume`, lxc-create.test.ts's own fixture) — but the
|
|
31
|
+
* exact `<segment>` spelling for `import` content is not independently confirmed against a
|
|
32
|
+
* live cluster. `storage-download-lifecycle.ts`'s `readDownload` does not depend on knowing
|
|
33
|
+
* it: it matches by filename SUFFIX within a list already server-filtered to `content=import`.
|
|
34
|
+
*/
|
|
35
|
+
volid: string;
|
|
36
|
+
size: number;
|
|
37
|
+
format: string;
|
|
38
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `download-url` and the content DELETE both answer a UPID (protocol.ts's "three 200-traps",
|
|
3
|
+
* trap (a)) — bounded task observation on the exact credential that started the worker.
|
|
4
|
+
* ★ THE SAME SHAPE AS qemu-task.ts/lxc-task.ts. Not merged with either: a fourth near-identical
|
|
5
|
+
* copy would be the moment to extract a shared poller, not this one (⚠️ flagged, not fixed here
|
|
6
|
+
* — see the PR description).
|
|
7
|
+
*/
|
|
8
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
9
|
+
import type { ProxmoxOpContext } from '@distilled.cloud/proxmox/Protocol';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import { runPveWith } from './distilled-pve.ts';
|
|
12
|
+
import { mint } from './mint.ts';
|
|
13
|
+
import { StorageDownloadRefusedError } from './storage-download-errors.ts';
|
|
14
|
+
import { text } from './values.ts';
|
|
15
|
+
import type { PveTarget } from './credentials.ts';
|
|
16
|
+
|
|
17
|
+
export interface StorageDownloadWhere {
|
|
18
|
+
readonly node: string;
|
|
19
|
+
readonly target: PveTarget;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ⛔ A CHECKSUM MISMATCH SURFACES HERE, NOT AS A DISTINCT TYPED ERROR. `download-url`'s POST
|
|
24
|
+
* answers 200 with a UPID regardless of whether the checksum will match — PVE validates the
|
|
25
|
+
* download itself, INSIDE the task, and reports the mismatch as that task's `exitstatus`. So
|
|
26
|
+
* the refusal below (`exit !== 'OK'`) IS the checksum-mismatch refusal: this poller fails the
|
|
27
|
+
* plan whenever the task did not report exactly `"OK"`, whatever the reason, without needing a
|
|
28
|
+
* new SDK tag to name it.
|
|
29
|
+
*/
|
|
30
|
+
export const storageDownloadTask = <E>(
|
|
31
|
+
where: StorageDownloadWhere,
|
|
32
|
+
operation: Effect.Effect<string, E, ProxmoxOpContext>,
|
|
33
|
+
what: string,
|
|
34
|
+
polls: number,
|
|
35
|
+
) =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const credential = yield* mint(where.target, 'provision');
|
|
38
|
+
const upid = text(yield* runPveWith(where.target, credential, true, operation));
|
|
39
|
+
if (upid === '') {
|
|
40
|
+
return yield* Effect.fail(
|
|
41
|
+
new StorageDownloadRefusedError(
|
|
42
|
+
`${what}: PVE returned no task id, so the write cannot be confirmed and is not claimed.`,
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
let exit = 'still running when this provider stopped waiting';
|
|
47
|
+
for (let attempt = 0; attempt < polls; attempt++) {
|
|
48
|
+
const status = yield* runPveWith(
|
|
49
|
+
where.target,
|
|
50
|
+
credential,
|
|
51
|
+
false,
|
|
52
|
+
nodes.getNodeTaskStatus({ node: where.node, upid }),
|
|
53
|
+
).pipe(Effect.orElseSucceed(() => undefined));
|
|
54
|
+
// ⚠️ Matches qemu-task.ts's own note: only exactly "stopped" ends the poll.
|
|
55
|
+
if (status === undefined || status.status === undefined) {
|
|
56
|
+
exit = 'the task could not be read';
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
if (status.status === 'stopped') {
|
|
60
|
+
exit = text(status.exitstatus, 'stopped with no exitstatus');
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
yield* Effect.sleep('1 second');
|
|
64
|
+
}
|
|
65
|
+
if (exit !== 'OK') {
|
|
66
|
+
return yield* Effect.fail(
|
|
67
|
+
new StorageDownloadRefusedError(
|
|
68
|
+
`${what}: task ${upid} ended "${exit}". Read the task log before running the deploy again.`,
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.StorageDownload` engine tests over a stub fetch. No live host, no live cluster.
|
|
3
|
+
* ⚠️ The exact `import/` volid segment is REASONED, not measured — see storage-download-props.ts.
|
|
4
|
+
* These fixtures spell it that way for readability only; `readDownload` matches by filename
|
|
5
|
+
* suffix, so the tests below would pass with any segment spelling PVE actually uses.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import * as Layer from 'effect/Layer';
|
|
10
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
11
|
+
import { FAKE_TARGET, type FakePve, fakePve, withoutBao } from './fake-pve.ts';
|
|
12
|
+
import type { StorageDownloadProps } from './storage-download-props.ts';
|
|
13
|
+
import { ProxmoxStorageDownload, ProxmoxStorageDownloadProvider } from './storage-download.ts';
|
|
14
|
+
|
|
15
|
+
const NODE = 'pve1';
|
|
16
|
+
const STORAGE = 'cephfs-tb4';
|
|
17
|
+
const FILENAME = 'talos-v1.13.0-metal-amd64.qcow2';
|
|
18
|
+
const UPID = `UPID:${NODE}:00000002:0:0:imgdownload:${STORAGE}:test@pve!fake:`;
|
|
19
|
+
const row = (over: Partial<Record<string, unknown>> = {}) => ({
|
|
20
|
+
format: 'qcow2',
|
|
21
|
+
size: 123_456,
|
|
22
|
+
volid: `${STORAGE}:import/${FILENAME}`,
|
|
23
|
+
...over,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const declare = (over: Partial<StorageDownloadProps> = {}): StorageDownloadProps => ({
|
|
27
|
+
checksum: 'a'.repeat(64),
|
|
28
|
+
checksumAlgorithm: 'sha256',
|
|
29
|
+
filename: FILENAME,
|
|
30
|
+
node: NODE,
|
|
31
|
+
storage: STORAGE,
|
|
32
|
+
target: FAKE_TARGET,
|
|
33
|
+
url: 'https://images.example.test/talos.qcow2',
|
|
34
|
+
...over,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const engine = (fake: FakePve) =>
|
|
38
|
+
engineOver(ProxmoxStorageDownloadProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
39
|
+
|
|
40
|
+
const isTaskStatus = (call: { method: string; path: string }) =>
|
|
41
|
+
call.path.includes('/tasks/') && call.path.endsWith('/status');
|
|
42
|
+
const isList = (call: { method: string; path: string }) =>
|
|
43
|
+
call.method === 'GET' && call.path.includes('/content?');
|
|
44
|
+
const isDownload = (call: { method: string; path: string }) =>
|
|
45
|
+
call.method === 'POST' && call.path.endsWith('/download-url');
|
|
46
|
+
|
|
47
|
+
describe('creating a download', () => {
|
|
48
|
+
test('one POST to download-url, a task wait, then the next plan is noop', async () => {
|
|
49
|
+
let downloaded = false;
|
|
50
|
+
const fake = fakePve((call) => {
|
|
51
|
+
if (isTaskStatus(call)) return { status: 'stopped', exitstatus: 'OK' };
|
|
52
|
+
if (isList(call)) return downloaded ? [row()] : [];
|
|
53
|
+
if (isDownload(call)) {
|
|
54
|
+
downloaded = true;
|
|
55
|
+
return UPID;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
});
|
|
59
|
+
await withoutBao(async () => {
|
|
60
|
+
const stack = engine(fake);
|
|
61
|
+
expect(await stack.deploy(ProxmoxStorageDownload('image', declare()))).toEqual({
|
|
62
|
+
image: 'create',
|
|
63
|
+
});
|
|
64
|
+
expect(
|
|
65
|
+
(await stack.verify(ProxmoxStorageDownload('image', declare()), { all: true })).rows[0],
|
|
66
|
+
).toMatchObject({ diff: 'noop' });
|
|
67
|
+
});
|
|
68
|
+
expect(fake.writes()).toEqual([`POST nodes/${NODE}/storage/${STORAGE}/download-url`]);
|
|
69
|
+
const posted = fake.calls.find((call) => call.method === 'POST')?.form;
|
|
70
|
+
expect(posted?.['checksum']).toBe('a'.repeat(64));
|
|
71
|
+
expect(posted?.['checksum-algorithm']).toBe('sha256');
|
|
72
|
+
expect(posted?.['content']).toBe('import');
|
|
73
|
+
expect(fake.calls.some((call) => call.path.includes('/tasks/'))).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('⛔ checksum is required: an empty one is refused before any write ("checksum-pinned")', async () => {
|
|
77
|
+
const fake = fakePve((call) => (isList(call) ? [] : null));
|
|
78
|
+
await withoutBao(async () => {
|
|
79
|
+
await expect(
|
|
80
|
+
engine(fake).deploy(ProxmoxStorageDownload('image', declare({ checksum: ' ' }))),
|
|
81
|
+
).rejects.toThrow(/checksum is required/);
|
|
82
|
+
});
|
|
83
|
+
expect(fake.writes()).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('a failed download task refuses the plan — the checksum-mismatch refusal', async () => {
|
|
87
|
+
const fake = fakePve((call) => {
|
|
88
|
+
if (isTaskStatus(call)) return { exitstatus: 'checksum mismatch', status: 'stopped' };
|
|
89
|
+
if (isList(call)) return [];
|
|
90
|
+
if (isDownload(call)) return UPID;
|
|
91
|
+
return null;
|
|
92
|
+
});
|
|
93
|
+
await withoutBao(async () => {
|
|
94
|
+
await expect(engine(fake).deploy(ProxmoxStorageDownload('image', declare()))).rejects.toThrow(
|
|
95
|
+
/checksum mismatch/,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('removing a download', () => {
|
|
102
|
+
test('not retain by default: dropping the declaration deletes the file and waits for its task', async () => {
|
|
103
|
+
let exists = false;
|
|
104
|
+
const fake = fakePve((call) => {
|
|
105
|
+
if (isTaskStatus(call)) return { status: 'stopped', exitstatus: 'OK' };
|
|
106
|
+
if (isList(call)) return exists ? [row()] : [];
|
|
107
|
+
if (isDownload(call)) {
|
|
108
|
+
exists = true;
|
|
109
|
+
return UPID;
|
|
110
|
+
}
|
|
111
|
+
if (call.method === 'DELETE') {
|
|
112
|
+
exists = false;
|
|
113
|
+
return UPID;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
});
|
|
117
|
+
await withoutBao(async () => {
|
|
118
|
+
const stack = engine(fake);
|
|
119
|
+
await stack.deploy(ProxmoxStorageDownload('image', declare()));
|
|
120
|
+
await stack.deploy(Effect.void);
|
|
121
|
+
});
|
|
122
|
+
expect(fake.writes()).toHaveLength(2);
|
|
123
|
+
expect(fake.writes()[0]).toBe(`POST nodes/${NODE}/storage/${STORAGE}/download-url`);
|
|
124
|
+
expect(fake.writes()[1]).toMatch(
|
|
125
|
+
new RegExp(`^DELETE nodes/${NODE}/storage/${STORAGE}/content/`),
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('⛔ idempotent delete: a volume already gone by removal time sends no DELETE', async () => {
|
|
130
|
+
let created = false;
|
|
131
|
+
let vanished = false;
|
|
132
|
+
const fake = fakePve((call) => {
|
|
133
|
+
if (isTaskStatus(call)) return { status: 'stopped', exitstatus: 'OK' };
|
|
134
|
+
if (isList(call)) return created && !vanished ? [row()] : [];
|
|
135
|
+
if (isDownload(call)) {
|
|
136
|
+
created = true;
|
|
137
|
+
return UPID;
|
|
138
|
+
}
|
|
139
|
+
return null; // a DELETE here would fail the test below by never answering it
|
|
140
|
+
});
|
|
141
|
+
await withoutBao(async () => {
|
|
142
|
+
const stack = engine(fake);
|
|
143
|
+
await stack.deploy(ProxmoxStorageDownload('image', declare()));
|
|
144
|
+
vanished = true; // out-of-band: something else already removed the file
|
|
145
|
+
await stack.deploy(Effect.void);
|
|
146
|
+
});
|
|
147
|
+
expect(fake.writes()).toEqual([`POST nodes/${NODE}/storage/${STORAGE}/download-url`]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('identity (red-team I1/I2 on PR 297)', () => {
|
|
152
|
+
test('a changed filename replaces: the old file is deleted, the new one downloaded', async () => {
|
|
153
|
+
const NEW_FILENAME = 'talos-v1.14.0-metal-amd64.qcow2';
|
|
154
|
+
// ⚠️ ALCHEMY REPLACES CREATE-FIRST (acl.test.ts's own note): the new file exists alongside the
|
|
155
|
+
// old one for a moment, so the fixture tracks a SET of files, not one current name.
|
|
156
|
+
const files = new Set<string>();
|
|
157
|
+
const fake = fakePve((call) => {
|
|
158
|
+
if (isTaskStatus(call)) return { status: 'stopped', exitstatus: 'OK' };
|
|
159
|
+
if (isList(call)) return [...files].map((f) => row({ volid: `${STORAGE}:import/${f}` }));
|
|
160
|
+
if (isDownload(call)) {
|
|
161
|
+
files.add(call.form['filename'] ?? '');
|
|
162
|
+
return UPID;
|
|
163
|
+
}
|
|
164
|
+
if (call.method === 'DELETE') {
|
|
165
|
+
// ⚠️ The volid path segment may arrive percent-encoded (its own `:` and `/`) — match by
|
|
166
|
+
// decoded suffix, the same way readDownload matches a listed row (storage-download-props.ts).
|
|
167
|
+
const decoded = decodeURIComponent(call.path);
|
|
168
|
+
const gone = [...files].find((f) => decoded.endsWith(f));
|
|
169
|
+
if (gone !== undefined) files.delete(gone);
|
|
170
|
+
return UPID;
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
});
|
|
174
|
+
await withoutBao(async () => {
|
|
175
|
+
const stack = engine(fake);
|
|
176
|
+
await stack.deploy(ProxmoxStorageDownload('image', declare()));
|
|
177
|
+
expect(
|
|
178
|
+
await stack.deploy(ProxmoxStorageDownload('image', declare({ filename: NEW_FILENAME }))),
|
|
179
|
+
).toEqual({ image: 'replace' });
|
|
180
|
+
});
|
|
181
|
+
const [download1, download2] = fake.writes().filter((w) => w.includes('download-url'));
|
|
182
|
+
expect(download1).toBeDefined();
|
|
183
|
+
expect(download2).toBeDefined();
|
|
184
|
+
expect(fake.writes().some((w) => w.startsWith('DELETE') && w.endsWith(FILENAME))).toBe(true);
|
|
185
|
+
expect(files).toEqual(new Set([NEW_FILENAME]));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('a changed checksum on the same filename is refused, never silently planned noop', async () => {
|
|
189
|
+
let downloaded = false;
|
|
190
|
+
const fake = fakePve((call) => {
|
|
191
|
+
if (isTaskStatus(call)) return { status: 'stopped', exitstatus: 'OK' };
|
|
192
|
+
if (isList(call)) return downloaded ? [row()] : [];
|
|
193
|
+
if (isDownload(call)) {
|
|
194
|
+
downloaded = true;
|
|
195
|
+
return UPID;
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
});
|
|
199
|
+
await withoutBao(async () => {
|
|
200
|
+
const stack = engine(fake);
|
|
201
|
+
await stack.deploy(ProxmoxStorageDownload('image', declare()));
|
|
202
|
+
await expect(
|
|
203
|
+
stack.deploy(ProxmoxStorageDownload('image', declare({ checksum: 'b'.repeat(64) }))),
|
|
204
|
+
).rejects.toThrow(/checksum changed but filename did not/);
|
|
205
|
+
});
|
|
206
|
+
expect(fake.writes()).toEqual([`POST nodes/${NODE}/storage/${STORAGE}/download-url`]);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('no secret prop', () => {
|
|
211
|
+
test('every declared field is a public identity or download parameter, never a credential', () => {
|
|
212
|
+
for (const key of Object.keys(declare())) {
|
|
213
|
+
expect(key).not.toMatch(/pass|secret|token|key$|credential/i);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.StorageDownload` — one `download-url` fetch of an image onto a named storage's
|
|
3
|
+
* `import` content, declared. Joined 2026-09-26 for Talos: a node needs a boot image on shared
|
|
4
|
+
* storage (`cephtb4`/`cephfs-tb4`) before `Proxmox.Vm` can reference it as a disk source.
|
|
5
|
+
*
|
|
6
|
+
* ★ A CREATE-ORIENTED FAMILY, NOT A GENERAL DOWNLOADER. `content` is fixed to `import` — never a
|
|
7
|
+
* prop — because that is the one content type a VM disk import consumes; an ISO or a container
|
|
8
|
+
* template already has its own path (`upload`, or an out-of-band copy) and does not belong here.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ `checksum` AND `checksumAlgorithm` ARE REQUIRED, NOT OPTIONAL, EVEN THOUGH PVE'S OWN SCHEMA
|
|
11
|
+
* MARKS BOTH OPTIONAL (`generated/pve/nodes-node-storage-storage.ts`). An unpinned download is
|
|
12
|
+
* not a declaration — it is a promise that whatever the URL answers today is what a Talos node
|
|
13
|
+
* boots from, silently, on every future adopt. This resource narrows the vendor's own contract
|
|
14
|
+
* on purpose; `storage-download-lifecycle.ts`'s `createRefusals` is the runtime backstop for a
|
|
15
|
+
* caller that casts around the type.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ THERE IS NO UPDATE PATH. PVE does not remember the `url` or `checksum` a volume was created
|
|
18
|
+
* from — `GET .../content` reports only `format`/`size`/`volid` — so once the file exists on
|
|
19
|
+
* `storage`, this resource has nothing left to compare a new declaration against except the
|
|
20
|
+
* PREVIOUS one (storage-download-identity.ts). A DIFFERENT `checksum`/`url` on the SAME `filename`
|
|
21
|
+
* is REFUSED, never silently written or silently `noop` (2026-09-26, red-team I2 on PR 297):
|
|
22
|
+
* change `filename` to force a new download, which plans a `replace` — the old file is deleted and
|
|
23
|
+
* the new one downloaded under its own name — exactly as an immutable artifact should be.
|
|
24
|
+
*
|
|
25
|
+
* ⛔ NO SECRET IS A PROP. Every field here — `node`, `storage`, `filename`, `url`, the checksum
|
|
26
|
+
* pair, `compression`, `verifyCertificates` — is either an identity or a public download
|
|
27
|
+
* parameter; none is a credential, matching the "no secret prop" rule every PVE family here
|
|
28
|
+
* carries (see qemu-props.ts's `cipassword`/`machine` for the family that has one to guard).
|
|
29
|
+
*
|
|
30
|
+
* ★ NOT `retain` BY DEFAULT, UNLIKE `Proxmox.Vm`/`Proxmox.Lxc`. A downloaded image is reproducible
|
|
31
|
+
* from its own declaration (the same `url` and `checksum` rebuild it byte-for-byte); a VM's
|
|
32
|
+
* disk state or a container's data is not. Dropping this declaration deletes the file — an
|
|
33
|
+
* operator who wants to keep an unreferenced image around pins its own `RemovalPolicy.retain()`.
|
|
34
|
+
*/
|
|
35
|
+
import { Resource } from 'alchemy';
|
|
36
|
+
import * as Provider from 'alchemy/Provider';
|
|
37
|
+
import * as Effect from 'effect/Effect';
|
|
38
|
+
import type { PveRequirements } from './resource.ts';
|
|
39
|
+
import { storageDownloadHandlers } from './storage-download-lifecycle.ts';
|
|
40
|
+
import type { StorageDownloadAttributes, StorageDownloadProps } from './storage-download-props.ts';
|
|
41
|
+
|
|
42
|
+
export type { StorageDownloadAttributes, StorageDownloadProps } from './storage-download-props.ts';
|
|
43
|
+
|
|
44
|
+
export interface ProxmoxStorageDownload extends Resource<
|
|
45
|
+
'Proxmox.StorageDownload',
|
|
46
|
+
StorageDownloadProps,
|
|
47
|
+
StorageDownloadAttributes,
|
|
48
|
+
never,
|
|
49
|
+
PveRequirements
|
|
50
|
+
> {}
|
|
51
|
+
|
|
52
|
+
export const ProxmoxStorageDownload = Resource<ProxmoxStorageDownload>('Proxmox.StorageDownload');
|
|
53
|
+
|
|
54
|
+
export const ProxmoxStorageDownloadProvider = () =>
|
|
55
|
+
Provider.effect(
|
|
56
|
+
ProxmoxStorageDownload,
|
|
57
|
+
Effect.succeed(ProxmoxStorageDownload.Provider.of(storageDownloadHandlers)),
|
|
58
|
+
);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake OTLP/HTTP collector for the tests beside it — `Bun.serve` on an ephemeral 127.0.0.1 port,
|
|
3
|
+
* answering every POST with 200 and recording each request's path, headers and raw body bytes.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ TEST-ONLY, and no live call ever leaves this process: nothing here reaches a real Victoria
|
|
6
|
+
* endpoint, satisfying the workflow's "no live calls to Victoria from tests" rule. A consumer's
|
|
7
|
+
* own integration test points `telemetryLayer` at ITS running Victoria; this file exists so this
|
|
8
|
+
* package's tests never need one.
|
|
9
|
+
* ★ 200 UNCONDITIONALLY. `OtlpExporter.make` treats any non-2xx as a transient failure and retries
|
|
10
|
+
* three times with backoff (OtlpExporter.ts `policy`/`retryTransient`) — a fake that ever answered
|
|
11
|
+
* with an error would make a test wait through that retry schedule for no reason.
|
|
12
|
+
* ⛔ RAW BYTES, NOT PARSED JSON (redteam PR 302 CRITICAL item 1, 2026-09-26): `telemetryLayer` now
|
|
13
|
+
* sends `OtlpSerialization.layerProtobuf`, not JSON, so there is no body to `JSON.parse`. `sawText`
|
|
14
|
+
* below is what every test needing to inspect content uses instead — it works unchanged for either
|
|
15
|
+
* encoding, since protobuf's string fields are raw UTF-8 with no escaping: a leaked literal (a
|
|
16
|
+
* secret, a span name, `<redacted>`) always appears as a contiguous byte run in the raw body,
|
|
17
|
+
* exactly like it would in JSON, with no OTLP protobuf decoder needed to find it.
|
|
18
|
+
*/
|
|
19
|
+
export interface CollectedRequest {
|
|
20
|
+
readonly path: string;
|
|
21
|
+
readonly headers: Headers;
|
|
22
|
+
readonly raw: Uint8Array;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FakeCollector {
|
|
26
|
+
readonly url: string;
|
|
27
|
+
readonly requests: CollectedRequest[];
|
|
28
|
+
/** `requests` filtered to POSTs at exactly this path (e.g. `/v1/traces`). */
|
|
29
|
+
at(path: string): CollectedRequest[];
|
|
30
|
+
/** True when `text`'s bytes appear anywhere in ANY collected request's raw body. ASCII text (a
|
|
31
|
+
* secret, a hostname, `<redacted>`) decodes to itself regardless of what surrounds it in an
|
|
32
|
+
* otherwise-binary protobuf buffer — see this file's own header for why that's sound. */
|
|
33
|
+
sawText(text: string): boolean;
|
|
34
|
+
stop(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const rawTextDecoder = new TextDecoder('utf-8', { fatal: false });
|
|
38
|
+
|
|
39
|
+
export const fakeCollector = (): FakeCollector => {
|
|
40
|
+
const requests: CollectedRequest[] = [];
|
|
41
|
+
const server = Bun.serve({
|
|
42
|
+
fetch: async (request) => {
|
|
43
|
+
const url = new URL(request.url);
|
|
44
|
+
const raw = new Uint8Array(await request.arrayBuffer());
|
|
45
|
+
requests.push({ headers: request.headers, path: url.pathname, raw });
|
|
46
|
+
return new Response('{}', { headers: { 'content-type': 'application/json' }, status: 200 });
|
|
47
|
+
},
|
|
48
|
+
hostname: '127.0.0.1',
|
|
49
|
+
port: 0,
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
at: (path) => requests.filter((request) => request.path === path),
|
|
53
|
+
requests,
|
|
54
|
+
sawText: (text) =>
|
|
55
|
+
requests.some((request) => rawTextDecoder.decode(request.raw).includes(text)),
|
|
56
|
+
stop: () => void server.stop(true),
|
|
57
|
+
url: `http://127.0.0.1:${String(server.port ?? 0)}`,
|
|
58
|
+
};
|
|
59
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The leak `layer.ts`'s own ★ describes: caddy's `admin-transport-scope.test.ts` measured that a
|
|
3
|
+
* provider Layer merging its own `HttpClient.HttpClient` straight into its output can shadow a
|
|
4
|
+
* sibling fetch-based provider's ambient client once both sit in the same `Layer.mergeAll`. This
|
|
5
|
+
* file is that same test shape, for `telemetryLayer`'s own (real, `fetch`-backed) `FetchHttpClient`.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
8
|
+
import { Resource } from 'alchemy';
|
|
9
|
+
import * as Provider from 'alchemy/Provider';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
13
|
+
import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
|
|
14
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
15
|
+
import { type FakeCollector, fakeCollector } from './fake-collector.ts';
|
|
16
|
+
import { telemetryLayer } from './layer.ts';
|
|
17
|
+
|
|
18
|
+
interface Probe extends Resource<'Test.TelemetrySealingProbe', { name: string }, { via: string }> {}
|
|
19
|
+
const Probe = Resource<Probe>('Test.TelemetrySealingProbe');
|
|
20
|
+
|
|
21
|
+
/** A sibling fetch-based provider (Forgejo, LiteLLM in the estate) — it just needs SOME
|
|
22
|
+
* `HttpClient.HttpClient` in scope, and reports which one it got. */
|
|
23
|
+
const ProbeProvider = (vias: string[]) => {
|
|
24
|
+
const hit = Effect.gen(function* () {
|
|
25
|
+
const client = yield* HttpClient.HttpClient;
|
|
26
|
+
const response = yield* client.get('http://sibling.invalid/probe');
|
|
27
|
+
vias.push(yield* response.text);
|
|
28
|
+
});
|
|
29
|
+
return Provider.succeed(Probe, {
|
|
30
|
+
read: () => Effect.as(hit, undefined),
|
|
31
|
+
reconcile: () => Effect.as(hit, { via: 'ambient' }),
|
|
32
|
+
delete: () => Effect.void,
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Answers instantly and records that IT (not telemetry's own client) was the one asked. */
|
|
37
|
+
const labeledClient = (label: string, seen: string[]): Layer.Layer<HttpClient.HttpClient> =>
|
|
38
|
+
Layer.succeed(
|
|
39
|
+
HttpClient.HttpClient,
|
|
40
|
+
HttpClient.make((request) => {
|
|
41
|
+
seen.push(label);
|
|
42
|
+
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(label)));
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
let otlp: FakeCollector | undefined;
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
otlp?.stop();
|
|
49
|
+
otlp = undefined;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('telemetryLayer HttpClient sealing', () => {
|
|
53
|
+
test("a sibling provider merged alongside it gets the AMBIENT client, never telemetry's own", async () => {
|
|
54
|
+
otlp = fakeCollector();
|
|
55
|
+
const vias: string[] = [];
|
|
56
|
+
const ambient = labeledClient('ambient', vias);
|
|
57
|
+
const engine = engineOver(
|
|
58
|
+
Layer.mergeAll(
|
|
59
|
+
ProbeProvider(vias),
|
|
60
|
+
telemetryLayer({ endpoints: { traces: `${otlp.url}/v1/traces` }, serviceName: 'test' }),
|
|
61
|
+
).pipe(Layer.provideMerge(ambient)),
|
|
62
|
+
);
|
|
63
|
+
await engine.deploy(Probe('p', { name: 'sibling' }));
|
|
64
|
+
expect(vias.length).toBeGreaterThan(0);
|
|
65
|
+
expect(new Set(vias)).toEqual(new Set(['ambient']));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("with no ambient client at all, the sibling fails rather than silently reaching telemetry's own", async () => {
|
|
69
|
+
otlp = fakeCollector();
|
|
70
|
+
const vias: string[] = [];
|
|
71
|
+
// ★ No `ambient` layer this time. If `telemetryLayer`'s `FetchHttpClient` ever leaked into its
|
|
72
|
+
// own output, the sibling below would resolve it and this would succeed instead of throwing —
|
|
73
|
+
// exactly the failure mode docs/telemetry-spike.md's first run hit by accident before `ambient` was added.
|
|
74
|
+
const engine = engineOver(
|
|
75
|
+
Layer.mergeAll(
|
|
76
|
+
ProbeProvider(vias),
|
|
77
|
+
telemetryLayer({ endpoints: { traces: `${otlp.url}/v1/traces` }, serviceName: 'test' }),
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
await expect(engine.deploy(Probe('p', { name: 'sibling' }))).rejects.toThrow();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@homeflare/alchemy/telemetry` — an OTLP tracing/logging/metrics Layer for a stack's own
|
|
3
|
+
* `providers`, off unless a consumer gives it real endpoints. See docs/telemetry.md for how a site
|
|
4
|
+
* file wires it up and docs/telemetry-spike.md for what a stack's `providers` Layer can and cannot
|
|
5
|
+
* capture (measured against alchemy 2.0.0-beta.79's own engine).
|
|
6
|
+
*/
|
|
7
|
+
export { telemetryLayer } from './layer.ts';
|
|
8
|
+
export type { TelemetryEndpoints, TelemetryOptions } from './layer.ts';
|
|
9
|
+
export { DROP, REDACTED, redactAttribute, wrapTracer } from './redact.ts';
|
|
10
|
+
export type { RedactionPolicy } from './redact.ts';
|