@intentius/chant-lexicon-k8s 0.1.14 → 0.1.16

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 (50) hide show
  1. package/README.md +0 -1
  2. package/dist/integrity.json +14 -6
  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/rules/wk8301.ts +11 -3
  13. package/dist/skills/chant-k8s-argo.md +176 -0
  14. package/dist/types/index.d.ts +34 -0
  15. package/package.json +1 -1
  16. package/src/codegen/docs.ts +14 -1
  17. package/src/codegen/versions.ts +8 -5
  18. package/src/composites/argo-app.ts +380 -0
  19. package/src/composites/composites.test.ts +136 -0
  20. package/src/composites/index.ts +12 -0
  21. package/src/crd/crd-sources.ts +18 -0
  22. package/src/crd/loader.ts +4 -5
  23. package/src/crd/parser.test.ts +61 -0
  24. package/src/crd/parser.ts +37 -2
  25. package/src/describe-resources.ts +8 -1
  26. package/src/export-resources-io.test.ts +72 -0
  27. package/src/export-resources.ts +60 -0
  28. package/src/generated/index.d.ts +34 -0
  29. package/src/generated/index.ts +25 -0
  30. package/src/generated/lexicon-k8s.json +216 -0
  31. package/src/import/live-export.test.ts +114 -0
  32. package/src/import/live-export.ts +89 -0
  33. package/src/index.ts +5 -0
  34. package/src/lifecycle-integration.test.ts +111 -0
  35. package/src/lint/post-synth/argo-helpers.ts +49 -0
  36. package/src/lint/post-synth/argo002.ts +47 -0
  37. package/src/lint/post-synth/argo003.ts +80 -0
  38. package/src/lint/post-synth/argo005.ts +59 -0
  39. package/src/lint/post-synth/post-synth.test.ts +146 -2
  40. package/src/lint/post-synth/wk8301.ts +11 -3
  41. package/src/lint/rules/argo-appset-single-project.ts +66 -0
  42. package/src/lint/rules/argo-ast.ts +121 -0
  43. package/src/lint/rules/argo-automated-prune.ts +75 -0
  44. package/src/lint/rules/rules.test.ts +109 -0
  45. package/src/plugin.test.ts +6 -1
  46. package/src/plugin.ts +44 -1
  47. package/src/serializer-ownership.test.ts +44 -0
  48. package/src/serializer.test.ts +25 -0
  49. package/src/serializer.ts +9 -4
  50. package/src/skills/chant-k8s-argo.md +176 -0
package/src/plugin.ts CHANGED
@@ -15,6 +15,8 @@ import { k8sSerializer } from "./serializer";
15
15
  import { hardcodedNamespaceRule } from "./lint/rules/hardcoded-namespace";
16
16
  import { latestImageTagRule } from "./lint/rules/latest-image-tag";
17
17
  import { missingResourceLimitsRule } from "./lint/rules/missing-resource-limits";
18
+ import { argoAutomatedPruneRule } from "./lint/rules/argo-automated-prune";
19
+ import { argoAppSetSingleProjectRule } from "./lint/rules/argo-appset-single-project";
18
20
  import { k8sCompletions } from "./lsp/completions";
19
21
  import { k8sHover } from "./lsp/hover";
20
22
  import { K8sParser } from "./import/parser";
@@ -25,7 +27,13 @@ export const k8sPlugin: LexiconPlugin = {
25
27
  serializer: k8sSerializer,
26
28
 
27
29
  lintRules(): LintRule[] {
28
- return [hardcodedNamespaceRule, latestImageTagRule, missingResourceLimitsRule];
30
+ return [
31
+ hardcodedNamespaceRule,
32
+ latestImageTagRule,
33
+ missingResourceLimitsRule,
34
+ argoAutomatedPruneRule,
35
+ argoAppSetSingleProjectRule,
36
+ ];
29
37
  },
30
38
 
31
39
  postSynthChecks() {
@@ -566,10 +574,45 @@ const { deployment, service, serviceMonitor, prometheusRule } = MonitoredService
566
574
  },
567
575
  ],
568
576
  },
