@intentius/behold 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +4 -0
  2. package/demos.json +39 -0
  3. package/dist/cli.js +147 -40
  4. package/example-flux-estate/README.md +41 -0
  5. package/example-flux-estate/app-a/chant.config.ts +18 -0
  6. package/example-flux-estate/app-a/manifests/app.yaml +56 -0
  7. package/example-flux-estate/app-a/package.json +13 -0
  8. package/example-flux-estate/app-a/src/app.ts +35 -0
  9. package/example-flux-estate/app-a/tsconfig.json +1 -0
  10. package/example-flux-estate/app-b/chant.config.ts +20 -0
  11. package/example-flux-estate/app-b/manifests/app.yaml +54 -0
  12. package/example-flux-estate/app-b/package.json +13 -0
  13. package/example-flux-estate/app-b/src/app.ts +37 -0
  14. package/example-flux-estate/app-b/tsconfig.json +1 -0
  15. package/example-flux-estate/control-plane/chant.config.ts +19 -0
  16. package/example-flux-estate/control-plane/package.json +13 -0
  17. package/example-flux-estate/control-plane/src/flux.ts +51 -0
  18. package/example-flux-estate/control-plane/tsconfig.json +1 -0
  19. package/example-flux-estate/package-lock.json +1949 -0
  20. package/example-flux-estate/package.json +10 -0
  21. package/example-flux-estate/scripts/estate-down.sh +19 -0
  22. package/example-flux-estate/scripts/estate-up.sh +61 -0
  23. package/example-k8s/README.md +120 -0
  24. package/example-k8s/chant.config.ts +21 -0
  25. package/example-k8s/ops/k3d-apply.op.ts +18 -0
  26. package/example-k8s/package-lock.json +2071 -0
  27. package/example-k8s/package.json +14 -0
  28. package/example-k8s/scripts/local/local-down.sh +23 -0
  29. package/example-k8s/scripts/local/local-up.sh +46 -0
  30. package/example-k8s/src/config.ts +5 -0
  31. package/example-k8s/src/web.ts +29 -0
  32. package/example-k8s/tsconfig.json +1 -0
  33. package/package.json +4 -1
package/README.md CHANGED
@@ -28,6 +28,10 @@ npx @intentius/behold demo # copies the example to ./behold-demo, ins
28
28
  ```
29
29
 
30
30
  The copied project is yours: edit its source and watch the graph change live.
31
+ There's a whole catalog — `behold demo --list` names the rest (`behold demo
32
+ k8s` stands the same loop up on a throwaway k3d cluster: runtime Pods, field
33
+ ownership). Every loaded demo lands in the panel's recents, so switching
34
+ between them is the Scope tab.
31
35
 
32
36
  Already have a chant project?
33
37
 
package/demos.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "demos": [
3
+ {
4
+ "name": "writes",
5
+ "description": "One S3 bucket + policy on a local Floci emulator — blue to green in five minutes. The default.",
6
+ "source": "bundled",
7
+ "dir": "example-writes",
8
+ "requires": ["docker"],
9
+ "serve": { "local": true, "env": "prod" }
10
+ },
11
+ {
12
+ "name": "k8s",
13
+ "description": "An nginx Deployment + Service on a throwaway k3d cluster — runtime children (Pods), field ownership, honest unobserved. Cluster persists across runs; scripts/local/local-down.sh removes it.",
14
+ "source": "bundled",
15
+ "dir": "example-k8s",
16
+ "requires": ["docker", "k3d", "kubectl"],
17
+ "setup": "bash scripts/local/local-up.sh",
18
+ "serve": { "local": true, "env": "local" }
19
+ },
20
+ {
21
+ "name": "flux-estate",
22
+ "description": "A GitOps estate: a Flux control plane (GitRepository + Kustomizations) plus two app projects, served as one composed estate — cross-stack sourceRef edges, estate-wide drift, and the namespace-mismatch note on purpose (app-b uses targetNamespace). k3d + Flux, no cloud. scripts/estate-down.sh removes the cluster.",
23
+ "source": "bundled",
24
+ "dir": "example-flux-estate",
25
+ "requires": ["docker", "k3d", "kubectl"],
26
+ "setup": "bash scripts/estate-up.sh",
27
+ "serve": { "env": "local", "dirs": ["control-plane", "app-a", "app-b"] }
28
+ },
29
+ {
30
+ "name": "fountain",
31
+ "description": "The mature estate: self-hosted fountain (a real app) on a throwaway k3d cluster — tiers (try the ha tier picker), seams, drift, runtime Pods. Clones INTENTIUS/fountain-ops; ~5 minutes, mostly image pulls. `just down` in the clone removes the cluster.",
32
+ "source": "git",
33
+ "repo": "https://github.com/INTENTIUS/fountain-ops",
34
+ "requires": ["docker", "k3d", "kubectl", "jq", "just"],
35
+ "setup": "just up",
36
+ "serve": { "env": "local" }
37
+ }
38
+ ]
39
+ }
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/cli.ts
2
- import { resolve as resolve3, dirname as dirname5, join as join12, relative as relative2, sep } from "node:path";
2
+ import { resolve as resolve3, dirname as dirname5, join as join13, relative as relative2, sep } from "node:path";
3
3
  import { realpathSync, existsSync as existsSync10, cpSync } from "node:fs";
4
- import { spawnSync } from "node:child_process";
4
+ import { spawnSync as spawnSync2 } from "node:child_process";
5
5
  import { fileURLToPath as fileURLToPath3 } from "node:url";
6
6
 
7
7
  // src/server.ts
@@ -2593,7 +2593,7 @@ function namespaceMismatchNote(nodes) {
2593
2593
  const k8s = nodes.filter((n) => n?.lexicon === "k8s");
2594
2594
  if (k8s.length === 0) return void 0;
2595
2595
  const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
2596
- if (pending.length < 3) return void 0;
2596
+ if (pending.length < 2) return void 0;
2597
2597
  if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
2598
2598
  if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
2599
2599
  return `${pending.length} pending k8s objects declare no metadata.namespace \u2014 if a controller stamps it at apply time (e.g. Flux's targetNamespace), the live read looked in "default", not where they run`;
@@ -4611,6 +4611,9 @@ async function startServer(cfg) {
4611
4611
  );
4612
4612
  }
