@homeflare/alchemy 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/linux/index.js +140 -93
- package/dist/linux/index.js.map +11 -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/unit-handlers.d.ts.map +1 -1
- package/dist/linux/unit-lifecycle.d.ts +1 -5
- package/dist/linux/unit-lifecycle.d.ts.map +1 -1
- package/dist/linux/unit-preflight.d.ts +50 -0
- package/dist/linux/unit-preflight.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +15 -3
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +4 -1
- package/dist/proxmox/index.js.map +1 -1
- package/docs/adopted-deploys.md +13 -0
- package/docs/linux-host.md +11 -1
- package/docs/provider-standard.md +138 -0
- package/docs/upstream-conformance.md +164 -0
- package/package.json +1 -1
- 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/unit-handlers.ts +12 -5
- package/src/linux/unit-lifecycle.ts +12 -30
- package/src/linux/unit-preflight.test.ts +128 -0
- package/src/linux/unit-preflight.ts +116 -0
- package/src/linux/unit-rename.test.ts +76 -0
- package/src/proxmox/ceph-adopt.test.ts +210 -0
- package/src/proxmox/constraints-forms.test.ts +14 -6
- package/src/proxmox/constraints-node-forms.test.ts +12 -3
- package/src/proxmox/index.test.ts +7 -4
- package/src/proxmox/index.ts +15 -3
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# The provider standard
|
|
2
|
+
|
|
3
|
+
Status: in force. Verified 2026-09-22 against `alchemy@2.0.0-beta.79` (tag
|
|
4
|
+
`v2.0.0-beta.79`, commit `473c3959`) and the distilled commit that tag pins (`c2a78002`).
|
|
5
|
+
|
|
6
|
+
This page is the kit-side statement of the house standard for a custom Alchemy provider.
|
|
7
|
+
The full rule set is the `alchemy-provider-standard` skill in the estate's workflow plugin.
|
|
8
|
+
It has 46 numbered rules (S1–S46) and 14 house differences (H1–H14), and every rule cites
|
|
9
|
+
`alchemy-run/alchemy` as `<path>@v2.0.0-beta.79#<section>`. This page keeps the rule numbers
|
|
10
|
+
so that a review comment can name the rule a change breaks. How each family measures up is
|
|
11
|
+
in [upstream-conformance.md](./upstream-conformance.md).
|
|
12
|
+
|
|
13
|
+
★ **Why this package holds itself to upstream's bar.** Everything in `@homeflare/alchemy`
|
|
14
|
+
should be good enough to contribute to Alchemy. So the kit builds only what upstream lacks,
|
|
15
|
+
and builds it the way upstream would. A house re-implementation of something Alchemy
|
|
16
|
+
already ships is a finding, even when it works.
|
|
17
|
+
|
|
18
|
+
## Three questions before any code
|
|
19
|
+
|
|
20
|
+
1. **Does upstream ship the resource?** Look under `packages/alchemy/src/<Cloud>/` at the
|
|
21
|
+
pinned tag. If it does, use it. `github/` does exactly this: it composes
|
|
22
|
+
`GitHub.Repository` and `GitHub.Ruleset`. `website.ts` does it too, over
|
|
23
|
+
`Website.Astro` and `Website.Vite`. A missing prop means an upstream PR, never a fork
|
|
24
|
+
(S1).
|
|
25
|
+
2. **Does `@distilled.cloud/<vendor>` exist?** If it does, every call goes through it,
|
|
26
|
+
with its typed error unions and Credentials layer. A second SDK or a hand-rolled client
|
|
27
|
+
is a finding. On 2026-09-22 npm had distilled packages for Forgejo, GitHub, Kubernetes
|
|
28
|
+
and Docker, among others, and none for Proxmox, OpenBao, Caddy, Talos, NetBox or UniFi
|
|
29
|
+
(S23).
|
|
30
|
+
3. **Does Alchemy ship the helper?** Hashing (`alchemy/Util/sha256`), polling
|
|
31
|
+
(`alchemy/Util/poll`), atomic writes (`alchemy/Util/AtomicFile`), equality
|
|
32
|
+
(`deepEqual` in `alchemy/Diff`), names (`alchemy/PhysicalName`), tags (`alchemy/Tags`),
|
|
33
|
+
ownership (`alchemy/AdoptPolicy`), credentials (`alchemy/Auth`) and the test harness
|
|
34
|
+
(`alchemy/Test/Bun`) all exist. Use them.
|
|
35
|
+
|
|
36
|
+
## The contract, in brief
|
|
37
|
+
|
|
38
|
+
- **Shape (S2, S3).** Declare the interface as
|
|
39
|
+
`Resource<"Vendor.Service.Type", Props, Attrs, never, Providers>`, and the constructor as
|
|
40
|
+
`const X = Resource<X>(…)`. The type string starts with the vendor's own name. The
|
|
41
|
+
contract and its provider share a file, and helpers go in sibling files that `index.ts`
|
|
42
|
+
does not export.
|
|
43
|
+
- **Props (S5).** Props are plain types, never `Input<T>`, and every field has JSDoc.
|
|
44
|
+
- **Diff (S6).** `diff` narrows with `isResolved`. It returns `undefined` for an ordinary
|
|
45
|
+
update and `replace` only for identity changes.
|
|
46
|
+
- **Read (S7, S8).** `read` works with `output: undefined`. It answers `undefined`, plain
|
|
47
|
+
attributes or `Unowned(attrs)`. This package answers `Unowned` for any live object this
|
|
48
|
+
stack cannot prove it made (H1; [ownership.md](./ownership.md)).
|
|
49
|
+
- **Reconcile (S9, S10).** `reconcile` is one flow: observe, ensure, sync, return. It
|
|
50
|
+
never trusts `output` as proof, and it makes zero writes when nothing drifted.
|
|
51
|
+
- **Delete (S11, S14).** `delete` is idempotent and bounded. A family whose deletion
|
|
52
|
+
breaks its consumers defaults to `retain`, and still implements `delete` in full.
|
|
53
|
+
- **List (S12).** `list` enumerates every object of the type in its scope, or returns `[]`
|
|
54
|
+
with the reason written down. A family whose objects carry ownership marks filters to
|
|
55
|
+
owned ones, as upstream's third-party providers do; the core contract does not ask for it. `nuke` is declared wherever nuke must never reach the object.
|
|
56
|
+
- **Scope (S15).** The account, zone and region are resolved inside each operation. They
|
|
57
|
+
are never props.
|
|
58
|
+
|
|
59
|
+
## Code, errors, credentials
|
|
60
|
+
|
|
61
|
+
- **Effect only (S19).** Provider code, helpers and tests use `FileSystem`, `Path`,
|
|
62
|
+
`HttpClient` and `ChildProcessSpawner`. They never use `async`/`await`, a raw `Promise`,
|
|
63
|
+
`node:fs`, `node:fs/promises`, `node:os`, `node:path` or bare `fetch`. A synchronous,
|
|
64
|
+
CPU-only Node call (`node:crypto` `createHash`, `Buffer`) goes inside `Effect.sync`, which
|
|
65
|
+
is upstream's own example. When a promise cannot be avoided, use `Effect.tryPromise`,
|
|
66
|
+
never `Effect.promise`.
|
|
67
|
+
- **No defects (S20).** Lifecycle operations contain no `Effect.orDie` and no
|
|
68
|
+
`Effect.die`. A refusal is a typed error.
|
|
69
|
+
- **Typed errors (S21, S22).** Handle errors with `Effect.catchTag` over the SDK's union.
|
|
70
|
+
Where the kit generates its own client from a vendor schema, the status-to-tag mapping
|
|
71
|
+
lives in that client, once, and never in a resource.
|
|
72
|
+
- **Credentials (S24).** Credentials come from an `alchemy/Auth` provider and a lazy
|
|
73
|
+
Credentials service. Short-lived credentials are minted per call, never reused, and never
|
|
74
|
+
taken as props.
|
|
75
|
+
- **Secrets (S25).** A secret value is never a prop or an attribute. Alchemy persists
|
|
76
|
+
`Redacted` values in plaintext, and only one state store encrypts them.
|
|
77
|
+
- **Bounded waits (S26).** A retry stops after at most 8 to 10 attempts and under about a
|
|
78
|
+
minute. A poll never runs past about 90 seconds.
|
|
79
|
+
|
|
80
|
+
## The Bun line for this package (S42–S45)
|
|
81
|
+
|
|
82
|
+
★ This package is Bun to author and runtime-neutral to consume. It builds with
|
|
83
|
+
`bun build --target node`, and consumers run `dist/` on Node.
|
|
84
|
+
|
|
85
|
+
- **`src/**` provider code** (resources, providers, clients, and helpers a provider calls)
|
|
86
|
+
never calls `Bun.*` and never imports `bun:*` or the `node:*` modules S19 names. A Node
|
|
87
|
+
consumer calling an exported helper that uses `Bun.YAML` fails with
|
|
88
|
+
`ReferenceError: Bun is not defined` at the call site. `node:crypto`, `node:http` and
|
|
89
|
+
`node:child_process` all load on Node. Where they break the standard, they break the
|
|
90
|
+
Effect-only rule, not the runtime.
|
|
91
|
+
- **Tests, fakes, scripts and codegen** are Bun-native. That means `bun:test` (never
|
|
92
|
+
`node:test`, never vitest), `Bun.serve` fakes on loopback, `Bun.YAML`, `Bun.file()`,
|
|
93
|
+
`Bun.Glob` and `Bun.CryptoHasher`. Upstream runs its scripts and its test runner on Bun
|
|
94
|
+
too.
|
|
95
|
+
- **A module that truly needs Bun** is named for Bun, sits behind its own subpath, or
|
|
96
|
+
guards on `typeof globalThis.Bun`. The package root never reaches it.
|
|
97
|
+
|
|
98
|
+
The files currently on the wrong side of the line are listed in
|
|
99
|
+
[upstream-conformance.md](./upstream-conformance.md#the-bun-line).
|
|
100
|
+
|
|
101
|
+
## Versions (S37–S41)
|
|
102
|
+
|
|
103
|
+
- **One aligned set.** `effect`, `@distilled.cloud/*` and `@effect/platform-*` are pinned
|
|
104
|
+
exactly to what the pinned `alchemy` release was built and tested against. They move only
|
|
105
|
+
in the same PR as the `alchemy` bump.
|
|
106
|
+
- **Every family records the vendor version it was walked against, in code.**
|
|
107
|
+
- For a generated client, the record is the schema manifest entry and the generated
|
|
108
|
+
header. `codegen/manifest.json` holds the product, version, sha256, fetch date and
|
|
109
|
+
source role for PVE, PBS, UniFi and NetBox.
|
|
110
|
+
- For a distilled-backed family, it is the chain: `alchemy` version, distilled package
|
|
111
|
+
version, distilled commit, spec-mirror commit and date, and Smithy service version.
|
|
112
|
+
- For a CLI or OS-service family, it is the binary or OS version, the command that
|
|
113
|
+
reports it, and the date, in that family's docs page.
|
|
114
|
+
- **And in its changeset.** A provider change names the vendor version it was walked
|
|
115
|
+
against. A pin move names every link, old and new.
|
|
116
|
+
|
|
117
|
+
| family | vendor version walked against | recorded in |
|
|
118
|
+
| ------------------- | -------------------------------------- | --------------------------------- |
|
|
119
|
+
| `proxmox` (PVE) | pve-manager 9.2.11 | `codegen/manifest.json` + headers |
|
|
120
|
+
| `proxmox` (PBS) | proxmox-backup-server 4.2.6-1 | `codegen/manifest.json` + headers |
|
|
121
|
+
| `netbox` | NetBox 4.7.0 OpenAPI | `codegen/manifest.json` + headers |
|
|
122
|
+
| `cloudflare` | distilled 1.0.0-rc.12 (API v4) | `package.json` pins only |
|
|
123
|
+
| `openbao` | OpenBao 2.6.2 | source comments only ⚠️ |
|
|
124
|
+
| `caddy` | Caddy 2.11.4 | source comments only ⚠️ |
|
|
125
|
+
| `forgejo` | Forgejo 16.0.3 | source comments only ⚠️ |
|
|
126
|
+
| `talos` | Talos v1.13 docs (never measured live) | source comments only ⚠️ |
|
|
127
|
+
| `launchd` / `linux` | macOS 27 / systemd 257 | source comments only ⚠️ |
|
|
128
|
+
|
|
129
|
+
⚠️ These rows record a version only in prose, so no test catches a drift. `schemas/manifest.json`
|
|
130
|
+
also repeats the PVE, PBS and UniFi entries, which means there are two manifests for one
|
|
131
|
+
fact.
|
|
132
|
+
|
|
133
|
+
## Where this package differs from upstream on purpose
|
|
134
|
+
|
|
135
|
+
Each difference is written down with its reason. H1 is strict `Unowned`. H2 is the
|
|
136
|
+
apply-time ownership re-check in `ownership/`. H4 is `retain` by default. H5 is `adopt(true)`
|
|
137
|
+
on each resource. H6 is `hf-adopt-verify`. [upstream-conformance.md](./upstream-conformance.md)
|
|
138
|
+
marks each one justified or a gap. Anything not listed there is a gap by definition.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Upstream conformance, family by family
|
|
2
|
+
|
|
3
|
+
Status: open ledger. Verified 2026-09-22 by reading `origin/main` `925454b` against
|
|
4
|
+
[provider-standard.md](./provider-standard.md) (`alchemy@2.0.0-beta.79`). Nothing in any
|
|
5
|
+
family was changed by this audit. Each row names the rule it breaks and the upstream
|
|
6
|
+
replacement or fix. A row is crossed off in the PR that fixes it, with that PR's evidence.
|
|
7
|
+
A decision marked **maintainer** is not an agent's to make.
|
|
8
|
+
|
|
9
|
+
## Ranked findings
|
|
10
|
+
|
|
11
|
+
Findings are ranked by what they can break: runtime safety first, then contributability,
|
|
12
|
+
then tidiness.
|
|
13
|
+
|
|
14
|
+
1. **`cloudflare/R2BucketLock` diverges on four rules.**
|
|
15
|
+
- `readLock` ends in `Effect.orDie` (S20).
|
|
16
|
+
- `reconcileLock` and `deleteLock` use `Effect.promise` (S19).
|
|
17
|
+
- `reconcileLock` skips the PUT whenever `output.rules` equals the declaration, without
|
|
18
|
+
reading the live lock (S10). So neither `--force` nor `drift --repair` can restore
|
|
19
|
+
lock rules that were removed out of band. ⚠️ Reasoned from the source, not measured
|
|
20
|
+
live.
|
|
21
|
+
- `accountId` is a prop (S15).
|
|
22
|
+
|
|
23
|
+
It is built on a second Cloudflare SDK, `cloudflare@4.5.0` (S23). Upstream
|
|
24
|
+
`Cloudflare.R2.Bucket` has lifecycle and CORS rules but no lock, so this is a real gap.
|
|
25
|
+
**Fix:** move to `@distilled.cloud/cloudflare/r2` `getBucketLock`/`putBucketLock`,
|
|
26
|
+
observe before the PUT, and resolve the account from `CloudflareEnvironment`. Then drop
|
|
27
|
+
the `cloudflare` peer. That makes it contributable as `lockRules` on `R2.Bucket`, or as
|
|
28
|
+
`Cloudflare.R2.BucketLock`. **Decision:** maintainer, on the upstream shape.
|
|
29
|
+
|
|
30
|
+
2. **`forgejo/*` duplicates an upstream building block.** `client.ts` is a hand-rolled
|
|
31
|
+
Effect `HttpClient` client with a status-carrying `ForgejoError`, while
|
|
32
|
+
`@distilled.cloud/forgejo@1.0.0-rc.12` exists. That package's Smithy service version is
|
|
33
|
+
16.0.3, the version this family measured (S21, S23). Credentials come from
|
|
34
|
+
`FORGEJO_TOKEN` at call time instead of an Auth provider (S24). `read` never answers
|
|
35
|
+
`Unowned` (H1). **Replacement:** the distilled SDK plus an `alchemy/Auth` provider.
|
|
36
|
+
Untyped errors become distilled patches. **Decision:** maintainer, because it changes
|
|
37
|
+
the error surface.
|
|
38
|
+
3. **`cloudflare/MeshNode` duplicates `Cloudflare.Tunnel.WarpConnector`.** The divergence is
|
|
39
|
+
deliberate and documented in [mesh-node.md](./mesh-node.md): upstream persists the
|
|
40
|
+
connector token in state as a `Redacted` attribute, and it cannot create an HA node. The
|
|
41
|
+
type string `Cloudflare.MeshNode` sits inside upstream's own namespace (H14).
|
|
42
|
+
**Options:** contribute `ha` upstream and raise the token-in-state concern, then switch;
|
|
43
|
+
or keep the house resource as a recorded divergence. **Decision:** maintainer.
|
|
44
|
+
4. **Unguarded `Bun.*`, and the `node:*` modules upstream names, in shipped provider code**
|
|
45
|
+
(S42). This is listed in [the Bun line](#the-bun-line). The exported `Bun.*` paths crash
|
|
46
|
+
under Node, which is the runtime this package promises its consumers. The `node:*` paths
|
|
47
|
+
load on Node but break the Effect-only rule.
|
|
48
|
+
5. **`launchd`, `linux` and `caddy` have promise-based seams (H3).** `HostRunner` and
|
|
49
|
+
`CaddyAdmin` are plain `async` interfaces, with `node:child_process`, `node:fs/promises`
|
|
50
|
+
and `node:http` behind them, and failures are untyped `Error` (S19, S21). The stated
|
|
51
|
+
reason is that a consumer implements plain async functions. Upstream meets the same need
|
|
52
|
+
by letting a caller provide `FileSystem`, `ChildProcessSpawner` or `CommandExecutor`
|
|
53
|
+
layers, and `alchemy/Util/AtomicFile.writeFileAtomic` covers the temp-and-rename write.
|
|
54
|
+
⚠️ Only in part. `local-runner.ts` creates its temp file with `O_EXCL`, mode 0600 and
|
|
55
|
+
`chown` before `chmod`, and it refuses a planted symlink. `writeFileAtomic` writes with
|
|
56
|
+
default flags and mode, `chmod`s afterwards, and never `chown`s.
|
|
57
|
+
**Decision:** maintainer. These families are also host-specific, so "house-only" is a
|
|
58
|
+
valid answer.
|
|
59
|
+
6. **`openbao/*` has no upstream equivalent, and it conforms on the contract** (Effect
|
|
60
|
+
`HttpClient`, strict `Unowned`, retain). It diverges in three ways:
|
|
61
|
+
- 30 `Effect.die` sites in non-test source, several of them on `diff` and
|
|
62
|
+
`reconcile` paths (S20);
|
|
63
|
+
- 42 test files on `node:test` (S43);
|
|
64
|
+
- `Bao.*` type strings, where the vendor name is `OpenBao` (H14).
|
|
65
|
+
|
|
66
|
+
No distilled SDK exists. OpenBao serves an OpenAPI document, so a generated SDK in
|
|
67
|
+
distilled's shape is the S23 path. **Decision:** maintainer, on whether the kit authors
|
|
68
|
+
a distilled package.
|
|
69
|
+
|
|
70
|
+
7. **`proxmox/*` is the model for S38.** Its types and constraints are generated from the
|
|
71
|
+
vendor schema, and a manifest records the version and sha256. It diverges in four ways:
|
|
72
|
+
- the generic factory's `read` returns plain attributes, so every family except `Lxc`
|
|
73
|
+
adopts silently, against the house's own H1;
|
|
74
|
+
- 27 `Effect.die` sites in non-test source (S20);
|
|
75
|
+
- 7 test files on `node:test`;
|
|
76
|
+
- `Pbs.*` type strings (H14).
|
|
77
|
+
8. **`talos/*`.** Its `talosctl` calls go through `ChildProcessSpawner`, as upstream's Docker
|
|
78
|
+
provider does, which conforms. Its file handling uses `Bun.file`, `Bun.write`,
|
|
79
|
+
`Bun.YAML` and `node:fs` (S42), and it uses `Effect.promise` in 3 files (S19). The
|
|
80
|
+
kubeconfig it writes is meant to be consumed through upstream's
|
|
81
|
+
`Kubernetes.KubeConfig({ path })`.
|
|
82
|
+
9. **`netbox/*` conforms mostly.** Its constraints are generated from NetBox 4.7.0's
|
|
83
|
+
OpenAPI, and it uses Effect `HttpClient`. Adopting first is documented as deliberate.
|
|
84
|
+
It diverges in its credentials: `NETBOX_TOKEN` is read at call time (S24).
|
|
85
|
+
10. **Every family repeats `list: () => Effect.succeed([])`**, 30 times, and only
|
|
86
|
+
`R2BucketLock` and `MeshNode` declare `nuke`. The constructor already defaults `list`
|
|
87
|
+
(S12). **Fix:** write the reason where it differs, and declare `nuke: { skip: true }`
|
|
88
|
+
where nuke must never reach the object.
|
|
89
|
+
11. **Resource JSDoc is not in upstream's generator format.** Zero files use `@resource`,
|
|
90
|
+
`**Example:**` or `### Section` (S31, H10). **Decision:** maintainer, because it moves
|
|
91
|
+
the house's glyph rationale into `//` comments.
|
|
92
|
+
12. **Tests never use `alchemy/Test/Bun`.** Every lifecycle is proven against loopback
|
|
93
|
+
fakes (S28, H12). Live suites need a place to run, and that is a maintainer decision.
|
|
94
|
+
|
|
95
|
+
## Conforms
|
|
96
|
+
|
|
97
|
+
- **`github/declareRepoPolicy`** composes upstream `GitHub.Repository` and
|
|
98
|
+
`GitHub.Ruleset`. Its one hazard is upstream's own: `Ruleset.read` returns `undefined`
|
|
99
|
+
without prior output, so a first deploy creates a duplicate
|
|
100
|
+
([repo-policy.md](./repo-policy.md)). The fix belongs upstream: read by name and answer
|
|
101
|
+
`Unowned`.
|
|
102
|
+
- **`cloudflare/website.ts`** sets house defaults over upstream `Website.Astro` and
|
|
103
|
+
`Website.Vite`.
|
|
104
|
+
- **`ownership/*`** is built only on `AdoptPolicy`, `Stack`, `State` and `Artifacts`. Its
|
|
105
|
+
apply-time re-check (H2) exists because beta.79 skips the adoption probe while props hold
|
|
106
|
+
an Output. That fix also belongs in the engine upstream.
|
|
107
|
+
- **`verify/*`** (`hf-adopt-verify`) runs Alchemy's own planner. It exists because beta.79
|
|
108
|
+
labels every cold adoption as an update (H6).
|
|
109
|
+
|
|
110
|
+
## The Bun line
|
|
111
|
+
|
|
112
|
+
These are non-test files under `src/` that the standard (S42) says must be portable. "Exp"
|
|
113
|
+
marks files exported from a subpath `index.ts`. `git grep` on `origin/main` finds 23 non-test
|
|
114
|
+
files under `src/` that call `Bun.*` or import `node:*`/`bun:*`. Six of them are outside
|
|
115
|
+
S42's scope:
|
|
116
|
+
|
|
117
|
+
- four loopback fakes (`caddy/fake-caddy.ts`, `openbao/fake-bao.ts`, `proxmox/fake-pve.ts`,
|
|
118
|
+
`proxmox/fake-pve-lxc.ts`), which are test-side under S44;
|
|
119
|
+
- `proxmox/provision-cli-fake.ts`, which is used only by tests;
|
|
120
|
+
- `verify/args.ts`, which belongs to the CLI and so is tooling under S43.
|
|
121
|
+
|
|
122
|
+
That leaves 17 files. The table lists them, plus `provision-cli-fake.ts`, which should move
|
|
123
|
+
out of `src/`. The six out-of-scope files still ship in the tarball's `src/`, but no export
|
|
124
|
+
reaches them.
|
|
125
|
+
⚠️ The first version of this table said 16 files and left out `launchd/job-form.ts`.
|
|
126
|
+
⚠️ Corrected 2026-09-22: 14 of the 17 break upstream's rule. `launchd/job-form.ts`,
|
|
127
|
+
`proxmox/write-only.ts` and `proxmox/pbs-notification-target-wire.ts` use only synchronous
|
|
128
|
+
`node:crypto` or `Buffer`. Upstream allows those inside `Effect.sync`
|
|
129
|
+
(`AGENTS.md@v2.0.0-beta.79#Workflow`), and 40 upstream `src/` files import `node:crypto`,
|
|
130
|
+
among them `Fly/Secret.ts` and `Railway/Variable.ts`. They were listed under a blanket
|
|
131
|
+
`node:*` ban that upstream does not have.
|
|
132
|
+
|
|
133
|
+
| file | API | exp | portable replacement |
|
|
134
|
+
| ---------------------------------------------------------- | -------------------------------------------- | --- | -------------------------------------------- |
|
|
135
|
+
| `openbao/cloudflare-roles-config.ts` | `Bun.YAML` | yes | parse on the tooling side; pass data in |
|
|
136
|
+
| `openbao/digest.ts` (14 importers) | `Bun.CryptoHasher` | — | `Crypto.digest` + hex; keep `canonical()` ⚠️ |
|
|
137
|
+
| `openbao/cloudflare-parity-snapshot.ts` | `Bun.file`, `Bun.Glob` | — | `FileSystem` + `Path` |
|
|
138
|
+
| `openbao/approle-login-form.ts` | `Bun.file` | — | `FileSystem.readFileString` |
|
|
139
|
+
| `openbao/approle-login-result.ts` | `Bun.inspect` | — | a plain formatter |
|
|
140
|
+
| `openbao/forgejo-bootstrap.ts` | `Bun.spawn` | — | unreferenced; `ChildProcessSpawner` or drop |
|
|
141
|
+
| `talos/credentials.ts` | `Bun.write/file/env/randomUUIDv7`, `node:fs` | — | `FileSystem.makeTempFileScoped` |
|
|
142
|
+
| `talos/kubeconfig.ts` | `Bun.file` | yes | `FileSystem` |
|
|
143
|
+
| `talos/talos-machine-config.ts` | `Bun.file` | yes | `FileSystem` |
|
|
144
|
+
| `talos/values.ts` | `Bun.YAML`, `node:crypto` | — | tooling-side parse; hash may stay |
|
|
145
|
+
| `launchd/local-runner.ts` | `node:child_process`, `node:fs/promises` | yes | H3 above |
|
|
146
|
+
| `launchd/sudo-stage.ts` | `node:fs/promises`, `node:os`, `node:path` | — | `FileSystem`, `Path` |
|
|
147
|
+
| `launchd/job-form.ts` | `node:crypto` (`createHash`) | — | allowed in `Effect.sync` |
|
|
148
|
+
| `linux/ssh-runner.ts` | `node:child_process`, `node:crypto` | yes | H3 above |
|
|
149
|
+
| `caddy/local-admin.ts` | `node:http` (unix socket) | yes | H3 above |
|
|
150
|
+
| `proxmox/write-only.ts`, `pbs-notification-target-wire.ts` | `node:crypto`, `node:buffer` | — | allowed in `Effect.sync` ⚠️ |
|
|
151
|
+
| `proxmox/provision-cli-fake.ts` | `Bun.spawn`, `node:fs` | — | test-only; move out of `src/` |
|
|
152
|
+
|
|
153
|
+
The ⚠️ rows:
|
|
154
|
+
|
|
155
|
+
- `openbao/digest.ts`: every `Bao.*` family persists this digest in state, so a swap must
|
|
156
|
+
hash the same bytes (`canonical()`, lowercase hex), or every row plans an update.
|
|
157
|
+
`Crypto.digest` from `effect/Crypto` is in every stack's services and fails with a typed
|
|
158
|
+
error; `alchemy/Util/sha256` wraps WebCrypto in `Effect.promise`.
|
|
159
|
+
- `proxmox/write-only.ts`: a salted scrypt seal with `timingSafeEqual`. No Alchemy helper
|
|
160
|
+
replaces it. A plain `sha256` would make a write-only secret cheap to brute-force from
|
|
161
|
+
state, and it would change the persisted seal format.
|
|
162
|
+
|
|
163
|
+
Test runners: 51 files import `node:test`/`node:assert` (openbao 42, proxmox 7, forgejo 1,
|
|
164
|
+
talos 1), and 126 import `bun:test`. S43 says `bun:test`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
package/src/linux/region.ts
CHANGED
|
@@ -34,6 +34,21 @@ const tokens = (spec: RegionSpec) => {
|
|
|
34
34
|
};
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Whether two declarations name the SAME block.
|
|
39
|
+
*
|
|
40
|
+
* ⛔ THE COMMENT TOKEN IS PART OF THE IDENTITY, not a formatting preference. The markers are
|
|
41
|
+
* matched as literal whole lines, so `# BEGIN pf` and `// BEGIN pf` are two different blocks:
|
|
42
|
+
* changing the token leaves the old block invisible to every later read, exactly as renaming
|
|
43
|
+
* does. A resource that treated either as a mere content change would splice a second block in
|
|
44
|
+
* and never be able to find the first one again.
|
|
45
|
+
* ★ Whole-file mode is its own identity: `undefined` equals only `undefined`.
|
|
46
|
+
*/
|
|
47
|
+
export const sameRegion = (a: RegionSpec | undefined, b: RegionSpec | undefined): boolean =>
|
|
48
|
+
a === undefined || b === undefined
|
|
49
|
+
? a === undefined && b === undefined
|
|
50
|
+
: a.name === b.name && (a.comment ?? DEFAULT_COMMENT) === (b.comment ?? DEFAULT_COMMENT);
|
|
51
|
+
|
|
37
52
|
export const regionProblems = (spec: RegionSpec): string[] => {
|
|
38
53
|
const found: string[] = [];
|
|
39
54
|
const comment = spec.comment ?? DEFAULT_COMMENT;
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { describe, expect, test } from 'bun:test';
|
|
6
6
|
import { fakeLinuxHost } from './fake-linux-host.ts';
|
|
7
|
-
import { fileProblems } from './remote-file-form.ts';
|
|
8
7
|
import {
|
|
9
8
|
deleteFile,
|
|
10
9
|
diffFile,
|
|
@@ -89,93 +88,6 @@ describe('whole-file mode', () => {
|
|
|
89
88
|
});
|
|
90
89
|
});
|
|
91
90
|
|
|
92
|
-
describe('region mode', () => {
|
|
93
|
-
const VENDOR = '# vendor ruleset\nanchor "system"\nrule one\n';
|
|
94
|
-
const region = { name: 'homeflare' };
|
|
95
|
-
const block = { content: 'anchor "declared"\n', path: '/etc/vendor.conf', region };
|
|
96
|
-
|
|
97
|
-
const withVendor = () => {
|
|
98
|
-
const fake = host();
|
|
99
|
-
fake.files.set('/etc/vendor.conf', {
|
|
100
|
-
bytes: new TextEncoder().encode(VENDOR),
|
|
101
|
-
gid: 0,
|
|
102
|
-
kind: 'file',
|
|
103
|
-
mode: 0o600,
|
|
104
|
-
uid: 0,
|
|
105
|
-
});
|
|
106
|
-
return fake;
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
test('editing the block leaves the rest of the file byte-identical', async () => {
|
|
110
|
-
const fake = withVendor();
|
|
111
|
-
const output = await reconcileFile(fake.runner, block);
|
|
112
|
-
const after = decode(fake.files.get('/etc/vendor.conf')?.bytes);
|
|
113
|
-
expect(after.startsWith(VENDOR)).toBe(true);
|
|
114
|
-
await reconcileFile(fake.runner, { ...block, content: 'anchor "changed"\n' }, output);
|
|
115
|
-
expect(decode(fake.files.get('/etc/vendor.conf')?.bytes).startsWith(VENDOR)).toBe(true);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test("the other owner's mode and uid are preserved, never re-declared", async () => {
|
|
119
|
-
const fake = withVendor();
|
|
120
|
-
await reconcileFile(fake.runner, block);
|
|
121
|
-
expect(fake.files.get('/etc/vendor.conf')?.mode).toBe(0o600);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
test('a change anywhere else in the file is not this resource’s drift', async () => {
|
|
125
|
-
const fake = withVendor();
|
|
126
|
-
const output = await reconcileFile(fake.runner, block);
|
|
127
|
-
const edited = `${decode(fake.files.get('/etc/vendor.conf')?.bytes)}rule two\n`;
|
|
128
|
-
fake.files.set('/etc/vendor.conf', {
|
|
129
|
-
bytes: new TextEncoder().encode(edited),
|
|
130
|
-
gid: 0,
|
|
131
|
-
kind: 'file',
|
|
132
|
-
mode: 0o600,
|
|
133
|
-
uid: 0,
|
|
134
|
-
});
|
|
135
|
-
expect(await diffFile(fake.runner, block, output)).toEqual({ action: 'noop' });
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test('delete removes only the block', async () => {
|
|
139
|
-
const fake = withVendor();
|
|
140
|
-
const output = await reconcileFile(fake.runner, block);
|
|
141
|
-
await deleteFile(fake.runner, output);
|
|
142
|
-
expect(decode(fake.files.get('/etc/vendor.conf')?.bytes)).toBe(VENDOR);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test('a missing file is a refusal, because a typo is likelier than an intention', async () => {
|
|
146
|
-
const fake = host();
|
|
147
|
-
await expect(
|
|
148
|
-
reconcileFile(fake.runner, { ...block, path: '/etc/example/absent.conf' }),
|
|
149
|
-
).rejects.toThrow(/does not create the file it lives in/);
|
|
150
|
-
const created = await reconcileFile(fake.runner, {
|
|
151
|
-
...block,
|
|
152
|
-
create: true,
|
|
153
|
-
path: '/etc/example/absent.conf',
|
|
154
|
-
});
|
|
155
|
-
expect(created.region).toEqual(region);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
test('mode and owner without create are refused at validation', () => {
|
|
159
|
-
expect(fileProblems({ ...block, mode: 0o600 })).toHaveLength(1);
|
|
160
|
-
expect(fileProblems({ ...block, create: true, mode: 0o600 })).toEqual([]);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test('a block already there with other content needs --adopt', async () => {
|
|
164
|
-
const fake = withVendor();
|
|
165
|
-
fake.files.set('/etc/vendor.conf', {
|
|
166
|
-
bytes: new TextEncoder().encode(
|
|
167
|
-
`${VENDOR}# BEGIN homeflare\nsomething else\n# END homeflare\n`,
|
|
168
|
-
),
|
|
169
|
-
gid: 0,
|
|
170
|
-
kind: 'file',
|
|
171
|
-
mode: 0o600,
|
|
172
|
-
uid: 0,
|
|
173
|
-
});
|
|
174
|
-
await expect(reconcileFile(fake.runner, block)).rejects.toThrow(/managed region/);
|
|
175
|
-
await expect(reconcileFile(fake.runner, block, undefined, true)).resolves.toBeDefined();
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
91
|
describe('a failed read refuses', () => {
|
|
180
92
|
test('a stat that throws is never read as “nothing is there”', async () => {
|
|
181
93
|
const fake = host();
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
planWrite,
|
|
29
29
|
readFileAttributes,
|
|
30
30
|
refuse,
|
|
31
|
+
relocation,
|
|
31
32
|
} from './remote-file-plan.ts';
|
|
32
33
|
|
|
33
34
|
export { readFileAttributes } from './remote-file-plan.ts';
|
|
@@ -42,7 +43,8 @@ export const diffFile = async (
|
|
|
42
43
|
output: RemoteFileAttributes,
|
|
43
44
|
): Promise<Diff> => {
|
|
44
45
|
const want = await identity(runner, news);
|
|
45
|
-
|
|
46
|
+
// ⛔ A mode flip throws HERE, at plan time, before anything is written (remote-file-plan.ts).
|
|
47
|
+
if (relocation(news, output) === 'path') {
|
|
46
48
|
// ★ Create-before-delete: two paths hold two files at once, so nothing forces deleteFirst.
|
|
47
49
|
await runner.checkWrite?.(news.path, { mode: news.mode ?? DEFAULT_MODE, ...want });
|
|
48
50
|
return { action: 'replace' };
|
|
@@ -80,8 +82,13 @@ export const reconcileFile = async (
|
|
|
80
82
|
'a root destination or a privileged HostRunner. This provider never calls sudo itself.',
|
|
81
83
|
);
|
|
82
84
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
/**
|
|
86
|
+
* ⛔ WHAT WE OWNED LAST TIME MAY NOT BE WHAT WE OWN NOW. A new path, or a new region name or
|
|
87
|
+
* comment token, means the old block or file is still out there and must be taken back — and
|
|
88
|
+
* a flip between the two modes is refused outright (remote-file-plan.ts relocation).
|
|
89
|
+
*/
|
|
90
|
+
const move = output === undefined ? 'none' : relocation(props, output);
|
|
91
|
+
const prior = move === 'none' ? output : undefined;
|
|
85
92
|
const planned = await planWrite(runner, props, want);
|
|
86
93
|
const live = ownedDigest(planned.before, props.region);
|
|
87
94
|
const declared = digestOf(props.content);
|
|
@@ -127,10 +134,21 @@ export const reconcileFile = async (
|
|
|
127
134
|
'the write returned but the file on disk does not match the declaration',
|
|
128
135
|
);
|
|
129
136
|
}
|
|
130
|
-
// ★ Create-before-delete, as the replace would have been: the old path
|
|
131
|
-
// written and verified.
|
|
132
|
-
if (
|
|
133
|
-
|
|
137
|
+
// ★ Create-before-delete, as the replace would have been: the old path — or, for a rename inside
|
|
138
|
+
// one file, the old block — goes once the new one is written and verified.
|
|
139
|
+
if (move === 'none' || output === undefined) return after;
|
|
140
|
+
await deleteFile(runner, output);
|
|
141
|
+
if (move === 'path') return after;
|
|
142
|
+
/**
|
|
143
|
+
* ⛔ A REGION RENAME TOOK THE OLD BLOCK OUT OF THIS SAME FILE, so `after` — read before that
|
|
144
|
+
* removal — describes bytes that no longer exist. Storing it would make the whole-file digest
|
|
145
|
+
* wrong from the moment it was written and every later plan would read it as drift.
|
|
146
|
+
*/
|
|
147
|
+
const settled = await readFileAttributes(runner, props.path, props.region);
|
|
148
|
+
if (settled === undefined || settled.contentSha256 !== declared) {
|
|
149
|
+
throw refuse(props.path, 'removing the previous managed block did not leave ours intact');
|
|
150
|
+
}
|
|
151
|
+
return settled;
|
|
134
152
|
};
|
|
135
153
|
|
|
136
154
|
/**
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { sha256Hex } from '../launchd/job-form.ts';
|
|
10
10
|
import type { FileStat, HostRunner, WriteOptions } from '../launchd/runner.ts';
|
|
11
|
-
import { type RegionSpec, readRegion, spliceRegion } from './region.ts';
|
|
11
|
+
import { type RegionSpec, readRegion, sameRegion, spliceRegion } from './region.ts';
|
|
12
12
|
import {
|
|
13
13
|
DEFAULT_MODE,
|
|
14
14
|
type RemoteFileAttributes,
|
|
@@ -46,6 +46,42 @@ export const identity = async (runner: HostRunner, props: RemoteFileProps): Prom
|
|
|
46
46
|
|
|
47
47
|
export const digestOf = (text: string): string => sha256Hex(encoder.encode(text));
|
|
48
48
|
|
|
49
|
+
/** What this declaration now owns, compared with what the last deploy owned. */
|
|
50
|
+
export type Move = 'none' | 'path' | 'region';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Has the thing this resource owns MOVED since the last deploy — and if so, how?
|
|
54
|
+
*
|
|
55
|
+
* 🔴 WHY THIS EXISTS (found by review, reproduced against the fake host, 2026-09-22). Without it,
|
|
56
|
+
* only `path` was identity and both of these read as a routine `update`:
|
|
57
|
+
* - RENAMING THE REGION left the old block in the file FOREVER. The new markers were spliced in,
|
|
58
|
+
* the old ones were never touched, and `delete` — which can only look for the name in state,
|
|
59
|
+
* now the new one — could never remove them. Two `anchor` lines in a packet filter, two
|
|
60
|
+
* entries in a host table, and nothing in the stack able to take either back.
|
|
61
|
+
* - DROPPING `region` TOOK OVER THE WHOLE FILE. The plan said `update`; the apply replaced every
|
|
62
|
+
* byte of a file belonging to a vendor package with this resource's four lines. That is the
|
|
63
|
+
* one thing the managed-region design exists to make impossible.
|
|
64
|
+
* ⛔ A FLIP BETWEEN THE TWO MODES IS A REFUSAL, NOT A MOVE. Neither order is safe: writing the
|
|
65
|
+
* whole file first destroys the other owner's bytes before anything can be undone, and removing
|
|
66
|
+
* the block first destroys our own claim and then refuses. The operator destroys the resource
|
|
67
|
+
* and declares a new one, which is the only sequence with a reviewable diff at each step.
|
|
68
|
+
* ★ A region-to-region rename IS safe as a move, because both blocks can exist at once: write the
|
|
69
|
+
* new one, verify it, then take the old one out (remote-file-lifecycle.ts reconcileFile).
|
|
70
|
+
*/
|
|
71
|
+
export const relocation = (props: RemoteFileProps, output: RemoteFileAttributes): Move => {
|
|
72
|
+
if (props.path !== output.path) return 'path';
|
|
73
|
+
if (sameRegion(props.region, output.region)) return 'none';
|
|
74
|
+
if (props.region === undefined || output.region === undefined) {
|
|
75
|
+
throw refuse(
|
|
76
|
+
props.path,
|
|
77
|
+
`changes between owning the whole file and owning the managed region ` +
|
|
78
|
+
`${JSON.stringify((props.region ?? output.region)?.name ?? '')} at the same path. Those ` +
|
|
79
|
+
'are two different claims on one file: destroy this resource and declare a new one.',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return 'region';
|
|
83
|
+
};
|
|
84
|
+
|
|
49
85
|
/** The digest of the part this resource owns, or `undefined` when that part is not there. */
|
|
50
86
|
export const ownedDigest = (
|
|
51
87
|
file: string | undefined,
|