577
+ {
578
+ file: "chant-k8s-argo.md",
579
+ name: "chant-k8s-argo",
580
+ description: "Argo CD composites — ArgoAppFor, ArgoAppSetForRegions, AppProject scoping, cluster registration, and the Argo-vs-Temporal split",
581
+ triggers: [
582
+ { type: "context", value: "argo" },
583
+ { type: "context", value: "argo cd" },
584
+ { type: "context", value: "argocd" },
585
+ { type: "context", value: "gitops" },
586
+ { type: "context", value: "application" },
587
+ { type: "context", value: "applicationset" },
588
+ { type: "context", value: "appproject" },
589
+ { type: "context", value: "reconcile" },
590
+ ],
591
+ parameters: [],
592
+ examples: [
593
+ {
594
+ title: "Application from a build target",
595
+ description: "Reconcile a Chant build target with Argo CD",
596
+ input: "Deploy my api target through Argo CD",
597
+ output: "import { ArgoAppFor } from \"@intentius/chant-lexicon-k8s\";\n\nexport const api = ArgoAppFor(\"api\", {\n repo: \"https://github.com/acme/infra\",\n path: \"dist/api\",\n destination: { server: \"https://kubernetes.default.svc\", namespace: \"api\" },\n});",
598
+ },
599
+ {
600
+ title: "Per-region ApplicationSet",
601
+ description: "Fan one app out across regional clusters",
602
+ input: "Deploy crdb to east, central, and west clusters via Argo",
603
+ output: "import { ArgoAppSetForRegions } from \"@intentius/chant-lexicon-k8s\";\n\nexport const crdb = ArgoAppSetForRegions(\n [\"east\", \"central\", \"west\"],\n (region) => ({ server: servers[region], namespace: `crdb-${region}`, path: `dist/${region}` }),\n { name: \"crdb\", repo: \"https://github.com/acme/infra\", project: \"crdb\" },\n);",
604
+ },
605
+ ],
606
+ },
569
607
  ]),
570
608
 
571
609
  async describeResources(options) {
572
610
  const { describeResources } = await import("./describe-resources");
573
611
  return describeResources(options);
574
612
  },
613
+
614
+ async exportResources(options) {
615
+ const { exportResources } = await import("./export-resources");
616
+ return exportResources(options);
617
+ },
575
618
  };
@@ -0,0 +1,44 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { k8sSerializer } from "./serializer";
3
+ import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
4
+
5
+ function mockResource(entityType: string, props: Record<string, unknown>): any {
6
+ return { [DECLARABLE_MARKER]: true, lexicon: "k8s", entityType, kind: "resource", props };
7
+ }
8
+
9
+ describe("k8sSerializer ownership stamping (#119)", () => {
10
+ test("stamps the ownership marker as labels when context.ownership is set", () => {
11
+ const entities = new Map<string, any>([
12
+ ["web", mockResource("K8s::Apps::Deployment", { metadata: { name: "web" }, spec: { replicas: 1 } })],
13
+ ]);
14
+ const yaml = k8sSerializer.serialize(entities, [], { ownership: { stack: "billing", env: "prod" } });
15
+ expect(yaml).toContain("app.kubernetes.io/managed-by: chant");
16
+ expect(yaml).toContain("chant.intentius.io/stack: billing");
17
+ expect(yaml).toContain("chant.intentius.io/env: prod");
18
+ });
19
+
20
+ test("explicit resource labels still win over the stamped marker", () => {
21
+ const entities = new Map<string, any>([
22
+ [
23
+ "web",
24
+ mockResource("K8s::Apps::Deployment", {
25
+ metadata: { name: "web", labels: { "app.kubernetes.io/managed-by": "argocd" } },
26
+ spec: { replicas: 1 },
27
+ }),
28
+ ],
29
+ ]);
30
+ const yaml = k8sSerializer.serialize(entities, [], { ownership: { stack: "billing" } });
31
+ expect(yaml).toContain("app.kubernetes.io/managed-by: argocd");
32
+ // stack identity still stamped (no explicit override)
33
+ expect(yaml).toContain("chant.intentius.io/stack: billing");
34
+ });
35
+
36
+ test("no ownership context → no chant labels", () => {
37
+ const entities = new Map<string, any>([
38
+ ["web", mockResource("K8s::Apps::Deployment", { metadata: { name: "web" }, spec: { replicas: 1 } })],
39
+ ]);
40
+ const yaml = k8sSerializer.serialize(entities, []);
41
+ expect(yaml).not.toContain("app.kubernetes.io/managed-by: chant");
42
+ expect(yaml).not.toContain("chant.intentius.io/stack");
43
+ });
44
+ });
@@ -128,6 +128,31 @@ describe("k8sSerializer", () => {
128
128
  expect(result).not.toContain("spec:");
129
129
  });