4613
4613
  }
4614
+ for (const dir of cfg.projectDirs ?? [cfg.projectDir]) {
4615
+ if (existsSync9(join10(dir, "chant.config.ts"))) addRecent(dir);
4616
+ }
4614
4617
  const broadcaster = new Broadcaster();
4615
4618
  const frames = new FrameBuffer();
4616
4619
  const runner = new OpRunner({
@@ -4714,9 +4717,45 @@ async function startServer(cfg) {
4714
4717
  });
4715
4718
  }
4716
4719
 
4720
+ // src/demos.ts
4721
+ import { readFileSync as readFileSync8 } from "node:fs";
4722
+ import { join as join11 } from "node:path";
4723
+ import { spawnSync } from "node:child_process";
4724
+ function loadDemoRegistry(pkgRoot) {
4725
+ let raw;
4726
+ try {
4727
+ raw = JSON.parse(readFileSync8(join11(pkgRoot, "demos.json"), "utf8"));
4728
+ } catch {
4729
+ return [];
4730
+ }
4731
+ const list = raw?.demos;
4732
+ if (!Array.isArray(list)) return [];
4733
+ return list.filter((e) => {
4734
+ const d = e;
4735
+ if (!d || typeof d.name !== "string" || !d.name || typeof d.description !== "string") return false;
4736
+ if (d.source === "bundled") {
4737
+ if (typeof d.dir !== "string" || !d.dir) return false;
4738
+ } else if (d.source === "git") {
4739
+ if (typeof d.repo !== "string" || !d.repo) return false;
4740
+ } else {
4741
+ return false;
4742
+ }
4743
+ if (!Array.isArray(d.requires) || d.requires.some((r) => typeof r !== "string")) return false;
4744
+ if (!d.serve || typeof d.serve !== "object") return false;
4745
+ if (d.serve.dirs !== void 0 && (!Array.isArray(d.serve.dirs) || d.serve.dirs.some((x) => typeof x !== "string") || !d.serve.dirs.length))
4746
+ return false;
4747
+ return true;
4748
+ });
4749
+ }
4750
+ function missingRequirements(entry) {
4751
+ const bins = entry.source === "git" && !entry.requires.includes("git") ? [...entry.requires, "git"] : entry.requires;
4752
+ const finder = process.platform === "win32" ? "where" : "which";
4753
+ return bins.filter((bin) => spawnSync(finder, [bin], { stdio: "ignore" }).status !== 0);
4754
+ }
4755
+
4717
4756
  // src/export.ts
