@homeflare/alchemy 0.40.0 → 0.41.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/caddy/index.js.map +2 -2
- package/dist/ceph/index.js +2 -1
- package/dist/ceph/index.js.map +3 -3
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +1 -1
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +7 -1
- package/dist/launchd/index.js.map +5 -5
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +21 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/linux/directory-lifecycle.d.ts.map +1 -1
- package/dist/linux/fake-linux-host.d.ts.map +1 -1
- package/dist/linux/index.d.ts +1 -1
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +16 -4
- package/dist/linux/index.js.map +7 -7
- package/dist/linux/ssh-runner.d.ts.map +1 -1
- package/dist/linux/sudo-runner.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +14 -1
- package/dist/opnsense/alias-form.d.ts.map +1 -1
- package/dist/opnsense/alias.d.ts +1 -1
- package/dist/opnsense/alias.d.ts.map +1 -1
- package/dist/opnsense/group-form.d.ts +8 -2
- package/dist/opnsense/group-form.d.ts.map +1 -1
- package/dist/opnsense/group.d.ts +1 -1
- package/dist/opnsense/group.d.ts.map +1 -1
- package/dist/opnsense/index.js +15 -7
- package/dist/opnsense/index.js.map +7 -7
- package/dist/opnsense/wire.d.ts +42 -12
- package/dist/opnsense/wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +48 -34
- package/dist/proxmox/index.js.map +7 -6
- package/dist/proxmox/storage-form.d.ts +28 -3
- package/dist/proxmox/storage-form.d.ts.map +1 -1
- package/dist/proxmox/storage-plugin-options.d.ts +2 -0
- package/dist/proxmox/storage-plugin-options.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +28 -1
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts +6 -1
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js.map +2 -2
- package/docs/distilled-interim.md +7 -0
- package/docs/launchd.md +5 -3
- package/docs/provider-standard.md +5 -1
- package/docs/talos-argocd-dogfood.md +160 -0
- package/package.json +2 -2
- package/src/launchd/fake-runner.ts +2 -0
- package/src/launchd/index.ts +8 -1
- package/src/launchd/local-runner.ts +16 -1
- package/src/launchd/runner.ts +22 -0
- package/src/launchd/sudo-runner.ts +3 -0
- package/src/linux/directory-lifecycle.ts +18 -3
- package/src/linux/directory-sudo.test.ts +59 -0
- package/src/linux/directory.test.ts +34 -8
- package/src/linux/fake-linux-host.ts +2 -0
- package/src/linux/index.ts +1 -0
- package/src/linux/ssh-runner.ts +4 -0
- package/src/linux/sudo-runner.ts +3 -0
- package/src/opnsense/alias-form.test.ts +24 -12
- package/src/opnsense/alias-form.ts +20 -7
- package/src/opnsense/alias.test.ts +13 -6
- package/src/opnsense/alias.ts +16 -10
- package/src/opnsense/group-form.test.ts +12 -8
- package/src/opnsense/group-form.ts +13 -4
- package/src/opnsense/group.test.ts +18 -7
- package/src/opnsense/group.ts +10 -3
- package/src/opnsense/wire.ts +57 -12
- package/src/proxmox/storage-form.ts +48 -8
- package/src/proxmox/storage-plugin-options.ts +45 -0
- package/src/proxmox/storage-update.test.ts +183 -0
- package/src/proxmox/storage-wire.ts +54 -7
- package/src/proxmox/storage.ts +18 -6
- package/src/release/index.ts +1 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Talos + Argo CD dogfood — Distilled and Alchemy path
|
|
2
|
+
|
|
3
|
+
Status: decision record — fixes the per-vendor Distilled/Alchemy path for the
|
|
4
|
+
Talos + Argo CD dogfood stack; retire when the stack ships and each row is
|
|
5
|
+
proven live.
|
|
6
|
+
Verified: 2026-09-27 (LAND pass: CRD ownership, Talos K-A3, Cilium CNI
|
|
7
|
+
ordering, topology and the interim-package rule checked against merged PRs
|
|
8
|
+
262/295/307/311; see each section below).
|
|
9
|
+
|
|
10
|
+
Opened 2026-09-24 after
|
|
11
|
+
[PR 242](https://github.com/taslabs-net/homeflare-kit/pull/242) closed without
|
|
12
|
+
merge.
|
|
13
|
+
|
|
14
|
+
Placeholder stack: Talos Kubernetes on Proxmox VE, **Argo CD owns GitOps**.
|
|
15
|
+
This page is the Distilled / Alchemy path for each vendor — not a stack file,
|
|
16
|
+
and not a licence to invent SDKs.
|
|
17
|
+
|
|
18
|
+
## Why there are no interim packages
|
|
19
|
+
|
|
20
|
+
[distilled-interim.md](./distilled-interim.md) is generate+copy from a real
|
|
21
|
+
vendor schema (distilled clone Steps 1–8, then copy `src/` into
|
|
22
|
+
`packages/distilled-<vendor>`). ⛔ **`src/` is copied, never hand-written.**
|
|
23
|
+
A stub invented so a `@distilled.cloud/<vendor>` name can publish is the
|
|
24
|
+
failure #242 shipped: Alchemy would `catchTag` errors from a client nobody
|
|
25
|
+
can regenerate. ⛔ Do not add npm aliases for packages that do not exist.
|
|
26
|
+
|
|
27
|
+
## Per-vendor decision
|
|
28
|
+
|
|
29
|
+
| vendor | decision |
|
|
30
|
+
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
31
|
+
| Headlamp | Skip Distilled / Alchemy vendor family — Argo Application / Helm |
|
|
32
|
+
| Traefik | No Distilled SDK (no official OpenAPI; do not invent) |
|
|
33
|
+
| Cilium | No hand-written interim — install vehicle is D-S2 (open); CRDs via upstream Alchemy `Kubernetes.Manifest` |
|
|
34
|
+
| CNPG | `postgresql.cnpg.io/v1` CRDs — upstream Alchemy `Kubernetes.Manifest` (S1), not `@distilled.cloud/kubernetes` (built-in API groups only) |
|
|
35
|
+
| Valkey | operator CRDs — upstream Alchemy `Kubernetes.Manifest` (S1); Sentinel is RESP, not HTTP |
|
|
36
|
+
| Argo CD | `Application`/`AppProject`/`ApplicationSet` — upstream Alchemy `Kubernetes.Manifest` (decision 49); `Repository`/`RepoCreds`/`Cluster` — `Argocd.Repository`/`RepoCreds`/`Cluster` (kit, built over `@distilled.cloud/argocd`, [PR 262](https://github.com/taslabs-net/homeflare-kit/pull/262), merged) |
|
|
37
|
+
| Talos CLI | No Distilled Talos SDK — stays on `talosctl`, reworked under K-A3 ([PR 307](https://github.com/taslabs-net/homeflare-kit/pull/307)/[311](https://github.com/taslabs-net/homeflare-kit/pull/311): `insecure` prop dropped, `Bootstrap.peers` added) |
|
|
38
|
+
|
|
39
|
+
Headlamp's `/config` and cluster proxy are not a vendor control plane.
|
|
40
|
+
|
|
41
|
+
Traefik declarative config is Kubernetes CRDs (`IngressRoute`, `Middleware`, …
|
|
42
|
+
— `traefik.io`, not a built-in group) / Helm values (experimental flags as
|
|
43
|
+
Helm props). CRDs go through upstream Alchemy `Kubernetes.Manifest` (S1), the
|
|
44
|
+
same as every other vendor's CRDs on this page, never
|
|
45
|
+
`@distilled.cloud/kubernetes`. The dashboard `GET /api/…` is read-only
|
|
46
|
+
runtime — optional later, not the dogfood install path.
|
|
47
|
+
|
|
48
|
+
Cilium's BGP and network-policy objects are genuine CRDs, not a built-in
|
|
49
|
+
Kubernetes API group, so they go through upstream Alchemy's
|
|
50
|
+
`Kubernetes.Manifest` (S1) — never `@distilled.cloud/kubernetes`: its 24
|
|
51
|
+
service files (checked at the kit's pin, `c2a78002`) cover only built-in
|
|
52
|
+
apiserver groups, and its `apiextensions` service defines CRD _schemas_, not
|
|
53
|
+
CRD _instances_. The install vehicle itself — inline manifests in the seeded
|
|
54
|
+
Talos machine config vs. a Helm step between `Kubeconfig` and `ClusterHealth`
|
|
55
|
+
— is **D-S2, still open**
|
|
56
|
+
([first-boot doc](https://github.com/taslabs-net/homeflare-kit/blob/main/docs/plans/2026-09-26-talos-stack-first-boot.md#tim-must-decide),
|
|
57
|
+
"Tim must decide"; inline manifests is the doc's recommendation, not yet
|
|
58
|
+
accepted). Either vehicle must set `cluster.network.cni.name: none` and
|
|
59
|
+
`cluster.proxy.disabled: true` (kube-proxy replacement) in the seeded config,
|
|
60
|
+
because Argo CD cannot install Cilium — Argo CD itself needs pod networking
|
|
61
|
+
first. Official swagger exists (`cilium/cilium` `api/v1/openapi.yaml`) but
|
|
62
|
+
the default transport is the unix socket `/var/run/cilium/cilium.sock`;
|
|
63
|
+
generate+copy a Distilled SDK later only if node-local agent health over
|
|
64
|
+
that socket is needed.
|
|
65
|
+
|
|
66
|
+
Argo CD's own storage — `Repository`, `RepoCreds` and `Cluster` — is a `Secret`
|
|
67
|
+
labeled with Argo CD's own convention, not a published Kubernetes API type, so
|
|
68
|
+
the kit's `Argocd.Repository`/`RepoCreds`/`Cluster` (built over
|
|
69
|
+
`@distilled.cloud/argocd`, already on npm) are the correct owner
|
|
70
|
+
([PR 262](https://github.com/taslabs-net/homeflare-kit/pull/262), merged). The
|
|
71
|
+
objects Argo CD reconciles — `Application`, `AppProject`, `ApplicationSet` —
|
|
72
|
+
are genuine `argoproj.io/v1alpha1` CRDs, so upstream Alchemy's
|
|
73
|
+
`Kubernetes.Manifest` owns those (decision 49, "upstream wins"), not a house
|
|
74
|
+
wrapper — see [argocd.md](./argocd.md) and
|
|
75
|
+
[argocd-kubernetes.md](./argocd-kubernetes.md). No Distilled Talos API in this
|
|
76
|
+
path.
|
|
77
|
+
|
|
78
|
+
## Gates
|
|
79
|
+
|
|
80
|
+
- ⛔ Do not publish an interim Distilled package until generate+copy from a
|
|
81
|
+
real vendor schema ([distilled-interim.md](./distilled-interim.md) steps 1–2).
|
|
82
|
+
- ⛔ Do not invent a Traefik Distilled SDK without an official schema.
|
|
83
|
+
- `Argocd.Repository`/`RepoCreds`/`Cluster` were built ahead of a live
|
|
84
|
+
Talos-on-PVE cluster ([PR 262](https://github.com/taslabs-net/homeflare-kit/pull/262),
|
|
85
|
+
merged 2026-09-24; tested against `fake-argocd.ts`, a fake in-memory Argo CD
|
|
86
|
+
API — the HTTP client and Distilled protocol underneath it are real, only
|
|
87
|
+
the Argo CD server is faked; never exercised against a real instance) —
|
|
88
|
+
Tim's build-ahead-of-need call. Declaring and applying
|
|
89
|
+
`Application`/`AppProject`/`ApplicationSet` objects in a real stack still
|
|
90
|
+
waits on that cluster existing.
|
|
91
|
+
- Prefer upstream Alchemy Kubernetes resources when they exist (S1).
|
|
92
|
+
|
|
93
|
+
## Placeholder variables
|
|
94
|
+
|
|
95
|
+
Names a consuming stack substitutes. Origins use RFC 2606 `*.example.com`.
|
|
96
|
+
Addresses use RFC 5737 documentation ranges. Secret _values_ never appear —
|
|
97
|
+
only env-var NAMES (S25). Nothing here is applied.
|
|
98
|
+
|
|
99
|
+
### Topology — 3 control plane, no workers today
|
|
100
|
+
|
|
101
|
+
`homeflare-proxmox`'s `src/talos-shape.ts` declares exactly 3 control-plane
|
|
102
|
+
VMs (`n2`/`n3`/`n4`, vmids 10000–10002) and no worker pool. The
|
|
103
|
+
`TALOS_WORKER_*` rows are placeholders for if/when a worker pool is added;
|
|
104
|
+
nothing consumes them yet.
|
|
105
|
+
|
|
106
|
+
| variable | placeholder |
|
|
107
|
+
| ------------------------------------- | ------------------------------- |
|
|
108
|
+
| `TALOS_CLUSTER_NAME` | `dogfood` |
|
|
109
|
+
| `TALOS_CP_NODES` | `cp-a`, `cp-b`, `cp-c` |
|
|
110
|
+
| `TALOS_WORKER_NODES` (none today) | `wk-a`, `wk-b`, `wk-c` |
|
|
111
|
+
| `TALOS_CP_ADDRESSES` | `192.0.2.10/24` … `.12` |
|
|
112
|
+
| `TALOS_WORKER_ADDRESSES` (none today) | `192.0.2.20/24` … `.22` |
|
|
113
|
+
| `TALOS_CONTROL_PLANE_ENDPOINT` | `https://k8s.example.com:6443` |
|
|
114
|
+
| `TALOS_VERSION` | `v1.13.x` (never measured live) |
|
|
115
|
+
| `STORAGE_CLASS` | `STORAGE_CLASS` |
|
|
116
|
+
| `STORAGE_SIZE` | `STORAGE_SIZE` |
|
|
117
|
+
|
|
118
|
+
### Cilium BGP
|
|
119
|
+
|
|
120
|
+
| variable | placeholder |
|
|
121
|
+
| ------------------------- | ----------------- |
|
|
122
|
+
| `CILIUM_BGP_CLUSTER_ASN` | `64512` (private) |
|
|
123
|
+
| `CILIUM_BGP_PEER_ASN` | `64513` |
|
|
124
|
+
| `CILIUM_BGP_PEER_ADDRESS` | `198.51.100.1` |
|
|
125
|
+
| `CILIUM_BGP_VIP_CIDR` | `203.0.113.0/24` |
|
|
126
|
+
|
|
127
|
+
### Traefik
|
|
128
|
+
|
|
129
|
+
| variable | placeholder |
|
|
130
|
+
| ---------------------------- | ----------------------------------------------- |
|
|
131
|
+
| `TRAEFIK_ENTRYPOINTS` | `web`, `websecure` |
|
|
132
|
+
| `TRAEFIK_EXPERIMENTAL_FLAGS` | Helm props, e.g. `kubernetesGateway`, `knative` |
|
|
133
|
+
| `TRAEFIK_NAMESPACE` | `traefik` |
|
|
134
|
+
|
|
135
|
+
### Installs Argo owns
|
|
136
|
+
|
|
137
|
+
| variable | placeholder |
|
|
138
|
+
| --------------------------- | ---------------------------- |
|
|
139
|
+
| `HEADLAMP_INGRESS_HOST` | `headlamp.example.com` |
|
|
140
|
+
| `CNPG_CLUSTER_NAME` | `dogfood-pg` |
|
|
141
|
+
| `CNPG_INSTANCES` | `3` |
|
|
142
|
+
| `CNPG_SUPERUSER_SECRET_ENV` | `CNPG_SUPERUSER_PASSWORD` |
|
|
143
|
+
| `VALKEY_SENTINEL_NAME` | `dogfood-valkey` |
|
|
144
|
+
| `VALKEY_PASSWORD_ENV` | `VALKEY_PASSWORD` |
|
|
145
|
+
| `ARGOCD_SERVER` | `https://argocd.example.com` |
|
|
146
|
+
| `ARGOCD_TOKEN_ENV` | `ARGOCD_TOKEN` |
|
|
147
|
+
|
|
148
|
+
## Non-goals
|
|
149
|
+
|
|
150
|
+
- No Flux. Argo CD is the GitOps controller.
|
|
151
|
+
- No new Distilled Talos SDK from this page — `@homeflare/alchemy/talos`
|
|
152
|
+
already reworked under K-A3 ([PR 307](https://github.com/taslabs-net/homeflare-kit/pull/307),
|
|
153
|
+
[PR 311](https://github.com/taslabs-net/homeflare-kit/pull/311)); it stays on
|
|
154
|
+
`talosctl`.
|
|
155
|
+
- No new Alchemy provider code from this page itself — `Argocd.*` (PR 262)
|
|
156
|
+
and upstream `Kubernetes.Manifest` already cover this stack's objects.
|
|
157
|
+
Declaring and applying them against a real cluster still waits on
|
|
158
|
+
Talos-on-PVE existing (see Gates).
|
|
159
|
+
- No `packages/distilled-traefik`, `packages/distilled-cilium`, or other
|
|
160
|
+
interim Distilled packages for this stack.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.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",
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
"@distilled.cloud/litellm": "npm:@homeflare/distilled-litellm@0.2.0",
|
|
132
132
|
"@distilled.cloud/netbox": "npm:@homeflare/distilled-netbox@0.3.0",
|
|
133
133
|
"@distilled.cloud/openbao": "npm:@homeflare/distilled-openbao@0.2.2",
|
|
134
|
-
"@distilled.cloud/opnsense": "npm:@homeflare/distilled-opnsense@0.
|
|
134
|
+
"@distilled.cloud/opnsense": "npm:@homeflare/distilled-opnsense@0.3.0",
|
|
135
135
|
"@distilled.cloud/paperless-ngx": "npm:@homeflare/distilled-paperless-ngx@0.3.0",
|
|
136
136
|
"@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.3.3",
|
|
137
137
|
"@distilled.cloud/proxmox-backup": "npm:@homeflare/distilled-proxmox-backup@0.3.1",
|
|
@@ -156,6 +156,8 @@ export const fakeRunner = (options: FakeOptions = {}) => {
|
|
|
156
156
|
lookupUser: async (nameOrId) =>
|
|
157
157
|
options.users?.[nameOrId] ??
|
|
158
158
|
Object.values(options.users ?? {}).find((user) => String(user.uid) === nameOrId),
|
|
159
|
+
// ★ This fake models launchd, which only ever exists on a Mac — fixed, not an option.
|
|
160
|
+
platform: 'darwin',
|
|
159
161
|
privileged,
|
|
160
162
|
readFile: async (path) => files.get(path)?.bytes,
|
|
161
163
|
removeFile: async (path) => {
|
package/src/launchd/index.ts
CHANGED
|
@@ -24,7 +24,14 @@ export { PlistError, renderPlist } from './plist.ts';
|
|
|
24
24
|
export type { PortClaim } from './port-claims.ts';
|
|
25
25
|
export { PortRefused, claimPorts, portClaimProblems } from './port-claims.ts';
|
|
26
26
|
export { launchdProviders } from './providers.ts';
|
|
27
|
-
export type {
|
|
27
|
+
export type {
|
|
28
|
+
ExecResult,
|
|
29
|
+
FileStat,
|
|
30
|
+
HostPlatform,
|
|
31
|
+
HostRunner,
|
|
32
|
+
HostUser,
|
|
33
|
+
WriteOptions,
|
|
34
|
+
} from './runner.ts';
|
|
28
35
|
export { HostRunnerService, canActAsRoot, hostRunnerLayer } from './runner.ts';
|
|
29
36
|
export type { SudoRunnerOptions } from './sudo-runner.ts';
|
|
30
37
|
export { SudoRefusedError, sudoRunner } from './sudo-runner.ts';
|
|
@@ -15,7 +15,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
15
15
|
import { lstat, open, readFile, rename, unlink } from 'node:fs/promises';
|
|
16
16
|
import { basename, dirname, join } from 'node:path';
|
|
17
17
|
import { groupQuery, parseGroup, parseUser, userQuery } from './host-lookup.ts';
|
|
18
|
-
import type { ExecResult, FileStat, HostRunner, WriteOptions } from './runner.ts';
|
|
18
|
+
import type { ExecResult, FileStat, HostPlatform, HostRunner, WriteOptions } from './runner.ts';
|
|
19
19
|
|
|
20
20
|
export type LocalRunnerOptions = {
|
|
21
21
|
/** Kill a spawned program after this long. @default 120_000 */
|
|
@@ -98,6 +98,20 @@ const writeAtomic = async (path: string, bytes: Uint8Array, options: WriteOption
|
|
|
98
98
|
export const localRunner = (options: LocalRunnerOptions = {}): HostRunner => {
|
|
99
99
|
const timeoutMs = options.execTimeoutMs ?? 120_000;
|
|
100
100
|
const platform = process.platform;
|
|
101
|
+
// ★ THE ONE PLACE `process.platform` MAY DECIDE HOST BEHAVIOUR: here, the target IS this
|
|
102
|
+
// process's own machine, so `process.platform` and `HostRunner.platform` name the same host.
|
|
103
|
+
// Everywhere else (chmodChownEnd, host-lookup's callers over ssh) reads `runner.platform`
|
|
104
|
+
// instead — see the incident on `HostRunner.platform`'s own doc comment.
|
|
105
|
+
// ⛔ DECLARED, NEVER GUESSED, past these two: silently folding freebsd/win32/etc. into 'linux'
|
|
106
|
+
// would hand a BSD host GNU's `--`, which macOS's own (BSD-flavoured) chmod/chown already
|
|
107
|
+
// reject — the same shape of bug this PR fixes. sshRunner's own `uname -s` probe refuses a
|
|
108
|
+
// non-Linux target the same way; localRunner refuses here instead of guessing.
|
|
109
|
+
if (platform !== 'darwin' && platform !== 'linux') {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`localRunner(): unsupported process.platform ${JSON.stringify(platform)} — HostRunner only targets darwin and linux.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const hostPlatform: HostPlatform = platform;
|
|
101
115
|
return {
|
|
102
116
|
effectiveUid: () => process.geteuid?.() ?? -1,
|
|
103
117
|
exec: (argv) => run(argv, timeoutMs),
|
|
@@ -109,6 +123,7 @@ export const localRunner = (options: LocalRunnerOptions = {}): HostRunner => {
|
|
|
109
123
|
const result = await run(userQuery(platform, nameOrId), timeoutMs);
|
|
110
124
|
return result.exitCode === 0 ? parseUser(platform, result.stdout) : undefined;
|
|
111
125
|
},
|
|
126
|
+
platform: hostPlatform,
|
|
112
127
|
privileged: false,
|
|
113
128
|
readFile: async (path) => {
|
|
114
129
|
try {
|
package/src/launchd/runner.ts
CHANGED
|
@@ -59,7 +59,29 @@ export type WriteOptions = {
|
|
|
59
59
|
|
|
60
60
|
export type HostUser = { readonly uid: number; readonly gid: number; readonly home: string };
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The two host families a HostRunner ever actually targets. ★ Not `NodeJS.Platform`: nothing
|
|
64
|
+
* here runs against `win32`/`aix`/etc, and a narrower type is what makes `runner.platform ===
|
|
65
|
+
* 'darwin'` exhaustive rather than a string comparison that silently matches nothing.
|
|
66
|
+
*/
|
|
67
|
+
export type HostPlatform = 'darwin' | 'linux';
|
|
68
|
+
|
|
62
69
|
export interface HostRunner {
|
|
70
|
+
/**
|
|
71
|
+
* ⛔ DECLARED, NEVER INFERRED — same rule as `privileged`, and the same failure mode. The OS of
|
|
72
|
+
* the TARGET this runner writes to, never of the process running Alchemy: `localRunner()` may
|
|
73
|
+
* run ON either, but `sshRunner()`/`sshSudoRunner()` always reach a Linux host from wherever
|
|
74
|
+
* Alchemy itself happens to run.
|
|
75
|
+
* 🔴 INCIDENT, homeflare-ct100 2026-09-27: `Host.Directory`'s chmod/chown end-of-options token
|
|
76
|
+
* (directory-lifecycle.ts `chmodChownEnd`) read `process.platform` — the Mac mini's own OS —
|
|
77
|
+
* instead of the runner's target. Deploying from the Mac to CT100 (a Debian LXC over
|
|
78
|
+
* `sshSudoRunner`) dropped GNU's required `--`, and `sudo-allowlist-dir.ts`'s `dirProgramProblem`
|
|
79
|
+
* refused every chown with `SudoRefusedError` even though the path was under a declared prefix.
|
|
80
|
+
* `localRunner()`'s own local deploys never showed it: there, `process.platform` and the
|
|
81
|
+
* target happen to be the same machine. This field is the fix: every call site that needs to
|
|
82
|
+
* know the target's OS reads `runner.platform`, never `process.platform`.
|
|
83
|
+
*/
|
|
84
|
+
readonly platform: HostPlatform;
|
|
63
85
|
/**
|
|
64
86
|
* ⛔ DECLARED, NEVER INFERRED. `true` only when this runner can do every root-needing call the
|
|
65
87
|
* providers make of it: every call already runs as root (a runner that talks to a root helper,
|
|
@@ -162,6 +162,9 @@ export const makeSudoRunner = (prefixList: readonly string[], deps: SudoDeps): H
|
|
|
162
162
|
},
|
|
163
163
|
lookupGroup: (nameOrId) => base.lookupGroup(nameOrId),
|
|
164
164
|
lookupUser: (nameOrId) => base.lookupUser(nameOrId),
|
|
165
|
+
// ★ Forwarded, never hardcoded: this runner elevates on the SAME target `base` already
|
|
166
|
+
// declared (localRunner's own machine), so it carries no opinion of its own.
|
|
167
|
+
platform: base.platform,
|
|
165
168
|
// ★ True, so the providers let a system-domain job through to this runner (canActAsRoot).
|
|
166
169
|
privileged: true,
|
|
167
170
|
readFile: (path) => base.readFile(path),
|
|
@@ -100,8 +100,18 @@ const octal = (mode: number) => mode.toString(8).padStart(3, '0');
|
|
|
100
100
|
* keep it. These paths are absolute, so the token is only an option guard.
|
|
101
101
|
* ⛔ Linux keeps `--`. The sudo allowlist requires that exact token; dropping it there
|
|
102
102
|
* refuses the elevation.
|
|
103
|
+
* 🔴 INCIDENT, homeflare-ct100 2026-09-27: this used to decide on `process.platform` — the OS of
|
|
104
|
+
* the machine RUNNING Alchemy — instead of `runner.platform`, the OS of the TARGET the runner
|
|
105
|
+
* actually writes to. Deploying from the Mac mini (`darwin`) to CT100, a Debian LXC reached
|
|
106
|
+
* over `sshSudoRunner`, dropped the required `--` and `sudo-allowlist-dir.ts`'s
|
|
107
|
+
* `dirProgramProblem` refused every chown with `SudoRefusedError`, even though the path was
|
|
108
|
+
* under a declared prefix — `localRunner()`'s own local (Mac-to-Mac) deploys never showed it,
|
|
109
|
+
* because there `process.platform` and the target happen to be the same machine. Reading
|
|
110
|
+
* `runner.platform` instead (declared by every HostRunner — see its own doc comment) makes the
|
|
111
|
+
* decision follow the target, not the caller.
|
|
103
112
|
*/
|
|
104
|
-
const chmodChownEnd = (): readonly string[] =>
|
|
113
|
+
const chmodChownEnd = (runner: HostRunner): readonly string[] =>
|
|
114
|
+
runner.platform === 'darwin' ? [] : ['--'];
|
|
105
115
|
|
|
106
116
|
/** One program, checked: anything but exit 0 is an Error naming the argv and the host's words. */
|
|
107
117
|
const must = async (runner: HostRunner, path: string, argv: readonly string[]): Promise<void> => {
|
|
@@ -160,7 +170,12 @@ export const reconcileDirectory = async (
|
|
|
160
170
|
}
|
|
161
171
|
await must(runner, props.path, ['mkdir', '-m', octal(want.mode), '--', props.path]);
|
|
162
172
|
} else if (stat.mode !== want.mode) {
|
|
163
|
-
await must(runner, props.path, [
|
|
173
|
+
await must(runner, props.path, [
|
|
174
|
+
'chmod',
|
|
175
|
+
octal(want.mode),
|
|
176
|
+
...chmodChownEnd(runner),
|
|
177
|
+
props.path,
|
|
178
|
+
]);
|
|
164
179
|
}
|
|
165
180
|
/**
|
|
166
181
|
* ★ Only when it is actually wrong. A `chown` that changes nothing is still a write on the host
|
|
@@ -174,7 +189,7 @@ export const reconcileDirectory = async (
|
|
|
174
189
|
? undefined
|
|
175
190
|
: ownerArg(want);
|
|
176
191
|
if (owner !== undefined)
|
|
177
|
-
await must(runner, props.path, ['chown', owner, ...chmodChownEnd(), props.path]);
|
|
192
|
+
await must(runner, props.path, ['chown', owner, ...chmodChownEnd(runner), props.path]);
|
|
178
193
|
const after = await readDirectory(runner, props.path);
|
|
179
194
|
// ⚠️ READ BACK: a runner that ignored the mode shows up here, not as a forever-`update`.
|
|
180
195
|
if (after === undefined || after.mode !== want.mode || !ownerMatches(after, want)) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host.Directory driven through sshSudoRunner — the exact path the homeflare-ct100 incident
|
|
3
|
+
* (2026-09-27) broke: a deploy running on a Mac (darwin) targeting a Linux host over ssh dropped
|
|
4
|
+
* the sudo allowlist's required `--`, because directory-lifecycle.ts's `chmodChownEnd()` read
|
|
5
|
+
* `process.platform` — the Mac's own OS — instead of the runner's own declared target.
|
|
6
|
+
*
|
|
7
|
+
* ★ WHY A SEPARATE FILE FROM directory.test.ts. Those tests exercise the lifecycle directly
|
|
8
|
+
* against a bare fake host; these go through the REAL `sshSudoRunner` + `sudo-allowlist.ts`
|
|
9
|
+
* layer (`canonicalize`, `dirProgramProblem`), which is what actually enforces the `--` token
|
|
10
|
+
* and is what refused the live deploy with `SudoRefusedError`.
|
|
11
|
+
* ⛔ NEITHER TEST BELOW READS `process.platform`. `fakeLinuxHost()` (via `fakeSudoHost()`)
|
|
12
|
+
* declares `platform: 'linux'` itself — sshRunner's own probe requires it — so this suite
|
|
13
|
+
* catches the regression on every machine that runs `bun test`, regardless of that machine's
|
|
14
|
+
* own OS. (This repo's own `check` job runs on `ubuntu-latest`, not the mini, so CI alone never
|
|
15
|
+
* reproduces the "darwin process, Linux target" split the live incident measured; this suite's
|
|
16
|
+
* fixed, declared target is what actually proves the fix, on any OS.)
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, test } from 'bun:test';
|
|
19
|
+
import { reconcileDirectory } from './directory-lifecycle.ts';
|
|
20
|
+
import { fakeSudoHost } from './fake-sudo.ts';
|
|
21
|
+
|
|
22
|
+
describe('Host.Directory through sshSudoRunner (a Linux target)', () => {
|
|
23
|
+
test('the elevated chown and chmod carry -- and the allowlist accepts them', async () => {
|
|
24
|
+
const { privileged, runner } = fakeSudoHost();
|
|
25
|
+
expect(runner.platform).toBe('linux'); // ★ declared by sshRunner's own uname probe.
|
|
26
|
+
|
|
27
|
+
// A fresh create under a declared prefix: mkdir runs as root, then chown (owner/group
|
|
28
|
+
// declared) also runs as root — both routed through routeExec()/elevatedExec().
|
|
29
|
+
const created = await reconcileDirectory(runner, {
|
|
30
|
+
group: 0,
|
|
31
|
+
mode: 0o755,
|
|
32
|
+
owner: 0,
|
|
33
|
+
path: '/etc/systemd/system/hf-test-dir',
|
|
34
|
+
});
|
|
35
|
+
expect(created).toEqual({
|
|
36
|
+
gid: 0,
|
|
37
|
+
mode: 0o755,
|
|
38
|
+
path: '/etc/systemd/system/hf-test-dir',
|
|
39
|
+
uid: 0,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// A mode drift on the next reconcile routes an elevated chmod too.
|
|
43
|
+
await reconcileDirectory(
|
|
44
|
+
runner,
|
|
45
|
+
{ group: 0, mode: 0o750, owner: 0, path: '/etc/systemd/system/hf-test-dir' },
|
|
46
|
+
created,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const chown = privileged().find((call) => call[0] === '/usr/bin/chown');
|
|
50
|
+
const chmod = privileged().find((call) => call[0] === '/usr/bin/chmod');
|
|
51
|
+
// ⛔ REGRESSION: before the fix, these two calls carried no `--` whenever `bun test` ran on a
|
|
52
|
+
// Mac, and `sudo-allowlist-dir.ts`'s `dirProgramProblem` refused them with
|
|
53
|
+
// `SudoRefusedError` — the exact failure measured against homeflare-ct100 2026-09-27.
|
|
54
|
+
expect(chown).toBeDefined();
|
|
55
|
+
expect(chown).toContain('--');
|
|
56
|
+
expect(chmod).toBeDefined();
|
|
57
|
+
expect(chmod).toContain('--');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* to create a parent.
|
|
4
4
|
*/
|
|
5
5
|
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import type { HostRunner } from '../launchd/runner.ts';
|
|
6
7
|
import {
|
|
7
8
|
deleteDirectory,
|
|
8
9
|
diffDirectory,
|
|
@@ -105,8 +106,16 @@ describe('drift', () => {
|
|
|
105
106
|
});
|
|
106
107
|
|
|
107
108
|
describe('argv', () => {
|
|
108
|
-
|
|
109
|
+
// ⛔ REGRESSION, 2026-09-27: this used to read `process.platform !== 'darwin'` to decide what
|
|
110
|
+
// the fake (a LINUX host) should have produced — so it only ever proved the real behaviour
|
|
111
|
+
// when `bun test` itself happened to run on Linux (matching the fake's target by
|
|
112
|
+
// coincidence), and passed just as happily on a Mac while asserting the bug itself (no `--`
|
|
113
|
+
// for a Linux target). `runner.platform` is declared by the fixture instead, so these two
|
|
114
|
+
// tests hold regardless of what OS actually runs the suite — the exact "darwin process,
|
|
115
|
+
// Linux target" split the homeflare-ct100 incident measured.
|
|
116
|
+
test('mkdir keeps --, and chmod/chown carry -- for a Linux target', async () => {
|
|
109
117
|
const fake = host();
|
|
118
|
+
expect(fake.runner.platform).toBe('linux'); // ★ fakeLinuxHost's own declaration.
|
|
110
119
|
const created = await reconcileDirectory(fake.runner, {
|
|
111
120
|
group: 0,
|
|
112
121
|
mode: 0o755,
|
|
@@ -118,13 +127,30 @@ describe('argv', () => {
|
|
|
118
127
|
{ group: 0, mode: 0o750, owner: 0, path: '/opt/app/bin' },
|
|
119
128
|
created,
|
|
120
129
|
);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
130
|
+
expect(fake.calls.find((call) => call[0] === 'mkdir')).toContain('--');
|
|
131
|
+
expect(fake.calls.find((call) => call[0] === 'chown')).toContain('--');
|
|
132
|
+
expect(fake.calls.find((call) => call[0] === 'chmod')).toContain('--');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('mkdir still keeps --, but chmod/chown omit it for a runner declared as Darwin', async () => {
|
|
136
|
+
const fake = host();
|
|
137
|
+
// ★ Same fake host, only the declared target changes — proves chmodChownEnd() reads
|
|
138
|
+
// `runner.platform`, never the machine `bun test` itself happens to run on.
|
|
139
|
+
const darwinRunner: HostRunner = { ...fake.runner, platform: 'darwin' };
|
|
140
|
+
const created = await reconcileDirectory(darwinRunner, {
|
|
141
|
+
group: 0,
|
|
142
|
+
mode: 0o755,
|
|
143
|
+
owner: 0,
|
|
144
|
+
path: '/opt/app/bin',
|
|
145
|
+
});
|
|
146
|
+
await reconcileDirectory(
|
|
147
|
+
darwinRunner,
|
|
148
|
+
{ group: 0, mode: 0o750, owner: 0, path: '/opt/app/bin' },
|
|
149
|
+
created,
|
|
150
|
+
);
|
|
151
|
+
expect(fake.calls.find((call) => call[0] === 'mkdir')).toContain('--');
|
|
152
|
+
expect(fake.calls.find((call) => call[0] === 'chown')?.includes('--')).toBe(false);
|
|
153
|
+
expect(fake.calls.find((call) => call[0] === 'chmod')?.includes('--')).toBe(false);
|
|
128
154
|
});
|
|
129
155
|
});
|
|
130
156
|
|
|
@@ -127,6 +127,8 @@ export const fakeLinuxHost = (options: FakeLinuxOptions = {}) => {
|
|
|
127
127
|
lookupUser: async (nameOrId) =>
|
|
128
128
|
options.users?.[nameOrId] ??
|
|
129
129
|
Object.values(options.users ?? {}).find((user) => String(user.uid) === nameOrId),
|
|
130
|
+
// ★ This fake models sshRunner()'s own target, which is always Linux — fixed, not an option.
|
|
131
|
+
platform: 'linux',
|
|
130
132
|
privileged,
|
|
131
133
|
readFile: async (path) => files.get(path)?.bytes,
|
|
132
134
|
removeFile: async (path) => {
|
package/src/linux/index.ts
CHANGED
package/src/linux/ssh-runner.ts
CHANGED
|
@@ -154,6 +154,10 @@ export const sshRunner = async (options: SshRunnerOptions): Promise<HostRunner>
|
|
|
154
154
|
const result = await run(execScript(userQuery('linux', nameOrId)));
|
|
155
155
|
return result.exitCode === 0 ? parseUser('linux', result.stdout) : undefined;
|
|
156
156
|
},
|
|
157
|
+
// ★ Fixed, not read from the caller's own `process.platform`: the probe above already
|
|
158
|
+
// refused anything but `uname -s` = Linux, so this is the one true answer for this runner —
|
|
159
|
+
// whatever OS is running Alchemy itself is irrelevant (HostRunner.platform's doc comment).
|
|
160
|
+
platform: 'linux',
|
|
157
161
|
privileged: false,
|
|
158
162
|
readFile: async (path) => {
|
|
159
163
|
const result = await run(readScript(path));
|
package/src/linux/sudo-runner.ts
CHANGED
|
@@ -99,6 +99,9 @@ export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDe
|
|
|
99
99
|
},
|
|
100
100
|
lookupGroup: (nameOrId) => base.lookupGroup(nameOrId),
|
|
101
101
|
lookupUser: (nameOrId) => base.lookupUser(nameOrId),
|
|
102
|
+
// ★ Forwarded, never hardcoded — same reasoning as ../launchd/sudo-runner.ts's own forward:
|
|
103
|
+
// this runner elevates on the SAME target `base` (sshRunner) already declared.
|
|
104
|
+
platform: base.platform,
|
|
102
105
|
privileged: true,
|
|
103
106
|
readFile: (path) => base.readFile(path),
|
|
104
107
|
removeFile: async (path) => {
|
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `alias-form.ts`'s pure functions against literal `
|
|
3
|
-
*
|
|
2
|
+
* `alias-form.ts`'s pure functions against literal `ModelAliasReadItem` fixtures (the WHOLE-MODEL
|
|
3
|
+
* `get()`'s read-shaped item, option maps and all — OPNSENSE-2) — no network, no fake server.
|
|
4
|
+
* Mirrors `../discord/command-form.ts`'s own test file in spirit.
|
|
4
5
|
*/
|
|
5
6
|
import { describe, expect, test } from 'bun:test';
|
|
6
7
|
import type * as alias from '@distilled.cloud/opnsense/firewall_alias';
|
|
7
8
|
import { attributesOf, matches } from './alias-form.ts';
|
|
8
9
|
import type { AliasProps } from './alias.ts';
|
|
10
|
+
import { optionMap } from './wire.ts';
|
|
9
11
|
|
|
10
12
|
const UUID = '11111111-1111-1111-1111-111111111111';
|
|
11
13
|
|
|
12
|
-
const liveHost = (overrides: Partial<alias.
|
|
13
|
-
content: '10.20.10.1',
|
|
14
|
+
const liveHost = (overrides: Partial<alias.ModelAliasReadItem> = {}): alias.ModelAliasReadItem => ({
|
|
15
|
+
content: optionMap('10.20.10.1'),
|
|
14
16
|
enabled: '1',
|
|
15
17
|
name: 'homeflare_edge',
|
|
16
|
-
type: 'host',
|
|
18
|
+
type: optionMap('host'),
|
|
17
19
|
...overrides,
|
|
18
20
|
});
|
|
19
21
|
|
|
@@ -38,10 +40,20 @@ describe('alias-form attributesOf', () => {
|
|
|
38
40
|
expect(attributesOf(UUID, liveHost({ enabled: '0' })).enabled).toBe(false);
|
|
39
41
|
});
|
|
40
42
|
|
|
41
|
-
test('categories are
|
|
42
|
-
const live = liveHost({ categories: 'c-two,c-one
|
|
43
|
+
test('categories are the selected keys, sorted and deduplicated', () => {
|
|
44
|
+
const live = liveHost({ categories: optionMap('c-two', 'c-one') });
|
|
43
45
|
expect(attributesOf(UUID, live).categories).toEqual(['c-one', 'c-two']);
|
|
44
46
|
});
|
|
47
|
+
|
|
48
|
+
test('an unselected option-map entry is not returned (single-select `type`)', () => {
|
|
49
|
+
const live = liveHost({
|
|
50
|
+
type: {
|
|
51
|
+
host: { value: 'Host(s)', selected: 1 },
|
|
52
|
+
network: { value: 'Network(s)', selected: 0 },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
expect(attributesOf(UUID, live).type).toBe('host');
|
|
56
|
+
});
|
|
45
57
|
});
|
|
46
58
|
|
|
47
59
|
describe('alias-form matches', () => {
|
|
@@ -57,12 +69,12 @@ describe('alias-form matches', () => {
|
|
|
57
69
|
});
|
|
58
70
|
|
|
59
71
|
test('content drift is caught', () => {
|
|
60
|
-
const drifted = attributesOf(UUID, liveHost({ content: '10.20.10.2' }));
|
|
72
|
+
const drifted = attributesOf(UUID, liveHost({ content: optionMap('10.20.10.2') }));
|
|
61
73
|
expect(matches(drifted, props)).toBe(false);
|
|
62
74
|
});
|
|
63
75
|
|
|
64
76
|
test('category order alone is not drift — both sides normalise through the same set', () => {
|
|
65
|
-
const live = attributesOf(UUID, liveHost({ categories: 'b,a' }));
|
|
77
|
+
const live = attributesOf(UUID, liveHost({ categories: optionMap('b', 'a') }));
|
|
66
78
|
const declared: AliasProps = { ...props, categories: ['a', 'b'] };
|
|
67
79
|
expect(matches(live, declared)).toBe(true);
|
|
68
80
|
});
|
|
@@ -79,9 +91,9 @@ describe('propsFromLive round-trip — the declaration renderer whole contract',
|
|
|
79
91
|
// alias.ts re-exports `attributesOf` itself as `propsFromLive`; tested directly here since
|
|
80
92
|
// alias.ts also wires up the Resource machinery this file does not need to import.
|
|
81
93
|
const live = liveHost({
|
|
82
|
-
categories: 'c-two,c-one',
|
|
83
|
-
content: '203.0.113.0/24',
|
|
84
|
-
type: 'network',
|
|
94
|
+
categories: optionMap('c-two', 'c-one'),
|
|
95
|
+
content: optionMap('203.0.113.0/24'),
|
|
96
|
+
type: optionMap('network'),
|
|
85
97
|
});
|
|
86
98
|
const rendered = attributesOf(UUID, live);
|
|
87
99
|
expect(matches(attributesOf(UUID, live), rendered)).toBe(true);
|
|
@@ -18,24 +18,37 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import type { AliasAttributes, AliasProps } from './alias.ts';
|
|
20
20
|
import type * as alias from '@distilled.cloud/opnsense/firewall_alias';
|
|
21
|
-
import { bool01,
|
|
21
|
+
import { bool01, csvSetOf, numField, selectedOf, selectedOneOf } from './wire.ts';
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* ★ ATTRIBUTES AND THE DECLARATION RENDERER ARE ONE FUNCTION. Attributes ARE exactly the props a
|
|
25
25
|
* noop declaration needs — see the exported `propsFromLive` alias in alias.ts's barrel and
|
|
26
26
|
* `alias-form.test.ts`'s round-trip test (`matches(attributesOf(uuid, live), attributesOf(uuid,
|
|
27
27
|
* live))` is always true by construction, and so is the general case).
|
|
28
|
+
*
|
|
29
|
+
* `live` is `ModelAliasReadItem` (the WHOLE-MODEL `get()`'s read-shaped item), not `AliasItem` —
|
|
30
|
+
* `type`/`interface`/`proto`/`categories` decode as option maps, not strings (OPNSENSE-2; see
|
|
31
|
+
* wire.ts's module doc). `selectedOf`/`selectedOneOf` extract the selected key(s) back to the
|
|
32
|
+
* same plain-string/string[] shape `AliasAttributes` already declared.
|
|
33
|
+
*
|
|
34
|
+
* `content` is `.\AliasContentField`, which overrides `getNodeData()` independently of
|
|
35
|
+
* `BaseListField` (`Firewall/FieldTypes/AliasContentField.php:69-76`) — it still decodes as a map,
|
|
36
|
+
* but every entry is unconditionally `selected: 1` (it is a raw newline-separated list, not a
|
|
37
|
+
* real option set), and ITS wire separator is `\n`, not `,`. `Object.keys` preserves the
|
|
38
|
+
* insertion order OPNsense's own `explode()` built the map in, which matters for a host/URL list
|
|
39
|
+
* in a way `selectedOf`'s alphabetical sort does not preserve — so this one field is reconstructed
|
|
40
|
+
* directly rather than through `selectedOf`.
|
|
28
41
|
*/
|
|
29
|
-
export const attributesOf = (uuid: string, live: alias.
|
|
30
|
-
categories:
|
|
31
|
-
content: live.content ?? '',
|
|
42
|
+
export const attributesOf = (uuid: string, live: alias.ModelAliasReadItem): AliasAttributes => ({
|
|
43
|
+
categories: selectedOf(live.categories),
|
|
44
|
+
content: Object.keys(live.content ?? {}).join('\n'),
|
|
32
45
|
counters: bool01(live.counters, false),
|
|
33
46
|
description: live.description ?? '',
|
|
34
47
|
enabled: bool01(live.enabled, true),
|
|
35
|
-
interface: live.interface
|
|
48
|
+
interface: selectedOneOf(live.interface),
|
|
36
49
|
name: live.name,
|
|
37
|
-
proto: live.proto
|
|
38
|
-
type: live.type,
|
|
50
|
+
proto: selectedOf(live.proto).join(','),
|
|
51
|
+
type: selectedOneOf(live.type),
|
|
39
52
|
updatefreq: numField(live.updatefreq, 0),
|
|
40
53
|
uuid,
|
|
41
54
|
});
|