@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.
- package/README.md +4 -0
- package/demos.json +39 -0
- package/dist/cli.js +147 -40
- package/example-flux-estate/README.md +41 -0
- package/example-flux-estate/app-a/chant.config.ts +18 -0
- package/example-flux-estate/app-a/manifests/app.yaml +56 -0
- package/example-flux-estate/app-a/package.json +13 -0
- package/example-flux-estate/app-a/src/app.ts +35 -0
- package/example-flux-estate/app-a/tsconfig.json +1 -0
- package/example-flux-estate/app-b/chant.config.ts +20 -0
- package/example-flux-estate/app-b/manifests/app.yaml +54 -0
- package/example-flux-estate/app-b/package.json +13 -0
- package/example-flux-estate/app-b/src/app.ts +37 -0
- package/example-flux-estate/app-b/tsconfig.json +1 -0
- package/example-flux-estate/control-plane/chant.config.ts +19 -0
- package/example-flux-estate/control-plane/package.json +13 -0
- package/example-flux-estate/control-plane/src/flux.ts +51 -0
- package/example-flux-estate/control-plane/tsconfig.json +1 -0
- package/example-flux-estate/package-lock.json +1949 -0
- package/example-flux-estate/package.json +10 -0
- package/example-flux-estate/scripts/estate-down.sh +19 -0
- package/example-flux-estate/scripts/estate-up.sh +61 -0
- package/example-k8s/README.md +120 -0
- package/example-k8s/chant.config.ts +21 -0
- package/example-k8s/ops/k3d-apply.op.ts +18 -0
- package/example-k8s/package-lock.json +2071 -0
- package/example-k8s/package.json +14 -0
- package/example-k8s/scripts/local/local-down.sh +23 -0
- package/example-k8s/scripts/local/local-up.sh +46 -0
- package/example-k8s/src/config.ts +5 -0
- package/example-k8s/src/web.ts +29 -0
- package/example-k8s/tsconfig.json +1 -0
- 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
|
|
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 <
|
|
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
|
|
4719
|
-
import { join as
|
|
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
|
|
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 =
|
|
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(
|
|
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(
|
|
4800
|
-
const html =
|
|
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(
|
|
4844
|
+
writeFileSync2(join12(outDir, "index.html"), html);
|
|
4806
4845
|
for (const f of readdirSync4(webDir())) {
|
|
4807
4846
|
if (f === "index.html") continue;
|
|
4808
|
-
copyFileSync(
|
|
4847
|
+
copyFileSync(join12(webDir(), f), join12(outDir, f));
|
|
4809
4848
|
}
|
|
4810
|
-
writeFileSync2(
|
|
4849
|
+
writeFileSync2(join12(outDir, "README.md"), BUNDLE_README);
|
|
4811
4850
|
const name = workerName(cfg.projectDir, opts.name);
|
|
4812
4851
|
writeFileSync2(
|
|
4813
|
-
|
|
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.
|
|
4870
|
-
|
|
4871
|
-
|
|
4872
|
-
|
|
4873
|
-
|
|
4874
|
-
|
|
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(
|
|
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 === "
|
|
5037
|
-
|
|
5038
|
-
|
|
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
|
|
5049
|
-
if (!
|
|
5050
|
-
process.stderr.write(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
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(
|
|
5066
|
-
process.stdout.write(
|
|
5067
|
-
|
|
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
|
-
|
|
5075
|
-
|
|
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"] }
|