@homeflare/alchemy 0.8.0 → 0.10.1
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 -11
- package/dist/caddy/config-lifecycle.d.ts +1 -1
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/index.js +221 -53
- package/dist/caddy/index.js.map +13 -9
- package/dist/launchd/fake-runner.d.ts +1 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +1 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -1
- package/dist/launchd/host-effect.d.ts +9 -18
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +14 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts +2 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.js +345 -86
- package/dist/launchd/index.js.map +18 -12
- package/dist/launchd/job-lifecycle.d.ts +8 -1
- package/dist/launchd/job-lifecycle.d.ts.map +1 -1
- package/dist/launchd/job-preflight.d.ts +13 -1
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job.d.ts +2 -1
- package/dist/launchd/job.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-acl.d.ts +25 -0
- package/dist/launchd/sudo-acl.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +31 -4
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +3 -3
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/launchd/sudo-said.d.ts +10 -0
- package/dist/launchd/sudo-said.d.ts.map +1 -0
- package/dist/openbao/auth-method-reconcile.d.ts +6 -2
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-bao.d.ts +18 -0
- package/dist/openbao/fake-bao.d.ts.map +1 -1
- package/dist/openbao/fake-core.d.ts +39 -0
- package/dist/openbao/fake-core.d.ts.map +1 -0
- package/dist/openbao/fake-families.d.ts +6 -3
- package/dist/openbao/fake-families.d.ts.map +1 -1
- package/dist/openbao/fake-mounts.d.ts +16 -0
- package/dist/openbao/fake-mounts.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +5 -1
- package/dist/openbao/fake-stack.d.ts.map +1 -1
- package/dist/openbao/index.js +541 -270
- package/dist/openbao/index.js.map +26 -21
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -2
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts +6 -2
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +5 -1
- package/dist/openbao/mount-reconcile.d.ts.map +1 -1
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin-reconcile.d.ts +6 -2
- package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/role-reconcile.d.ts +11 -1
- package/dist/openbao/role-reconcile.d.ts.map +1 -1
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/dist/ownership/adopt.d.ts +52 -0
- package/dist/ownership/adopt.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +13 -0
- package/dist/ownership/probe.d.ts.map +1 -0
- package/dist/ownership/resume.d.ts +40 -0
- package/dist/ownership/resume.d.ts.map +1 -0
- package/dist/ownership/rows.d.ts +33 -0
- package/dist/ownership/rows.d.ts.map +1 -0
- package/dist/ownership/whole.d.ts +14 -0
- package/dist/ownership/whole.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-form.d.ts +3 -3
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/fake-bao-env.d.ts +3 -0
- package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
- package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +38 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +6 -2
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +970 -186
- package/dist/proxmox/index.js.map +22 -9
- package/dist/proxmox/lxc-create-form.d.ts +6 -0
- package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +34 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -0
- package/dist/proxmox/lxc-identity.d.ts +18 -0
- package/dist/proxmox/lxc-identity.d.ts.map +1 -0
- package/dist/proxmox/lxc-judge.d.ts +46 -0
- package/dist/proxmox/lxc-judge.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/lxc-props.d.ts +139 -0
- package/dist/proxmox/lxc-props.d.ts.map +1 -0
- package/dist/proxmox/lxc-volume.d.ts +29 -0
- package/dist/proxmox/lxc-volume.d.ts.map +1 -0
- package/dist/proxmox/lxc-wire.d.ts +51 -0
- package/dist/proxmox/lxc-wire.d.ts.map +1 -0
- package/dist/proxmox/lxc.d.ts +27 -47
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/dist/proxmox/network-apply-read.d.ts +1 -1
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/update-guard.d.ts +27 -0
- package/dist/proxmox/update-guard.d.ts.map +1 -0
- package/dist/verify/args.d.ts +16 -0
- package/dist/verify/args.d.ts.map +1 -0
- package/dist/verify/cli.d.ts +3 -0
- package/dist/verify/cli.d.ts.map +1 -0
- package/dist/verify/cli.js +370 -0
- package/dist/verify/cli.js.map +17 -0
- package/dist/verify/fake-engine.d.ts +65 -0
- package/dist/verify/fake-engine.d.ts.map +1 -0
- package/dist/verify/index.d.ts +16 -0
- package/dist/verify/index.d.ts.map +1 -0
- package/dist/verify/index.js +347 -0
- package/dist/verify/index.js.map +16 -0
- package/dist/verify/plan-scope.d.ts +14 -0
- package/dist/verify/plan-scope.d.ts.map +1 -0
- package/dist/verify/recheck.d.ts +42 -0
- package/dist/verify/recheck.d.ts.map +1 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +67 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +43 -0
- package/dist/verify/spy.d.ts.map +1 -0
- package/dist/verify/verify.d.ts +43 -0
- package/dist/verify/verify.d.ts.map +1 -0
- package/docs/adopt-verify.md +119 -0
- package/docs/adopted-deploys.md +85 -0
- package/docs/credentials.md +15 -0
- package/docs/launchd-sudo.md +31 -6
- package/docs/launchd.md +7 -3
- package/docs/ownership.md +113 -0
- package/docs/proxmox-lxc.md +196 -0
- package/docs/proxmox.md +12 -0
- package/package.json +9 -2
- package/src/caddy/adopt-scope.test.ts +1 -1
- package/src/caddy/config-lifecycle.ts +1 -1
- package/src/caddy/config.ts +2 -1
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/host-effect.ts +1 -33
- package/src/launchd/host-file-lifecycle.ts +39 -14
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +18 -1
- package/src/launchd/job.ts +13 -4
- package/src/launchd/runner.ts +7 -0
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.ts +89 -10
- package/src/launchd/sudo-modes.test.ts +135 -0
- package/src/launchd/sudo-runner.ts +40 -59
- package/src/launchd/sudo-said.ts +58 -0
- package/src/openbao/README.md +5 -2
- package/src/openbao/REPLACE.md +13 -11
- package/src/openbao/adopt-core.test.ts +119 -0
- package/src/openbao/adopt-families.test.ts +97 -0
- package/src/openbao/adopt-holes.test.ts +168 -0
- package/src/openbao/adopt-recovery.test.ts +71 -0
- package/src/openbao/auth-method-reconcile.ts +15 -3
- package/src/openbao/auth-method.ts +15 -6
- package/src/openbao/auth-role.ts +14 -4
- package/src/openbao/cloudflare-role.ts +17 -4
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-families.ts +11 -5
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/jwt-config.ts +10 -7
- package/src/openbao/jwt-role.ts +10 -7
- package/src/openbao/kubernetes-role.ts +13 -7
- package/src/openbao/mfa-enforcement.ts +20 -6
- package/src/openbao/mfa-totp.ts +22 -7
- package/src/openbao/mount-move.test.ts +2 -35
- package/src/openbao/mount-reconcile.ts +13 -2
- package/src/openbao/mount.ts +15 -6
- package/src/openbao/pki-role.ts +15 -4
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +17 -6
- package/src/openbao/policy.ts +17 -4
- package/src/openbao/proxmox-role.ts +13 -4
- package/src/openbao/rename-adoption.test.ts +32 -19
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role.ts +14 -4
- package/src/ownership/adopt.ts +122 -0
- package/src/ownership/ownership.test.ts +200 -0
- package/src/ownership/probe.ts +65 -0
- package/src/ownership/resume.ts +118 -0
- package/src/ownership/rows.ts +117 -0
- package/src/ownership/whole.test.ts +39 -0
- package/src/ownership/whole.ts +60 -0
- package/src/proxmox/adopt-noop.test.ts +112 -0
- package/src/proxmox/ceph-pool-adopt.test.ts +120 -0
- package/src/proxmox/ceph-pool-form.ts +3 -3
- package/src/proxmox/ceph-pool.ts +10 -3
- package/src/proxmox/fake-bao-env.ts +28 -0
- package/src/proxmox/fake-pve-lxc.ts +210 -0
- package/src/proxmox/fake-pve.ts +75 -0
- package/src/proxmox/index.test.ts +6 -6
- package/src/proxmox/index.ts +6 -2
- package/src/proxmox/lxc-adopt.test.ts +170 -0
- package/src/proxmox/lxc-create-form.ts +76 -0
- package/src/proxmox/lxc-create.test.ts +126 -0
- package/src/proxmox/lxc-guards.test.ts +158 -0
- package/src/proxmox/lxc-harness.ts +150 -0
- package/src/proxmox/lxc-identity.ts +43 -0
- package/src/proxmox/lxc-judge.test.ts +175 -0
- package/src/proxmox/lxc-judge.ts +196 -0
- package/src/proxmox/lxc-lifecycle.ts +200 -0
- package/src/proxmox/lxc-ownership.test.ts +159 -0
- package/src/proxmox/lxc-props.ts +215 -0
- package/src/proxmox/lxc-read.test.ts +60 -0
- package/src/proxmox/lxc-volume.test.ts +96 -0
- package/src/proxmox/lxc-volume.ts +153 -0
- package/src/proxmox/lxc-wire.ts +210 -0
- package/src/proxmox/lxc.ts +216 -93
- package/src/proxmox/network-apply-read.ts +3 -1
- package/src/proxmox/pbs-datastore.ts +4 -3
- package/src/proxmox/resource.ts +7 -7
- package/src/proxmox/update-guard.ts +31 -0
- package/src/verify/args.ts +82 -0
- package/src/verify/cli.test.ts +86 -0
- package/src/verify/cli.ts +49 -0
- package/src/verify/fake-engine.ts +182 -0
- package/src/verify/index.ts +37 -0
- package/src/verify/plan-scope.ts +34 -0
- package/src/verify/recheck.test.ts +56 -0
- package/src/verify/recheck.ts +71 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +210 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +160 -0
- package/src/verify/verify.test.ts +146 -0
- package/src/verify/verify.ts +185 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ProviderService } from 'alchemy/Provider';
|
|
2
|
+
import * as Context from 'effect/Context';
|
|
3
|
+
export type ReadAnswer = 'found' | 'unowned' | 'absent' | 'failed' | 'not-read';
|
|
4
|
+
export type DiffAnswer = 'noop' | 'update' | 'replace' | 'none' | 'not-run';
|
|
5
|
+
/** What the diff answered when asked again with the live values as its recorded props (recheck.ts). */
|
|
6
|
+
export type RecheckAnswer = DiffAnswer | 'failed';
|
|
7
|
+
/** Exactly what the planner handed a provider's `diff`. */
|
|
8
|
+
export type DiffInput = Parameters<NonNullable<ProviderService['diff']>>[0];
|
|
9
|
+
/** What one FQN's provider answered during the plan — the LAST answer of each kind. */
|
|
10
|
+
export interface Seen {
|
|
11
|
+
read?: {
|
|
12
|
+
readonly answer: ReadAnswer;
|
|
13
|
+
readonly attributes: unknown;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* ★ `input` IS THE WHOLE CALL, NOT ONLY `news`, so recheck.ts can ask the same question again
|
|
17
|
+
* with one field changed. In memory only; the report carries field names, never values. An
|
|
18
|
+
* observation built by hand without it (rows.ts's tests) is simply never rechecked.
|
|
19
|
+
*/
|
|
20
|
+
diff?: {
|
|
21
|
+
readonly answer: DiffAnswer;
|
|
22
|
+
readonly news: unknown;
|
|
23
|
+
readonly input?: DiffInput;
|
|
24
|
+
};
|
|
25
|
+
recheck?: RecheckAnswer;
|
|
26
|
+
}
|
|
27
|
+
/** Keyed by FQN. Filled while the plan runs; read once it is done. */
|
|
28
|
+
export type Observations = Map<string, Seen>;
|
|
29
|
+
/** The refusal every write path answers with. Exported so a test can match it. */
|
|
30
|
+
export declare const WRITE_REFUSED = "hf-adopt-verify never writes";
|
|
31
|
+
export declare const diffAnswer: (diff: unknown) => DiffAnswer;
|
|
32
|
+
/**
|
|
33
|
+
* One provider service, watched. ⚠️ The spread keeps `version`, `aliases`, `mode`, `stables`,
|
|
34
|
+
* `list` and the rest exactly as registered — the planner reads several of them.
|
|
35
|
+
*/
|
|
36
|
+
export declare const spyService: (type: string, service: ProviderService, seen: Observations) => ProviderService<import("alchemy").ResourceLike<string, any, object, any, any>, never, never, never, never, never, never, never, never>;
|
|
37
|
+
/**
|
|
38
|
+
* The same context with every provider service — direct or inside a `Provider.collection` —
|
|
39
|
+
* replaced by its watched copy. Everything else (the state store, the stack, credentials) is the
|
|
40
|
+
* very object the session built.
|
|
41
|
+
*/
|
|
42
|
+
export declare const spyContext: <R>(context: Context.Context<R>, seen: Observations) => Context.Context<R>;
|
|
43
|
+
//# sourceMappingURL=spy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spy.d.ts","sourceRoot":"","sources":["../../src/verify/spy.ts"],"names":[],"mappings":"AAsBA,OAAO,EAEL,KAAK,eAAe,EAGrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,OAAO,MAAM,gBAAgB,CAAC;AAG1C,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,UAAU,CAAC;AAChF,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAAC;AAC5E,uGAAuG;AACvG,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,QAAQ,CAAC;AAElD,2DAA2D;AAC3D,MAAM,MAAM,SAAS,GAAG,UAAU,CAAC,WAAW,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAE5E,uFAAuF;AACvF,MAAM,WAAW,IAAI;IACnB,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;QAAC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAA;KAAE,CAAC;IACrE;;;;OAIG;IACH,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;QAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAA;KAAE,CAAC;IAC3F,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,sEAAsE;AACtE,MAAM,MAAM,YAAY,GAAG,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;AAU7C,kFAAkF;AAClF,eAAO,MAAM,aAAa,iCAAiC,CAAC;AAa5D,eAAO,MAAM,UAAU,SAAU,OAAO,KAAG,UAG1C,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,UAAU,SAAU,MAAM,WAAW,eAAe,QAAQ,YAAY,2IAsCpF,CAAC;AAeF;;;;GAIG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,WACjB,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,QACrB,YAAY,KACjB,OAAO,CAAC,OAAO,CAAC,CAAC,CAanB,CAAC"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { CompiledStack } from 'alchemy/Stack';
|
|
2
|
+
import type * as Context from 'effect/Context';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import { type AdoptRow } from './rows.ts';
|
|
5
|
+
export interface VerifyOptions {
|
|
6
|
+
/** Report every declared row and every pending deletion, not only rows without state. */
|
|
7
|
+
readonly all?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface AdoptReport {
|
|
10
|
+
readonly stack: string;
|
|
11
|
+
readonly stage: string;
|
|
12
|
+
/** Resources the stack declares. */
|
|
13
|
+
readonly declared: number;
|
|
14
|
+
readonly rows: readonly AdoptRow[];
|
|
15
|
+
}
|
|
16
|
+
/** An imported stack and the context it plans under — what `Alchemist.open` returns. */
|
|
17
|
+
export interface VerifySession {
|
|
18
|
+
readonly stack: CompiledStack<unknown, unknown>;
|
|
19
|
+
readonly context: Context.Context<unknown>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Plan `session.stack` with watched providers and report each row without state (or, with
|
|
23
|
+
* `all`, every row). Never writes.
|
|
24
|
+
*/
|
|
25
|
+
export declare const verifySession: (session: VerifySession, options?: VerifyOptions) => Effect.Effect<AdoptReport, unknown>;
|
|
26
|
+
/** Where the stack lives — the fields `alchemy plan` takes as flags. */
|
|
27
|
+
export interface VerifyTarget {
|
|
28
|
+
/** The stack entrypoint, e.g. `alchemy.run.ts`. */
|
|
29
|
+
readonly entrypoint: string;
|
|
30
|
+
readonly stage: string;
|
|
31
|
+
readonly profile?: string;
|
|
32
|
+
readonly envFile?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Import the stack the way the Alchemy CLI does and verify it.
|
|
36
|
+
*
|
|
37
|
+
* ★ `adopt: true`, ALWAYS, AND IT CANNOT WRITE. Planning is side-effect free (Plan.ts keeps the
|
|
38
|
+
* adopted state in memory, alchemy#793), so the flag only lets an `Unowned` row reach its
|
|
39
|
+
* `diff` instead of failing the plan — the report marks it, because the real deploy then needs
|
|
40
|
+
* `--adopt`. A resource declared `.pipe(adopt(false))` still fails the plan, as it would deploy.
|
|
41
|
+
*/
|
|
42
|
+
export declare const verifyStack: (target: VerifyTarget, options?: VerifyOptions) => Effect.Effect<AdoptReport, unknown>;
|
|
43
|
+
//# sourceMappingURL=verify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../../src/verify/verify.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEnD,OAAO,KAAK,KAAK,OAAO,MAAM,gBAAgB,CAAC;AAC/C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAGxC,OAAO,EAAE,KAAK,QAAQ,EAAyB,MAAM,WAAW,CAAC;AAGjE,MAAM,WAAW,aAAa;IAC5B,yFAAyF;IACzF,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,oCAAoC;IACpC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,SAAS,QAAQ,EAAE,CAAC;CACpC;AAED,wFAAwF;AACxF,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IAChD,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;CAC5C;AA+DD;;;GAGG;AACH,eAAO,MAAM,aAAa,YACf,aAAa,YACb,aAAa,KACrB,MAAM,CAAC,MAAM,CAAC,WAAW,EAAE,OAAO,CAyCpC,CAAC;AAEF,wEAAwE;AACxE,MAAM,WAAW,YAAY;IAC3B,mDAAmD;IACnD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,WACd,YAAY,YACX,aAAa,KACrB,MAAM,CAAC,MAAM,CAAC,WAAW,EAAE,OAAO,CAIsB,CAAC"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Adopt verifier — `adopted` must mean no-op
|
|
2
|
+
|
|
3
|
+
`hf-adopt-verify` plans a stack with Alchemy's own planner, never writes, and reports for each row
|
|
4
|
+
**without a state row** what the provider's `read` and its own `diff` said. It exits `0` only
|
|
5
|
+
when every such row is a no-op. Run it before any gated deploy that adopts.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bunx --bun hf-adopt-verify --config alchemy.run.ts --stage live # rows without state
|
|
9
|
+
bunx --bun hf-adopt-verify --config alchemy.pbs.ts --stage live --all # every row + deletions
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Wrap it in the same credential lane the deploy uses (`bun ./scripts/with-mini-bao.ts --admin …`
|
|
13
|
+
in the Proxmox stacks): a lane that cannot read an object sees it as absent, and the verifier
|
|
14
|
+
then reports `create` for something that exists.
|
|
15
|
+
|
|
16
|
+
## Why a plan line is not enough
|
|
17
|
+
|
|
18
|
+
Measured in alchemy `2.0.0-beta.79`:
|
|
19
|
+
|
|
20
|
+
- `Plan.ts`: when a row has no state, the engine calls `read` (the adoption probe). If the object
|
|
21
|
+
exists it sets `forceUpdateAfterAdoption`, calls `diff`, and turns a `noop` into `update`. The
|
|
22
|
+
node is `adopted` either way. Nothing is logged.
|
|
23
|
+
- `Apply.ts`: `adopted` goes down the `update` branch and calls `reconcile` with `olds: undefined`.
|
|
24
|
+
- So `alchemy plan` prints `(+) adopted` for a matching object **and** for a drifting one, and
|
|
25
|
+
the deploy reconciles both. Whether that writes is up to each family's `reconcile`.
|
|
26
|
+
|
|
27
|
+
Measured on TB4, `pvesh get /cluster/tasks`, 2026-09-21: six `cephsetpool` tasks by
|
|
28
|
+
`hf-provision@pve`, one per pool, on 2026-09-13 and again on 2026-09-20 — the deploys that
|
|
29
|
+
adopted the three Ceph pools. `Proxmox.CephPool` sent `setpool` whenever the pool existed,
|
|
30
|
+
whatever its diff said. It was the one PVE/PBS family that wrote on a no-op adoption, and it is
|
|
31
|
+
fixed. [adopted-deploys.md](./adopted-deploys.md) shows what every family does.
|
|
32
|
+
|
|
33
|
+
## Where the answer is taken from
|
|
34
|
+
|
|
35
|
+
| looked at | what it offers |
|
|
36
|
+
| -------------------------- | --------------------------------------------------------------- |
|
|
37
|
+
| `alchemy plan` (CLI) | the forced action only; `--detailed` is declared YAML, no JSON |
|
|
38
|
+
| `Plan.describePlan` | serializable rows, again only the forced action |
|
|
39
|
+
| `forceUpdateAfterAdoption` | a local variable; no log, no warning, no field on the node |
|
|
40
|
+
| `alchemy drift` | rows **with** state only, and its non-dry run reconciles |
|
|
41
|
+
| `deploy --adopt --dry-run` | the adoption plan (`plan` has no `--adopt`), same forced action |
|
|
42
|
+
| **the provider service** | the raw `read` / `diff` answer, per FQN — used |
|
|
43
|
+
|
|
44
|
+
The engine finds every provider in the Effect context by resource type. The verifier opens the
|
|
45
|
+
stack the way `alchemy plan` does (`Alchemist.open`). It swaps each provider for a watched copy
|
|
46
|
+
(`src/verify/spy.ts`) and runs `Plan.make` itself, so output resolution, state lookup, renames,
|
|
47
|
+
the adoption probe and `adopt(…)` are all still Alchemy's. The copy records what `read` and
|
|
48
|
+
`diff` answered. `reconcile`, `delete` and `precreate` are replaced with a refusal, so a
|
|
49
|
+
verification cannot write even by accident.
|
|
50
|
+
|
|
51
|
+
## The report
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
planned diff read type fqn
|
|
55
|
+
FAIL adopted update found Test.Thing drifted changed: comment
|
|
56
|
+
the provider's diff says update: the deploy writes
|
|
57
|
+
ok adopted noop found Test.Thing same
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
| column | meaning |
|
|
61
|
+
| --------- | ---------------------------------------------------------------------------------------- |
|
|
62
|
+
| `planned` | the engine's action — what `alchemy plan` prints |
|
|
63
|
+
| `diff` | the provider's own answer before Plan.ts forced it; `none` = no diff; `not-run` |
|
|
64
|
+
| `read` | `found`, `unowned` (the deploy needs `--adopt`), `absent`, `failed`, `not-read` |
|
|
65
|
+
| `changed` | declared fields whose live value differs, **names only**, same-named keys of props/attrs |
|
|
66
|
+
|
|
67
|
+
A row is a no-op when `planned` is `adopted` or `noop`, the provider said `noop`, **and** no
|
|
68
|
+
binding changes (Plan.ts diffs bindings separately and `reconcile` applies them anyway). An
|
|
69
|
+
adopted row whose provider has no `diff` is not proven: the engine compared the declaration with
|
|
70
|
+
itself. A row with state and no diff is trusted at `noop`, as `alchemy plan` trusts it. A stack
|
|
71
|
+
task (an Alchemy action) that will `run` is always a failing row: no provider sees it.
|
|
72
|
+
|
|
73
|
+
An adopted `noop` with something under `changed` is asked once more (`src/verify/recheck.ts`).
|
|
74
|
+
Plan.ts hands an adopted row's `diff` the declaration as its recorded props (`olds: news`), so a
|
|
75
|
+
diff that compares recorded props with the declaration answers `noop` whatever the cloud holds.
|
|
76
|
+
Alchemy's own `Cloudflare.R2Bucket` diff has that shape (`olds.domains` against `news.domains`).
|
|
77
|
+
The replay passes the read's values for the changed fields as `olds`. A diff that reads the live
|
|
78
|
+
object ignores them and says `noop` again, so the row passes with a note: the family does not
|
|
79
|
+
manage those fields (CephPool's autoscaled `pg_num`). Anything else fails the row, and the JSON
|
|
80
|
+
report carries the answer as `recheck`. Before this check, such a row passed with its drift
|
|
81
|
+
listed beside the `ok`.
|
|
82
|
+
|
|
83
|
+
Exit codes: `0` all no-op (or nothing to verify), `1` any row is not, `2` the plan could not be
|
|
84
|
+
computed (a provider died, an `adopt(false)` row is not ours, state unreadable). A gate should stop
|
|
85
|
+
on anything but `0`.
|
|
86
|
+
|
|
87
|
+
## Flags and API
|
|
88
|
+
|
|
89
|
+
`--config` / `-c`, `--stage`, `--profile`, `--env-file` behave as in `alchemy plan`. The
|
|
90
|
+
difference is that `--stage` is never guessed: it comes from the flag or `$ALCHEMY_STAGE`, or the
|
|
91
|
+
run stops. `--all` adds every row with state (read again) and every pending delete. `--json`
|
|
92
|
+
prints the report.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { verifyStack, formatReport, exitCodeOf } from '@homeflare/alchemy/verify';
|
|
96
|
+
|
|
97
|
+
const report = await Effect.runPromise(
|
|
98
|
+
verifyStack({ entrypoint: 'alchemy.run.ts', stage: 'live' }),
|
|
99
|
+
);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`verifySession({ stack, context })` takes an already-opened session. The kit's own tests pass it
|
|
103
|
+
a compiled stack (`src/verify/fake-engine.ts`).
|
|
104
|
+
|
|
105
|
+
## Limits
|
|
106
|
+
|
|
107
|
+
- ⚠️ **`noop` covers what the family compares.** A family's `diff` and its `reconcile` guard share
|
|
108
|
+
one predicate (`matches`). A field that `matches` leaves out is never written on a no-op
|
|
109
|
+
adoption and never reported. `Proxmox.CephPool` `target_size_ratio` is the known case.
|
|
110
|
+
- ⚠️ **`changed` is not the verdict.** It compares same-named keys, and only decides whether an
|
|
111
|
+
adopted `noop` is asked again. A drift in a field the attributes rename or normalise shows
|
|
112
|
+
nothing there, and a diff that ignores the live object then goes unchallenged.
|
|
113
|
+
- ⚠️ **Reads are what `alchemy plan` does.** A kit PVE/PBS read mints a lease through OpenBao,
|
|
114
|
+
which creates a short-lived API token on the cluster. `--all` adds one read per row with state.
|
|
115
|
+
- ⚠️ **Outside the kit, `noop` is only the diff's word.** For a kit PVE/PBS family, `noop` means
|
|
116
|
+
the deploy writes nothing ([adopted-deploys.md](./adopted-deploys.md)). For any other provider,
|
|
117
|
+
`noop` means its diff found nothing. Whether its `reconcile` still PUTs is up to that provider.
|
|
118
|
+
- It runs where `alchemy` runs: under bun it needs `@effect/platform-bun`, as the Alchemy CLI
|
|
119
|
+
does. Like `alchemy plan`, it writes a run log under `.alchemy/log/` in the working directory.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# What a deploy does to an adopted PVE/PBS row
|
|
2
|
+
|
|
3
|
+
Traced through the source on 2026-09-21, for every family the Proxmox and PBS stacks use. A row
|
|
4
|
+
**without state** that the adoption probe finds is planned `adopted` and reconciled, even when
|
|
5
|
+
its diff said `noop` (alchemy beta.79 `Plan.ts` `forceUpdateAfterAdoption`, then `Apply.ts`).
|
|
6
|
+
This page answers one question: does that reconcile write when live already equals declared?
|
|
7
|
+
|
|
8
|
+
**Answer: no, for every family below. `Proxmox.CephPool` did until this change.** Its
|
|
9
|
+
hand-written reconcile PUT `setpool` whenever the pool existed. It now skips the PUT when its own
|
|
10
|
+
`matches` holds. `src/proxmox/update-guard.ts` is the one predicate that `resource.ts`,
|
|
11
|
+
`ceph-pool.ts` and `pbs-datastore.ts` share. `hf-adopt-verify` ([adopt-verify.md](./adopt-verify.md))
|
|
12
|
+
reports the diff that decides it, before the deploy.
|
|
13
|
+
|
|
14
|
+
## The shared path — every `pveHandlers` family
|
|
15
|
+
|
|
16
|
+
One `alchemy deploy` plans, then applies, in one process. For a row with no state:
|
|
17
|
+
|
|
18
|
+
| step | calls (path is the family's `path`) |
|
|
19
|
+
| -------------------- | ------------------------------------------------------------------------------------------------ |
|
|
20
|
+
| plan: adoption probe | `GET path` |
|
|
21
|
+
| plan: `diff` | `GET path`; `matches` → `noop` or `update` (printed `adopted`) |
|
|
22
|
+
| apply: `reconcile` | `GET path`; **PUT only when `matches` is false** and the form is non-empty; `GET path` read-back |
|
|
23
|
+
|
|
24
|
+
So a matching adoption costs **four GETs and no write**. A drifting one adds exactly one
|
|
25
|
+
`PUT path` carrying the family's `updateForm`. Each GET goes out on a lease, and each role's lease
|
|
26
|
+
is minted once per run (`lease-cache.ts`). The mint is an OpenBao call that creates a short-lived
|
|
27
|
+
API token on the cluster, just as it does for `alchemy plan`.
|
|
28
|
+
|
|
29
|
+
## Per family
|
|
30
|
+
|
|
31
|
+
| family | read path | lease | reconcile on a no-op adoption |
|
|
32
|
+
| ------------------------------ | --------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------- |
|
|
33
|
+
| `Proxmox.Pool` | `pools/{poolid}` | read | shared path: GET, GET — no write |
|
|
34
|
+
| `Proxmox.Storage` | `storage/{storage}` | provision ¹ | shared path — no write |
|
|
35
|
+
| `Proxmox.Role` | `access/roles/{roleid}` | read | shared path — no write |
|
|
36
|
+
| `Proxmox.Group` | `access/groups/{groupid}` | read | shared path — no write |
|
|
37
|
+
| `Proxmox.User` | `access/users/{userid}` | read | shared path — no write |
|
|
38
|
+
| `Proxmox.BackupJob` | `cluster/backup/{id}` | read | shared path — no write |
|
|
39
|
+
| `Proxmox.MetricServer` | `cluster/metrics/server/{id}` | read | shared path — no write |
|
|
40
|
+
| `Proxmox.NotificationTarget` ² | `cluster/notifications/endpoints/{type}/{name}` | read | shared path — no write |
|
|
41
|
+
| `Proxmox.HaResource` | `cluster/ha/resources/{sid}` | read | shared path — no write |
|
|
42
|
+
| `Proxmox.HaRule` | `cluster/ha/rules/{rule}` | read | shared path — no write |
|
|
43
|
+
| `Proxmox.SdnZone` | `cluster/sdn/zones/{zone}` | provision ¹ | shared path — no write |
|
|
44
|
+
| `Proxmox.SdnVnet` | `cluster/sdn/vnets/{vnet}` | provision ¹ | shared path — no write |
|
|
45
|
+
| `Proxmox.SdnSubnet` | `cluster/sdn/vnets/{vnet}/subnets/{id}` | read | shared path — no write |
|
|
46
|
+
| `Proxmox.Lxc` ³ | `nodes/{node}/lxc/{vmid}/config` | read | own: GET, `judge` finds no drift so no `PUT …/config`, GET — no write |
|
|
47
|
+
| `Pbs.PruneJob` | `config/prune/{id}` | read (PBS) | shared path — no write |
|
|
48
|
+
| `Pbs.SyncJob` | `config/sync/{id}` | read (PBS) | shared path — no write |
|
|
49
|
+
| `Pbs.VerifyJob` | `config/verify/{id}` | read (PBS) | shared path — no write |
|
|
50
|
+
| `Proxmox.Acl` | `access/acl` (whole list, filtered) | provision ¹ | shared path, then its own `bound` check — no write |
|
|
51
|
+
| `Pbs.Datastore` | `config/datastore/{name}` | read (PBS) | own: GET, path/backend guards, PUT skipped on `matches`, settle GET — no write |
|
|
52
|
+
| `Proxmox.SdnApply` | 5 × `cluster/sdn/*?pending=1` + fabrics `?pending` / `?running` | read | own: counts staged objects; zero returns at once — **no `PUT /cluster/sdn`** |
|
|
53
|
+
| `Proxmox.CephPool` ⁴ | `nodes/{node}/ceph/pool/{name}/status?verbose=1` | read | own: GET, **PUT skipped on `matches` (was: always `PUT nodes/{node}/ceph/pool/{name}`)**, settle GET |
|
|
54
|
+
|
|
55
|
+
¹ These object reads are gated on an allocate privilege, so the family reads with the provision
|
|
56
|
+
lease (`readRole`, resource.ts). That is still a read.
|
|
57
|
+
² The PVE notification endpoints: `smtp` (the mail target), `sendmail`, `gotify`, `webhook`. The
|
|
58
|
+
secrets are never props (notification-target.ts), so no write is ever needed to "reset" them.
|
|
59
|
+
³ Rewritten 2026-09-21 (PR 80, [proxmox-lxc.md](./proxmox-lxc.md)): its own reconcile, and its
|
|
60
|
+
probe answers `Unowned`, so the deploy needs `--adopt`. Its `diff` also logs the keys a deploy
|
|
61
|
+
would write, by name. Pinned by `src/proxmox/lxc-adopt.test.ts` ("deploys with no write").
|
|
62
|
+
⁴ `updateBody` sends `target_size_ratio`, but `matches` does not compare it (float equality). A
|
|
63
|
+
declared ratio that differs from the live one is never written on a no-op adoption and never
|
|
64
|
+
reported as a diff.
|
|
65
|
+
|
|
66
|
+
## Before the fix: `Proxmox.CephPool`
|
|
67
|
+
|
|
68
|
+
`reconcile` read the pool and, whenever it existed, PUT the declared `size`, `min_size`,
|
|
69
|
+
`pg_autoscale_mode`, `crush_rule` (and any declared hints) to `nodes/{node}/ceph/pool/{name}`.
|
|
70
|
+
That PUT forks a `cephsetpool` worker under the provision token. PVE's `set_pool` skips each
|
|
71
|
+
setting whose value is unchanged (Tools.pm:293-300), so a matching pool was not re-tuned. It was
|
|
72
|
+
still a provision-lease write and a task on the node every time the pool was adopted. TB4's task
|
|
73
|
+
list shows six such tasks (2026-09-13 and 2026-09-20), one per declared pool per deploy.
|
|
74
|
+
|
|
75
|
+
## How it is pinned
|
|
76
|
+
|
|
77
|
+
- `src/proxmox/ceph-pool-adopt.test.ts` runs the real CephPool provider through Alchemy's Plan
|
|
78
|
+
and Apply over a fake cluster. A matching pool gets GETs only. A drifted one gets exactly one PUT.
|
|
79
|
+
On the old reconcile the first test fails with that PUT.
|
|
80
|
+
- `src/proxmox/adopt-noop.test.ts` does the same for each reconcile shape: the shared path
|
|
81
|
+
(`Proxmox.Pool`), the wrapper (`Proxmox.Acl`), and the two hand-written ones (`Pbs.Datastore`,
|
|
82
|
+
`Proxmox.SdnApply`). A drifted Pool row proves the harness sees a write when there is one.
|
|
83
|
+
- `src/verify/verify.test.ts` pins Alchemy's own behaviour: `adopted` for a match and for a
|
|
84
|
+
drift, and a reconcile for both. If an Alchemy upgrade changes that, the test fails and this
|
|
85
|
+
page needs re-reading.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Credentials
|
|
2
|
+
|
|
3
|
+
Moved out of the README (2026-09-21) to make room under its 200-line cap; the text is unchanged.
|
|
4
|
+
|
|
5
|
+
`CLOUDFLARE_API_TOKEN` is read from the environment at call time, never at module scope.
|
|
6
|
+
`MeshNode` instead resolves credentials and the account the way `Cloudflare.providers()` does
|
|
7
|
+
(an Alchemy profile, or `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` in CI).
|
|
8
|
+
|
|
9
|
+
⛔ **Mint a short-lived, scoped token** — do not reuse a long-lived one, and never a Global
|
|
10
|
+
API Key. An empty value fails closed with a message saying so, because an empty render is
|
|
11
|
+
a denied grant rather than a missing file, and `Bearer ` + nothing 401s in a way that reads
|
|
12
|
+
like a bad credential.
|
|
13
|
+
|
|
14
|
+
The Proxmox subpath mints every call's token from an OpenBao mount instead; see
|
|
15
|
+
[proxmox.md](./proxmox.md).
|
package/docs/launchd-sudo.md
CHANGED
|
@@ -9,8 +9,9 @@ argv shapes, logging each one. It is an explicit opt-in: nothing falls back to i
|
|
|
9
9
|
import { launchdProviders, sudoRunner } from '@homeflare/alchemy/launchd';
|
|
10
10
|
|
|
11
11
|
const runner = sudoRunner({
|
|
12
|
-
// ⛔ Required: the directories root may write. Each
|
|
13
|
-
//
|
|
12
|
+
// ⛔ Required: the directories root may write. Each — every directory above it, and every
|
|
13
|
+
// directory below it on the way to a file — must be one that root owns and only root may
|
|
14
|
+
// write, by mode bits and by ACL; that is checked at every privileged call.
|
|
14
15
|
prefixes: ['/Library/LaunchDaemons', '/opt/example'],
|
|
15
16
|
// log: (line) => …, // default: one line on stderr per privileged call
|
|
16
17
|
});
|
|
@@ -45,6 +46,12 @@ Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path, never thro
|
|
|
45
46
|
|
|
46
47
|
★ So **a plan never calls sudo.** `read` and `diff` are reads; only an apply elevates.
|
|
47
48
|
|
|
49
|
+
★ **A plan that will write runs the checks below first** (`HostRunner.checkWrite`): a `HostFile`
|
|
50
|
+
or `LaunchdJob` diff that plans an update or a replace asks the runner, as you, whether it would
|
|
51
|
+
refuse that write. A refusal then fails the plan before any resource is applied, instead of the
|
|
52
|
+
apply halfway through. A create has no diff, so its refusal still comes at the write — before
|
|
53
|
+
sudo.
|
|
54
|
+
|
|
48
55
|
## Refused before sudo is asked
|
|
49
56
|
|
|
50
57
|
- Any other argv. That includes a bare `bootout system` (which removes the whole system domain),
|
|
@@ -57,6 +64,24 @@ Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path, never thro
|
|
|
57
64
|
the next boot.
|
|
58
65
|
- A prefix that is missing, a symlink, not owned by root, or writable by group or other. ⛔ A
|
|
59
66
|
symlinked prefix makes root write wherever it points, a path the log never names.
|
|
67
|
+
- **A directory between the prefix and the file** that is not owned by root, or that group or
|
|
68
|
+
other may write (checked since 0.9.0). ⛔ Its owner, or anyone who may write it, could swap
|
|
69
|
+
what lies under it for a symlink between the check and the call.
|
|
70
|
+
- **A directory above the prefix**, from `/` down, that is not owned by root or that group or other
|
|
71
|
+
may write (red team, 2026-09-21). ⛔ Whoever may change the prefix's parent may rename the prefix
|
|
72
|
+
away and put a symlink in its place: the same swap, one level up. A root-owned symlink among them
|
|
73
|
+
(`/etc` -> `private/etc`) is root's own and is followed.
|
|
74
|
+
- **An ACL from `/` down to the file that grants a write right** (`add_file`, `add_subdirectory`,
|
|
75
|
+
`delete_child`, `delete`, `write`, `append`, `writesecurity`, `chown`), read with `ls -lden` as
|
|
76
|
+
you. ⛔ On macOS an ACL entry can hand another user exactly the swap the mode bits forbid, and an
|
|
77
|
+
inheritable `allow write` hands them every file root installs there. Deny entries (a home
|
|
78
|
+
folder's `everyone deny delete`) and read-only rights refuse nothing; ACLs that cannot be read
|
|
79
|
+
refuse. Measured 2026-09-21: `/Library/LaunchDaemons`, `/private/etc`, `/opt` and `/usr/local`,
|
|
80
|
+
and everything above them, carry none.
|
|
81
|
+
- **A root-owned file that would be group- or world-writable, setuid or setgid** (`mode & 0o6022`;
|
|
82
|
+
an omitted owner is root). ⛔ Anyone in that class could rewrite a file root installed (a
|
|
83
|
+
daemon's config, a script it runs), and a setuid root file runs as root for whoever executes it.
|
|
84
|
+
A file handed to another user (`owner`) is theirs to change, so its mode is theirs too.
|
|
60
85
|
- A path outside every prefix that needs root: another user as the owner, say.
|
|
61
86
|
- A symlink or missing directory between the prefix and the file, or anything but a regular file
|
|
62
87
|
at the path. ⚠️ `install src <directory>` copies _into_ the directory, and a symlink to one does
|
|
@@ -115,8 +140,8 @@ record of what ran as root.
|
|
|
115
140
|
remove, then an add.
|
|
116
141
|
- ⚠️ **Under a prefix, the runner writes as root.** An omitted owner is root, and an omitted group
|
|
117
142
|
is the directory's group (a new file's group on macOS).
|
|
118
|
-
- ⚠️ **The checks
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
143
|
+
- ⚠️ **The checks run as you, just before the call.** Every directory from `/` down to the file
|
|
144
|
+
must be root-only, by mode bits and by ACL, so no other user can swap a path in between; root
|
|
145
|
+
itself still could. The target of a root-owned symlink above the prefix is root's choice and is
|
|
146
|
+
not walked.
|
|
122
147
|
- ⛔ **macOS hosts.** Every argv shape was checked against macOS 27.2 man pages.
|
package/docs/launchd.md
CHANGED
|
@@ -87,8 +87,9 @@ The plist path is derived, never declared: `/Library/LaunchDaemons/<label>.plist
|
|
|
87
87
|
it becomes visible — then renamed over the path. Read back after every write. Diffed by SHA-256,
|
|
88
88
|
mode and owner. A new `path` is a create-before-delete replace. Delete removes the file.
|
|
89
89
|
|
|
90
|
-
- ⛔ A different file already at a new `path` is refused, not overwritten: the
|
|
91
|
-
never looks at a replace's new path, so a typo there would otherwise overwrite a
|
|
90
|
+
- ⛔ A different file already at a new `path` is refused, not overwritten, `--adopt` or not: the
|
|
91
|
+
adoption probe never looks at a replace's new path, so a typo there would otherwise overwrite a
|
|
92
|
+
system file.
|
|
92
93
|
|
|
93
94
|
- ⛔ A symlink or directory at the path is refused, never replaced: rename over a symlink replaces
|
|
94
95
|
the link, which takes the path from whatever tool owns it (nix-darwin's `/etc` entries point
|
|
@@ -126,7 +127,10 @@ you deploy as.
|
|
|
126
127
|
## Adoption: nothing is taken over silently
|
|
127
128
|
|
|
128
129
|
`read` with no prior state reports anything it finds — a loaded label, a plist, a file — as
|
|
129
|
-
`Unowned`, so Alchemy refuses to take it over without `--adopt`.
|
|
130
|
+
`Unowned`, so Alchemy refuses to take it over without `--adopt`. Where the plan never asked (a prop
|
|
131
|
+
still an `Output`), `reconcile` refuses the same takeover, and `--adopt` or the resource's own
|
|
132
|
+
`adopt(true)` lets a create (or an interrupted generation) through there too — never a fresh
|
|
133
|
+
replace's new path or label. The whole rule: [ownership.md](./ownership.md).
|
|
130
134
|
|
|
131
135
|
Labels under `org.nixos.`, `com.apple.` and `homebrew.mxcl.` are **refused outright**. nix-darwin
|
|
132
136
|
rewrites its plists on every activation and unloads any it no longer lists, macOS owns
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Ownership — nothing is adopted without `--adopt`
|
|
2
|
+
|
|
3
|
+
A live object this stack holds **no state for** is never taken over silently. That holds for every
|
|
4
|
+
`Bao.*` family, `HostFile`, `LaunchdJob`, `CaddyConfig` and `ProxmoxLxc`, and it holds even when the
|
|
5
|
+
live object is **identical** to the declaration: identical is not ours. Another stack, a person or an old
|
|
6
|
+
script put it there, and once state claims it, a delete under `RemovalPolicy.destroy()` removes it
|
|
7
|
+
from its real owner. (Decided 2026-09-21; the Bao families adopted silently until 0.9.0.)
|
|
8
|
+
|
|
9
|
+
To take an object over, say so:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
alchemy deploy --adopt --dry-run # read it first: `alchemy plan` has no --adopt (beta.79)
|
|
13
|
+
alchemy deploy --adopt # every resource in this deploy
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
⚠️ Only `deploy` declares `--adopt` in alchemy 2.0.0-beta.79, so `alchemy plan` fails "Cannot
|
|
17
|
+
adopt" before any resource can say what taking the object over would write. `--dry-run` shows it.
|
|
18
|
+
★ Even then every adoption prints `adopted`, drift or not. `hf-adopt-verify` plans with adoption on,
|
|
19
|
+
never writes, and prints each row's own diff and an exit code: [adopt-verify.md](./adopt-verify.md).
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
23
|
+
|
|
24
|
+
// this resource only
|
|
25
|
+
export const app = BaoPolicy('app', { fragments: './policies/app', name: 'app' }).pipe(adopt(true));
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
A resource-scoped `adopt(…)` wins over the flag, both ways: `.pipe(adopt(false))` stays refused
|
|
29
|
+
under `--adopt`.
|
|
30
|
+
|
|
31
|
+
## Where it is checked
|
|
32
|
+
|
|
33
|
+
| when | what happens to a live object with no state |
|
|
34
|
+
| ----------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
35
|
+
| **plan** — Alchemy's adoption probe | `read` answers `Unowned`; the plan fails with `OwnedBySomeoneElse` unless adoption is on |
|
|
36
|
+
| **apply** — the probe never ran | `reconcile` refuses before any write unless adoption is on |
|
|
37
|
+
|
|
38
|
+
Alchemy skips the probe while a prop is still an `Output` (it comes from a resource created in the
|
|
39
|
+
same deploy), and it never probes the new generation of a replace. An object can also appear
|
|
40
|
+
between plan and apply. So each family checks again in `reconcile`, reading the object before it
|
|
41
|
+
writes, and resolves `--adopt` / `adopt(…)` exactly as the planner does
|
|
42
|
+
(`src/ownership/adopt.ts`).
|
|
43
|
+
|
|
44
|
+
- A refused create also **forgets the `creating` row** Apply wrote for it, so the next plan probes
|
|
45
|
+
again and refuses again. Left behind, that row's recovery read could have adopted the very object
|
|
46
|
+
the apply refused (measured: `Bao.Mount` and `Bao.AuthMethod`, whose omitted props are not managed).
|
|
47
|
+
- `Bao.Mount` / `Bao.AuthMethod`: a create with `remountFrom` moves a live mount that this stack
|
|
48
|
+
holds no state for. That move is a takeover too, and it is refused the same way.
|
|
49
|
+
- **Adoption at apply is for a create, or an unfinished generation.** The planner offers `--adopt`
|
|
50
|
+
to a resource with no state, and Alchemy offers it to resume an interrupted create; so at apply
|
|
51
|
+
it lets through a `creating` row, or a generation whose row the plan's `diff` saw with no
|
|
52
|
+
attributes (an interrupted create or replace). A **fresh** replace's new generation that finds
|
|
53
|
+
its new identity taken is refused, `--adopt` or not: the plan never asked about that identity
|
|
54
|
+
(measured 2026-09-21: a `HostFile` whose path changed onto a file it did not own overwrote it
|
|
55
|
+
under a deploy-wide `--adopt`). For the Bao families a rename onto a taken identity already fails
|
|
56
|
+
the plan; this keeps the apply to the same answer.
|
|
57
|
+
|
|
58
|
+
## Crash recovery still works — when the row can prove it
|
|
59
|
+
|
|
60
|
+
A deploy that dies after its write and before its commit leaves a `creating` (or `replacing`) row
|
|
61
|
+
and a live object. The next deploy finishes it **without** `--adopt`:
|
|
62
|
+
|
|
63
|
+
- **An interrupted create.** Alchemy's recovery read passes the row's own instance id, which the
|
|
64
|
+
state store records (a probe's id is freshly minted, so no row holds it). The object is ours when
|
|
65
|
+
it also matches that row's props. If it does not, it may have lost a race to someone else, so it
|
|
66
|
+
is `Unowned`: the plan says `Cannot resume creating`, and `--adopt` resumes it.
|
|
67
|
+
- **An interrupted replace.** Alchemy never probes a `replacing` row; it calls `diff`. For a row
|
|
68
|
+
with no attributes, every Bao family's and `ProxmoxLxc`'s `diff` first asks its own `read` the
|
|
69
|
+
recovery question —
|
|
70
|
+
recorded instance, whole row, matching object (`provingResumes`, `src/ownership/resume.ts`) — and
|
|
71
|
+
only a "yes" leaves a note in Alchemy's per-deploy `Artifacts` bag that lets the apply's own
|
|
72
|
+
object through.
|
|
73
|
+
|
|
74
|
+
⛔ **A row proves nothing by itself.** Apply commits it **before** reconcile runs, so a deploy killed
|
|
75
|
+
before the claim (an upstream that failed, a Ctrl-C) leaves a row whose create never asked whose
|
|
76
|
+
object sat at its identity. Found by the red team on 2026-09-21 (`openbao/adopt-holes.test.ts`):
|
|
77
|
+
|
|
78
|
+
- **A row missing part of its declaration proves nothing.** Apply strips a prop that was still an
|
|
79
|
+
`Output` from the row, and most families read an absent prop as "not managed". So the row must
|
|
80
|
+
carry every value the declaration names (`src/ownership/whole.ts`); one with a hole is `Unowned`.
|
|
81
|
+
Measured: a `Bao.Mount` whose `defaultLeaseTtl` was an Output adopted, and tuned, another owner's
|
|
82
|
+
mount; with the name an Output, every role family resumed onto another owner's role.
|
|
83
|
+
- **So a create or replace interrupted while a prop was still an Output resumes with `--adopt`.**
|
|
84
|
+
Its row cannot say what it wrote, and that deploy's plan never asked whose object was there.
|
|
85
|
+
|
|
86
|
+
⚠️ **A multi-step create interrupted halfway is not proven ours.** `Bao.Mount` enables, then tunes.
|
|
87
|
+
If the deploy dies between the two, the mount does not match its props, and the resume needs
|
|
88
|
+
`--adopt`. That is the safe direction.
|
|
89
|
+
|
|
90
|
+
⚠️ **It fails closed.** If plan and apply do not share an `Artifacts` bag, or the store cannot be
|
|
91
|
+
read, a resume reads as a fresh create and asks for `--adopt`. `alchemy deploy` and the Alchemist
|
|
92
|
+
session both share one. An orphan's interrupted create (no longer declared) cannot be compared with
|
|
93
|
+
a declaration, so Apply leaves its object in place with a note instead of deleting it.
|
|
94
|
+
|
|
95
|
+
## Limits
|
|
96
|
+
|
|
97
|
+
- ⚠️ **A check, then a write: not a lock.** Two resources that declare the same name in **one**
|
|
98
|
+
deploy create concurrently. Both read nothing, and both write (measured with the fakes). Declare
|
|
99
|
+
each name once.
|
|
100
|
+
- ⚠️ **Proven at plan, written at apply.** A resume is proven against the object the plan read.
|
|
101
|
+
One swapped for another between that read and the apply is written over. That takes a race.
|
|
102
|
+
- ⚠️ **Identical is still ours to a recovery read.** A foreign object identical to a whole row's
|
|
103
|
+
props, at the identity an interrupted create or replace was going to write, reads as ours — the
|
|
104
|
+
rule Plan.ts itself applies to a recovery read.
|
|
105
|
+
- ⚠️ **`HostFile` and `LaunchdJob` answer `Unowned` to the recovery read too.** A create of theirs
|
|
106
|
+
interrupted after its write needs `--adopt` on the next deploy. Both remove what a failed create
|
|
107
|
+
wrote, so this needs a kill between the write and the commit.
|
|
108
|
+
- ⚠️ **`CaddyConfig` keeps its own probe.** A running config identical to the declaration reads as
|
|
109
|
+
ours, because adopting it changes nothing Caddy serves ([caddy.md](./caddy.md#adoption)).
|
|
110
|
+
`ProxmoxLxc` does not share that exception: its delete removes a guest and its volumes
|
|
111
|
+
([proxmox-lxc.md](./proxmox-lxc.md#adopting-what-the-plan-says)).
|
|
112
|
+
- ⚠️ **The other `@homeflare/alchemy/proxmox` resources do not follow this rule yet.** Those built
|
|
113
|
+
on `pveHandlers` (`src/proxmox/resource.ts`) still read a live object with no state as ours.
|