@homeflare/alchemy 0.36.0 → 0.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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/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 +31 -0
- package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-daemon.d.ts +40 -6
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-distilled.d.ts +33 -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 +30 -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 +19 -12
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-wire.d.ts +37 -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 +8 -13
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +605 -483
- package/dist/proxmox/index.js.map +20 -17
- package/dist/proxmox/storage-wire.d.ts +15 -11
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/unreadable-read.d.ts +7 -7
- package/dist/proxmox/user-wire.d.ts +8 -11
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts +5 -5
- package/docs/argocd-kubernetes.md +100 -0
- package/docs/argocd.md +139 -0
- package/docs/credentials.md +9 -0
- package/docs/distilled-interim.md +15 -3
- package/docs/litellm.md +17 -0
- package/docs/peers.md +7 -0
- package/docs/provider-standard.md +24 -6
- package/docs/proxmox.md +20 -0
- package/docs/unifi.md +45 -0
- package/docs/upstream-conformance-bun.md +67 -0
- package/docs/upstream-conformance-conforms.md +55 -0
- package/docs/upstream-conformance-families.md +95 -0
- package/docs/upstream-conformance-grafana.md +106 -0
- package/docs/upstream-conformance.md +20 -298
- 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/litellm/operations.ts +8 -4
- package/src/proxmox/ceph-daemon-form.ts +7 -8
- package/src/proxmox/ceph-daemon-wire.ts +112 -0
- package/src/proxmox/ceph-daemon-write.test.ts +158 -0
- package/src/proxmox/ceph-daemon.ts +70 -53
- package/src/proxmox/ceph-fs-delete.test.ts +81 -0
- package/src/proxmox/ceph-fs-distilled.ts +100 -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 +68 -73
- 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 +24 -17
- package/src/proxmox/ceph-pool-wire.ts +101 -0
- package/src/proxmox/ceph-pool-write.test.ts +146 -0
- package/src/proxmox/ceph-pool.ts +87 -98
- package/src/proxmox/group-wire.ts +7 -12
- package/src/proxmox/read-failure-propagation.test.ts +5 -5
- package/src/proxmox/storage-wire.ts +9 -10
- package/src/proxmox/typed-absence.test.ts +146 -0
- package/src/proxmox/unreadable-read.ts +7 -7
- package/src/proxmox/user-wire.ts +7 -10
- package/src/proxmox/user.ts +5 -5
- package/src/proxmox/distilled-task-await.test.ts +0 -124
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Argocd.RepoCreds` — a credential TEMPLATE that Argo CD applies to any repository whose URL
|
|
3
|
+
* starts with `url` (`/api/v1/repocreds`) — distinct from `Argocd.Repository`, which registers one
|
|
4
|
+
* specific repository. REST-managed, not a CRD; see `docs/argocd.md`.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ NO `GetRepoCredsService` OPERATION EXISTS — measured against the operation list in
|
|
7
|
+
* `services/argocd.ts` (distilled homeflare/base): Create/Update/Delete/List only, no single-item
|
|
8
|
+
* Get. `fetchLive` below lists and finds by `url`, the same shape
|
|
9
|
+
* `discord/guild-application-command.ts#fetchByName` and
|
|
10
|
+
* `forgejo/org-actions-secrets.ts#fetchLive` use for the same reason.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ NO LIVE ARGO CD INSTANCE EXISTS ON THE ESTATE (2026-09-24) — see `docs/argocd.md`.
|
|
13
|
+
*/
|
|
14
|
+
import { Resource } from 'alchemy';
|
|
15
|
+
import * as Provider from 'alchemy/Provider';
|
|
16
|
+
import * as argocd from '@distilled.cloud/argocd/argocd';
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
import {
|
|
19
|
+
type ArgocdSecretEnvUnsetError,
|
|
20
|
+
type GitCredentialRefs,
|
|
21
|
+
resolveGitCredentials,
|
|
22
|
+
} from './git-credentials.ts';
|
|
23
|
+
import { type ArgocdRequirements, type ArgocdSpec, argocdHandlers, present } from './resource.ts';
|
|
24
|
+
|
|
25
|
+
export interface RepoCredsProps {
|
|
26
|
+
/** The URL prefix this template matches — identity key; changing it is a replace. */
|
|
27
|
+
url: string;
|
|
28
|
+
/** "git" (default), "helm" or "oci". */
|
|
29
|
+
type?: string;
|
|
30
|
+
username?: string;
|
|
31
|
+
/** The client certificate itself — public, unlike its key. */
|
|
32
|
+
tlsClientCertData?: string;
|
|
33
|
+
/** Write-only — see `git-credentials.ts`. Never a value here, only env var names. */
|
|
34
|
+
credentials?: GitCredentialRefs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RepoCredsAttributes {
|
|
38
|
+
url: string;
|
|
39
|
+
type: string | undefined;
|
|
40
|
+
username: string | undefined;
|
|
41
|
+
tlsClientCertData: string | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ArgocdRepoCreds extends Resource<
|
|
45
|
+
'Argocd.RepoCreds',
|
|
46
|
+
RepoCredsProps,
|
|
47
|
+
RepoCredsAttributes,
|
|
48
|
+
never,
|
|
49
|
+
ArgocdRequirements
|
|
50
|
+
> {}
|
|
51
|
+
|
|
52
|
+
export const ArgocdRepoCreds = Resource<ArgocdRepoCreds>('Argocd.RepoCreds');
|
|
53
|
+
|
|
54
|
+
const attributesOf = (live: argocd.V1alpha1RepoCreds): RepoCredsAttributes => ({
|
|
55
|
+
tlsClientCertData: live.tlsClientCertData,
|
|
56
|
+
type: live.type,
|
|
57
|
+
url: live.url ?? '',
|
|
58
|
+
username: live.username,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** Only the plain fields — see `git-credentials.ts` for why the secret fields are excluded. */
|
|
62
|
+
const plainMatches = (attrs: RepoCredsAttributes, props: RepoCredsProps): boolean =>
|
|
63
|
+
(props.type ?? 'git') === (attrs.type ?? 'git') &&
|
|
64
|
+
(props.username ?? undefined) === (attrs.username ?? undefined) &&
|
|
65
|
+
(props.tlsClientCertData ?? undefined) === (attrs.tlsClientCertData ?? undefined);
|
|
66
|
+
|
|
67
|
+
type RepoCredsError =
|
|
68
|
+
| argocd.ListRepoCredsServiceRepositoryCredentialsError
|
|
69
|
+
| argocd.CreateRepoCredsServiceRepositoryCredentialsError
|
|
70
|
+
| argocd.DeleteRepoCredsServiceRepositoryCredentialsError
|
|
71
|
+
| ArgocdSecretEnvUnsetError;
|
|
72
|
+
|
|
73
|
+
/** ★ EXPORTED for direct testing against `fake-argocd.ts` — see `repo-creds.test.ts`. */
|
|
74
|
+
export const spec: ArgocdSpec<
|
|
75
|
+
RepoCredsProps,
|
|
76
|
+
argocd.V1alpha1RepoCreds,
|
|
77
|
+
RepoCredsAttributes,
|
|
78
|
+
RepoCredsError
|
|
79
|
+
> = {
|
|
80
|
+
attributes: (live) => attributesOf(live),
|
|
81
|
+
// ★ Catches the DELETE call's own NotFound too — closes the TOCTOU race between the pre-check
|
|
82
|
+
// `fetchLive` above and this call. Matches upstream's Hetzner/Certificate.ts `deleteById`.
|
|
83
|
+
destroy: (props) =>
|
|
84
|
+
argocd
|
|
85
|
+
.deleteRepoCredsServiceRepositoryCredentials({ url: props.url })
|
|
86
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.void)),
|
|
87
|
+
identityOfAttributes: (attrs) => attrs.url,
|
|
88
|
+
identityOfProps: (props) => props.url,
|
|
89
|
+
fetchLive: (props) =>
|
|
90
|
+
argocd.listRepoCredsServiceRepositoryCredentials({ url: props.url }).pipe(
|
|
91
|
+
Effect.map((page) => (page.items ?? []).find((row) => row.url === props.url)),
|
|
92
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
93
|
+
),
|
|
94
|
+
matches: plainMatches,
|
|
95
|
+
upsert: (props) =>
|
|
96
|
+
resolveGitCredentials(props.credentials ?? {}).pipe(
|
|
97
|
+
Effect.flatMap((creds) =>
|
|
98
|
+
argocd.createRepoCredsServiceRepositoryCredentials(
|
|
99
|
+
present({
|
|
100
|
+
upsert: true,
|
|
101
|
+
tlsClientCertData: props.tlsClientCertData,
|
|
102
|
+
type: props.type,
|
|
103
|
+
url: props.url,
|
|
104
|
+
username: props.username,
|
|
105
|
+
...creds,
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const handlers = argocdHandlers(spec);
|
|
113
|
+
|
|
114
|
+
export const ArgocdRepoCredsProvider = () =>
|
|
115
|
+
Provider.effect(ArgocdRepoCreds, Effect.succeed(ArgocdRepoCreds.Provider.of(handlers)));
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Argocd.Repository`'s `spec` against a fake Argo CD — proves the doctrine-required shapes:
|
|
3
|
+
*
|
|
4
|
+
* - `fetchLive` folds a real distilled `NotFound` (404) to `undefined`, and does NOT fold a
|
|
5
|
+
* 401 — the same `catchTag('NotFound', ...)` idiom `forgejo/repository.test.ts` proves.
|
|
6
|
+
* - `spec.destroy`-via-`argocdOperations#destroy` is idempotent: a delete of an already-absent
|
|
7
|
+
* repository makes no request at all (S11).
|
|
8
|
+
* - `spec.upsert` sends `upsert: true` and resolves write-only credentials from the environment,
|
|
9
|
+
* never a literal value on a stack file — and refuses (a typed domain error, not a distilled
|
|
10
|
+
* one) when the declared env var is unset.
|
|
11
|
+
*
|
|
12
|
+
* No live Argo CD instance exists on the estate (2026-09-24) — every assertion here runs against
|
|
13
|
+
* `fake-argocd.ts`, never a real server.
|
|
14
|
+
*/
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import { FAKE_BASE, fakeArgocd, fakeArgocdLayer, fakeFailure } from './fake-argocd.ts';
|
|
18
|
+
import { argocdOperations } from './resource.ts';
|
|
19
|
+
import { spec } from './repository.ts';
|
|
20
|
+
|
|
21
|
+
const REPO = `${FAKE_BASE}/homeflare/kit.git`;
|
|
22
|
+
const ENV_VAR = 'ARGOCD_REPO_KIT_PASSWORD';
|
|
23
|
+
|
|
24
|
+
const liveRepo = (overrides: Record<string, unknown> = {}) =>
|
|
25
|
+
Response.json({ enableLfs: true, repo: REPO, type: 'git', ...overrides });
|
|
26
|
+
|
|
27
|
+
describe('Argocd.Repository spec.fetchLive', () => {
|
|
28
|
+
test('a live repository decodes into attributes', async () => {
|
|
29
|
+
const fake = fakeArgocd((method) =>
|
|
30
|
+
method === 'GET' ? liveRepo() : fakeFailure(404, 'not found'),
|
|
31
|
+
);
|
|
32
|
+
const live = await Effect.runPromise(
|
|
33
|
+
spec.fetchLive({ repo: REPO }).pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
|
|
34
|
+
);
|
|
35
|
+
expect(live?.repo).toBe(REPO);
|
|
36
|
+
expect(live?.enableLfs).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('a 404 folds to undefined', async () => {
|
|
40
|
+
const fake = fakeArgocd(() => fakeFailure(404, 'repository not found'));
|
|
41
|
+
const result = await Effect.runPromise(
|
|
42
|
+
spec.fetchLive({ repo: REPO }).pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
|
|
43
|
+
);
|
|
44
|
+
expect(result).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a 401 propagates — not folded to absent', async () => {
|
|
48
|
+
const fake = fakeArgocd(() => fakeFailure(401, 'bad token'));
|
|
49
|
+
const failure = await Effect.runPromise(
|
|
50
|
+
Effect.flip(spec.fetchLive({ repo: REPO }).pipe(Effect.provide(fakeArgocdLayer(fake.fetch)))),
|
|
51
|
+
);
|
|
52
|
+
expect(failure._tag).toBe('Unauthorized');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('Argocd.Repository destroy — idempotent (S11)', () => {
|
|
57
|
+
test('deleting an already-absent repository makes no delete call', async () => {
|
|
58
|
+
const fake = fakeArgocd(() => fakeFailure(404, 'repository not found'));
|
|
59
|
+
await Effect.runPromise(
|
|
60
|
+
argocdOperations(spec)
|
|
61
|
+
.destroy({ repo: REPO })
|
|
62
|
+
.pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
|
|
63
|
+
);
|
|
64
|
+
// Only the read (fetchLive) happened — never a DELETE against an object that was never there.
|
|
65
|
+
expect(fake.seen.every((call) => call.method !== 'DELETE')).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('deleting a live repository calls DELETE once', async () => {
|
|
69
|
+
const fake = fakeArgocd((method) => (method === 'GET' ? liveRepo() : Response.json({})));
|
|
70
|
+
await Effect.runPromise(
|
|
71
|
+
argocdOperations(spec)
|
|
72
|
+
.destroy({ repo: REPO })
|
|
73
|
+
.pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
|
|
74
|
+
);
|
|
75
|
+
expect(fake.seen.filter((call) => call.method === 'DELETE')).toHaveLength(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the DELETE call itself returning 404 (a race with an external removal) still succeeds', async () => {
|
|
79
|
+
// ⚠️ Different from the "already-absent" case above: fetchLive's pre-check finds the object
|
|
80
|
+
// live, but something else deletes it before this call's own DELETE lands — matching
|
|
81
|
+
// upstream's Hetzner/Certificate.ts `deleteById` pattern (catchTag on the delete itself).
|
|
82
|
+
const fake = fakeArgocd((method) =>
|
|
83
|
+
method === 'GET' ? liveRepo() : fakeFailure(404, 'repository not found'),
|
|
84
|
+
);
|
|
85
|
+
await Effect.runPromise(
|
|
86
|
+
argocdOperations(spec)
|
|
87
|
+
.destroy({ repo: REPO })
|
|
88
|
+
.pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
|
|
89
|
+
);
|
|
90
|
+
expect(fake.seen.filter((call) => call.method === 'DELETE')).toHaveLength(1);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('Argocd.Repository spec.upsert — write-only credentials', () => {
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
delete process.env[ENV_VAR];
|
|
97
|
+
});
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
delete process.env[ENV_VAR];
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('refuses with a typed domain error when the declared env var is unset', async () => {
|
|
103
|
+
const fake = fakeArgocd(() => fakeFailure(404, 'unused'));
|
|
104
|
+
const failure = await Effect.runPromise(
|
|
105
|
+
Effect.flip(
|
|
106
|
+
spec
|
|
107
|
+
.upsert({ credentials: { password: { fromEnv: ENV_VAR } }, repo: REPO, username: 'ci' })
|
|
108
|
+
.pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
expect(failure._tag).toBe('ArgocdSecretEnvUnsetError');
|
|
112
|
+
expect(fake.seen).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('resolves the env var and sends it — never the FromEnv reference itself', async () => {
|
|
116
|
+
process.env[ENV_VAR] = 'hunter2';
|
|
117
|
+
const fake = fakeArgocd((method) =>
|
|
118
|
+
method === 'POST' ? Response.json({ repo: REPO }) : fakeFailure(404, 'x'),
|
|
119
|
+
);
|
|
120
|
+
await Effect.runPromise(
|
|
121
|
+
spec
|
|
122
|
+
.upsert({ credentials: { password: { fromEnv: ENV_VAR } }, repo: REPO, username: 'ci' })
|
|
123
|
+
.pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
|
|
124
|
+
);
|
|
125
|
+
const [create] = fake.seen.filter((call) => call.method === 'POST');
|
|
126
|
+
// `upsert` is a query-string trait on this operation (`T.Query()`), not a body field —
|
|
127
|
+
// measured against `CreateRepositoryServiceRepositoryRequest` in services/argocd.ts.
|
|
128
|
+
expect(create?.path).toContain('upsert=true');
|
|
129
|
+
const body = create?.body as Record<string, unknown>;
|
|
130
|
+
expect(body.password).toBe('hunter2');
|
|
131
|
+
expect(body.username).toBe('ci');
|
|
132
|
+
expect(JSON.stringify(body)).not.toContain('fromEnv');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('Argocd.Repository diff — a changed identity key is a replace, never an update', () => {
|
|
137
|
+
// ⛔ REGRESSION: adversarial review 2026-09-24 found `diff` could only ever return
|
|
138
|
+
// noop/update, so a changed `repo` (Argo CD indexes by it) silently created a new
|
|
139
|
+
// registration at the new URL and left the old, still-credentialed one orphaned — no state
|
|
140
|
+
// pointed at it, nothing would ever delete it. Fixed via `identityOfProps`/
|
|
141
|
+
// `identityOfAttributes` in resource.ts.
|
|
142
|
+
const RENAMED_REPO = `${FAKE_BASE}/homeflare/kit-renamed.git`;
|
|
143
|
+
const oldOutput = spec.attributes({ enableLfs: true, repo: REPO, type: 'git' }, { repo: REPO });
|
|
144
|
+
|
|
145
|
+
test('a changed repo URL is a replace, and no read of the new URL happens first', async () => {
|
|
146
|
+
const fake = fakeArgocd(() => fakeFailure(500, 'must not be called — identity differs'));
|
|
147
|
+
const result = await Effect.runPromise(
|
|
148
|
+
argocdOperations(spec)
|
|
149
|
+
.diff({ repo: RENAMED_REPO }, oldOutput)
|
|
150
|
+
.pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
|
|
151
|
+
);
|
|
152
|
+
expect(result).toEqual({ action: 'replace' });
|
|
153
|
+
expect(fake.seen).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('the SAME repo URL with a plain-field change is an update, not a replace', async () => {
|
|
157
|
+
const fake = fakeArgocd((method) =>
|
|
158
|
+
method === 'GET' ? liveRepo({ enableLfs: false }) : fakeFailure(404, 'x'),
|
|
159
|
+
);
|
|
160
|
+
const result = await Effect.runPromise(
|
|
161
|
+
argocdOperations(spec)
|
|
162
|
+
.diff({ enableLfs: true, repo: REPO }, oldOutput)
|
|
163
|
+
.pipe(Effect.provide(fakeArgocdLayer(fake.fetch))),
|
|
164
|
+
);
|
|
165
|
+
expect(result).toEqual({ action: 'update' });
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Argocd.Repository` — a git/helm/OCI source registered with an Argo CD instance
|
|
3
|
+
* (`/api/v1/repositories`). REST-managed, NOT a Kubernetes CRD — see `docs/argocd.md` for the
|
|
4
|
+
* measured split between this family and `Kubernetes.Manifest` (upstream Alchemy's family, for
|
|
5
|
+
* `Application`/`AppProject`, which genuinely are CRDs).
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ NO LIVE ARGO CD INSTANCE EXISTS ON THE ESTATE (2026-09-24). Built ahead of the Talos-on-PVE
|
|
8
|
+
* cluster this targets; every test runs against `fake-argocd.ts`. See `docs/argocd.md`.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ `defaultRemovalPolicy: 'retain'` — deregistering a repository Argo CD applications still
|
|
11
|
+
* reference breaks their sync, the same reasoning `forgejo/repository.ts` gives for git repos.
|
|
12
|
+
* `delete` below is fully implemented; opt in with `.pipe(RemovalPolicy.destroy())`.
|
|
13
|
+
*/
|
|
14
|
+
import { Resource } from 'alchemy';
|
|
15
|
+
import * as Provider from 'alchemy/Provider';
|
|
16
|
+
import * as argocd from '@distilled.cloud/argocd/argocd';
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
import {
|
|
19
|
+
type ArgocdSecretEnvUnsetError,
|
|
20
|
+
type GitCredentialRefs,
|
|
21
|
+
resolveGitCredentials,
|
|
22
|
+
} from './git-credentials.ts';
|
|
23
|
+
import { type ArgocdRequirements, type ArgocdSpec, argocdHandlers, present } from './resource.ts';
|
|
24
|
+
|
|
25
|
+
export interface RepositoryProps {
|
|
26
|
+
/** The repository URL — identity key; changing it is a replace, not an update. */
|
|
27
|
+
repo: string;
|
|
28
|
+
/** "git" (default), "helm" or "oci". */
|
|
29
|
+
type?: string;
|
|
30
|
+
/** Required for "helm" repositories (Argo CD indexes helm repos by name, not URL). */
|
|
31
|
+
name?: string;
|
|
32
|
+
/** Restricts this repository to one Argo CD project; omit for every project. */
|
|
33
|
+
project?: string;
|
|
34
|
+
username?: string;
|
|
35
|
+
insecure?: boolean;
|
|
36
|
+
insecureIgnoreHostKey?: boolean;
|
|
37
|
+
enableLfs?: boolean;
|
|
38
|
+
enableOCI?: boolean;
|
|
39
|
+
forceHttpBasicAuth?: boolean;
|
|
40
|
+
proxy?: string;
|
|
41
|
+
noProxy?: string;
|
|
42
|
+
/** The client certificate itself — public, unlike its key. */
|
|
43
|
+
tlsClientCertData?: string;
|
|
44
|
+
/** Write-only — see `git-credentials.ts`. Never a value here, only env var names. */
|
|
45
|
+
credentials?: GitCredentialRefs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RepositoryAttributes {
|
|
49
|
+
repo: string;
|
|
50
|
+
type: string | undefined;
|
|
51
|
+
name: string | undefined;
|
|
52
|
+
project: string | undefined;
|
|
53
|
+
username: string | undefined;
|
|
54
|
+
insecure: boolean | undefined;
|
|
55
|
+
insecureIgnoreHostKey: boolean | undefined;
|
|
56
|
+
enableLfs: boolean | undefined;
|
|
57
|
+
enableOCI: boolean | undefined;
|
|
58
|
+
forceHttpBasicAuth: boolean | undefined;
|
|
59
|
+
proxy: string | undefined;
|
|
60
|
+
noProxy: string | undefined;
|
|
61
|
+
tlsClientCertData: string | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ArgocdRepository extends Resource<
|
|
65
|
+
'Argocd.Repository',
|
|
66
|
+
RepositoryProps,
|
|
67
|
+
RepositoryAttributes,
|
|
68
|
+
never,
|
|
69
|
+
ArgocdRequirements
|
|
70
|
+
> {}
|
|
71
|
+
|
|
72
|
+
export const ArgocdRepository = Resource<ArgocdRepository>('Argocd.Repository', {
|
|
73
|
+
defaultRemovalPolicy: 'retain',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const attributesOf = (live: argocd.V1alpha1Repository): RepositoryAttributes => ({
|
|
77
|
+
enableLfs: live.enableLfs,
|
|
78
|
+
enableOCI: live.enableOCI,
|
|
79
|
+
forceHttpBasicAuth: live.forceHttpBasicAuth,
|
|
80
|
+
insecure: live.insecure,
|
|
81
|
+
insecureIgnoreHostKey: live.insecureIgnoreHostKey,
|
|
82
|
+
name: live.name,
|
|
83
|
+
noProxy: live.noProxy,
|
|
84
|
+
project: live.project,
|
|
85
|
+
proxy: live.proxy,
|
|
86
|
+
repo: live.repo ?? '',
|
|
87
|
+
tlsClientCertData: live.tlsClientCertData,
|
|
88
|
+
type: live.type,
|
|
89
|
+
username: live.username,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** Only the plain fields — see `git-credentials.ts` for why the secret fields are excluded. */
|
|
93
|
+
const plainMatches = (attrs: RepositoryAttributes, props: RepositoryProps): boolean =>
|
|
94
|
+
(props.type ?? 'git') === (attrs.type ?? 'git') &&
|
|
95
|
+
(props.name ?? undefined) === (attrs.name ?? undefined) &&
|
|
96
|
+
(props.project ?? undefined) === (attrs.project ?? undefined) &&
|
|
97
|
+
(props.username ?? undefined) === (attrs.username ?? undefined) &&
|
|
98
|
+
Boolean(props.insecure) === Boolean(attrs.insecure) &&
|
|
99
|
+
Boolean(props.insecureIgnoreHostKey) === Boolean(attrs.insecureIgnoreHostKey) &&
|
|
100
|
+
Boolean(props.enableLfs) === Boolean(attrs.enableLfs) &&
|
|
101
|
+
Boolean(props.enableOCI) === Boolean(attrs.enableOCI) &&
|
|
102
|
+
Boolean(props.forceHttpBasicAuth) === Boolean(attrs.forceHttpBasicAuth) &&
|
|
103
|
+
(props.proxy ?? undefined) === (attrs.proxy ?? undefined) &&
|
|
104
|
+
(props.noProxy ?? undefined) === (attrs.noProxy ?? undefined) &&
|
|
105
|
+
(props.tlsClientCertData ?? undefined) === (attrs.tlsClientCertData ?? undefined);
|
|
106
|
+
|
|
107
|
+
type RepositoryError =
|
|
108
|
+
| argocd.GetRepositoryServiceError
|
|
109
|
+
| argocd.CreateRepositoryServiceRepositoryError
|
|
110
|
+
| argocd.DeleteRepositoryServiceRepositoryError
|
|
111
|
+
| ArgocdSecretEnvUnsetError;
|
|
112
|
+
|
|
113
|
+
/** ★ EXPORTED for direct testing against `fake-argocd.ts` — see `repository.test.ts`. */
|
|
114
|
+
export const spec: ArgocdSpec<
|
|
115
|
+
RepositoryProps,
|
|
116
|
+
argocd.V1alpha1Repository,
|
|
117
|
+
RepositoryAttributes,
|
|
118
|
+
RepositoryError
|
|
119
|
+
> = {
|
|
120
|
+
attributes: (live) => attributesOf(live),
|
|
121
|
+
// ★ Catches the DELETE call's own NotFound too, not just the pre-check `fetchLive` fold above
|
|
122
|
+
// — closes the TOCTOU race between that read and this call (the object could be removed by
|
|
123
|
+
// something else in between). Matches upstream's own pattern: Hetzner/Certificate.ts's
|
|
124
|
+
// `deleteById` (`.pipe(Effect.catchTag('NotFound', () => Effect.void))`).
|
|
125
|
+
destroy: (props) =>
|
|
126
|
+
argocd
|
|
127
|
+
.deleteRepositoryServiceRepository({ repo: props.repo })
|
|
128
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.void)),
|
|
129
|
+
identityOfAttributes: (attrs) => attrs.repo,
|
|
130
|
+
identityOfProps: (props) => props.repo,
|
|
131
|
+
fetchLive: (props) =>
|
|
132
|
+
argocd
|
|
133
|
+
.getRepositoryService({ repo: props.repo })
|
|
134
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
135
|
+
matches: plainMatches,
|
|
136
|
+
upsert: (props) =>
|
|
137
|
+
resolveGitCredentials(props.credentials ?? {}).pipe(
|
|
138
|
+
Effect.flatMap((creds) =>
|
|
139
|
+
argocd.createRepositoryServiceRepository(
|
|
140
|
+
present({
|
|
141
|
+
upsert: true,
|
|
142
|
+
enableLfs: props.enableLfs,
|
|
143
|
+
enableOCI: props.enableOCI,
|
|
144
|
+
forceHttpBasicAuth: props.forceHttpBasicAuth,
|
|
145
|
+
insecure: props.insecure,
|
|
146
|
+
insecureIgnoreHostKey: props.insecureIgnoreHostKey,
|
|
147
|
+
name: props.name,
|
|
148
|
+
noProxy: props.noProxy,
|
|
149
|
+
project: props.project,
|
|
150
|
+
proxy: props.proxy,
|
|
151
|
+
repo: props.repo,
|
|
152
|
+
tlsClientCertData: props.tlsClientCertData,
|
|
153
|
+
type: props.type,
|
|
154
|
+
username: props.username,
|
|
155
|
+
...creds,
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const handlers = argocdHandlers(spec);
|
|
163
|
+
|
|
164
|
+
export const ArgocdRepositoryProvider = () =>
|
|
165
|
+
Provider.effect(ArgocdRepository, Effect.succeed(ArgocdRepository.Provider.of(handlers)));
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One shape for every Argo CD REST object — Repository, RepoCreds, Cluster — the same four
|
|
3
|
+
* operations, different distilled call.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THIS IS A NEAR-VERBATIM COPY OF `../forgejo/resource.ts`'S ENGINE, ON PURPOSE. Both SDKs speak
|
|
6
|
+
* plain bearer-authenticated REST with a real `NotFound` tag (see the family's own docs.md for
|
|
7
|
+
* why Argo CD, UNLIKE Discord, gets a genuine typed 404 — measured from `protocol.ts`), so the
|
|
8
|
+
* same read/diff/reconcile/destroy shape applies unchanged. A second hand-rolled engine here
|
|
9
|
+
* would just be forgejo's with the names swapped.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ NO `Unowned`/`adopt(true)` WRAPPING HERE — mirrors forgejo's own choice (not netbox's or
|
|
12
|
+
* discord's `Unowned` marker), not an oversight. A live match on a cold read (no persisted
|
|
13
|
+
* `output`) is treated as ours, same as every other forgejo-shaped family in this kit today.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE.
|
|
16
|
+
* `Effect.catchTag`'s tag-literal inference needs a concrete error union to resolve `"NotFound"`
|
|
17
|
+
* against; a spec's `E` is only concrete at each resource file's own call site. So every
|
|
18
|
+
* `fetchLive` ends `.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined)))` itself.
|
|
19
|
+
*/
|
|
20
|
+
import { isResolved } from 'alchemy/Diff';
|
|
21
|
+
import type { Input } from 'alchemy/Input';
|
|
22
|
+
import { CredentialsFromEnv } from '@distilled.cloud/argocd/Credentials';
|
|
23
|
+
import type { ArgocdOpContext } from '@distilled.cloud/argocd/Protocol';
|
|
24
|
+
import * as Effect from 'effect/Effect';
|
|
25
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
26
|
+
|
|
27
|
+
/** What every handler needs from the caller's runtime, once `argocdHandlers` bakes in credentials. */
|
|
28
|
+
export type ArgocdRequirements = HttpClient.HttpClient;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Strips undefined-valued keys, mapping each to a genuinely optional key rather than a present
|
|
32
|
+
* key typed `X | undefined`. `tsconfig.base.json` turns on `exactOptionalPropertyTypes`, and every
|
|
33
|
+
* generated Argo CD request type declares its optional fields the strict way (`foo?: X`, never
|
|
34
|
+
* `foo?: X | undefined`) — so `{ foo: props.foo }` where `props.foo?: X` does not typecheck against
|
|
35
|
+
* it even when `foo` is genuinely absent at runtime. Every resource file's request body goes
|
|
36
|
+
* through this once, mirroring `forgejo/repository.ts#editForm`'s conditional-spread idiom, just
|
|
37
|
+
* generalized instead of repeated per field.
|
|
38
|
+
*/
|
|
39
|
+
export const present = <T extends object>(obj: T): { [K in keyof T]?: Exclude<T[K], undefined> } =>
|
|
40
|
+
Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as never;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One Argo CD object's distilled calls. `Live` is whatever the SDK decodes (`V1alpha1Repository`,
|
|
44
|
+
* `V1alpha1Cluster`, …); `E` is left to each resource file to declare.
|
|
45
|
+
*/
|
|
46
|
+
export type ArgocdSpec<Props extends object, Live, Attributes, E> = {
|
|
47
|
+
/**
|
|
48
|
+
* The identity key's value — `repo`/`url`/`server` depending on the resource — read off
|
|
49
|
+
* declared props and off persisted attributes respectively. `diff` compares the two to detect
|
|
50
|
+
* an identity change, which `upsert`'s single create-or-converge call cannot express as an
|
|
51
|
+
* in-place update: Argo CD indexes each of these objects BY this value, so a changed identity
|
|
52
|
+
* means a create at the new key, never a rename, and the object left at the old key must be
|
|
53
|
+
* deleted — exactly what a `replace` diff action tells Alchemy's own Plan/Apply to do (delete
|
|
54
|
+
* the old physical object, using `olds`, then reconcile `news`). Without this, a changed
|
|
55
|
+
* `repo`/`url`/`server` orphans the old, still-credentialed object with no state pointing at it
|
|
56
|
+
* — found in adversarial review 2026-09-24, fixed here.
|
|
57
|
+
*/
|
|
58
|
+
readonly identityOfProps: (props: Props) => string;
|
|
59
|
+
readonly identityOfAttributes: (attributes: Attributes) => string;
|
|
60
|
+
/** Already folds the SDK's `NotFound` to `undefined` via its own `catchTag` — see the note above. */
|
|
61
|
+
readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, ArgocdOpContext>;
|
|
62
|
+
readonly attributes: (live: Live, props: Props) => Attributes | undefined;
|
|
63
|
+
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
64
|
+
/**
|
|
65
|
+
* A single upsert call — every Argo CD create endpoint this family uses takes `upsert: true`
|
|
66
|
+
* and converges create-or-update in one request, so there is no separate update path to wire.
|
|
67
|
+
*/
|
|
68
|
+
readonly upsert: (props: Props) => Effect.Effect<unknown, E, ArgocdOpContext>;
|
|
69
|
+
readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, ArgocdOpContext>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const argocdOperations = <Props extends object, Live, Attributes, E>(
|
|
73
|
+
spec: ArgocdSpec<Props, Live, Attributes, E>,
|
|
74
|
+
) => {
|
|
75
|
+
const read = (props: Props) =>
|
|
76
|
+
spec
|
|
77
|
+
.fetchLive(props)
|
|
78
|
+
.pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
read,
|
|
82
|
+
|
|
83
|
+
diff: (news: Input<Props>, output: Attributes | undefined) =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
86
|
+
// The identity key changed (repo/url/server) — `upsert` cannot rename in place, it would
|
|
87
|
+
// create at the new key and leave the old, still-live object orphaned. See the spec type.
|
|
88
|
+
if (spec.identityOfProps(news) !== spec.identityOfAttributes(output)) {
|
|
89
|
+
return { action: 'replace' } as const;
|
|
90
|
+
}
|
|
91
|
+
const live = yield* read(news);
|
|
92
|
+
if (live === undefined) return { action: 'update' } as const;
|
|
93
|
+
return spec.matches(live, news)
|
|
94
|
+
? ({ action: 'noop' } as const)
|
|
95
|
+
: ({ action: 'update' } as const);
|
|
96
|
+
}),
|
|
97
|
+
|
|
98
|
+
reconcile: (news: Props) =>
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const before = yield* read(news);
|
|
101
|
+
// S10: no write when what's declared already matches what's live.
|
|
102
|
+
if (before === undefined || !spec.matches(before, news)) {
|
|
103
|
+
yield* spec.upsert(news);
|
|
104
|
+
}
|
|
105
|
+
const after = yield* read(news);
|
|
106
|
+
if (after === undefined) {
|
|
107
|
+
return yield* Effect.die(
|
|
108
|
+
new Error(
|
|
109
|
+
'the write returned no error but the object is still absent — read back rather ' +
|
|
110
|
+
'than trust the status code.',
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return after;
|
|
115
|
+
}),
|
|
116
|
+
|
|
117
|
+
destroy: (olds: Props) =>
|
|
118
|
+
Effect.gen(function* () {
|
|
119
|
+
const live = yield* spec.fetchLive(olds);
|
|
120
|
+
if (live === undefined) return;
|
|
121
|
+
yield* spec.destroy(olds, live);
|
|
122
|
+
}),
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The four provider handlers for a spec'd Argo CD object, wired once.
|
|
128
|
+
*
|
|
129
|
+
* ★ CREDENTIALS ARE PROVIDED HERE, ONCE. `CredentialsFromEnv` resolves `ARGOCD_TOKEN` /
|
|
130
|
+
* `ARGOCD_SERVER` on the calling fiber per request (credentials.ts), never captured at load.
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ `list` answers empty — Argo CD's list endpoints return every object of that kind on the whole
|
|
133
|
+
* instance; adoption stays explicit (a declared resource's own `read`), same reasoning as
|
|
134
|
+
* forgejo and discord.
|
|
135
|
+
*/
|
|
136
|
+
export const argocdHandlers = <Props extends object, Live, Attributes extends object, E>(
|
|
137
|
+
spec: ArgocdSpec<Props, Live, Attributes, E>,
|
|
138
|
+
) => {
|
|
139
|
+
const ops = argocdOperations(spec);
|
|
140
|
+
const withCredentials = <A>(effect: Effect.Effect<A, E, ArgocdOpContext>) =>
|
|
141
|
+
Effect.provide(effect, CredentialsFromEnv);
|
|
142
|
+
return {
|
|
143
|
+
list: () => Effect.succeed([]),
|
|
144
|
+
read: ({ olds }: { olds: Props }) => withCredentials(ops.read(olds)),
|
|
145
|
+
diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
|
|
146
|
+
withCredentials(ops.diff(news, output)),
|
|
147
|
+
reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
|
|
148
|
+
delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
|
|
149
|
+
};
|
|
150
|
+
};
|