130
130
 
131
+ test("Argo Application serializes with argoproj.io/v1alpha1 GVK", () => {
132
+ const entities = new Map<string, any>();
133
+ entities.set(
134
+ "guestbook",
135
+ mockResource("K8s::Argo::Application", {
136
+ metadata: { name: "guestbook", namespace: "argocd" },
137
+ spec: {
138
+ project: "default",
139
+ source: {
140
+ repoURL: "https://github.com/argoproj/argocd-example-apps",
141
+ path: "guestbook",
142
+ targetRevision: "HEAD",
143
+ },
144
+ destination: { server: "https://kubernetes.default.svc", namespace: "guestbook" },
145
+ },
146
+ }),
147
+ );
148
+
149
+ const result = k8sSerializer.serialize(entities);
150
+ expect(result).toContain("apiVersion: argoproj.io/v1alpha1");
151
+ expect(result).toContain("kind: Application");
152
+ expect(result).toContain("name: guestbook");
153
+ expect(result).toContain("project: default");
154
+ });
155
+
131
156
  test("Namespace is specless type", () => {
132
157
  const entities = new Map<string, any>();
133
158
  entities.set(
package/src/serializer.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  import { createRequire } from "module";
9
9
  import type { Declarable } from "@intentius/chant/declarable";
10
10
  import { isPropertyDeclarable } from "@intentius/chant/declarable";
11
- import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
11
+ import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
12
+ import { ownershipEntries } from "@intentius/chant/ownership";
12
13
  import type { LexiconOutput } from "@intentius/chant/lexicon-output";
13
14
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
14
15
  import { emitYAML } from "@intentius/chant/yaml";
@@ -153,15 +154,19 @@ export const k8sSerializer: Serializer = {
153
154
  name: "k8s",
154
155
  rulePrefix: "WK8",
155
156
 
156
- serialize(entities: Map<string, Declarable>, _outputs?: LexiconOutput[]): string {
157
+ serialize(entities: Map<string, Declarable>, _outputs?: LexiconOutput[], context?: SerializeContext): string {
157
158
  // Build reverse map: entity → name
158
159
  const entityNames = new Map<Declarable, string>();
159
160
  for (const [name, entity] of entities) {
160
161
  entityNames.set(entity, name);
161
162
  }
162
163
 
163
- // Collect default labels and annotations
164
- let defaultLabelEntries: Record<string, unknown> = {};
164
+ // Collect default labels and annotations. Ownership markers are stamped as
165
+ // labels, so they seed defaultLabelEntries and flow through the same merge
166
+ // (explicit resource labels still win).
167
+ let defaultLabelEntries: Record<string, unknown> = context?.ownership
168
+ ? { ...ownershipEntries("label", context.ownership) }
169
+ : {};
165
170
  let defaultAnnotationEntries: Record<string, unknown> = {};
166
171
 
167
172
  for (const [, entity] of entities) {
@@ -0,0 +1,176 @@
1
+ ---
2
+ skill: chant-k8s-argo
3
+ description: Argo CD composites for GitOps reconciliation — ArgoAppFor, ArgoAppSetForRegions, AppProject scoping, cluster registration, and the Argo-vs-Temporal split
4
+ user-invocable: true
5
+ ---
6
+
7
+ # Argo CD Composites
8
+
9
+ Chant authors typed infrastructure into manifests. Argo CD continuously reconciles those manifests into a cluster. These composites are the opt-in bridge — the k8s lexicon itself stays runtime-agnostic and only emits YAML; nothing here is implied unless you reach for it.
10
+
11
+ ## The three-layer model
12
+
13
+ | Layer | Owns | In Chant |
14
+ |---|---|---|
15
+ | **Chant** | Authoring typed infra → manifests | the lexicons |
16
+ | **Argo CD** | Continuously reconciling declarative manifests (the apply layer) | `ArgoAppFor` / `ArgoAppSetForRegions` |
17
+ | **Temporal** | Procedural steps Argo can't express — ordering, signals, human gates, one-shot RPCs | the temporal lexicon + `waitForArgoSync` |
18
+
19
+ Rule of thumb: **if it's declarative and converges, let Argo reconcile it. If it's a procedure with ordering, gates, or out-of-band steps, orchestrate it in Temporal.** Prefer Argo CD over Argo Workflows — the procedural layer stays Temporal.
20
+
21
+ ## Prerequisites
22
+
23
+ Argo CD must be installed in the target cluster before applying any Argo CRs:
24
+
25
+ ```bash
26
+ kubectl create namespace argocd
27
+ kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/v2.13.3/manifests/install.yaml
28
+ kubectl -n argocd wait deploy/argocd-server --for=condition=Available --timeout=180s
29
+ ```
30
+
31
+ ## When to use which composite
32
+
33
+ | Composite | Use case |
34
+ |---|---|
35
+ | `ArgoAppFor` | A single Chant build target reconciled by Argo |
36
+ | `ArgoAppSetForRegions` | The same app fanned out across regions/clusters from one declaration |
37
+ | `registerArgoCluster` | Teaching Argo about an external (non in-cluster) target |
38
+
39
+ ---
40
+
41
+ ## ArgoAppFor — one Application from a build target
42
+
43
+ ```typescript
44
+ import { ArgoAppFor } from "@intentius/chant-lexicon-k8s";
45
+
46
+ export const api = ArgoAppFor("api", {
47
+ repo: "https://github.com/acme/infra",
48
+ path: "dist/api",
49
+ destination: { server: "https://kubernetes.default.svc", namespace: "api" },
50
+ });
51
+ ```
52
+
53
+ One call replaces ~30 lines of hand-written `Application` YAML. Defaults are production-friendly:
54
+
55
+ - **destination** — defaults to the in-cluster target (`https://kubernetes.default.svc`, namespace = target name) when omitted.
56
+ - **project** — defaults to `default`. Pass `project` to scope it to a declared `AppProject`.
57
+ - **syncPolicy** — defaults to **automated, non-pruning, self-healing** with `CreateNamespace=true`. Pass `syncPolicy: {}` for manual sync, or override fields explicitly.
58
+
59
+ ```typescript
60
+ export const api = ArgoAppFor("api", {
61
+ repo: "https://github.com/acme/infra",
62
+ path: "dist/api",
63
+ project: "payments",
64
+ syncPolicy: { automated: { prune: false, selfHeal: true }, syncOptions: ["ServerSideApply=true"] },
65
+ });
66
+ ```
67
+
68
+ > **ARGO001** — on a *production* Application (name / namespace / destination namespace contains `prod`), automated `prune` must be `false` unless you opt in with the `argocd.chant.dev/allow-prune` annotation. Pruning deletes live resources that vanish from git; on prod that's a foot-gun.
69
+
70
+ ---
71
+
72
+ ## ArgoAppSetForRegions — fan out across clusters
73
+
74
+ ```typescript
75
+ import { ArgoAppSetForRegions } from "@intentius/chant-lexicon-k8s";
76
+
77
+ const clusterServers: Record<string, string> = {
78
+ east: "https://east.example.com",
79
+ central: "https://central.example.com",
80
+ west: "https://west.example.com",
81
+ };
82
+
83
+ export const crdb = ArgoAppSetForRegions(
84
+ ["east", "central", "west"],
85
+ (region) => ({
86
+ server: clusterServers[region],
87
+ namespace: `crdb-${region}`,
88
+ path: `dist/${region}`,
89
+ }),
90
+ { name: "crdb", repo: "https://github.com/acme/infra", project: "crdb" },
91
+ );
92
+ ```
93
+
94
+ Emits **one `ApplicationSet`** with a list generator — Argo expands it into one synced `Application` per region (`east-crdb`, `central-crdb`, `west-crdb`). The mapper resolves per-region values (`server`, `namespace`, `path`, `targetRevision`); the template interpolates them (`{{server}}`, `{{namespace}}`, `{{path}}`).
95
+
96
+ > **ARGO004** — the template scopes to a **single static** `AppProject`. `ArgoAppSetForRegions` always sets a static `project`; never template it (`project: "{{...}}"`) or the set sprays Applications across projects and defeats the RBAC boundary.
97
+
98
+ ---
99
+
100
+ ## AppProject scoping
101
+
102
+ An `AppProject` is the RBAC and source/destination guardrail for a group of Applications. Declare one and reference it by name:
103
+
104
+ ```typescript
105
+ import { AppProject } from "@intentius/chant-lexicon-k8s";
106
+
107
+ export const payments = new AppProject({
108
+ metadata: { name: "payments", namespace: "argocd" },
109
+ spec: {
110
+ description: "Payments team applications",
111
+ sourceRepos: ["https://github.com/acme/infra"],
112
+ destinations: [{ server: "https://kubernetes.default.svc", namespace: "payments-*" }],
113
+ },
114
+ });
115
+ ```
116
+
117
+ > **ARGO002** — every `Application.spec.project` must reference a declared `AppProject` (the built-in `default` is exempt). Declaring the project in the same build keeps the reference honest.
118
+
119
+ ---
120
+
121
+ ## registerArgoCluster — external clusters
122
+
123
+ The in-cluster target needs no registration. For any other cluster, emit the registration Secret:
124
+
125
+ ```typescript
126
+ import { registerArgoCluster } from "@intentius/chant-lexicon-k8s";
127
+
128
+ export const east = registerArgoCluster({
129
+ name: "east",
130
+ server: "https://east.example.com",
131
+ config: { tlsClientConfig: { insecure: false }, bearerToken: process.env.EAST_TOKEN },
132
+ });
133
+ ```
134
+
135
+ Produces a `Secret` labelled `argocd.argoproj.io/secret-type: cluster`. After this, Applications can target the cluster by `destination.server: "https://east.example.com"` or `destination.name: "east"`.
136
+
137
+ > **ARGO003** — every `Application.spec.destination` must reference a registered cluster (a cluster Secret) or the in-cluster target. Register external clusters before pointing Applications at them.
138
+
139
+ ---
140
+
141
+ ## The Argo-vs-Temporal split
142
+
143
+ When a deploy has both declarative and procedural parts, let each layer own what it's good at. Example — the multi-region CockroachDB deploy:
144
+
145
+ | Step | Owner | Why |
146
+ |---|---|---|
147
+ | Apply shared + regional infra | **Argo** | Declarative, converges — Argo reconciles it |
148
+ | Install ESO / operators (Helm) | **Argo** | Declarative Helm source |
149
+ | Apply per-cluster K8s manifests | **Argo** (`ApplicationSet`) | One App per workload cluster |
150
+ | Wait for workloads Healthy | **Argo** (`Health=Healthy`) | Subsumed by Application health |
151
+ | Wait for DNS delegation | **Temporal** | Signal/update/auto-poll race — out of band |
152
+ | Generate + push TLS certs | **Temporal** | One-shot procedure, secrets not in git |
153
+ | `cockroach init`, configure regions | **Temporal** | Ordered one-shot RPCs |
154
+
155
+ From a Temporal workflow, gate procedural steps on Argo finishing a declarative apply with the `waitForArgoSync` activity (temporal lexicon, `argoSync` profile):
156
+
157
+ ```typescript
158
+ // In a Temporal Op workflow:
159
+ await waitForArgoSync({ appName: "east-crdb", namespace: "argocd" });
160
+ // ...now run the procedural steps that depend on the workloads being Healthy.
161
+ ```
162
+
163
+ `waitForArgoSync` is dependency-free — it polls the Application's status (`health=Healthy && sync=Synced`) and never imports the Argo CRD types.
164
+
165
+ ---
166
+
167
+ ## Troubleshooting
168
+
169
+ | Symptom | Likely cause | Fix |
170
+ |---|---|---|
171
+ | Application stuck `OutOfSync` | Manual sync policy, no auto-sync | Set `syncPolicy.automated`, or sync via `argocd app sync <name>` |
172
+ | Application `Healthy` but resources missing | Wrong `destination.namespace` or `source.path` | Check ARGO005 (path) / ARGO003 (destination) |
173
+ | `ComparisonError: project not found` | `spec.project` references an undeclared `AppProject` | Declare the project (ARGO002) |
174
+ | `cluster ... not found` at sync | Destination cluster not registered | `registerArgoCluster` before targeting it (ARGO003) |
175
+ | Prod resources unexpectedly deleted | Automated `prune: true` on prod | Set `prune: false` (ARGO001) |
176
+ | `ApplicationSet` generates apps in wrong projects | Templated `spec.project` | Pin to a single static project (ARGO004) |