@intentius/chant-lexicon-k8s 0.1.14 → 0.1.15
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 +0 -1
- package/dist/integrity.json +13 -5
- package/dist/manifest.json +1 -1
- package/dist/meta.json +216 -0
- package/dist/rules/argo-appset-single-project.ts +66 -0
- package/dist/rules/argo-ast.ts +121 -0
- package/dist/rules/argo-automated-prune.ts +75 -0
- package/dist/rules/argo-helpers.ts +49 -0
- package/dist/rules/argo002.ts +47 -0
- package/dist/rules/argo003.ts +80 -0
- package/dist/rules/argo005.ts +59 -0
- package/dist/skills/chant-k8s-argo.md +176 -0
- package/dist/types/index.d.ts +34 -0
- package/package.json +1 -1
- package/src/codegen/docs.ts +13 -0
- package/src/codegen/versions.ts +8 -5
- package/src/composites/argo-app.ts +380 -0
- package/src/composites/composites.test.ts +136 -0
- package/src/composites/index.ts +12 -0
- package/src/crd/crd-sources.ts +18 -0
- package/src/crd/loader.ts +4 -5
- package/src/crd/parser.test.ts +61 -0
- package/src/crd/parser.ts +37 -2
- package/src/describe-resources.ts +8 -1
- package/src/export-resources-io.test.ts +72 -0
- package/src/export-resources.ts +60 -0
- package/src/generated/index.d.ts +34 -0
- package/src/generated/index.ts +25 -0
- package/src/generated/lexicon-k8s.json +216 -0
- package/src/import/live-export.test.ts +114 -0
- package/src/import/live-export.ts +89 -0
- package/src/index.ts +5 -0
- package/src/lifecycle-integration.test.ts +111 -0
- package/src/lint/post-synth/argo-helpers.ts +49 -0
- package/src/lint/post-synth/argo002.ts +47 -0
- package/src/lint/post-synth/argo003.ts +80 -0
- package/src/lint/post-synth/argo005.ts +59 -0
- package/src/lint/post-synth/post-synth.test.ts +123 -0
- package/src/lint/rules/argo-appset-single-project.ts +66 -0
- package/src/lint/rules/argo-ast.ts +121 -0
- package/src/lint/rules/argo-automated-prune.ts +75 -0
- package/src/lint/rules/rules.test.ts +109 -0
- package/src/plugin.test.ts +6 -1
- package/src/plugin.ts +44 -1
- package/src/serializer-ownership.test.ts +44 -0
- package/src/serializer.test.ts +25 -0
- package/src/serializer.ts +9 -4
- package/src/skills/chant-k8s-argo.md +176 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argo CD composites — turn Chant build targets into Argo `Application`s.
|
|
3
|
+
*
|
|
4
|
+
* The k8s lexicon stays runtime-agnostic: it only emits manifests. These
|
|
5
|
+
* composites are the opt-in bridge to Argo CD's reconciliation layer. Authoring
|
|
6
|
+
* an `Application` by hand is ~30 lines of nested YAML; `ArgoAppFor` collapses
|
|
7
|
+
* it to one call with production-friendly defaults.
|
|
8
|
+
*
|
|
9
|
+
* - ArgoAppFor(target, opts) → a single K8s::Argo::Application
|
|
10
|
+
* - ArgoAppSetForRegions(regions, …) → one ApplicationSet, fanned out per region
|
|
11
|
+
* - registerArgoCluster(opts) → the cluster-registration Secret
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Composite, mergeDefaults } from "@intentius/chant";
|
|
15
|
+
import {
|
|
16
|
+
Application as ApplicationResource,
|
|
17
|
+
ApplicationSet as ApplicationSetResource,
|
|
18
|
+
Secret as SecretResource,
|
|
19
|
+
} from "../generated";
|
|
20
|
+
|
|
21
|
+
// ── Shared types ─────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/** Where Argo deploys the synced manifests. `server` and `name` are exclusive. */
|
|
24
|
+
export interface ArgoDestination {
|
|
25
|
+
/** Target cluster API server URL (e.g. https://kubernetes.default.svc). */
|
|
26
|
+
server?: string;
|
|
27
|
+
/** Registered cluster name (alternative to server). */
|
|
28
|
+
name?: string;
|
|
29
|
+
/** Namespace the synced resources land in. */
|
|
30
|
+
namespace: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Argo sync policy. Omit `automated` for manual sync. */
|
|
34
|
+
export interface ArgoSyncPolicy {
|
|
35
|
+
automated?: {
|
|
36
|
+
/** Delete resources that disappear from git. Default false (and required
|
|
37
|
+
* false on prod Applications — see ARGO001). */
|
|
38
|
+
prune?: boolean;
|
|
39
|
+
/** Revert out-of-band changes back to git state. */
|
|
40
|
+
selfHeal?: boolean;
|
|
41
|
+
};
|
|
42
|
+
/** e.g. ["CreateNamespace=true", "ServerSideApply=true"]. */
|
|
43
|
+
syncOptions?: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const IN_CLUSTER_SERVER = "https://kubernetes.default.svc";
|
|
47
|
+
|
|
48
|
+
/** The default, safe sync policy: automated, non-pruning, self-healing. */
|
|
49
|
+
function defaultSyncPolicy(): ArgoSyncPolicy {
|
|
50
|
+
return {
|
|
51
|
+
automated: { prune: false, selfHeal: true },
|
|
52
|
+
syncOptions: ["CreateNamespace=true"],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderSyncPolicy(policy: ArgoSyncPolicy): Record<string, unknown> {
|
|
57
|
+
const out: Record<string, unknown> = {};
|
|
58
|
+
if (policy.automated) {
|
|
59
|
+
out.automated = {
|
|
60
|
+
...(policy.automated.prune !== undefined && { prune: policy.automated.prune }),
|
|
61
|
+
...(policy.automated.selfHeal !== undefined && { selfHeal: policy.automated.selfHeal }),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (policy.syncOptions && policy.syncOptions.length > 0) {
|
|
65
|
+
out.syncOptions = policy.syncOptions;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderDestination(dest: ArgoDestination): Record<string, unknown> {
|
|
71
|
+
return {
|
|
72
|
+
...(dest.server !== undefined && { server: dest.server }),
|
|
73
|
+
...(dest.name !== undefined && { name: dest.name }),
|
|
74
|
+
namespace: dest.namespace,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── ArgoAppFor ───────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
export interface ArgoAppForOptions {
|
|
81
|
+
/** Git repository URL the Application syncs from. */
|
|
82
|
+
repo: string;
|
|
83
|
+
/** Path within the repo holding the manifests. */
|
|
84
|
+
path: string;
|
|
85
|
+
/** Where Argo deploys. Defaults to the in-cluster target if omitted. */
|
|
86
|
+
destination?: ArgoDestination;
|
|
87
|
+
/** Git revision to track (default "HEAD"). */
|
|
88
|
+
targetRevision?: string;
|
|
89
|
+
/** AppProject the Application belongs to (default "default"). */
|
|
90
|
+
project?: string;
|
|
91
|
+
/** Namespace the Application object itself lives in (default "argocd"). */
|
|
92
|
+
argoNamespace?: string;
|
|
93
|
+
/** Sync policy. Omit for the safe automated default; pass `{}` for manual. */
|
|
94
|
+
syncPolicy?: ArgoSyncPolicy;
|
|
95
|
+
/** Extra labels applied to the Application. */
|
|
96
|
+
labels?: Record<string, string>;
|
|
97
|
+
defaults?: { application?: Partial<Record<string, unknown>> };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ArgoAppForResult {
|
|
101
|
+
application: InstanceType<typeof ApplicationResource>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ArgoApplication = Composite<{ target: string } & ArgoAppForOptions, ArgoAppForResult>(
|
|
105
|
+
(props) => {
|
|
106
|
+
const {
|
|
107
|
+
target,
|
|
108
|
+
repo,
|
|
109
|
+
path,
|
|
110
|
+
destination = { server: IN_CLUSTER_SERVER, namespace: target },
|
|
111
|
+
targetRevision = "HEAD",
|
|
112
|
+
project = "default",
|
|
113
|
+
argoNamespace = "argocd",
|
|
114
|
+
syncPolicy,
|
|
115
|
+
labels = {},
|
|
116
|
+
defaults,
|
|
117
|
+
} = props;
|
|
118
|
+
|
|
119
|
+
const resolvedSyncPolicy = syncPolicy ?? defaultSyncPolicy();
|
|
120
|
+
const renderedSync = renderSyncPolicy(resolvedSyncPolicy);
|
|
121
|
+
|
|
122
|
+
const application = new ApplicationResource(mergeDefaults({
|
|
123
|
+
metadata: {
|
|
124
|
+
name: target,
|
|
125
|
+
namespace: argoNamespace,
|
|
126
|
+
labels: {
|
|
127
|
+
"app.kubernetes.io/name": target,
|
|
128
|
+
"app.kubernetes.io/managed-by": "chant",
|
|
129
|
+
...labels,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
spec: {
|
|
133
|
+
project,
|
|
134
|
+
source: { repoURL: repo, path, targetRevision },
|
|
135
|
+
destination: renderDestination(destination),
|
|
136
|
+
...(Object.keys(renderedSync).length > 0 && { syncPolicy: renderedSync }),
|
|
137
|
+
},
|
|
138
|
+
}, defaults?.application));
|
|
139
|
+
|
|
140
|
+
return { application };
|
|
141
|
+
},
|
|
142
|
+
"ArgoApplication",
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Turn a Chant build target into an Argo CD `Application` in one call.
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* ```ts
|
|
150
|
+
* import { ArgoAppFor } from "@intentius/chant-lexicon-k8s";
|
|
151
|
+
*
|
|
152
|
+
* export const api = ArgoAppFor("api", {
|
|
153
|
+
* repo: "https://github.com/acme/infra",
|
|
154
|
+
* path: "dist/api",
|
|
155
|
+
* destination: { server: "https://kubernetes.default.svc", namespace: "api" },
|
|
156
|
+
* });
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
export function ArgoAppFor(target: string, options: ArgoAppForOptions): ArgoAppForResult {
|
|
160
|
+
return ArgoApplication({ target, ...options });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── ArgoAppSetForRegions ─────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
/** Per-region values resolved by the caller's mapper. */
|
|
166
|
+
export interface ArgoRegionTarget {
|
|
167
|
+
/** Target cluster API server URL for this region. */
|
|
168
|
+
server?: string;
|
|
169
|
+
/** Registered cluster name for this region (alternative to server). */
|
|
170
|
+
name?: string;
|
|
171
|
+
/** Namespace the synced resources land in. */
|
|
172
|
+
namespace: string;
|
|
173
|
+
/** Per-region repo path (overrides the set-level path). */
|
|
174
|
+
path?: string;
|
|
175
|
+
/** Per-region git revision (overrides the set-level revision). */
|
|
176
|
+
targetRevision?: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface ArgoAppSetForRegionsOptions {
|
|
180
|
+
/** Base name; generated Applications are `<region>-<name>`. */
|
|
181
|
+
name: string;
|
|
182
|
+
/** Git repository URL the generated Applications sync from. */
|
|
183
|
+
repo: string;
|
|
184
|
+
/** Set-level repo path (per-region mapper may override). */
|
|
185
|
+
path?: string;
|
|
186
|
+
/** Single static AppProject for every generated Application (see ARGO004). */
|
|
187
|
+
project?: string;
|
|
188
|
+
/** Namespace the ApplicationSet object lives in (default "argocd"). */
|
|
189
|
+
argoNamespace?: string;
|
|
190
|
+
/** Set-level git revision (default "HEAD"). */
|
|
191
|
+
targetRevision?: string;
|
|
192
|
+
/** Sync policy applied to the template. Omit for the safe automated default. */
|
|
193
|
+
syncPolicy?: ArgoSyncPolicy;
|
|
194
|
+
/** Extra labels applied to the ApplicationSet. */
|
|
195
|
+
labels?: Record<string, string>;
|
|
196
|
+
defaults?: { applicationSet?: Partial<Record<string, unknown>> };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface ArgoAppSetForRegionsResult {
|
|
200
|
+
applicationSet: InstanceType<typeof ApplicationSetResource>;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Emit one `ApplicationSet` that fans out across regions via a list generator —
|
|
205
|
+
* one synced Application per region, all scoped to a single AppProject.
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```ts
|
|
209
|
+
* import { ArgoAppSetForRegions } from "@intentius/chant-lexicon-k8s";
|
|
210
|
+
*
|
|
211
|
+
* export const crdb = ArgoAppSetForRegions(
|
|
212
|
+
* ["east", "central", "west"],
|
|
213
|
+
* (region) => ({
|
|
214
|
+
* server: clusterServers[region],
|
|
215
|
+
* namespace: `crdb-${region}`,
|
|
216
|
+
* path: `dist/${region}`,
|
|
217
|
+
* }),
|
|
218
|
+
* { name: "crdb", repo: "https://github.com/acme/infra", project: "crdb" },
|
|
219
|
+
* );
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
export function ArgoAppSetForRegions(
|
|
223
|
+
regions: string[],
|
|
224
|
+
fn: (region: string) => ArgoRegionTarget,
|
|
225
|
+
options: ArgoAppSetForRegionsOptions,
|
|
226
|
+
): ArgoAppSetForRegionsResult {
|
|
227
|
+
return ArgoApplicationSet({ regions, fn, options });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const ArgoApplicationSet = Composite<
|
|
231
|
+
{
|
|
232
|
+
regions: string[];
|
|
233
|
+
fn: (region: string) => ArgoRegionTarget;
|
|
234
|
+
options: ArgoAppSetForRegionsOptions;
|
|
235
|
+
},
|
|
236
|
+
ArgoAppSetForRegionsResult
|
|
237
|
+
>((props) => {
|
|
238
|
+
const { regions, fn, options } = props;
|
|
239
|
+
const {
|
|
240
|
+
name,
|
|
241
|
+
repo,
|
|
242
|
+
path: setPath,
|
|
243
|
+
project = "default",
|
|
244
|
+
argoNamespace = "argocd",
|
|
245
|
+
targetRevision: setRevision = "HEAD",
|
|
246
|
+
syncPolicy,
|
|
247
|
+
labels = {},
|
|
248
|
+
defaults,
|
|
249
|
+
} = options;
|
|
250
|
+
|
|
251
|
+
// One list-generator element per region, carrying the resolved per-region
|
|
252
|
+
// values the template interpolates.
|
|
253
|
+
const elements = regions.map((region) => {
|
|
254
|
+
const t = fn(region);
|
|
255
|
+
const path = t.path ?? setPath;
|
|
256
|
+
if (path === undefined) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`ArgoAppSetForRegions("${name}"): region "${region}" has no path — set options.path or return a path from the mapper.`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
region,
|
|
263
|
+
...(t.server !== undefined && { server: t.server }),
|
|
264
|
+
...(t.name !== undefined && { clusterName: t.name }),
|
|
265
|
+
namespace: t.namespace,
|
|
266
|
+
path,
|
|
267
|
+
targetRevision: t.targetRevision ?? setRevision,
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Destination interpolates server or name depending on what the mapper gave.
|
|
272
|
+
const usesServer = elements.every((e) => "server" in e);
|
|
273
|
+
const destination: Record<string, unknown> = usesServer
|
|
274
|
+
? { server: "{{server}}", namespace: "{{namespace}}" }
|
|
275
|
+
: { name: "{{clusterName}}", namespace: "{{namespace}}" };
|
|
276
|
+
|
|
277
|
+
const resolvedSyncPolicy = syncPolicy ?? defaultSyncPolicy();
|
|
278
|
+
const renderedSync = renderSyncPolicy(resolvedSyncPolicy);
|
|
279
|
+
|
|
280
|
+
const applicationSet = new ApplicationSetResource(mergeDefaults({
|
|
281
|
+
metadata: {
|
|
282
|
+
name,
|
|
283
|
+
namespace: argoNamespace,
|
|
284
|
+
labels: {
|
|
285
|
+
"app.kubernetes.io/name": name,
|
|
286
|
+
"app.kubernetes.io/managed-by": "chant",
|
|
287
|
+
...labels,
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
spec: {
|
|
291
|
+
generators: [{ list: { elements } }],
|
|
292
|
+
template: {
|
|
293
|
+
metadata: { name: `{{region}}-${name}` },
|
|
294
|
+
spec: {
|
|
295
|
+
// Single static AppProject for the whole set (ARGO004).
|
|
296
|
+
project,
|
|
297
|
+
source: { repoURL: repo, path: "{{path}}", targetRevision: "{{targetRevision}}" },
|
|
298
|
+
destination,
|
|
299
|
+
...(Object.keys(renderedSync).length > 0 && { syncPolicy: renderedSync }),
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
}, defaults?.applicationSet));
|
|
304
|
+
|
|
305
|
+
return { applicationSet };
|
|
306
|
+
}, "ArgoApplicationSet");
|
|
307
|
+
|
|
308
|
+
// ── registerArgoCluster ──────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
export interface RegisterArgoClusterOptions {
|
|
311
|
+
/** Cluster name as Argo will know it (referenced by Application destinations). */
|
|
312
|
+
name: string;
|
|
313
|
+
/** Cluster API server URL. */
|
|
314
|
+
server: string;
|
|
315
|
+
/**
|
|
316
|
+
* Argo cluster connection config (TLS, bearer token, exec auth). Serialized
|
|
317
|
+
* into the Secret's `config` field. See the Argo CD cluster Secret format.
|
|
318
|
+
*/
|
|
319
|
+
config?: Record<string, unknown> | string;
|
|
320
|
+
/** Namespace Argo CD runs in (default "argocd"). */
|
|
321
|
+
argoNamespace?: string;
|
|
322
|
+
/** Extra labels (merged with the required secret-type label). */
|
|
323
|
+
labels?: Record<string, string>;
|
|
324
|
+
defaults?: { secret?: Partial<Record<string, unknown>> };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export interface RegisterArgoClusterResult {
|
|
328
|
+
secret: InstanceType<typeof SecretResource>;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Register an external cluster with Argo CD — emits the cluster Secret labelled
|
|
333
|
+
* `argocd.argoproj.io/secret-type: cluster` that ARGO003 looks for.
|
|
334
|
+
*
|
|
335
|
+
* @example
|
|
336
|
+
* ```ts
|
|
337
|
+
* import { registerArgoCluster } from "@intentius/chant-lexicon-k8s";
|
|
338
|
+
*
|
|
339
|
+
* export const east = registerArgoCluster({
|
|
340
|
+
* name: "east",
|
|
341
|
+
* server: "https://east.example.com",
|
|
342
|
+
* config: { tlsClientConfig: { insecure: false } },
|
|
343
|
+
* });
|
|
344
|
+
* ```
|
|
345
|
+
*/
|
|
346
|
+
export function registerArgoCluster(options: RegisterArgoClusterOptions): RegisterArgoClusterResult {
|
|
347
|
+
return ArgoClusterSecret(options);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const ArgoClusterSecret = Composite<RegisterArgoClusterOptions, RegisterArgoClusterResult>(
|
|
351
|
+
(options) => {
|
|
352
|
+
const { name, server, config, argoNamespace = "argocd", labels = {}, defaults } = options;
|
|
353
|
+
|
|
354
|
+
const configString =
|
|
355
|
+
config === undefined ? undefined : typeof config === "string" ? config : JSON.stringify(config);
|
|
356
|
+
|
|
357
|
+
const secret = new SecretResource(mergeDefaults({
|
|
358
|
+
metadata: {
|
|
359
|
+
// Secret names can't contain characters from the server URL; use the
|
|
360
|
+
// cluster name as the object name.
|
|
361
|
+
name: `cluster-${name}`,
|
|
362
|
+
namespace: argoNamespace,
|
|
363
|
+
labels: {
|
|
364
|
+
"argocd.argoproj.io/secret-type": "cluster",
|
|
365
|
+
"app.kubernetes.io/managed-by": "chant",
|
|
366
|
+
...labels,
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
type: "Opaque",
|
|
370
|
+
stringData: {
|
|
371
|
+
name,
|
|
372
|
+
server,
|
|
373
|
+
...(configString !== undefined && { config: configString }),
|
|
374
|
+
},
|
|
375
|
+
}, defaults?.secret));
|
|
376
|
+
|
|
377
|
+
return { secret };
|
|
378
|
+
},
|
|
379
|
+
"ArgoClusterSecret",
|
|
380
|
+
);
|
|
@@ -8,6 +8,7 @@ function p(member: unknown): Record<string, unknown> {
|
|
|
8
8
|
return (member as any).props;
|
|
9
9
|
}
|
|
10
10
|
import { StatefulApp } from "./stateful-app";
|
|
11
|
+
import { ArgoAppFor, ArgoAppSetForRegions, registerArgoCluster } from "./argo-app";
|
|
11
12
|
import { CronWorkload } from "./cron-workload";
|
|
12
13
|
import { AutoscaledService } from "./autoscaled-service";
|
|
13
14
|
import { WorkerPool } from "./worker-pool";
|
|
@@ -3223,3 +3224,138 @@ describe("CockroachDbCluster", () => {
|
|
|
3223
3224
|
expect(container.image).toBe("cockroachdb/cockroach:v23.2.0");
|
|
3224
3225
|
});
|
|
3225
3226
|
});
|
|
3227
|
+
|
|
3228
|
+
describe("ArgoAppFor", () => {
|
|
3229
|
+
test("returns a single Application", () => {
|
|
3230
|
+
const result = ArgoAppFor("api", { repo: "https://github.com/acme/infra", path: "dist/api" });
|
|
3231
|
+
expect(result.application).toBeDefined();
|
|
3232
|
+
expect((result.application as any).entityType).toBe("K8s::Argo::Application");
|
|
3233
|
+
});
|
|
3234
|
+
|
|
3235
|
+
test("maps repo/path/revision onto spec.source", () => {
|
|
3236
|
+
const result = ArgoAppFor("api", {
|
|
3237
|
+
repo: "https://github.com/acme/infra",
|
|
3238
|
+
path: "dist/api",
|
|
3239
|
+
targetRevision: "v1.2.3",
|
|
3240
|
+
});
|
|
3241
|
+
const spec = p(result.application).spec as any;
|
|
3242
|
+
expect(spec.source.repoURL).toBe("https://github.com/acme/infra");
|
|
3243
|
+
expect(spec.source.path).toBe("dist/api");
|
|
3244
|
+
expect(spec.source.targetRevision).toBe("v1.2.3");
|
|
3245
|
+
});
|
|
3246
|
+
|
|
3247
|
+
test("defaults to the in-cluster destination and default project", () => {
|
|
3248
|
+
const spec = p(ArgoAppFor("api", { repo: "r", path: "p" }).application).spec as any;
|
|
3249
|
+
expect(spec.destination.server).toBe("https://kubernetes.default.svc");
|
|
3250
|
+
expect(spec.destination.namespace).toBe("api");
|
|
3251
|
+
expect(spec.project).toBe("default");
|
|
3252
|
+
});
|
|
3253
|
+
|
|
3254
|
+
test("default sync policy is automated, non-pruning, self-healing", () => {
|
|
3255
|
+
const spec = p(ArgoAppFor("api", { repo: "r", path: "p" }).application).spec as any;
|
|
3256
|
+
expect(spec.syncPolicy.automated.prune).toBe(false);
|
|
3257
|
+
expect(spec.syncPolicy.automated.selfHeal).toBe(true);
|
|
3258
|
+
expect(spec.syncPolicy.syncOptions).toContain("CreateNamespace=true");
|
|
3259
|
+
});
|
|
3260
|
+
|
|
3261
|
+
test("explicit empty sync policy yields manual sync (no automated block)", () => {
|
|
3262
|
+
const spec = p(ArgoAppFor("api", { repo: "r", path: "p", syncPolicy: {} }).application).spec as any;
|
|
3263
|
+
expect(spec.syncPolicy).toBeUndefined();
|
|
3264
|
+
});
|
|
3265
|
+
|
|
3266
|
+
test("honors an explicit destination", () => {
|
|
3267
|
+
const spec = p(
|
|
3268
|
+
ArgoAppFor("api", {
|
|
3269
|
+
repo: "r",
|
|
3270
|
+
path: "p",
|
|
3271
|
+
destination: { server: "https://east.example.com", namespace: "prod" },
|
|
3272
|
+
}).application,
|
|
3273
|
+
).spec as any;
|
|
3274
|
+
expect(spec.destination.server).toBe("https://east.example.com");
|
|
3275
|
+
expect(spec.destination.namespace).toBe("prod");
|
|
3276
|
+
});
|
|
3277
|
+
});
|
|
3278
|
+
|
|
3279
|
+
describe("ArgoAppSetForRegions", () => {
|
|
3280
|
+
const opts = { name: "crdb", repo: "https://github.com/acme/infra", project: "crdb" };
|
|
3281
|
+
const mapper = (region: string) => ({
|
|
3282
|
+
server: `https://${region}.example.com`,
|
|
3283
|
+
namespace: `crdb-${region}`,
|
|
3284
|
+
path: `dist/${region}`,
|
|
3285
|
+
});
|
|
3286
|
+
|
|
3287
|
+
test("produces one ApplicationSet fanned out to 3 Applications", () => {
|
|
3288
|
+
const result = ArgoAppSetForRegions(["east", "central", "west"], mapper, opts);
|
|
3289
|
+
expect((result.applicationSet as any).entityType).toBe("K8s::Argo::ApplicationSet");
|
|
3290
|
+
const spec = p(result.applicationSet).spec as any;
|
|
3291
|
+
const elements = spec.generators[0].list.elements;
|
|
3292
|
+
expect(elements).toHaveLength(3);
|
|
3293
|
+
expect(elements.map((e: any) => e.region)).toEqual(["east", "central", "west"]);
|
|
3294
|
+
expect(elements[0].server).toBe("https://east.example.com");
|
|
3295
|
+
expect(elements[0].path).toBe("dist/east");
|
|
3296
|
+
});
|
|
3297
|
+
|
|
3298
|
+
test("template scopes to a single static AppProject (ARGO004)", () => {
|
|
3299
|
+
const spec = p(ArgoAppSetForRegions(["east"], mapper, opts).applicationSet).spec as any;
|
|
3300
|
+
expect(spec.template.spec.project).toBe("crdb");
|
|
3301
|
+
expect(spec.template.spec.project).not.toContain("{{");
|
|
3302
|
+
});
|
|
3303
|
+
|
|
3304
|
+
test("template destination interpolates the per-region server", () => {
|
|
3305
|
+
const spec = p(ArgoAppSetForRegions(["east"], mapper, opts).applicationSet).spec as any;
|
|
3306
|
+
expect(spec.template.spec.destination.server).toBe("{{server}}");
|
|
3307
|
+
expect(spec.template.spec.destination.namespace).toBe("{{namespace}}");
|
|
3308
|
+
});
|
|
3309
|
+
|
|
3310
|
+
test("falls back to the set-level path when the mapper omits one", () => {
|
|
3311
|
+
const spec = p(
|
|
3312
|
+
ArgoAppSetForRegions(
|
|
3313
|
+
["east"],
|
|
3314
|
+
(r) => ({ server: `https://${r}.example.com`, namespace: `ns-${r}` }),
|
|
3315
|
+
{ ...opts, path: "dist/shared" },
|
|
3316
|
+
).applicationSet,
|
|
3317
|
+
).spec as any;
|
|
3318
|
+
expect(spec.generators[0].list.elements[0].path).toBe("dist/shared");
|
|
3319
|
+
});
|
|
3320
|
+
|
|
3321
|
+
test("throws when no path is resolvable for a region", () => {
|
|
3322
|
+
expect(() =>
|
|
3323
|
+
ArgoAppSetForRegions(
|
|
3324
|
+
["east"],
|
|
3325
|
+
(r) => ({ server: `https://${r}.example.com`, namespace: `ns-${r}` }),
|
|
3326
|
+
opts,
|
|
3327
|
+
),
|
|
3328
|
+
).toThrow(/no path/);
|
|
3329
|
+
});
|
|
3330
|
+
});
|
|
3331
|
+
|
|
3332
|
+
describe("registerArgoCluster", () => {
|
|
3333
|
+
test("emits a Secret labelled as an Argo cluster registration", () => {
|
|
3334
|
+
const result = registerArgoCluster({ name: "east", server: "https://east.example.com" });
|
|
3335
|
+
expect((result.secret as any).entityType).toBe("K8s::Core::Secret");
|
|
3336
|
+
const meta = (p(result.secret).metadata as any);
|
|
3337
|
+
expect(meta.labels["argocd.argoproj.io/secret-type"]).toBe("cluster");
|
|
3338
|
+
const stringData = p(result.secret).stringData as any;
|
|
3339
|
+
expect(stringData.name).toBe("east");
|
|
3340
|
+
expect(stringData.server).toBe("https://east.example.com");
|
|
3341
|
+
});
|
|
3342
|
+
|
|
3343
|
+
test("serializes an object config to JSON", () => {
|
|
3344
|
+
const result = registerArgoCluster({
|
|
3345
|
+
name: "east",
|
|
3346
|
+
server: "https://east.example.com",
|
|
3347
|
+
config: { tlsClientConfig: { insecure: false } },
|
|
3348
|
+
});
|
|
3349
|
+
const stringData = p(result.secret).stringData as any;
|
|
3350
|
+
expect(JSON.parse(stringData.config)).toEqual({ tlsClientConfig: { insecure: false } });
|
|
3351
|
+
});
|
|
3352
|
+
});
|
|
3353
|
+
|
|
3354
|
+
describe("package index re-exports (regression guard)", () => {
|
|
3355
|
+
test("Argo composites are reachable from the package entry", async () => {
|
|
3356
|
+
const pkg: any = await import("../index");
|
|
3357
|
+
expect(typeof pkg.ArgoAppFor).toBe("function");
|
|
3358
|
+
expect(typeof pkg.ArgoAppSetForRegions).toBe("function");
|
|
3359
|
+
expect(typeof pkg.registerArgoCluster).toBe("function");
|
|
3360
|
+
});
|
|
3361
|
+
});
|
package/src/composites/index.ts
CHANGED
|
@@ -81,3 +81,15 @@ export { RayJob } from "./ray-job";
|
|
|
81
81
|
export type { RayJobProps, RayJobResult } from "./ray-job";
|
|
82
82
|
export { RayService } from "./ray-service";
|
|
83
83
|
export type { RayServiceProps, RayServiceResult } from "./ray-service";
|
|
84
|
+
export { ArgoAppFor, ArgoAppSetForRegions, registerArgoCluster } from "./argo-app";
|
|
85
|
+
export type {
|
|
86
|
+
ArgoDestination,
|
|
87
|
+
ArgoSyncPolicy,
|
|
88
|
+
ArgoAppForOptions,
|
|
89
|
+
ArgoAppForResult,
|
|
90
|
+
ArgoRegionTarget,
|
|
91
|
+
ArgoAppSetForRegionsOptions,
|
|
92
|
+
ArgoAppSetForRegionsResult,
|
|
93
|
+
RegisterArgoClusterOptions,
|
|
94
|
+
RegisterArgoClusterResult,
|
|
95
|
+
} from "./argo-app";
|
package/src/crd/crd-sources.ts
CHANGED
|
@@ -22,8 +22,26 @@ import type { CRDSource } from "./types";
|
|
|
22
22
|
const KUBERAY_VERSION = "v1.3.0";
|
|
23
23
|
const KUBERAY_CRD_BASE = `https://raw.githubusercontent.com/ray-project/kuberay/${KUBERAY_VERSION}/helm-chart/kuberay-operator/crds`;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Argo CD CRDs — argoproj.io/v1alpha1
|
|
27
|
+
*
|
|
28
|
+
* Produces (the `argoproj.io` group is mapped to the `Argo` namespace —
|
|
29
|
+
* see GROUP_NAMESPACE_OVERRIDES in crd/parser.ts):
|
|
30
|
+
* K8s::Argo::Application → apiVersion: argoproj.io/v1alpha1, kind: Application
|
|
31
|
+
* K8s::Argo::ApplicationSet → apiVersion: argoproj.io/v1alpha1, kind: ApplicationSet
|
|
32
|
+
* K8s::Argo::AppProject → apiVersion: argoproj.io/v1alpha1, kind: AppProject
|
|
33
|
+
*
|
|
34
|
+
* Operator install: kubectl apply -n argocd -f
|
|
35
|
+
* https://raw.githubusercontent.com/argoproj/argo-cd/v2.13.3/manifests/install.yaml
|
|
36
|
+
*/
|
|
37
|
+
const ARGOCD_VERSION = "v2.13.3";
|
|
38
|
+
const ARGOCD_CRD_BASE = `https://raw.githubusercontent.com/argoproj/argo-cd/${ARGOCD_VERSION}/manifests/crds`;
|
|
39
|
+
|
|
25
40
|
export const CRD_SOURCES: CRDSource[] = [
|
|
26
41
|
{ type: "url", url: `${KUBERAY_CRD_BASE}/ray.io_rayclusters.yaml` },
|
|
27
42
|
{ type: "url", url: `${KUBERAY_CRD_BASE}/ray.io_rayjobs.yaml` },
|
|
28
43
|
{ type: "url", url: `${KUBERAY_CRD_BASE}/ray.io_rayservices.yaml` },
|
|
44
|
+
{ type: "url", url: `${ARGOCD_CRD_BASE}/application-crd.yaml` },
|
|
45
|
+
{ type: "url", url: `${ARGOCD_CRD_BASE}/applicationset-crd.yaml` },
|
|
46
|
+
{ type: "url", url: `${ARGOCD_CRD_BASE}/appproject-crd.yaml` },
|
|
29
47
|
];
|
package/src/crd/loader.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, readFileSync } from "fs";
|
|
9
|
+
import { fetchWithRetry } from "@intentius/chant/codegen/fetch";
|
|
9
10
|
import type { CRDSource, CRDSpec } from "./types";
|
|
10
11
|
import type { K8sParseResult } from "../spec/parse";
|
|
11
12
|
import { parseCRD } from "./parser";
|
|
@@ -69,11 +70,9 @@ async function loadFromURL(source: CRDSource): Promise<string> {
|
|
|
69
70
|
throw new Error("CRD source type 'url' requires a 'url' property");
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
73
|
+
// fetchWithRetry retries transient failures and throws on a permanent
|
|
74
|
+
// status (or after exhausting retries); the returned response is `ok`.
|
|
75
|
+
const response = await fetchWithRetry(source.url);
|
|
77
76
|
return response.text();
|
|
78
77
|
}
|
|
79
78
|
|
package/src/crd/parser.test.ts
CHANGED
|
@@ -214,4 +214,65 @@ describe("parseCRDSpec", () => {
|
|
|
214
214
|
const results = parseCRDSpec(spec);
|
|
215
215
|
expect(results[0].resource.typeName).toBe("K8s::CertManager::Certificate");
|
|
216
216
|
});
|
|
217
|
+
|
|
218
|
+
test("argoproj.io group maps to the Argo namespace (override)", () => {
|
|
219
|
+
const spec = {
|
|
220
|
+
group: "argoproj.io",
|
|
221
|
+
names: { kind: "Application", plural: "applications" },
|
|
222
|
+
scope: "Namespaced" as const,
|
|
223
|
+
versions: [
|
|
224
|
+
{ name: "v1alpha1", served: true, storage: true },
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const results = parseCRDSpec(spec);
|
|
229
|
+
expect(results[0].resource.typeName).toBe("K8s::Argo::Application");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("dedupes property-type names when a scalar and array sibling collide", () => {
|
|
233
|
+
// Argo Application has both `source` (object) and `sources` (array of the
|
|
234
|
+
// same shape); singularizing `sources` → `Source` would collide.
|
|
235
|
+
const spec = {
|
|
236
|
+
group: "argoproj.io",
|
|
237
|
+
names: { kind: "Application", plural: "applications" },
|
|
238
|
+
scope: "Namespaced" as const,
|
|
239
|
+
versions: [
|
|
240
|
+
{
|
|
241
|
+
name: "v1alpha1",
|
|
242
|
+
served: true,
|
|
243
|
+
storage: true,
|
|
244
|
+
schema: {
|
|
245
|
+
openAPIV3Schema: {
|
|
246
|
+
type: "object",
|
|
247
|
+
properties: {
|
|
248
|
+
spec: {
|
|
249
|
+
type: "object",
|
|
250
|
+
properties: {
|
|
251
|
+
source: {
|
|
252
|
+
type: "object",
|
|
253
|
+
properties: { repoURL: { type: "string" } },
|
|
254
|
+
},
|
|
255
|
+
sources: {
|
|
256
|
+
type: "array",
|
|
257
|
+
items: {
|
|
258
|
+
type: "object",
|
|
259
|
+
properties: { repoURL: { type: "string" } },
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const results = parseCRDSpec(spec);
|
|
272
|
+
const names = results[0].propertyTypes.map((pt) => pt.name);
|
|
273
|
+
expect(names).toContain("Application_Source");
|
|
274
|
+
expect(names).toContain("Application_Sources");
|
|
275
|
+
// No duplicate identifiers.
|
|
276
|
+
expect(new Set(names).size).toBe(names.length);
|
|
277
|
+
});
|
|
217
278
|
});
|