@homeflare/alchemy 0.35.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/argocd/cluster.d.ts +102 -0
- package/dist/argocd/cluster.d.ts.map +1 -0
- package/dist/argocd/credentials.d.ts +37 -0
- package/dist/argocd/credentials.d.ts.map +1 -0
- package/dist/argocd/fake-argocd.d.ts +42 -0
- package/dist/argocd/fake-argocd.d.ts.map +1 -0
- package/dist/argocd/git-credentials.d.ts +29 -0
- package/dist/argocd/git-credentials.d.ts.map +1 -0
- package/dist/argocd/index.d.ts +19 -0
- package/dist/argocd/index.d.ts.map +1 -0
- package/dist/argocd/index.js +306 -0
- package/dist/argocd/index.js.map +17 -0
- package/dist/argocd/providers.d.ts +13 -0
- package/dist/argocd/providers.d.ts.map +1 -0
- package/dist/argocd/repo-creds.d.ts +67 -0
- package/dist/argocd/repo-creds.d.ts.map +1 -0
- package/dist/argocd/repository.d.ts +87 -0
- package/dist/argocd/repository.d.ts.map +1 -0
- package/dist/argocd/resource.d.ts +90 -0
- package/dist/argocd/resource.d.ts.map +1 -0
- package/dist/cloudflare/index.js +4 -4
- package/dist/cloudflare/index.js.map +3 -3
- package/dist/cloudflare/r2-bucket-lock.d.ts +14 -5
- package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
- package/dist/grafana/index.d.ts +5 -4
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +204 -90
- package/dist/grafana/index.js.map +7 -4
- package/dist/grafana/notification-policy-drop-warning.d.ts +27 -0
- package/dist/grafana/notification-policy-drop-warning.d.ts.map +1 -0
- package/dist/grafana/notification-policy-fixtures.d.ts +34 -0
- package/dist/grafana/notification-policy-fixtures.d.ts.map +1 -0
- package/dist/grafana/notification-policy-model.d.ts +53 -0
- package/dist/grafana/notification-policy-model.d.ts.map +1 -0
- package/dist/grafana/notification-policy.d.ts +57 -0
- package/dist/grafana/notification-policy.d.ts.map +1 -0
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/litellm/index.js.map +2 -2
- package/dist/litellm/operations.d.ts.map +1 -1
- package/dist/proxmox/ceph-daemon-form.d.ts +4 -5
- package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -1
- package/dist/proxmox/ceph-daemon-wire.d.ts +33 -0
- package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-daemon.d.ts +35 -0
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-distilled.d.ts +14 -0
- package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -0
- package/dist/proxmox/ceph-fs-wire.d.ts +20 -11
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs.d.ts +31 -6
- package/dist/proxmox/ceph-fs.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts +12 -3
- package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-write.d.ts +17 -2
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd.d.ts +6 -0
- package/dist/proxmox/ceph-osd.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts +10 -4
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-wire.d.ts +46 -0
- package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool.d.ts +10 -5
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/group-wire.d.ts +1 -1
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +611 -477
- package/dist/proxmox/index.js.map +15 -12
- package/dist/proxmox/storage-wire.d.ts +8 -1
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/user-wire.d.ts +1 -1
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/docs/argocd-kubernetes.md +100 -0
- package/docs/argocd.md +139 -0
- package/docs/credentials.md +9 -0
- package/docs/grafana-alerting.md +3 -2
- package/docs/grafana-notification-policy.md +127 -0
- package/docs/grafana.md +12 -8
- package/docs/litellm.md +17 -0
- package/docs/peers.md +7 -0
- package/docs/unifi.md +45 -0
- package/docs/upstream-conformance.md +19 -5
- package/package.json +8 -3
- package/src/argocd/cluster.test.ts +120 -0
- package/src/argocd/cluster.ts +222 -0
- package/src/argocd/credentials.ts +38 -0
- package/src/argocd/fake-argocd.ts +60 -0
- package/src/argocd/git-credentials.ts +71 -0
- package/src/argocd/index.ts +40 -0
- package/src/argocd/providers.ts +17 -0
- package/src/argocd/repo-creds.test.ts +123 -0
- package/src/argocd/repo-creds.ts +115 -0
- package/src/argocd/repository.test.ts +167 -0
- package/src/argocd/repository.ts +165 -0
- package/src/argocd/resource.ts +150 -0
- package/src/cloudflare/r2-bucket-lock.test.ts +54 -18
- package/src/cloudflare/r2-bucket-lock.ts +25 -18
- package/src/grafana/index.ts +11 -4
- package/src/grafana/notification-policy-drop-warning.test.ts +163 -0
- package/src/grafana/notification-policy-drop-warning.ts +60 -0
- package/src/grafana/notification-policy-fixtures.ts +27 -0
- package/src/grafana/notification-policy-model.test.ts +104 -0
- package/src/grafana/notification-policy-model.ts +100 -0
- package/src/grafana/notification-policy-refusals.test.ts +119 -0
- package/src/grafana/notification-policy.test.ts +86 -0
- package/src/grafana/notification-policy.ts +191 -0
- package/src/grafana/providers.ts +2 -0
- package/src/litellm/operations.ts +8 -4
- package/src/proxmox/ceph-daemon-form.ts +7 -8
- package/src/proxmox/ceph-daemon-wire.ts +115 -0
- package/src/proxmox/ceph-daemon-write.test.ts +158 -0
- package/src/proxmox/ceph-daemon.ts +65 -47
- package/src/proxmox/ceph-fs-delete.test.ts +81 -0
- package/src/proxmox/ceph-fs-distilled.ts +102 -0
- package/src/proxmox/ceph-fs-wire.ts +17 -31
- package/src/proxmox/ceph-fs-write.test.ts +67 -0
- package/src/proxmox/ceph-fs.ts +66 -69
- package/src/proxmox/ceph-osd-tree.ts +14 -5
- package/src/proxmox/ceph-osd-write.test.ts +61 -0
- package/src/proxmox/ceph-osd-write.ts +33 -6
- package/src/proxmox/ceph-osd.ts +6 -0
- package/src/proxmox/ceph-pool-settle.ts +13 -7
- package/src/proxmox/ceph-pool-wire.ts +110 -0
- package/src/proxmox/ceph-pool-write.test.ts +145 -0
- package/src/proxmox/ceph-pool.ts +87 -98
- package/src/proxmox/distilled-task-await.test.ts +0 -124
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.Storage`'s READ side: how a live item becomes attributes, the read itself, and
|
|
3
|
+
* `matches`. Split out of storage.ts (2026-09-24, the distilled migration); the write side is
|
|
4
|
+
* storage-form.ts (that file's own header has the reasoning) — both stay under the 250-line cap,
|
|
5
|
+
* the api-token.ts/api-token-form.ts seam.
|
|
6
|
+
*/
|
|
7
|
+
import * as storage from '@distilled.cloud/proxmox/storage';
|
|
1
8
|
import * as Effect from 'effect/Effect';
|
|
2
9
|
import type { StorageProps } from './storage.ts';
|
|
3
10
|
import { UNREADABLE, type Unreadable } from './unreadable-read.ts';
|
|
@@ -33,7 +40,7 @@ export declare const same: <T>(declared: T | undefined, live: T) => boolean;
|
|
|
33
40
|
* `readUser`/`readGroup` do, so a brand-new declaration can still be created; `readStorageOrFail`
|
|
34
41
|
* (used by `diff`) does not.
|
|
35
42
|
*/
|
|
36
|
-
export declare const readStorageOrFail: (props: StorageProps) => Effect.Effect<typeof UNREADABLE | StorageAttributes, import("@distilled.cloud/caddy").BadGateway | import("@distilled.cloud/caddy").BadRequest | import("@distilled.cloud/caddy").ConfigError | Error | import("@distilled.cloud/caddy").GatewayTimeout | import("effect/unstable/http/HttpClientError").HttpClientError | import("@distilled.cloud/caddy").InternalServerError | import("@distilled.cloud/proxmox/Errors").ParameterVerificationFailed | import("@distilled.cloud/proxmox/Errors").ProxmoxParseError | import("./distilled-pve.ts").PveClusterExhausted | import("@distilled.cloud/caddy").ServiceUnavailable | import("effect/Cause").TimeoutError | import("@distilled.cloud/caddy").TooManyRequests | import("@distilled.cloud/caddy").Unauthorized | import("@distilled.cloud/proxmox/Errors").UnknownProxmoxError, import("effect/unstable/http/HttpClient").HttpClient>;
|
|
43
|
+
export declare const readStorageOrFail: (props: StorageProps) => Effect.Effect<typeof UNREADABLE | StorageAttributes, import("@distilled.cloud/caddy").BadGateway | import("@distilled.cloud/caddy").BadRequest | import("@distilled.cloud/caddy").ConfigError | Error | import("@distilled.cloud/caddy").GatewayTimeout | import("effect/unstable/http/HttpClientError").HttpClientError | import("@distilled.cloud/caddy").InternalServerError | import("@distilled.cloud/proxmox/Errors").ParameterVerificationFailed | import("@distilled.cloud/proxmox/Errors").ProxmoxParseError | import("./distilled-pve.ts").PveClusterExhausted | import("@distilled.cloud/caddy").ServiceUnavailable | storage.StorageNotFound | import("effect/Cause").TimeoutError | import("@distilled.cloud/caddy").TooManyRequests | import("@distilled.cloud/caddy").Unauthorized | import("@distilled.cloud/proxmox/Errors").UnknownProxmoxError, import("effect/unstable/http/HttpClient").HttpClient>;
|
|
37
44
|
/**
|
|
38
45
|
* ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT —
|
|
39
46
|
* user.ts's `readUser` has the full reasoning. Safe here because a wrongful fold costs at most a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"storage-wire.d.ts","sourceRoot":"","sources":["../../src/proxmox/storage-wire.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"storage-wire.d.ts","sourceRoot":"","sources":["../../src/proxmox/storage-wire.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,OAAO,MAAM,kCAAkC,CAAC;AAC5D,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAoB,MAAM,sBAAsB,CAAC;AAGrF,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,6FAA6F;IAC7F,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;CACjB;AAmBD,eAAO,MAAM,QAAQ,aAAc,MAAM,GAAG,SAAS,QAAQ,MAAM,YACN,CAAC;AAE9D,eAAO,MAAM,IAAI,GAAI,CAAC,YAAY,CAAC,GAAG,SAAS,QAAQ,CAAC,YACX,CAAC;AAwB9C;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,iBAAiB,UAAW,YAAY,63BAGyC,CAAC;AAE/F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,UAAW,YAAY,kIACuB,CAAC;AAEvE,gGAAgG;AAChG,eAAO,MAAM,cAAc,SAAU,iBAAiB,GAAG,UAAU,GAAG,SAAS,kCACvC,CAAC;AAEzC;;;GAGG;AACH,eAAO,MAAM,OAAO,eAAgB,iBAAiB,SAAS,YAAY,YAOjC,CAAC"}
|
|
@@ -29,7 +29,7 @@ export declare const matches: (attributes: UserAttributes, props: UserProps) =>
|
|
|
29
29
|
* `PveClusterExhausted`, then silently folded to "absent" by the old `orElseSucceed`, forcing a
|
|
30
30
|
* false `update` on every account.
|
|
31
31
|
*/
|
|
32
|
-
export declare const readUserOrFail: (props: UserProps) => Effect.Effect<typeof UNREADABLE | UserAttributes, import("@distilled.cloud/caddy").BadGateway | import("@distilled.cloud/caddy").BadRequest | import("@distilled.cloud/caddy").ConfigError | Error | import("@distilled.cloud/caddy").GatewayTimeout | import("effect/unstable/http/HttpClientError").HttpClientError | import("@distilled.cloud/caddy").InternalServerError | import("@distilled.cloud/proxmox/Errors").ParameterVerificationFailed | import("@distilled.cloud/proxmox/Errors").ProxmoxParseError | import("./distilled-pve.ts").PveClusterExhausted | import("@distilled.cloud/caddy").ServiceUnavailable | import("effect/Cause").TimeoutError | import("@distilled.cloud/caddy").TooManyRequests | import("@distilled.cloud/caddy").Unauthorized | import("@distilled.cloud/proxmox/Errors").UnknownProxmoxError, import("effect/unstable/http/HttpClient").HttpClient>;
|
|
32
|
+
export declare const readUserOrFail: (props: UserProps) => Effect.Effect<typeof UNREADABLE | UserAttributes, import("@distilled.cloud/caddy").BadGateway | import("@distilled.cloud/caddy").BadRequest | import("@distilled.cloud/caddy").ConfigError | Error | import("@distilled.cloud/caddy").GatewayTimeout | import("effect/unstable/http/HttpClientError").HttpClientError | import("@distilled.cloud/caddy").InternalServerError | import("@distilled.cloud/proxmox/Errors").ParameterVerificationFailed | import("@distilled.cloud/proxmox/Errors").ProxmoxParseError | import("./distilled-pve.ts").PveClusterExhausted | import("@distilled.cloud/caddy").ServiceUnavailable | import("effect/Cause").TimeoutError | import("@distilled.cloud/caddy").TooManyRequests | import("@distilled.cloud/caddy").Unauthorized | import("@distilled.cloud/proxmox/Errors").UnknownProxmoxError | access.UserNotFound, import("effect/unstable/http/HttpClient").HttpClient>;
|
|
33
33
|
/**
|
|
34
34
|
* ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT. A
|
|
35
35
|
* missing user is a thrown 500 (user.ts's own header), so `reconcile`'s create branch NEEDS
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user-wire.d.ts","sourceRoot":"","sources":["../../src/proxmox/user-wire.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,MAAM,MAAM,iCAAiC,CAAC;AAC1D,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3D,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAoB,MAAM,sBAAsB,CAAC;AAGrF,eAAO,MAAM,WAAW,2BAA2B,CAAC;AACpD,eAAO,MAAM,WAAW,mCAAmC,CAAC;AA2D5D,eAAO,MAAM,UAAU,UAAW,SAAS,KAAG,MAAM,CAAC,uBAGnD,CAAC;AAEH,eAAO,MAAM,UAAU,UAAW,SAAS,KAAG,MAAM,CAAC,oBAGnD,CAAC;AAoBH;;;GAGG;AACH,eAAO,MAAM,OAAO,eAAgB,cAAc,SAAS,SAAS,YAOF,CAAC;AAEnE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,cAAc,UAAW,SAAS,
|
|
1
|
+
{"version":3,"file":"user-wire.d.ts","sourceRoot":"","sources":["../../src/proxmox/user-wire.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,MAAM,MAAM,iCAAiC,CAAC;AAC1D,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3D,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAoB,MAAM,sBAAsB,CAAC;AAGrF,eAAO,MAAM,WAAW,2BAA2B,CAAC;AACpD,eAAO,MAAM,WAAW,mCAAmC,CAAC;AA2D5D,eAAO,MAAM,UAAU,UAAW,SAAS,KAAG,MAAM,CAAC,uBAGnD,CAAC;AAEH,eAAO,MAAM,UAAU,UAAW,SAAS,KAAG,MAAM,CAAC,oBAGnD,CAAC;AAoBH;;;GAGG;AACH,eAAO,MAAM,OAAO,eAAgB,cAAc,SAAS,SAAS,YAOF,CAAC;AAEnE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,cAAc,UAAW,SAAS,s3BAG+C,CAAC;AAE/F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,QAAQ,UAAW,SAAS,+HAC0B,CAAC;AAEpE,gGAAgG;AAChG,eAAO,MAAM,cAAc,SAAU,cAAc,GAAG,UAAU,GAAG,SAAS,+BACpC,CAAC"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Argo CD `Application`/`AppProject` — via upstream's `Kubernetes` family, no new code
|
|
2
|
+
|
|
3
|
+
Companion to [argocd.md](./argocd.md#the-rest-vs-crd-split-the-actual-design-decision). `Application`
|
|
4
|
+
and `AppProject` are Kubernetes CRDs (`argoproj.io/v1alpha1`), not Argo CD REST-only objects — the
|
|
5
|
+
upstream-correct owner is Alchemy's own `Kubernetes.Manifest`
|
|
6
|
+
(`alchemy/Kubernetes`, v2.0.0-beta.79), which this kit does not wrap, re-implement, or need to:
|
|
7
|
+
decision 49 ("Kubernetes uses UPSTREAM Alchemy's existing Kubernetes family... don't build a
|
|
8
|
+
second one"). This page is documentation only — **no code in this kit declares an `Application` or
|
|
9
|
+
`AppProject`**; it shows how a stack does, once a cluster exists.
|
|
10
|
+
|
|
11
|
+
## Why not an `Argocd.Application` REST resource?
|
|
12
|
+
|
|
13
|
+
Argo CD's `ApplicationService` REST API is real and full-featured (`Create`/`Get`/`Update`/
|
|
14
|
+
`Patch`/`Delete`/`List`/`Sync`/`Rollback`/…), but underneath it the object it manages **is** the
|
|
15
|
+
`Application` CRD — the same object `kubectl get applications.argoproj.io -n argocd` lists, with
|
|
16
|
+
its own versioned OpenAPI schema, validated by the Kubernetes API server itself, not by Argo CD's
|
|
17
|
+
application logic. A second, house-maintained REST wrapper around that would duplicate exactly the
|
|
18
|
+
generic capability `Kubernetes.Manifest` already provides — apply any object, CRDs included, via
|
|
19
|
+
server-side apply — for no benefit over declaring the CRD directly. Argo CD's own docs recommend
|
|
20
|
+
exactly this: `kubectl`/GitOps management of `Application`/`AppProject` manifests is the primary
|
|
21
|
+
documented workflow, not a detour around the REST API.
|
|
22
|
+
|
|
23
|
+
## Declaring an `AppProject`
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import * as Kubernetes from 'alchemy/Kubernetes';
|
|
27
|
+
import * as Effect from 'effect/Effect';
|
|
28
|
+
|
|
29
|
+
const project = Effect.gen(function* () {
|
|
30
|
+
return yield* Kubernetes.Manifest('HomeflareProject', {
|
|
31
|
+
cluster, // a Kubernetes.KubeConfig(...) or a managed cluster resource
|
|
32
|
+
manifest: {
|
|
33
|
+
apiVersion: 'argoproj.io/v1alpha1',
|
|
34
|
+
kind: 'AppProject',
|
|
35
|
+
metadata: { name: 'homeflare', namespace: 'argocd' },
|
|
36
|
+
spec: {
|
|
37
|
+
description: 'HomeFlare estate applications',
|
|
38
|
+
sourceRepos: ['https://forgejo.homeflare.dev/homeflare/*'],
|
|
39
|
+
destinations: [{ namespace: '*', server: 'https://kubernetes.default.svc' }],
|
|
40
|
+
clusterResourceWhitelist: [{ group: '*', kind: '*' }],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Declaring an `Application`
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const app = Effect.gen(function* () {
|
|
51
|
+
return yield* Kubernetes.Manifest('HomeflareKitApp', {
|
|
52
|
+
cluster,
|
|
53
|
+
manifest: {
|
|
54
|
+
apiVersion: 'argoproj.io/v1alpha1',
|
|
55
|
+
kind: 'Application',
|
|
56
|
+
metadata: { name: 'homeflare-kit', namespace: 'argocd' },
|
|
57
|
+
spec: {
|
|
58
|
+
project: 'homeflare',
|
|
59
|
+
source: {
|
|
60
|
+
repoURL: 'https://forgejo.homeflare.dev/homeflare/kit.git',
|
|
61
|
+
targetRevision: 'main',
|
|
62
|
+
path: 'deploy/manifests',
|
|
63
|
+
},
|
|
64
|
+
destination: { server: 'https://kubernetes.default.svc', namespace: 'homeflare-kit' },
|
|
65
|
+
syncPolicy: { automated: { prune: true, selfHeal: true } },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`Kubernetes.Manifest` resolves unknown kinds through the cluster's own API discovery endpoint
|
|
73
|
+
(`Manifest.ts:31-32,89-93`), so `Application`/`AppProject` need no house registration — the CRD
|
|
74
|
+
just has to already exist on the cluster (Argo CD's own install manifest creates it).
|
|
75
|
+
|
|
76
|
+
## Ordering: the CRD before the object
|
|
77
|
+
|
|
78
|
+
`Application`/`AppProject` are CRD-backed, so the `argoproj.io` `CustomResourceDefinition`s must
|
|
79
|
+
exist before a `Kubernetes.Manifest` for either can apply — normally true the moment Argo CD
|
|
80
|
+
itself is installed (its own install manifest, or `Kubernetes.HelmChart` installing the
|
|
81
|
+
`argo-cd` chart, both ship the CRDs). A stack that installs Argo CD and declares Applications in
|
|
82
|
+
the same deploy should thread the install as a dependency (an `Output` the Application's `cluster`
|
|
83
|
+
or an explicit ordering prop depends on) so Plan waits on it — an `Output` referenced in props is a
|
|
84
|
+
real dependency edge Alchemy's own Plan/Apply order on (v2.0.0-beta.79), the general rule this
|
|
85
|
+
follows.
|
|
86
|
+
|
|
87
|
+
## Registering the Talos-on-PVE cluster itself with Argo CD
|
|
88
|
+
|
|
89
|
+
Argo CD's own control-plane cluster (where Argo CD runs) never needs registering — it is implicit
|
|
90
|
+
as `https://kubernetes.default.svc`. A **separate** cluster Argo CD deploys onto (or, if Argo CD
|
|
91
|
+
itself runs off-cluster) uses `Argocd.Cluster` from this kit ([argocd.md](./argocd.md)) — that
|
|
92
|
+
registration genuinely is Argo CD's own non-CRD storage (a labeled `Secret`), covered by the REST
|
|
93
|
+
family, not this page.
|
|
94
|
+
|
|
95
|
+
## Installing Argo CD itself
|
|
96
|
+
|
|
97
|
+
Out of scope for this change (no cluster exists to install onto), but the answer is also "no new
|
|
98
|
+
code": `Kubernetes.HelmChart` (`alchemy/Kubernetes`) installs the upstream `argo-cd` Helm chart
|
|
99
|
+
the same way any other chart installs, and `Kubernetes.Manifest` covers a plain-manifest install
|
|
100
|
+
if the chart is not wanted. Neither needs anything from this kit.
|
package/docs/argocd.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Argo CD — `@homeflare/alchemy/argocd`
|
|
2
|
+
|
|
3
|
+
Built 2026-09-24, ahead of need — Tim: "we are using talos on pve once we get things settled ...
|
|
4
|
+
i thought we are using argocd, i wanted it on the radar" and "even if we don't use it yet but we
|
|
5
|
+
know we will." **No Talos-on-PVE cluster and no Argo CD instance exist on the estate yet.** Every
|
|
6
|
+
test in this family runs against `fake-argocd.ts`; nothing here has touched a real server.
|
|
7
|
+
|
|
8
|
+
## Is `@distilled.cloud/argocd` real, and does it match the kit's pins?
|
|
9
|
+
|
|
10
|
+
✅ **MEASURED**, `npm view @distilled.cloud/argocd`, 2026-09-24: published, `latest` =
|
|
11
|
+
**1.0.0-rc.12**, `peerDependencies.effect` = `>=4.0.0-rc.115`, `dependencies["@distilled.cloud/
|
|
12
|
+
core"]` = `1.0.0-rc.12` — exactly the kit's own pins (`packages/alchemy/package.json`'s `effect`
|
|
13
|
+
and every other `@distilled.cloud/*` peer). Added as a direct peer dependency, the same shape as
|
|
14
|
+
`@distilled.cloud/forgejo`/`discord`/`cloudflare`/`google-workspace` — **no interim package**
|
|
15
|
+
([docs/distilled-interim.md](./distilled-interim.md) applies only when the real package is
|
|
16
|
+
unpublished; it is not, here).
|
|
17
|
+
|
|
18
|
+
## Does upstream Alchemy already ship (or build) ArgoCD? (decision 49)
|
|
19
|
+
|
|
20
|
+
✅ **MEASURED, no**: `grep -ril "argocd"` across `alchemy-run/alchemy` v2.0.0-beta.79 matches
|
|
21
|
+
exactly two files, both AWS EKS "add-on capability" JSDoc examples (`src/AWS/EKS/
|
|
22
|
+
DescribeCapability.ts:20`, `ListCapabilities.ts` — `describeCapability({ capabilityName: "argocd"
|
|
23
|
+
})`, an EKS add-on name, unrelated to a provider). No `src/Argocd/` directory, no changeset, no
|
|
24
|
+
CHANGELOG entry. So decision 49 ("upstream wins... don't build a second one") does not apply here
|
|
25
|
+
— there is nothing upstream to duplicate.
|
|
26
|
+
|
|
27
|
+
## What upstream's `Kubernetes` family already ships (measured)
|
|
28
|
+
|
|
29
|
+
`packages/alchemy/src/Kubernetes/` (v2.0.0-beta.79): `Connection.ts`, `ClusterAdapter.ts`,
|
|
30
|
+
`BuiltinAdapters.ts`, `Deployment.ts`, `HelmChart.ts`, `Job.ts`, `Manifest.ts`, `Providers.ts`.
|
|
31
|
+
`Kubernetes.Manifest` (`Manifest.ts:34-284`) applies **any literal Kubernetes object — built-in
|
|
32
|
+
kinds and CRDs alike** via server-side apply, with a worked "Custom resource (CRD)" example in its
|
|
33
|
+
own doc comment (`Manifest.ts:116-127`). This is the upstream-correct owner for `Application` and
|
|
34
|
+
`AppProject` — see [argocd-kubernetes.md](./argocd-kubernetes.md) for why and how, with examples.
|
|
35
|
+
`HelmChart.ts` is how the house will install Argo CD itself later (its own Helm chart) — no new
|
|
36
|
+
code needed there either.
|
|
37
|
+
|
|
38
|
+
## The REST-vs-CRD split (the actual design decision)
|
|
39
|
+
|
|
40
|
+
Measured against Argo CD's declarative-setup documentation and the generated operation list in
|
|
41
|
+
`@distilled.cloud/argocd`'s `services/argocd.ts` (106 operations, distilled homeflare/base):
|
|
42
|
+
|
|
43
|
+
| Object | Backing store | Owner |
|
|
44
|
+
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
|
|
45
|
+
| `Application` | `argoproj.io/v1alpha1` CRD, its own OpenAPI schema, listable with plain `kubectl` | **`Kubernetes.Manifest`** — [argocd-kubernetes.md](./argocd-kubernetes.md) |
|
|
46
|
+
| `AppProject` | `argoproj.io/v1alpha1` CRD, same as above | **`Kubernetes.Manifest`** |
|
|
47
|
+
| Repository | a `Secret` labeled `argocd.argoproj.io/secret-type: repository` — no CRD of its own; Argo CD's own storage format, not a published Kubernetes API type | **`Argocd.Repository`** (this family) |
|
|
48
|
+
| RepoCreds | a `Secret` labeled `…secret-type: repo-creds` — same reasoning | **`Argocd.RepoCreds`** |
|
|
49
|
+
| Cluster | a `Secret` labeled `…secret-type: cluster` — same reasoning | **`Argocd.Cluster`** |
|
|
50
|
+
|
|
51
|
+
The line is not "REST API exists" (all seven have full REST CRUD — `ApplicationServiceService`,
|
|
52
|
+
`ProjectService`, `RepositoryService`, `RepoCredsService`, `ClusterService`) — it is **whether the
|
|
53
|
+
object is a real, versioned Kubernetes API type**. `Application`/`AppProject` are; Repository/
|
|
54
|
+
RepoCreds/Cluster are Argo CD's own encoding smuggled into a generic `Secret`, which
|
|
55
|
+
`Kubernetes.Manifest` could technically apply too, but only by hand-reconstructing Argo CD's
|
|
56
|
+
private labeling convention — exactly the vendor-specific knowledge a REST SDK exists to own.
|
|
57
|
+
|
|
58
|
+
## What this family builds
|
|
59
|
+
|
|
60
|
+
Three resources on `@distilled.cloud/argocd/argocd`, the peer-dependency pattern
|
|
61
|
+
`packages/alchemy/src/forgejo` already uses:
|
|
62
|
+
|
|
63
|
+
- **`Argocd.Repository`** (`repository.ts`) — `/api/v1/repositories`.
|
|
64
|
+
- **`Argocd.RepoCreds`** (`repo-creds.ts`) — `/api/v1/repocreds`, a URL-prefix credential
|
|
65
|
+
template. No single-item `Get` operation exists (measured against the operation list) —
|
|
66
|
+
`fetchLive` lists and finds, the same shape `forgejo/org-actions-secrets.ts` and
|
|
67
|
+
`discord/guild-application-command.ts` use for the same reason.
|
|
68
|
+
- **`Argocd.Cluster`** (`cluster.ts`) — `/api/v1/clusters`. Only bearer-token and static
|
|
69
|
+
TLS-client-cert auth are modeled; `awsAuthConfig` (EKS IAM) and `execProviderConfig` (an exec
|
|
70
|
+
plugin) are unbuilt — neither applies to a Talos cluster.
|
|
71
|
+
|
|
72
|
+
All three share one engine (`resource.ts`, `argocdOperations`/`argocdHandlers`), a near-verbatim
|
|
73
|
+
copy of `forgejo/resource.ts` — both SDKs are plain bearer-REST with a real typed `NotFound`, so
|
|
74
|
+
the same read/diff/reconcile/destroy shape applies unchanged.
|
|
75
|
+
|
|
76
|
+
## The SDK gap that _isn't_ there (measured, and why it matters)
|
|
77
|
+
|
|
78
|
+
🔴 Unlike Discord (`docs/discord.md#sdk-gaps`), Argo CD's generated error TYPE is **not** narrowed
|
|
79
|
+
to `DEFAULT_ERROR_STATUSES`. Measured directly, `packages/argocd/src/protocol.ts` (distilled
|
|
80
|
+
homeflare/base):
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
export type ArgocdOpError =
|
|
84
|
+
| InstanceType<(typeof API_ERRORS)[number]> // the FULL 12-class list, incl. NotFound/Forbidden/Conflict
|
|
85
|
+
| UnknownArgocdError
|
|
86
|
+
| ConfigError
|
|
87
|
+
| HttpClientError.HttpClientError;
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`API_ERRORS` (not `DEFAULT_ERRORS`) is the full `HTTP_STATUS_MAP` set. And no operation carries a
|
|
91
|
+
`patches/` file (`git ls-tree` on `packages/argocd/` shows none, unlike forgejo's `patches/
|
|
92
|
+
organization/orgDelete.json` exemplar) — every op's own declared `errors: [...]` array is just
|
|
93
|
+
`[UnknownArgocdError]`. That looked, at first read, like the same gap Discord has. It is not:
|
|
94
|
+
`core/protocol-rest.ts#decode` (lines ~320-370) checks the operation's own typed matchers first,
|
|
95
|
+
then **unconditionally** falls back to `HTTP_STATUS_MAP[status]` — a 404 maps to `NotFound`
|
|
96
|
+
regardless of whether the operation declared it. `UnknownArgocdError` is reached only for a status
|
|
97
|
+
outside that map. **So `catchTag('NotFound', ...)` works correctly today, with no distilled patch
|
|
98
|
+
needed** — confirmed by `repository.test.ts`/`repo-creds.test.ts`/`cluster.test.ts` running the
|
|
99
|
+
REAL protocol against a fake 404/401/403, not a hand-rolled status check.
|
|
100
|
+
|
|
101
|
+
## Secrets never in state
|
|
102
|
+
|
|
103
|
+
Every secret-shaped field (`password`, `sshPrivateKey`, `bearerToken`, `tlsClientCertKey` /
|
|
104
|
+
`tlsClientKeyData`) is a prop typed `FromEnv` (`{ fromEnv: 'ENV_VAR_NAME' }`,
|
|
105
|
+
`../secrets/write-only.ts`) — never a literal value. `git-credentials.ts` (Repository/RepoCreds)
|
|
106
|
+
and `cluster.ts`'s own resolver read the named variable from the deploying process's own
|
|
107
|
+
environment **at call time**, refuse with a typed domain error
|
|
108
|
+
(`ArgocdSecretEnvUnsetError`/`ArgocdClusterSecretEnvUnsetError`) if it is unset, and send the
|
|
109
|
+
value on the wire — never into a prop, an attribute, or Alchemy's state store.
|
|
110
|
+
|
|
111
|
+
⚠️ **No drift detection on secret fields, on purpose** — the same simplification
|
|
112
|
+
`forgejo/org-actions-secrets.ts` makes: Argo CD's `Get`/`List` never returns a password, private
|
|
113
|
+
key or bearer token back, so there is nothing to diff against. `matches` compares only the fields
|
|
114
|
+
the API does return; a changed credential is written once on create and never rotated
|
|
115
|
+
automatically. Rotate by destroy-then-create, or a manual call outside Alchemy. A seal-based check
|
|
116
|
+
(`../secrets/write-only.ts#seal`, as `proxmox/pbs-notification-target-lifecycle.ts` uses) is
|
|
117
|
+
possible later if this becomes a real problem.
|
|
118
|
+
|
|
119
|
+
## Reconcile is one upsert call, always
|
|
120
|
+
|
|
121
|
+
Every `Create*` operation this family calls takes `upsert: true` as a query parameter (measured:
|
|
122
|
+
`CreateRepositoryServiceRepositoryRequest`, `CreateRepoCredsServiceRepositoryCredentialsRequest`,
|
|
123
|
+
`CreateClusterServiceRequest`, all `.pipe(T.Query())`) — a true create-or-converge in one request.
|
|
124
|
+
`resource.ts#reconcile` therefore has no separate `Update*` path: it reads, and calls `upsert`
|
|
125
|
+
only when the declared props and the live object disagree on a plain field (S10 — converged means
|
|
126
|
+
zero writes).
|
|
127
|
+
|
|
128
|
+
## `exactOptionalPropertyTypes`, and `present()`
|
|
129
|
+
|
|
130
|
+
The kit's `tsconfig.base.json` turns this on, and every generated Argo CD request type declares
|
|
131
|
+
optional fields the strict way. `resource.ts#present()` strips undefined-valued keys into
|
|
132
|
+
genuinely-absent keys before any create/upsert call — one shared helper instead of
|
|
133
|
+
`forgejo/repository.ts#editForm`'s per-field conditional spread repeated three times.
|
|
134
|
+
|
|
135
|
+
## Ownership: silent, like forgejo — not `Unowned` like discord/netbox
|
|
136
|
+
|
|
137
|
+
No `adopt(true)`/`Unowned` wrapping. A live object found on a cold read (no persisted state) is
|
|
138
|
+
treated as ours, mirroring `forgejo/resource.ts`'s own choice rather than `discord/resource.ts`'s
|
|
139
|
+
`Unowned` marker. Documented, not accidental — the same tradeoff forgejo already ships with.
|
package/docs/credentials.md
CHANGED
|
@@ -65,3 +65,12 @@ this one variable. `Authorization: Bearer <value>` is still built by the package
|
|
|
65
65
|
`GoogleWorkspaceKeyRef`/`describeKeyRef` (this subpath's own `credentials.ts`) are a typed,
|
|
66
66
|
key-free REFERENCE to where that service-account key lives, for a stack file to keep alongside its
|
|
67
67
|
declarations — full setup: [google-workspace.md](./google-workspace.md).
|
|
68
|
+
|
|
69
|
+
The Argo CD subpath (new 2026-09-24, `@distilled.cloud/argocd`, built ahead of any live instance)
|
|
70
|
+
resolves credentials the same way, through the SDK's own `CredentialsFromEnv` layer, which reads
|
|
71
|
+
`ARGOCD_TOKEN` (required) and `ARGOCD_SERVER` (optional instance origin, default
|
|
72
|
+
`https://localhost:8080`) — the package's own names, unchanged. `Authorization: Bearer <value>` is
|
|
73
|
+
built by the package's protocol layer. The repository/cluster CREDENTIALS this subpath's three
|
|
74
|
+
resources declare (a repo's password/SSH key, a cluster's bearer token/TLS key) are a separate,
|
|
75
|
+
per-object concern from the instance's own API token above — write-only `FromEnv` references, never
|
|
76
|
+
a prop or attribute either: see [argocd.md](./argocd.md#secrets-never-in-state).
|
package/docs/grafana-alerting.md
CHANGED
|
@@ -7,8 +7,9 @@ place). Ships in three stacked PRs, in the order this file's own history records
|
|
|
7
7
|
PR 250.
|
|
8
8
|
2. **`Grafana.AlertRuleGroup`** — [grafana-alerting-rules.md](./grafana-alerting-rules.md), a
|
|
9
9
|
separate stacked PR (its own diff off `main`, not this one) once this file's doc budget filled.
|
|
10
|
-
3.
|
|
11
|
-
contact points, mute timings and
|
|
10
|
+
3. **`Grafana.NotificationPolicy`** — [grafana-notification-policy.md](./grafana-notification-policy.md),
|
|
11
|
+
the singleton policy tree, last on purpose (it references contact points, mute timings and
|
|
12
|
+
templates by name).
|
|
12
13
|
|
|
13
14
|
All three ship on the same `@distilled.cloud/grafana@0.2.0` operations
|
|
14
15
|
[grafana.md](./grafana.md#the-sdk-gap--history-and-what-shipped-on-top-of-the-fix) already
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# `Grafana.NotificationPolicy` — `@homeflare/alchemy/grafana`
|
|
2
|
+
|
|
3
|
+
Third and last of the stacked PRs bringing Grafana alerting provisioning under Alchemy
|
|
4
|
+
([grafana-alerting.md](./grafana-alerting.md): `Grafana.ContactPoint`/`Grafana.MuteTiming`/
|
|
5
|
+
`Grafana.MessageTemplate`; [grafana-alerting-rules.md](./grafana-alerting-rules.md):
|
|
6
|
+
`Grafana.AlertRuleGroup`). Branched fresh off `main` after that PR merged. No live call was made
|
|
7
|
+
writing this PR.
|
|
8
|
+
|
|
9
|
+
## A singleton — adopt and update only
|
|
10
|
+
|
|
11
|
+
`GET`/`PUT /v1/provisioning/policies` always address the one tree an instance has; there is no path
|
|
12
|
+
parameter. MEASURED: `RouteGetPolicyTreeError` is `Forbidden | GrafanaOpError` — no `NotFound` case
|
|
13
|
+
at all, so `fetchLive` never returns `undefined`. `reconcile`'s `before === undefined` branch
|
|
14
|
+
(`resource.ts`) is therefore dead code for this resource: there is no create in the sense every
|
|
15
|
+
other resource in this family has, and `spec.create` exists only because `GrafanaSpec.create` is a
|
|
16
|
+
required field on the shared type — implemented as the same `PUT` `update` sends, defensively,
|
|
17
|
+
never actually reached.
|
|
18
|
+
|
|
19
|
+
## Two gates before `routeResetPolicyTree` ever runs
|
|
20
|
+
|
|
21
|
+
`destroy` never calls `routeResetPolicyTree` unless `allowReset: true` is explicitly declared — a
|
|
22
|
+
gate independent of Alchemy's own removal policy:
|
|
23
|
+
|
|
24
|
+
1. **`defaultRemovalPolicy: 'retain'`** — the same convention every multi-object-cascade resource
|
|
25
|
+
in this family uses (`Grafana.Folder`, `Grafana.AlertRuleGroup`, every `Bao.*`). Alchemy's own
|
|
26
|
+
engine never calls this resource's `delete` handler at all unless a stack opts in with
|
|
27
|
+
`.pipe(RemovalPolicy.destroy())`.
|
|
28
|
+
2. **`allowReset` (a prop, not a stack-level policy)** — even a stack that HAS opted into destroy
|
|
29
|
+
at the Alchemy level still needs this resource's own explicit `allowReset: true` before
|
|
30
|
+
`destroy` will actually reset. Without it, `destroy` logs a warning and returns — retained.
|
|
31
|
+
|
|
32
|
+
Two gates, not one, because `routeResetPolicyTree` is uniquely destructive among everything this
|
|
33
|
+
family calls: it wipes every declared route, receiver reference and matcher back to Grafana's own
|
|
34
|
+
bare default, for the WHOLE instance, not one object. A test
|
|
35
|
+
(`notification-policy-refusals.test.ts`) proves `destroy` sends no `DELETE` without `allowReset`,
|
|
36
|
+
and a foreign-provenance tree still refuses `destroy` even WITH `allowReset: true`.
|
|
37
|
+
|
|
38
|
+
## The tree is opaque `Record<string, unknown>`
|
|
39
|
+
|
|
40
|
+
Same reasoning as `mute-timing.ts`'s `timeIntervals`: Grafana's `Matchers`/`ObjectMatchers`/
|
|
41
|
+
`MatchRegexps` (matcher expression shapes) are complex nested union/array types this family does
|
|
42
|
+
not attempt to model field-by-field. This family's own measured passthrough behavior
|
|
43
|
+
(`mute-timing.ts`'s file header) means an opaque pass-through round-trips correctly regardless.
|
|
44
|
+
|
|
45
|
+
## Route order is significant
|
|
46
|
+
|
|
47
|
+
Same as `Grafana.AlertRuleGroup`'s rules: `subset-match.ts`'s array comparison is
|
|
48
|
+
position-significant, and Alertmanager itself routes an alert to the FIRST matching route in array
|
|
49
|
+
order — reordering routes is a genuine routing change, proven by a test that declares the same two
|
|
50
|
+
routes reversed and asserts a mismatch.
|
|
51
|
+
|
|
52
|
+
## Foreign-provenance refusal
|
|
53
|
+
|
|
54
|
+
Unchanged from kit PR 250/254, including the missing-means-foreign fix: `Route.provenance` is
|
|
55
|
+
optional on the wire, and `undefined` is never folded into `""`. `create` is never at risk — it is
|
|
56
|
+
unreachable (above).
|
|
57
|
+
|
|
58
|
+
## Grafana-injected defaults — tolerated the `declaredContentMatches` way
|
|
59
|
+
|
|
60
|
+
`matches()` runs the (`provenance`-stripped) declared and live trees through `subset-match.ts`'s
|
|
61
|
+
tolerant-subset comparison — the same mechanism `Grafana.Dashboard`'s panel-`datasource`
|
|
62
|
+
auto-decoration and `Grafana.ContactPoint`'s settings defaults already rely on: a live tree may
|
|
63
|
+
carry additional fields, at any depth, the declaration never mentioned (a default
|
|
64
|
+
`group_wait`/`group_interval`/`repeat_interval`/`group_by: []` Grafana injects when the declaration
|
|
65
|
+
omits them is the plausible case) without failing the match. ⚠️ Not measured against a live
|
|
66
|
+
instance — no live policy tree exists in this house to read — the documented, conservative default
|
|
67
|
+
every unmeasured value shape in this family takes.
|
|
68
|
+
|
|
69
|
+
## Dropped-route warning — accepted, never silent
|
|
70
|
+
|
|
71
|
+
Same fix kit PR 254 made for `Grafana.AlertRuleGroup`, applied here: a whole-tree `PUT` REPLACES
|
|
72
|
+
every route, so a nested route added out-of-band is removed with no error and no distinct `Diff`
|
|
73
|
+
action to report it under. `notification-policy-drop-warning.ts`'s `warnOnDroppedRoutes` logs every
|
|
74
|
+
dropped route by receiver and dotted-index path, called from both a local reimplementation of
|
|
75
|
+
`diff` and from `update` itself — mirrors `alert-rule-group-drop-warning.ts` exactly, including
|
|
76
|
+
being a LOCAL reimplementation of `resource.ts`'s generic `diff` rather than a wrap of it (no seam
|
|
77
|
+
in the generic version for this side effect without fetching live twice).
|
|
78
|
+
|
|
79
|
+
⚠️ **Identity is structural, not a stable key** — Grafana's policy routes have no id/uid.
|
|
80
|
+
`FlatRoute.key` (`notification-policy-model.ts`) is the chain of ancestor own-fields from the root
|
|
81
|
+
down to a node, `\0`-joined: two nodes are "the same" only under the identical lineage of parents,
|
|
82
|
+
which makes an identical route under a DIFFERENT parent correctly distinct while staying
|
|
83
|
+
order-insensitive among siblings (a shared parent contributes the same key to every child
|
|
84
|
+
regardless of index) — reordering the same routes logs nothing, only genuine removal does.
|
|
85
|
+
|
|
86
|
+
⛔ **Fixed after an adversarial review of this PR: the first version's identity collided across the
|
|
87
|
+
WHOLE TREE, not just among siblings.** A plain `Set` of every node's own-fields JSON meant a
|
|
88
|
+
duplicate route added anywhere (e.g. a second `severity=warning -> slack` alongside an
|
|
89
|
+
already-declared one) matched the existing `Set` entry and went unwarned, even though the `PUT`
|
|
90
|
+
still drops it — worse than the "siblings only" limitation the original comment claimed. Every key
|
|
91
|
+
is now counted as a MULTISET (declared count vs. live count) rather than checked for membership: a
|
|
92
|
+
key is "dropped" only for the amount its live count exceeds its declared count, so N identical live
|
|
93
|
+
occurrences under the same parent are dropped only past however many the declaration itself
|
|
94
|
+
repeats — proven by four tests: a duplicate added live warns; a plain sibling reorder doesn't; an
|
|
95
|
+
identical route under a different, undeclared parent still warns (distinct lineage); and two
|
|
96
|
+
declared duplicates against two live duplicates warns nothing (counts match).
|
|
97
|
+
|
|
98
|
+
## Example
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { GrafanaNotificationPolicy, grafanaProviders } from '@homeflare/alchemy/grafana';
|
|
102
|
+
|
|
103
|
+
export class Policy extends GrafanaNotificationPolicy('estate-policy', {
|
|
104
|
+
route: {
|
|
105
|
+
receiver: 'default-receiver',
|
|
106
|
+
group_by: ['alertname'],
|
|
107
|
+
routes: [
|
|
108
|
+
{ receiver: 'pagerduty', matchers: ['severity=critical'] },
|
|
109
|
+
{ receiver: 'slack', matchers: ['severity=warning'] },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
}) {}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Tests
|
|
116
|
+
|
|
117
|
+
`notification-policy.test.ts` (fetchLive/reconcile/order), `notification-policy-model.test.ts`
|
|
118
|
+
(pure `normalizeRoute`/`flattenRoutes`), `notification-policy-refusals.test.ts` (provenance +
|
|
119
|
+
reset-gate) and `notification-policy-drop-warning.test.ts` — split four ways to stay under the
|
|
120
|
+
house's 250-line file cap, sharing fixtures from `notification-policy-fixtures.ts`. Prove: a GET
|
|
121
|
+
never carries a body; reconcile never sends a create-shaped write; a foreign-provenance tree
|
|
122
|
+
(explicit `"file"` or MISSING) refuses update while an explicit `""` is writable; `destroy` never
|
|
123
|
+
resets without `allowReset: true`, and still refuses a foreign tree even with it; reordering routes
|
|
124
|
+
shows `update`; a live route the declaration doesn't mention is named in a logged warning from both
|
|
125
|
+
`diff` and `update`, while a plain reorder logs nothing; and — the multiset fix — a duplicate route
|
|
126
|
+
added live warns while an identical declared/live duplicate pair doesn't, and an identical route
|
|
127
|
+
under a different, undeclared parent is still correctly reported as dropped.
|
package/docs/grafana.md
CHANGED
|
@@ -122,15 +122,19 @@ export class TeslaMateDatasource extends GrafanaDatasource('teslamate-datasource
|
|
|
122
122
|
// …then provide `grafanaProviders({ baseUrl, tokenEnv })` alongside the stack's other providers.
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
+
## Family status
|
|
126
|
+
|
|
127
|
+
Every alerting-provisioning object this family set out to cover now ships:
|
|
128
|
+
|
|
129
|
+
- **`Grafana.Folder` and `Grafana.Dashboard`** — see
|
|
130
|
+
[grafana-folder-dashboard.md](./grafana-folder-dashboard.md).
|
|
131
|
+
- **`Grafana.ContactPoint`, `Grafana.MuteTiming` and `Grafana.MessageTemplate`** — see
|
|
132
|
+
[grafana-alerting.md](./grafana-alerting.md).
|
|
133
|
+
- **`Grafana.AlertRuleGroup`** — see [grafana-alerting-rules.md](./grafana-alerting-rules.md).
|
|
134
|
+
- **`Grafana.NotificationPolicy`** — the singleton policy tree, last on purpose (it references the
|
|
135
|
+
others by name) — see [grafana-notification-policy.md](./grafana-notification-policy.md).
|
|
136
|
+
|
|
125
137
|
## Not covered
|
|
126
138
|
|
|
127
|
-
- **`Grafana.Folder` and `Grafana.Dashboard` ship** — see
|
|
128
|
-
[grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here.
|
|
129
|
-
- **`Grafana.ContactPoint`, `Grafana.MuteTiming` and `Grafana.MessageTemplate` ship** — see
|
|
130
|
-
[grafana-alerting.md](./grafana-alerting.md), not repeated here.
|
|
131
|
-
- **`Grafana.AlertRuleGroup` ships** — see [grafana-alerting-rules.md](./grafana-alerting-rules.md),
|
|
132
|
-
not repeated here.
|
|
133
|
-
- **`Grafana.NotificationPolicy`** — the last stacked follow-up PR, the singleton policy tree, on
|
|
134
|
-
the same operations; not blocked on anything.
|
|
135
139
|
- **`read` never answers `Unowned`** — the same open gap forgejo's and netbox's own families
|
|
136
140
|
still carry (see upstream-conformance.md); a maintainer decision on the ownership-check shape.
|
package/docs/litellm.md
CHANGED
|
@@ -33,6 +33,23 @@ another `PROXY_ADMIN` key: `update_config_general_settings`, the function every
|
|
|
33
33
|
through, is `PROXY_ADMIN` only. Never a prop — see [docs/credentials.md](./credentials.md) for the
|
|
34
34
|
house rule.
|
|
35
35
|
|
|
36
|
+
`CredentialsFromEnv` ends in `Effect.orDie` on a missing/misspelled env var — matching distilled's
|
|
37
|
+
own convention (73 of 80 `packages/*/src/credentials.ts` end this way at `homeflare/base`, 78 of 80
|
|
38
|
+
at `origin/main`), not the house's own S20/S24 numbering. A worktree commit briefly changed this to
|
|
39
|
+
a typed `ConfigError` (`LitellmOpError` already declares one, and `makeRestProtocol`'s `encode` step
|
|
40
|
+
does thread a real credentials failure into the operation's own error channel — the plumbing for a
|
|
41
|
+
typed refusal is genuinely there); Tim's answer to Q6 (2026-09-24 walk-down, decision 49 "upstream
|
|
42
|
+
wins") reverted it, because that divergence was the house going stricter than distilled's own
|
|
43
|
+
convention on a point distilled is not silent on, and decision 49 says match exactly, not stricter.
|
|
44
|
+
|
|
45
|
+
★ **HELD upstream proposal, not raised.** If distilled's own convention changes — typed
|
|
46
|
+
`ConfigError` in place of `orDie` on a missing credential — this package (and the other 72) would
|
|
47
|
+
be one line each to update: `Credentials`'s `Context.Service` value type gains `ConfigError`, and
|
|
48
|
+
the layer drops its trailing `Effect.orDie` in favor of the `Effect.mapError` it already has to
|
|
49
|
+
build the error. LiteLLM would make a reasonable pilot, since its protocol layer already declares
|
|
50
|
+
and threads `ConfigError`. Nobody has proposed this to `alchemy-run/distilled`; it stays here as an
|
|
51
|
+
idea for Tim to raise, or not, later.
|
|
52
|
+
|
|
36
53
|
## Refusals
|
|
37
54
|
|
|
38
55
|
This resource refuses a plan rather than writing something it cannot safely undo:
|
package/docs/peers.md
CHANGED
|
@@ -78,3 +78,10 @@ upstream, the alias's target changes (or the entry becomes a normal peer, matchi
|
|
|
78
78
|
`HttpClient` client the same way `/netbox` did) is the same shape**: aliased onto
|
|
79
79
|
`@homeflare/distilled-litellm@0.2.0` as a plain `dependencies` entry, not a peer — nothing to
|
|
80
80
|
add here, nothing for `peers.test.ts` to check.
|
|
81
|
+
|
|
82
|
+
⚠️ **`@distilled.cloud/argocd` (added 2026-09-24, `/argocd`, built ahead of a Talos-on-PVE
|
|
83
|
+
cluster that does not exist yet) is required for the same reason `@distilled.cloud/forgejo` and
|
|
84
|
+
`/discord` are: a plain dependency of this package, not something a hoisting installer can hide
|
|
85
|
+
from a strict one.** Pinned to `1.0.0-rc.12` — measured (`npm view @distilled.cloud/argocd`,
|
|
86
|
+
2026-09-24) as genuinely published at that version, matching every other pin on this page; no
|
|
87
|
+
interim alias was needed (see [distilled-interim.md](./distilled-interim.md) for when one is).
|
package/docs/unifi.md
CHANGED
|
@@ -91,3 +91,48 @@ trigger but a future write path must respect — whole-object `PUT`, ordering en
|
|
|
91
91
|
replace the whole list, `removeDevice` unadopting and factory-resetting hardware — are recorded
|
|
92
92
|
in the distilled package's own `README.md` and in the estate's `docs/unifi-api-notes.md`
|
|
93
93
|
(outside this repo).
|
|
94
|
+
|
|
95
|
+
## Spec-version check against the live-measured console (2026-09-24)
|
|
96
|
+
|
|
97
|
+
`homeflare-network`'s `src/unifi/imported.ts` was generated 2026-09-24 against a live console
|
|
98
|
+
self-reporting **10.6.97** (`GET /v1/info`, recorded in that file's own header) — two minor
|
|
99
|
+
versions past this SDK's 10.4.57 pin. Checked whether any of the 11 imported networks or 14
|
|
100
|
+
imported firewall zones could decode differently on a newer controller than this SDK's
|
|
101
|
+
10.4.57-pinned types expect: diffed a 10.6.97 copy of Ubiquiti's OpenAPI document (a third-party
|
|
102
|
+
mirror, used only as a diffing aid — never as this SDK's spec of record; see the distilled
|
|
103
|
+
package's own `docs/spec-version-provenance.md`) against the pinned 10.4.57 document. Zero
|
|
104
|
+
operations added or removed anywhere in the API; every full raw operation object (parameters,
|
|
105
|
+
request body, responses, `$ref`s included) for every `Networks`-tagged operation and every
|
|
106
|
+
`*FirewallZone*` operation is identical, byte for byte, not just its named schemas (11 operations
|
|
107
|
+
on each side, same set — a named-schema diff alone would miss an inline, untagged parameter
|
|
108
|
+
changing shape, so this checks the operations directly).
|
|
109
|
+
|
|
110
|
+
**14 of the document's 379/380 component schemas do differ somewhere** — the switch-stack/LAG
|
|
111
|
+
family (`Switching` tag, matching Ubiquiti's own 10.6 release notes on LAG support), the generic
|
|
112
|
+
`filter`-query-syntax family (`FilterExpression`/`CompoundFilterExpression`/`NotFilterExpression`/
|
|
113
|
+
`PropertyFilterExpression`), and one mDNS enum addition (`SHELLY`, `UniFi Devices` tag) — but
|
|
114
|
+
**none of the 14 is reachable from a Networks or FirewallZone operation, even transitively**:
|
|
115
|
+
resolved every `$ref` reachable from each of the 11 operations' full parameter/body/response
|
|
116
|
+
trees, recursively, in both versions, and none of the 14 changed schema names appears in either
|
|
117
|
+
closure. Full breakdown of the 14 (which changed vs. added/removed, and why the reachability check
|
|
118
|
+
had to go beyond the direct operation-object diff above) is in the distilled package's own
|
|
119
|
+
`docs/spec-version-provenance.md`, not this repo.
|
|
120
|
+
|
|
121
|
+
**Answer: none of the imported rows would change shape against 10.6.97.** This does not
|
|
122
|
+
generalize past 10.6.97, and it says nothing about any tag besides Networks/FirewallZones —
|
|
123
|
+
re-check before importing or declaring against `Clients`, `WiFi Broadcasts`, `ACL Rules`, or any
|
|
124
|
+
other tag.
|
|
125
|
+
|
|
126
|
+
## 2026-09-24 doctrine walkdown
|
|
127
|
+
|
|
128
|
+
Read directly against `origin/main`'s `src/unifi/{resource,network,network-form,firewall-zone,
|
|
129
|
+
firewall-zone-form,policy}.ts`: `fetchLive` in `network.ts`/`firewall-zone.ts` folds only
|
|
130
|
+
`catchTag('NotFound', …)`, never a blanket catch. `destroy` always refuses via the typed
|
|
131
|
+
`UnifiWriteRefused` regardless of live state — correct here, not a departure from delete's usual
|
|
132
|
+
idempotency rule: this family will never issue a real DELETE by policy, so idempotency of a
|
|
133
|
+
delete it cannot perform is moot. No `Effect.orDie`/`Effect.die` anywhere in the family.
|
|
134
|
+
`sortedSet` normalizes every measured unordered array (`dhcpGuarding.trustedDhcpServerIpAddresses`,
|
|
135
|
+
`ipv6Configuration`'s two override lists, `firewall-zone-form.ts`'s `networkIds`) before it is
|
|
136
|
+
attributed, declared or compared. Both declaration renderers and both `matches` use
|
|
137
|
+
`value == null` / `deepEqual(..., { stripNullish: true })` consistently, per `network-form.ts`'s
|
|
138
|
+
own header. No departure found — nothing in this family needed changing.
|
|
@@ -289,7 +289,7 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
|
|
|
289
289
|
timings or templates configured either (consistent with the alerting-provisioning gap having
|
|
290
290
|
been real, not merely unexploited).
|
|
291
291
|
|
|
292
|
-
✅ **`Grafana.AlertRuleGroup` shipped** (
|
|
292
|
+
✅ **`Grafana.AlertRuleGroup` shipped** (kit PR 254, second of three, branched fresh off `main`
|
|
293
293
|
after PR 250 merged): group, not per-rule, is the unit — `PUT .../rule-groups/{Group}` owns
|
|
294
294
|
evaluation `interval` and the full ordered `rules[]` together, which per-rule operations
|
|
295
295
|
cannot express (no `interval` field, no reordering). Rule order is significant, proven by a
|
|
@@ -297,11 +297,25 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
|
|
|
297
297
|
the group type itself carries none) reuses `alerting-provenance.ts` unchanged, including the
|
|
298
298
|
missing-means-foreign fix from PR 250's review. A declared group whose folder does not exist
|
|
299
299
|
refuses as a typed `BadRequest` (`RoutePutAlertRuleGroupError` has no `NotFound` case) rather
|
|
300
|
-
than being auto-created — this resource never calls `Grafana.Folder`'s create path.
|
|
301
|
-
|
|
300
|
+
than being auto-created — this resource never calls `Grafana.Folder`'s create path. A whole-group
|
|
301
|
+
write silently dropping a live rule not in the declaration (found by that PR's own adversarial
|
|
302
|
+
review) is now warned about loudly rather than silent, never a refusal. Full detail:
|
|
303
|
+
[grafana-alerting-rules.md](./grafana-alerting-rules.md).
|
|
302
304
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
+
✅ **`Grafana.NotificationPolicy` shipped** (this PR, third and last of the stacked PRs,
|
|
306
|
+
branched fresh off `main` after PR 254 merged): a SINGLETON — `fetchLive` never returns
|
|
307
|
+
`undefined` (`RouteGetPolicyTreeError` has no `NotFound` case), so this resource has no create
|
|
308
|
+
or delete in the sense every sibling resource does, only adopt-and-update. `destroy` never
|
|
309
|
+
calls `routeResetPolicyTree` without an explicit `allowReset: true` prop, a gate independent of
|
|
310
|
+
(and in addition to) `defaultRemovalPolicy: 'retain'` — two gates, since resetting wipes the
|
|
311
|
+
WHOLE instance's routing back to Grafana's bare default, not one object. The tree is opaque
|
|
312
|
+
`Record<string, unknown>` (same reasoning as `mute-timing.ts`'s `timeIntervals` — Grafana's
|
|
313
|
+
matcher-expression types are complex unions this family does not hand-model), compared via
|
|
314
|
+
`subset-match.ts` for Grafana-injected-default tolerance, with route order significant. The
|
|
315
|
+
same dropped-item warning kit PR 254 introduced for rule groups is mirrored here for dropped
|
|
316
|
+
routes. Full detail: [grafana-notification-policy.md](./grafana-notification-policy.md).
|
|
317
|
+
|
|
318
|
+
This completes the alerting-provisioning family this project set out to cover.
|
|
305
319
|
- ⛔ **Same open gaps as `forgejo/*` and `netbox/*` above:** `read` never answers `Unowned`
|
|
306
320
|
(H1), and credentials come from an explicit env var NAME at call time rather than an
|
|
307
321
|
`alchemy/Auth` provider (S24) — here the house's own `grafanaCredentials(target)`, not the
|