4718
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync8, readdirSync as readdirSync4 } from "node:fs";
4719
- import { join as join11, dirname as dirname4, basename } from "node:path";
4757
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync9, readdirSync as readdirSync4 } from "node:fs";
4758
+ import { join as join12, dirname as dirname4, basename } from "node:path";
4720
4759
  import { fileURLToPath as fileURLToPath2 } from "node:url";
4721
4760
  var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
4722
4761
  function canonicalKey(path, params) {
@@ -4764,7 +4803,7 @@ function captureKeys(axes) {
4764
4803
  return [...keys];
4765
4804
  }
4766
4805
  function webDir() {
4767
- return join11(dirname4(fileURLToPath2(import.meta.url)), "..", "web");
4806
+ return join12(dirname4(fileURLToPath2(import.meta.url)), "..", "web");
4768
4807
  }
4769
4808
  function workerName(project, override) {
4770
4809
  const raw = override ?? `behold-${basename(project)}`;
@@ -4775,7 +4814,7 @@ async function runExport(cfg, outDir, opts = {}) {
4775
4814
  const app = createApp(cfg);
4776
4815
  const proj = await (await app.request("/api/project")).json();
4777
4816
  const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
4778
- const snapDir = join11(outDir, "snapshots");
4817
+ const snapDir = join12(outDir, "snapshots");
4779
4818
  mkdirSync2(snapDir, { recursive: true });
4780
4819
  const keyToFile = {};
4781
4820
  let ok = 0;
@@ -4784,7 +4823,7 @@ async function runExport(cfg, outDir, opts = {}) {
4784
4823
  const res = await app.request(key);
4785
4824
  const body = await res.text();
4786
4825
  const file = slug(key);
4787
- writeFileSync2(join11(snapDir, file), body);
4826
+ writeFileSync2(join12(snapDir, file), body);
4788
4827
  keyToFile[key] = `snapshots/${file}`;
4789
4828
  if (res.ok) ok++;
4790
4829
  else failed++;
@@ -4796,21 +4835,21 @@ async function runExport(cfg, outDir, opts = {}) {
4796
4835
  axes,
4797
4836
  keyToFile
4798
4837
  };
4799
- writeFileSync2(join11(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
4800
- const html = readFileSync8(join11(webDir(), "index.html"), "utf8").replace(
4838
+ writeFileSync2(join12(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
4839
+ const html = readFileSync9(join12(webDir(), "index.html"), "utf8").replace(
4801
4840
  /<\/head>/i,
4802
4841
  ` <script>window.__BEHOLD_STATIC__ = true;</script>
4803
4842
  </head>`
4804
4843
  );
4805
- writeFileSync2(join11(outDir, "index.html"), html);
4844
+ writeFileSync2(join12(outDir, "index.html"), html);
4806
4845
  for (const f of readdirSync4(webDir())) {
4807
4846
  if (f === "index.html") continue;
4808
- copyFileSync(join11(webDir(), f), join11(outDir, f));
4847
+ copyFileSync(join12(webDir(), f), join12(outDir, f));
4809
4848
  }
4810
- writeFileSync2(join11(outDir, "README.md"), BUNDLE_README);
4849
+ writeFileSync2(join12(outDir, "README.md"), BUNDLE_README);
4811
4850
  const name = workerName(cfg.projectDir, opts.name);
4812
4851
  writeFileSync2(
4813
- join11(outDir, "wrangler.jsonc"),
4852
+ join12(outDir, "wrangler.jsonc"),
4814
4853
  JSON.stringify(
4815
4854
  { $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
4816
4855
  null,
@@ -4861,17 +4900,20 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
4861
4900
  var USAGE = `behold \u2014 a live control plane on chant (read-only core)
4862
4901
 
4863
4902
  Usage:
4864
- behold demo [target-dir] [--port <n>]
4903
+ behold demo [name] [target-dir] [--port <n>] [--list]
4865
4904
  behold preview [project-dir] [--port <n>] [--emulator]
4866
4905
  behold export [project-dir] [--out <dir>] [--env <name>] [--name <worker>] [--emulator]
4867
4906
  behold serve <project-dir\u2026> [--port <n>] [--env <name>] [--poll <secs>] [--local]
4868
4907
 
4869
- demo The five-minute path from npm \u2014 no chant project needed. Copies the
4870
- bundled example (an S3 bucket + policy) into ./behold-demo (or
4871
- [target-dir]), installs its dependencies, and serves it against a
4872
- local emulator: blue = declared, click Deploy, watch it turn green.
4873
- Needs Docker. The copy is yours \u2014 edit its source and watch the
4874
- graph change live.
4908
+ demo The five-minute path from npm \u2014 no chant project needed. A catalog
4909
+ of demo estates (behold demo --list): bundled ones copy out of the
4910
+ package into a directory that's yours to edit; git ones shallow-
4911
+ clone a public estate. Bare \`behold demo\` is the AWS example \u2014 an
4912
+ S3 bucket + policy on a local emulator: blue = declared, click
4913
+ Deploy, watch it turn green. \`behold demo k8s\` stands a workload
4914
+ up on a throwaway k3d cluster instead. Needs Docker (and per-demo
4915
+ tools --list names). Loaded demos land in the panel's recents, so
4916
+ switching between them is the Scope tab.
4875
4917
 
4876
4918
  export Capture the live estate into a self-contained, interactive STATIC
4877
4919
  bundle (default ./behold-export) \u2014 every env/tier \xD7 zoom \xD7 radial,
@@ -5020,7 +5062,7 @@ ${USAGE}`);
5020
5062
  });
5021
5063
  }
5022
5064
  function warnIfNotChantProject(dir) {
5023
- if (existsSync10(join12(dir, "chant.config.ts"))) return;
5065
+ if (existsSync10(join13(dir, "chant.config.ts"))) return;
5024
5066
  process.stderr.write(
5025
5067
  `behold: warning \u2014 ${dir} has no chant.config.ts; this doesn't look like a chant project.
5026
5068
  No project yet? \`behold demo\` serves a bundled working example (needs Docker).
@@ -5028,14 +5070,37 @@ function warnIfNotChantProject(dir) {
5028
5070
  );
5029
5071
  }
5030
5072
  async function runDemo(rest) {
5073
+ const pkgRoot = join13(dirname5(fileURLToPath3(import.meta.url)), "..");
5074
+ const registry = loadDemoRegistry(pkgRoot);
5031
5075
  let port = 4600;
5076
+ let name;
5032
5077
  let dirArg;
5033
5078
  for (let i = 0; i < rest.length; i++) {
5034
5079
  const a = rest[i];
5035
5080
  if (a === "--port") port = Number(rest[++i]);
5036
- else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
5037
- else if (!a.startsWith("-")) dirArg = a;
5038
- else {
5081
+ else if (a === "--list") {
5082
+ if (!registry.length) {
5083
+ process.stdout.write("behold demo: no catalog in this install (demos.json missing)\n");
5084
+ return;
5085
+ }
5086
+ for (const e of registry) {
5087
+ const missing2 = missingRequirements(e);
5088
+ const ready = missing2.length ? `needs ${missing2.join(", ")}` : "ready";
5089
+ process.stdout.write(` ${e.name.padEnd(14)} ${ready.padEnd(20)} ${e.description}
5090
+ `);
5091
+ }
5092
+ process.stdout.write("\nRun one: behold demo <name> (bare `behold demo` = writes)\n");
5093
+ return;
5094
+ } else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
5095
+ else if (!a.startsWith("-")) {
5096
+ if (!name && registry.some((e) => e.name === a)) name = a;
5097
+ else if (!dirArg) dirArg = a;
5098
+ else {
5099
+ process.stderr.write(`behold demo: unexpected argument '${a}'
5100
+ `);
5101
+ process.exit(2);
5102
+ }
5103
+ } else {
5039
5104
  process.stderr.write(`behold demo: unexpected argument '${a}'
5040
5105
  `);
5041
5106
  process.exit(2);
@@ -5045,34 +5110,76 @@ async function runDemo(rest) {
5045
5110
  process.stderr.write("behold demo: --port must be a number\n");
5046
5111
  process.exit(2);
5047
5112
  }
5048
- const bundled = join12(dirname5(fileURLToPath3(import.meta.url)), "..", "example-writes");
5049
- if (!existsSync10(bundled)) {
5050
- process.stderr.write("behold demo: this install has no bundled example project (example-writes)\n");
5113
+ const entry = registry.find((e) => e.name === (name ?? "writes"));
5114
+ if (!entry) {
5115
+ process.stderr.write(`behold demo: no "${name ?? "writes"}" in this install's catalog \u2014 behold demo --list
5116
+ `);
5117
+ process.exit(2);
5118
+ }
5119
+ const missing = missingRequirements(entry);
5120
+ if (missing.length) {
5121
+ process.stderr.write(`behold demo ${entry.name}: missing ${missing.join(", ")} \u2014 install and re-run.
5122
+ `);
5051
5123
  process.exit(2);
5052
5124
  }
5053
- const target = resolve3(dirArg ?? "behold-demo");
5125
+ const target = resolve3(
5126
+ dirArg ?? (entry.name === "writes" && existsSync10("behold-demo") ? "behold-demo" : join13("behold-demos", entry.name))
5127
+ );
5054
5128
  if (!existsSync10(target)) {
5055
- process.stdout.write(`behold demo \u2192 copying the example project to ${target} (it's yours \u2014 edit it)
5129
+ if (entry.source === "bundled") {
5130
+ const bundled = join13(pkgRoot, entry.dir);
5131
+ if (!existsSync10(bundled)) {
5132
+ process.stderr.write(`behold demo ${entry.name}: this install has no bundled ${entry.dir}
5056
5133
  `);
5057
- cpSync(bundled, target, {
5058
- recursive: true,
5059
- filter: (src) => !relative2(bundled, src).split(sep).includes("node_modules")
5060
- });
5134
+ process.exit(2);
5135
+ }
5136
+ process.stdout.write(`behold demo ${entry.name} \u2192 copying to ${target} (it's yours \u2014 edit it)
5137
+ `);
5138
+ cpSync(bundled, target, {
5139
+ recursive: true,
5140
+ filter: (src) => !relative2(bundled, src).split(sep).includes("node_modules")
5141
+ });
5142
+ } else {
5143
+ process.stdout.write(`behold demo ${entry.name} \u2192 cloning ${entry.repo} to ${target}
5144
+ `);
5145
+ const r = spawnSync2("git", ["clone", "--depth", "1", entry.repo, target], { stdio: "inherit" });
5146
+ if (r.status !== 0) {
5147
+ process.stderr.write(`behold demo ${entry.name}: clone failed
5148
+ `);
5149
+ process.exit(r.status ?? 1);
5150
+ }
5151
+ }
5061
5152
  } else {
5062
- process.stdout.write(`behold demo \u2192 reusing ${target}
5153
+ process.stdout.write(`behold demo ${entry.name} \u2192 reusing ${target}
5063
5154
  `);
5064
5155
  }
5065
- if (!existsSync10(join12(target, "node_modules"))) {
5066
- process.stdout.write("behold demo \u2192 npm install (the example's own chant + lexicons)\u2026\n");
5067
- const r = spawnSync("npm", ["install"], { cwd: target, stdio: "inherit", shell: process.platform === "win32" });
5156
+ if (existsSync10(join13(target, "package.json")) && !existsSync10(join13(target, "node_modules"))) {
5157
+ process.stdout.write(`behold demo ${entry.name} \u2192 npm install\u2026
5158
+ `);
5159
+ const r = spawnSync2("npm", ["install"], { cwd: target, stdio: "inherit", shell: process.platform === "win32" });
5068
5160
  if (r.status !== 0) {
5069
5161
  process.stderr.write(`behold demo: npm install failed in ${target}${r.error ? ` (${r.error.message})` : ""}
5070
5162
  `);
5071
5163
  process.exit(r.status ?? 1);
5072
5164
  }
5073
5165
  }
5074
- process.stdout.write("behold demo \u2192 serving with a local emulator (Docker). Blue = declared; Deploy turns it green.\n");
5075
- await run3(["serve", target, "--local", "--env", "prod", "--port", String(port)]);
5166
+ if (entry.setup) {
5167
+ process.stdout.write(`behold demo ${entry.name} \u2192 ${entry.setup}
5168
+ `);
5169
+ const r = spawnSync2(entry.setup, { cwd: target, stdio: "inherit", shell: true });
5170
+ if (r.status !== 0) {
5171
+ process.stderr.write(`behold demo ${entry.name}: setup failed (${entry.setup})
5172
+ `);
5173
+ process.exit(r.status ?? 1);
5174
+ }
5175
+ }
5176
+ process.stdout.write(`behold demo ${entry.name} \u2192 serving. Blue = declared; Deploy turns it green.
5177
+ `);
5178
+ const serveDirs = entry.serve.dirs?.length ? entry.serve.dirs.map((d) => join13(target, d)) : [target];
5179
+ const serveArgs = ["serve", ...serveDirs, "--port", String(port)];
5180
+ if (entry.serve.local) serveArgs.push("--local");
5181
+ if (entry.serve.env) serveArgs.push("--env", entry.serve.env);
5182
+ await run3(serveArgs);
5076
5183
  }
5077
5184
  function injectEmulatorEnv(env) {
5078
5185
  process.env.LOOM_ENV ??= env ?? "local";
@@ -0,0 +1,41 @@
1
+ # flux-estate — behold's GitOps estate demo (#211)
2
+
3
+ Three chant projects served as one composed estate:
4
+
5
+ - **control-plane/** — the Flux machinery: a `GitRepository` pointing at
6
+ behold's own public repo and one `Kustomization` per app. The reconcilers
7
+ deploy the apps; this project never applies a workload itself.
8
+ - **app-a/** — a small web workload with explicit `metadata.namespace` on
9
+ every object: the fully-resolved half.
10
+ - **app-b/** — the same workload with **no** namespace anywhere: the control
11
+ plane's `targetNamespace` stamps it at apply time. Idiomatic Flux, and the
12
+ deliberate exhibit of behold's namespace-mismatch note (behold#192) — a
13
+ plain live read scopes to `default`, finds nothing, and behold explains the
14
+ all-pending paint instead of letting it read as "not deployed".
15
+
16
+ ```sh
17
+ npx @intentius/behold demo flux-estate
18
+ ```
19
+
20
+ That stands up a throwaway k3d cluster (`behold-flux-demo`), installs Flux's
21
+ controllers straight from the pinned release manifest (no `flux` CLI needed),
22
+ applies the control plane, waits for the reconcilers to deploy both apps from
23
+ this repo, and serves the three projects composed. What to look at:
24
+
25
+ - The **estate**: per-project boundary boxes, and the `sourceRef` edges wiring
26
+ Kustomization → GitRepository across stacks.
27
+ - The **overlay** (env `local`): app-a green, the control plane's CRs green,
28
+ app-b pending **with the note explaining why**.
29
+ - The **runtime tier** on the control plane alone
30
+ (`behold serve control-plane --env local`, zoom: runtime): the workloads
31
+ Flux deployed, attributed back to the declaring Kustomization via its
32
+ labels (chant#1549).
33
+
34
+ The app `manifests/` are committed `chant build` output — Flux syncs them
35
+ from GitHub `main`, so changes to an app's source need a rebuild
36
+ (`npm run build` in the app) and a merge before the cluster follows. To
37
+ verify an unmerged branch: `BEHOLD_FLUX_REF=<branch> bash scripts/estate-up.sh`
38
+ patches the live `GitRepository` (the declared source stays `main`).
39
+
40
+ `bash scripts/estate-down.sh` deletes the cluster and restores your previous
41
+ kubectl context.
@@ -0,0 +1,18 @@
1
+ import type { ChantConfig } from "@intentius/chant";
2
+ import type { K8sChantConfig } from "@intentius/chant-lexicon-k8s";
3
+
4
+ // App project A. Deployed by the control plane's Flux Kustomization, never by
5
+ // this project — its committed manifests/ (chant build output) are what Flux
6
+ // syncs from the repo. behold serves it as part of the estate; the live
7
+ // overlay reads the cluster through the same binding as every other member.
8
+ export default {
9
+ lexicons: ["k8s"],
10
+ sourceDir: "src",
11
+ environments: ["local"],
12
+ ownership: { stack: "app-a", env: "local" },
13
+ k8s: {
14
+ profiles: {
15
+ local: { context: "k3d-behold-flux-demo" },
16
+ },
17
+ } satisfies K8sChantConfig,
18
+ } satisfies ChantConfig;
@@ -0,0 +1,56 @@
1
+ apiVersion: apps/v1
2
+ kind: Deployment
3
+ metadata:
4
+ name: app-a
5
+ namespace: app-a
6
+ labels:
7
+ app.kubernetes.io/managed-by: chant
8
+ chant.intentius.io/stack: app-a
9
+ chant.intentius.io/env: local
10
+ app: app-a
11
+ spec:
12
+ replicas: 1
13
+ selector:
14
+ matchLabels:
15
+ app: app-a
16
+ template:
17
+ metadata:
18
+ labels:
19
+ app: app-a
20
+ spec:
21
+ containers:
22
+ - name: web
23
+ image: nginxinc/nginx-unprivileged:1.27-alpine
24
+ ports:
25
+ - containerPort: 8080
26
+ securityContext:
27
+ runAsNonRoot: true
28
+ runAsUser: 101
29
+ allowPrivilegeEscalation: false
30
+ capabilities:
31
+ drop:
32
+ - ALL
33
+ resources:
34
+ requests:
35
+ cpu: '25m'
36
+ memory: '32Mi'
37
+ limits:
38
+ cpu: '100m'
39
+ memory: '64Mi'
40
+
41
+ ---
42
+ apiVersion: v1
43
+ kind: Service
44
+ metadata:
45
+ name: app-a
46
+ namespace: app-a
47
+ labels:
48
+ app.kubernetes.io/managed-by: chant
49
+ chant.intentius.io/stack: app-a
50
+ chant.intentius.io/env: local
51
+ spec:
52
+ selector:
53
+ app: app-a
54
+ ports:
55
+ - port: 80
56
+ targetPort: 8080
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "behold-example-flux-app-a",
3
+ "private": true,
4
+ "type": "module",
5
+ "description": "App project A: declares its workload WITH explicit namespaces — the fully-resolved half of the estate.",
6
+ "scripts": {
7
+ "build": "chant build src -o manifests/app.yaml --format yaml"
8
+ },
9
+ "dependencies": {
10
+ "@intentius/chant": "^0.41.20",
11
+ "@intentius/chant-lexicon-k8s": "^0.41.20"
12
+ }
13
+ }
@@ -0,0 +1,35 @@
1
+ // App A: one small web workload, namespace declared EXPLICITLY on every
2
+ // object. Contrast app-b, which leaves namespace to the control plane's
3
+ // targetNamespace — the two apps together show both halves of the Flux
4
+ // namespace story (behold#192).
5
+ import { Deployment, Service } from "@intentius/chant-lexicon-k8s";
6
+
7
+ const labels = { app: "app-a" };
8
+ const image = "nginxinc/nginx-unprivileged:1.27-alpine";
9
+
10
+ export const deployment = new Deployment({
11
+ metadata: { name: "app-a", namespace: "app-a", labels },
12
+ spec: {
13
+ replicas: 1,
14
+ selector: { matchLabels: labels },
15
+ template: {
16
+ metadata: { labels },
17
+ spec: {
18
+ containers: [
19
+ {
20
+ name: "web",
21
+ image,
22
+ ports: [{ containerPort: 8080 }],
23
+ securityContext: { runAsNonRoot: true, runAsUser: 101, allowPrivilegeEscalation: false, capabilities: { drop: ["ALL"] } },
24
+ resources: { requests: { cpu: "25m", memory: "32Mi" }, limits: { cpu: "100m", memory: "64Mi" } },
25
+ },
26
+ ],
27
+ },
28
+ },
29
+ },
30
+ });
31
+
32
+ export const service = new Service({
33
+ metadata: { name: "app-a", namespace: "app-a" },
34
+ spec: { selector: labels, ports: [{ port: 80, targetPort: 8080 }] },
35
+ });
@@ -0,0 +1 @@
1
+ { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "skipLibCheck": true, "noEmit": true }, "include": ["src/**/*", "ops/**/*", "chant.config.ts"] }
@@ -0,0 +1,20 @@
1
+ import type { ChantConfig } from "@intentius/chant";
2
+ import type { K8sChantConfig } from "@intentius/chant-lexicon-k8s";
3
+
4
+ // App project B — the targetNamespace half of the estate: its objects declare
5
+ // no metadata.namespace (see src/app.ts), so the control plane's
6
+ // Kustomization stamps `app-b` at apply time. That is idiomatic Flux, AND it
7
+ // means a plain live read scopes to `default` and finds nothing — behold's
8
+ // namespace-mismatch note (behold#192) names exactly this, on purpose, in
9
+ // this demo.
10
+ export default {
11
+ lexicons: ["k8s"],
12
+ sourceDir: "src",
13
+ environments: ["local"],
14
+ ownership: { stack: "app-b", env: "local" },
15
+ k8s: {
16
+ profiles: {
17
+ local: { context: "k3d-behold-flux-demo" },
18
+ },
19
+ } satisfies K8sChantConfig,
20
+ } satisfies ChantConfig;
@@ -0,0 +1,54 @@
1
+ apiVersion: apps/v1
2
+ kind: Deployment
3
+ metadata:
4
+ name: app-b
5
+ labels:
6
+ app.kubernetes.io/managed-by: chant
7
+ chant.intentius.io/stack: app-b
8
+ chant.intentius.io/env: local
9
+ app: app-b
10
+ spec:
11
+ replicas: 1
12
+ selector:
13
+ matchLabels:
14
+ app: app-b
15
+ template:
16
+ metadata:
17
+ labels:
18
+ app: app-b
19
+ spec:
20
+ containers:
21
+ - name: web
22
+ image: nginxinc/nginx-unprivileged:1.27-alpine
23
+ ports:
24
+ - containerPort: 8080
25
+ securityContext:
26
+ runAsNonRoot: true
27
+ runAsUser: 101
28
+ allowPrivilegeEscalation: false
29
+ capabilities:
30
+ drop:
31
+ - ALL
32
+ resources:
33
+ requests:
34
+ cpu: '25m'
35
+ memory: '32Mi'
36
+ limits:
37
+ cpu: '100m'
38
+ memory: '64Mi'
39
+
40
+ ---
41
+ apiVersion: v1
42
+ kind: Service
43
+ metadata:
44
+ name: app-b
45
+ labels:
46
+ app.kubernetes.io/managed-by: chant
47
+ chant.intentius.io/stack: app-b
48
+ chant.intentius.io/env: local
49
+ spec:
50
+ selector:
51
+ app: app-b
52
+ ports:
53
+ - port: 80
54
+ targetPort: 8080
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "behold-example-flux-app-b",
3
+ "private": true,
4
+ "type": "module",
5
+ "description": "App project B: declares NO metadata.namespace — Flux's targetNamespace stamps it at apply time (the behold#192 shape).",
6
+ "scripts": {
7
+ "build": "chant build src -o manifests/app.yaml --format yaml"
8
+ },
9
+ "dependencies": {
10
+ "@intentius/chant": "^0.41.20",
11
+ "@intentius/chant-lexicon-k8s": "^0.41.20"
12
+ }
13
+ }
@@ -0,0 +1,37 @@
1
+ // App B: same small workload as app-a, with NO metadata.namespace anywhere —
2
+ // the control plane's Kustomization stamps targetNamespace: app-b at apply
3
+ // time. Idiomatic Flux (that's what the field is for), and the deliberate
4
+ // exhibit of behold's namespace-mismatch note (behold#192): a plain live read
5
+ // scopes to `default`, finds nothing, and behold explains the all-pending
6
+ // paint instead of letting it read as "not deployed".
7
+ import { Deployment, Service } from "@intentius/chant-lexicon-k8s";
8
+
9
+ const labels = { app: "app-b" };
10
+ const image = "nginxinc/nginx-unprivileged:1.27-alpine";
11
+
12
+ export const deployment = new Deployment({
13
+ metadata: { name: "app-b", labels },
14
+ spec: {
15
+ replicas: 1,
16
+ selector: { matchLabels: labels },
17
+ template: {
18
+ metadata: { labels },
19
+ spec: {
20
+ containers: [
21
+ {
22
+ name: "web",
23
+ image,
24
+ ports: [{ containerPort: 8080 }],
25
+ securityContext: { runAsNonRoot: true, runAsUser: 101, allowPrivilegeEscalation: false, capabilities: { drop: ["ALL"] } },
26
+ resources: { requests: { cpu: "25m", memory: "32Mi" }, limits: { cpu: "100m", memory: "64Mi" } },
27
+ },
28
+ ],
29
+ },
30
+ },
31
+ },
32
+ });
33
+
34
+ export const service = new Service({
35
+ metadata: { name: "app-b" },
36
+ spec: { selector: labels, ports: [{ port: 80, targetPort: 8080 }] },
37
+ });
@@ -0,0 +1 @@
1
+ { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "skipLibCheck": true, "noEmit": true }, "include": ["src/**/*", "ops/**/*", "chant.config.ts"] }