@intentius/chant-lexicon-k8s 0.1.13 → 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.
Files changed (48) hide show
  1. package/README.md +0 -1
  2. package/dist/integrity.json +13 -5
  3. package/dist/manifest.json +1 -1
  4. package/dist/meta.json +216 -0
  5. package/dist/rules/argo-appset-single-project.ts +66 -0
  6. package/dist/rules/argo-ast.ts +121 -0
  7. package/dist/rules/argo-automated-prune.ts +75 -0
  8. package/dist/rules/argo-helpers.ts +49 -0
  9. package/dist/rules/argo002.ts +47 -0
  10. package/dist/rules/argo003.ts +80 -0
  11. package/dist/rules/argo005.ts +59 -0
  12. package/dist/skills/chant-k8s-argo.md +176 -0
  13. package/dist/types/index.d.ts +34 -0
  14. package/package.json +1 -1
  15. package/src/codegen/docs.ts +13 -0
  16. package/src/codegen/versions.ts +8 -5
  17. package/src/composites/argo-app.ts +380 -0
  18. package/src/composites/composites.test.ts +136 -0
  19. package/src/composites/index.ts +12 -0
  20. package/src/crd/crd-sources.ts +18 -0
  21. package/src/crd/loader.ts +4 -5
  22. package/src/crd/parser.test.ts +61 -0
  23. package/src/crd/parser.ts +37 -2
  24. package/src/describe-resources.ts +8 -1
  25. package/src/export-resources-io.test.ts +72 -0
  26. package/src/export-resources.ts +60 -0
  27. package/src/generated/index.d.ts +34 -0
  28. package/src/generated/index.ts +25 -0
  29. package/src/generated/lexicon-k8s.json +216 -0
  30. package/src/import/live-export.test.ts +114 -0
  31. package/src/import/live-export.ts +89 -0
  32. package/src/index.ts +5 -0
  33. package/src/lifecycle-integration.test.ts +111 -0
  34. package/src/lint/post-synth/argo-helpers.ts +49 -0
  35. package/src/lint/post-synth/argo002.ts +47 -0
  36. package/src/lint/post-synth/argo003.ts +80 -0
  37. package/src/lint/post-synth/argo005.ts +59 -0
  38. package/src/lint/post-synth/post-synth.test.ts +123 -0
  39. package/src/lint/rules/argo-appset-single-project.ts +66 -0
  40. package/src/lint/rules/argo-ast.ts +121 -0
  41. package/src/lint/rules/argo-automated-prune.ts +75 -0
  42. package/src/lint/rules/rules.test.ts +109 -0
  43. package/src/plugin.test.ts +6 -1
  44. package/src/plugin.ts +44 -1
  45. package/src/serializer-ownership.test.ts +44 -0
  46. package/src/serializer.test.ts +25 -0
  47. package/src/serializer.ts +9 -4
  48. 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
+ });
@@ -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";
@@ -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
- const response = await fetch(source.url);
73
- if (!response.ok) {
74
- throw new Error(`Failed to fetch CRD from ${source.url}: ${response.status} ${response.statusText}`);
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
 
@@ -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
  });