@intentius/behold 0.4.1 → 0.5.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 +30 -0
- package/dist/cli.js +143 -39
- 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 +3 -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,30 @@
|
|
|
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": "fountain",
|
|
22
|
+
"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.",
|
|
23
|
+
"source": "git",
|
|
24
|
+
"repo": "https://github.com/INTENTIUS/fountain-ops",
|
|
25
|
+
"requires": ["docker", "k3d", "kubectl", "jq", "just"],
|
|
26
|
+
"setup": "just up",
|
|
27
|
+
"serve": { "env": "local" }
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
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
|
|
@@ -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,43 @@ 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
|
+
return true;
|
|
4746
|
+
});
|
|
4747
|
+
}
|
|
4748
|
+
function missingRequirements(entry) {
|
|
4749
|
+
const bins = entry.source === "git" && !entry.requires.includes("git") ? [...entry.requires, "git"] : entry.requires;
|
|
4750
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
4751
|
+
return bins.filter((bin) => spawnSync(finder, [bin], { stdio: "ignore" }).status !== 0);
|
|
4752
|
+
}
|
|
4753
|
+
|
|
4717
4754
|
// src/export.ts
|
|
4718
|
-
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, copyFileSync, readFileSync as
|
|
4719
|
-
import { join as
|
|
4755
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync9, readdirSync as readdirSync4 } from "node:fs";
|
|
4756
|
+
import { join as join12, dirname as dirname4, basename } from "node:path";
|
|
4720
4757
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
4721
4758
|
var LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"];
|
|
4722
4759
|
function canonicalKey(path, params) {
|
|
@@ -4764,7 +4801,7 @@ function captureKeys(axes) {
|
|
|
4764
4801
|
return [...keys];
|
|
4765
4802
|
}
|
|
4766
4803
|
function webDir() {
|
|
4767
|
-
return
|
|
4804
|
+
return join12(dirname4(fileURLToPath2(import.meta.url)), "..", "web");
|
|
4768
4805
|
}
|
|
4769
4806
|
function workerName(project, override) {
|
|
4770
4807
|
const raw = override ?? `behold-${basename(project)}`;
|
|
@@ -4775,7 +4812,7 @@ async function runExport(cfg, outDir, opts = {}) {
|
|
|
4775
4812
|
const app = createApp(cfg);
|
|
4776
4813
|
const proj = await (await app.request("/api/project")).json();
|
|
4777
4814
|
const axes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] };
|
|
4778
|
-
const snapDir =
|
|
4815
|
+
const snapDir = join12(outDir, "snapshots");
|
|
4779
4816
|
mkdirSync2(snapDir, { recursive: true });
|
|
4780
4817
|
const keyToFile = {};
|
|
4781
4818
|
let ok = 0;
|
|
@@ -4784,7 +4821,7 @@ async function runExport(cfg, outDir, opts = {}) {
|
|
|
4784
4821
|
const res = await app.request(key);
|
|
4785
4822
|
const body = await res.text();
|
|
4786
4823
|
const file = slug(key);
|
|
4787
|
-
writeFileSync2(
|
|
4824
|
+
writeFileSync2(join12(snapDir, file), body);
|
|
4788
4825
|
keyToFile[key] = `snapshots/${file}`;
|
|
4789
4826
|
if (res.ok) ok++;
|
|
4790
4827
|
else failed++;
|
|
@@ -4796,21 +4833,21 @@ async function runExport(cfg, outDir, opts = {}) {
|
|
|
4796
4833
|
axes,
|
|
4797
4834
|
keyToFile
|
|
4798
4835
|
};
|
|
4799
|
-
writeFileSync2(
|
|
4800
|
-
const html =
|
|
4836
|
+
writeFileSync2(join12(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
4837
|
+
const html = readFileSync9(join12(webDir(), "index.html"), "utf8").replace(
|
|
4801
4838
|
/<\/head>/i,
|
|
4802
4839
|
` <script>window.__BEHOLD_STATIC__ = true;</script>
|
|
4803
4840
|
</head>`
|
|
4804
4841
|
);
|
|
4805
|
-
writeFileSync2(
|
|
4842
|
+
writeFileSync2(join12(outDir, "index.html"), html);
|
|
4806
4843
|
for (const f of readdirSync4(webDir())) {
|
|
4807
4844
|
if (f === "index.html") continue;
|
|
4808
|
-
copyFileSync(
|
|
4845
|
+
copyFileSync(join12(webDir(), f), join12(outDir, f));
|
|
4809
4846
|
}
|
|
4810
|
-
writeFileSync2(
|
|
4847
|
+
writeFileSync2(join12(outDir, "README.md"), BUNDLE_README);
|
|
4811
4848
|
const name = workerName(cfg.projectDir, opts.name);
|
|
4812
4849
|
writeFileSync2(
|
|
4813
|
-
|
|
4850
|
+
join12(outDir, "wrangler.jsonc"),
|
|
4814
4851
|
JSON.stringify(
|
|
4815
4852
|
{ $schema: "node_modules/wrangler/config-schema.json", name, compatibility_date: "2025-06-01", assets: { directory: "." } },
|
|
4816
4853
|
null,
|
|
@@ -4861,17 +4898,20 @@ It's just files \u2014 GitHub Pages, S3, nginx, or Cloudflare Pages
|
|
|
4861
4898
|
var USAGE = `behold \u2014 a live control plane on chant (read-only core)
|
|
4862
4899
|
|
|
4863
4900
|
Usage:
|
|
4864
|
-
behold demo [target-dir] [--port <n>]
|
|
4901
|
+
behold demo [name] [target-dir] [--port <n>] [--list]
|
|
4865
4902
|
behold preview [project-dir] [--port <n>] [--emulator]
|
|
4866
4903
|
behold export [project-dir] [--out <dir>] [--env <name>] [--name <worker>] [--emulator]
|
|
4867
4904
|
behold serve <project-dir\u2026> [--port <n>] [--env <name>] [--poll <secs>] [--local]
|
|
4868
4905
|
|
|
4869
|
-
demo The five-minute path from npm \u2014 no chant project needed.
|
|
4870
|
-
|
|
4871
|
-
|
|
4872
|
-
|
|
4873
|
-
|
|
4874
|
-
|
|
4906
|
+
demo The five-minute path from npm \u2014 no chant project needed. A catalog
|
|
4907
|
+
of demo estates (behold demo --list): bundled ones copy out of the
|
|
4908
|
+
package into a directory that's yours to edit; git ones shallow-
|
|
4909
|
+
clone a public estate. Bare \`behold demo\` is the AWS example \u2014 an
|
|
4910
|
+
S3 bucket + policy on a local emulator: blue = declared, click
|
|
4911
|
+
Deploy, watch it turn green. \`behold demo k8s\` stands a workload
|
|
4912
|
+
up on a throwaway k3d cluster instead. Needs Docker (and per-demo
|
|
4913
|
+
tools --list names). Loaded demos land in the panel's recents, so
|
|
4914
|
+
switching between them is the Scope tab.
|
|
4875
4915
|
|
|
4876
4916
|
export Capture the live estate into a self-contained, interactive STATIC
|
|
4877
4917
|
bundle (default ./behold-export) \u2014 every env/tier \xD7 zoom \xD7 radial,
|
|
@@ -5020,7 +5060,7 @@ ${USAGE}`);
|
|
|
5020
5060
|
});
|
|
5021
5061
|
}
|
|
5022
5062
|
function warnIfNotChantProject(dir) {
|
|
5023
|
-
if (existsSync10(
|
|
5063
|
+
if (existsSync10(join13(dir, "chant.config.ts"))) return;
|
|
5024
5064
|
process.stderr.write(
|
|
5025
5065
|
`behold: warning \u2014 ${dir} has no chant.config.ts; this doesn't look like a chant project.
|
|
5026
5066
|
No project yet? \`behold demo\` serves a bundled working example (needs Docker).
|
|
@@ -5028,14 +5068,37 @@ function warnIfNotChantProject(dir) {
|
|
|
5028
5068
|
);
|
|
5029
5069
|
}
|
|
5030
5070
|
async function runDemo(rest) {
|
|
5071
|
+
const pkgRoot = join13(dirname5(fileURLToPath3(import.meta.url)), "..");
|
|
5072
|
+
const registry = loadDemoRegistry(pkgRoot);
|
|
5031
5073
|
let port = 4600;
|
|
5074
|
+
let name;
|
|
5032
5075
|
let dirArg;
|
|
5033
5076
|
for (let i = 0; i < rest.length; i++) {
|
|
5034
5077
|
const a = rest[i];
|
|
5035
5078
|
if (a === "--port") port = Number(rest[++i]);
|
|
5036
|
-
else if (a === "
|
|
5037
|
-
|
|
5038
|
-
|
|
5079
|
+
else if (a === "--list") {
|
|
5080
|
+
if (!registry.length) {
|
|
5081
|
+
process.stdout.write("behold demo: no catalog in this install (demos.json missing)\n");
|
|
5082
|
+
return;
|
|
5083
|
+
}
|
|
5084
|
+
for (const e of registry) {
|
|
5085
|
+
const missing2 = missingRequirements(e);
|
|
5086
|
+
const ready = missing2.length ? `needs ${missing2.join(", ")}` : "ready";
|
|
5087
|
+
process.stdout.write(` ${e.name.padEnd(14)} ${ready.padEnd(20)} ${e.description}
|
|
5088
|
+
`);
|
|
5089
|
+
}
|
|
5090
|
+
process.stdout.write("\nRun one: behold demo <name> (bare `behold demo` = writes)\n");
|
|
5091
|
+
return;
|
|
5092
|
+
} else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE);
|
|
5093
|
+
else if (!a.startsWith("-")) {
|
|
5094
|
+
if (!name && registry.some((e) => e.name === a)) name = a;
|
|
5095
|
+
else if (!dirArg) dirArg = a;
|
|
5096
|
+
else {
|
|
5097
|
+
process.stderr.write(`behold demo: unexpected argument '${a}'
|
|
5098
|
+
`);
|
|
5099
|
+
process.exit(2);
|
|
5100
|
+
}
|
|
5101
|
+
} else {
|
|
5039
5102
|
process.stderr.write(`behold demo: unexpected argument '${a}'
|
|
5040
5103
|
`);
|
|
5041
5104
|
process.exit(2);
|
|
@@ -5045,34 +5108,75 @@ async function runDemo(rest) {
|
|
|
5045
5108
|
process.stderr.write("behold demo: --port must be a number\n");
|
|
5046
5109
|
process.exit(2);
|
|
5047
5110
|
}
|
|
5048
|
-
const
|
|
5049
|
-
if (!
|
|
5050
|
-
process.stderr.write(
|
|
5111
|
+
const entry = registry.find((e) => e.name === (name ?? "writes"));
|
|
5112
|
+
if (!entry) {
|
|
5113
|
+
process.stderr.write(`behold demo: no "${name ?? "writes"}" in this install's catalog \u2014 behold demo --list
|
|
5114
|
+
`);
|
|
5115
|
+
process.exit(2);
|
|
5116
|
+
}
|
|
5117
|
+
const missing = missingRequirements(entry);
|
|
5118
|
+
if (missing.length) {
|
|
5119
|
+
process.stderr.write(`behold demo ${entry.name}: missing ${missing.join(", ")} \u2014 install and re-run.
|
|
5120
|
+
`);
|
|
5051
5121
|
process.exit(2);
|
|
5052
5122
|
}
|
|
5053
|
-
const target = resolve3(
|
|
5123
|
+
const target = resolve3(
|
|
5124
|
+
dirArg ?? (entry.name === "writes" && existsSync10("behold-demo") ? "behold-demo" : join13("behold-demos", entry.name))
|
|
5125
|
+
);
|
|
5054
5126
|
if (!existsSync10(target)) {
|
|
5055
|
-
|
|
5127
|
+
if (entry.source === "bundled") {
|
|
5128
|
+
const bundled = join13(pkgRoot, entry.dir);
|
|
5129
|
+
if (!existsSync10(bundled)) {
|
|
5130
|
+
process.stderr.write(`behold demo ${entry.name}: this install has no bundled ${entry.dir}
|
|
5056
5131
|
`);
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5132
|
+
process.exit(2);
|
|
5133
|
+
}
|
|
5134
|
+
process.stdout.write(`behold demo ${entry.name} \u2192 copying to ${target} (it's yours \u2014 edit it)
|
|
5135
|
+
`);
|
|
5136
|
+
cpSync(bundled, target, {
|
|
5137
|
+
recursive: true,
|
|
5138
|
+
filter: (src) => !relative2(bundled, src).split(sep).includes("node_modules")
|
|
5139
|
+
});
|
|
5140
|
+
} else {
|
|
5141
|
+
process.stdout.write(`behold demo ${entry.name} \u2192 cloning ${entry.repo} to ${target}
|
|
5142
|
+
`);
|
|
5143
|
+
const r = spawnSync2("git", ["clone", "--depth", "1", entry.repo, target], { stdio: "inherit" });
|
|
5144
|
+
if (r.status !== 0) {
|
|
5145
|
+
process.stderr.write(`behold demo ${entry.name}: clone failed
|
|
5146
|
+
`);
|
|
5147
|
+
process.exit(r.status ?? 1);
|
|
5148
|
+
}
|
|
5149
|
+
}
|
|
5061
5150
|
} else {
|
|
5062
|
-
process.stdout.write(`behold demo \u2192 reusing ${target}
|
|
5151
|
+
process.stdout.write(`behold demo ${entry.name} \u2192 reusing ${target}
|
|
5063
5152
|
`);
|
|
5064
5153
|
}
|
|
5065
|
-
if (!existsSync10(
|
|
5066
|
-
process.stdout.write(
|
|
5067
|
-
|
|
5154
|
+
if (existsSync10(join13(target, "package.json")) && !existsSync10(join13(target, "node_modules"))) {
|
|
5155
|
+
process.stdout.write(`behold demo ${entry.name} \u2192 npm install\u2026
|
|
5156
|
+
`);
|
|
5157
|
+
const r = spawnSync2("npm", ["install"], { cwd: target, stdio: "inherit", shell: process.platform === "win32" });
|
|
5068
5158
|
if (r.status !== 0) {
|
|
5069
5159
|
process.stderr.write(`behold demo: npm install failed in ${target}${r.error ? ` (${r.error.message})` : ""}
|
|
5070
5160
|
`);
|
|
5071
5161
|
process.exit(r.status ?? 1);
|
|
5072
5162
|
}
|
|
5073
5163
|
}
|
|
5074
|
-
|
|
5075
|
-
|
|
5164
|
+
if (entry.setup) {
|
|
5165
|
+
process.stdout.write(`behold demo ${entry.name} \u2192 ${entry.setup}
|
|
5166
|
+
`);
|
|
5167
|
+
const r = spawnSync2(entry.setup, { cwd: target, stdio: "inherit", shell: true });
|
|
5168
|
+
if (r.status !== 0) {
|
|
5169
|
+
process.stderr.write(`behold demo ${entry.name}: setup failed (${entry.setup})
|
|
5170
|
+
`);
|
|
5171
|
+
process.exit(r.status ?? 1);
|
|
5172
|
+
}
|
|
5173
|
+
}
|
|
5174
|
+
process.stdout.write(`behold demo ${entry.name} \u2192 serving. Blue = declared; Deploy turns it green.
|
|
5175
|
+
`);
|
|
5176
|
+
const serveArgs = ["serve", target, "--port", String(port)];
|
|
5177
|
+
if (entry.serve.local) serveArgs.push("--local");
|
|
5178
|
+
if (entry.serve.env) serveArgs.push("--env", entry.serve.env);
|
|
5179
|
+
await run3(serveArgs);
|
|
5076
5180
|
}
|
|
5077
5181
|
function injectEmulatorEnv(env) {
|
|
5078
5182
|
process.env.LOOM_ENV ??= env ?? "local";
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# The k3d turnkey demo — behold's Kubernetes counterpart to Loom-on-Floci
|
|
2
|
+
|
|
3
|
+
The smallest real thing on Kubernetes: one `nginx` Deployment + Service (+ a
|
|
4
|
+
PodDisruptionBudget), deployed from the browser with the **▶ Deploy
|
|
5
|
+
(k3d-apply)** header button, against a local, single-node
|
|
6
|
+
[k3d](https://k3d.io) cluster — no cloud account, no credentials.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
src/config.ts static config — app name, pinned image tag
|
|
10
|
+
src/web.ts WebApp composite → Deployment + Service + PodDisruptionBudget
|
|
11
|
+
ops/k3d-apply.op.ts ApplyOp "k3d-apply" — code → local k3d, server-side apply
|
|
12
|
+
chant.config.ts lexicons [k8s, temporal], k8s.profiles.local bound to k3d-behold-k3d-demo
|
|
13
|
+
scripts/local/ local-up.sh / local-down.sh — the k3d cluster's own lifecycle
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Run it
|
|
17
|
+
|
|
18
|
+
From the **behold** repo root:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm run demo:k8s
|
|
22
|
+
# → brings up a single-node k3d cluster ("behold-k3d-demo"),
|
|
23
|
+
# then serves this project at http://localhost:4600
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
That one command does two things: `scripts/local/local-up.sh` creates the
|
|
27
|
+
cluster (idempotent — reuses one already up), then `behold serve --local`
|
|
28
|
+
points its live overlay at it. `--local`'s usual boot step (`chant emulator
|
|
29
|
+
up`) is a no-op here — chant's k8s lexicon has no local-emulator capability
|
|
30
|
+
the way Floci does for aws — so the cluster comes up via the same generic
|
|
31
|
+
substrate **Bring up** mechanism the Floci demo's `scripts/local/local-up.sh`
|
|
32
|
+
convention already uses (see `src/substrates.ts`), just run up front instead
|
|
33
|
+
of from a click. Ctrl-C stops `behold serve` and tears the cluster back down;
|
|
34
|
+
Docker or k3d missing degrades to the source graph with a clear message
|
|
35
|
+
instead of crashing.
|
|
36
|
+
|
|
37
|
+
1. Open **http://localhost:4600**. The graph shows the Deployment, Service and
|
|
38
|
+
PodDisruptionBudget — **blue** (declared, not yet deployed).
|
|
39
|
+
2. Click **▶ Deploy (k3d-apply)** in the header (or ⌘K → "Deploy: Sync"). The
|
|
40
|
+
now-line streams Build → Plan (a live
|
|
41
|
+
diff) → Apply: a Kubernetes **server-side apply**, field manager
|
|
42
|
+
`chant:behold-k3d-demo` (chant#1074/#1075) — deletes are **owned-only**, a
|
|
43
|
+
marker-scoped prune that only ever touches what chant itself applied.
|
|
44
|
+
3. The three nodes flip **blue → green (managed)**. Zoom in on the Deployment
|
|
45
|
+
and its two Pods appear **nested underneath it** — the runtime tier
|
|
46
|
+
(chant#1077/behold#86): live children a Deployment's controller created,
|
|
47
|
+
never declared here, never classified as drift or an orphan.
|
|
48
|
+
|
|
49
|
+
## The four things this proves live (epic #84)
|
|
50
|
+
|
|
51
|
+
behold's k8s parity (#85–#87) shipped against fixtures; this is it against a
|
|
52
|
+
real cluster.
|
|
53
|
+
|
|
54
|
+
**Declared, not yet deployed.** Before step 2, `/api/overlay` reports all
|
|
55
|
+
three nodes `_status: accent` (pending) — chant knows about them, the cluster
|
|
56
|
+
doesn't yet.
|
|
57
|
+
|
|
58
|
+
**Runtime children.** After apply, `/api/overlay`'s `ir.groups.byContainer`
|
|
59
|
+
nests the Deployment's Pods under it, each `_status: runtime` — a tier below
|
|
60
|
+
what's declared, sourced from the cluster's own `ownerReferences`, that
|
|
61
|
+
behold's zoom dial can descend into.
|
|
62
|
+
|
|
63
|
+
**Managed-fields drift.** Scale or label the Deployment out of band —
|
|
64
|
+
`kubectl scale deployment/web --replicas=3`, bypassing chant entirely — and
|
|
65
|
+
`/api/diff`'s `fieldDrift` reports `spec.replicas: { kind: "changed", declared:
|
|
66
|
+
2, live: 3 }`: chant's own SSA-tracked field, now diverged from a competing
|
|
67
|
+
field manager (`kubectl`, visible in `kubectl get deploy/web -o json`'s
|
|
68
|
+
`metadata.managedFields`). Re-running `k3d-apply` at that point doesn't
|
|
69
|
+
silently overwrite it: chant's server-side apply refuses —
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
k8s: server-side apply of apps/v1 Deployment web was refused — 1 field is owned by another field manager.
|
|
73
|
+
|
|
74
|
+
"kubectl" owns:
|
|
75
|
+
.spec.replicas
|
|
76
|
+
|
|
77
|
+
chant applied as field manager "chant:behold-k3d-demo". Taking these fields means the managers above
|
|
78
|
+
stop owning them, and will contest them again on their next apply.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
— naming the contested path and the competing manager, and leaving the field
|
|
82
|
+
alone rather than force-resolving it. (One honest gap found running this
|
|
83
|
+
live: a **new** out-of-band key with no chant-declared counterpart at all —
|
|
84
|
+
`kubectl label deployment/web team=platform`, which has no corresponding
|
|
85
|
+
`web.spec` field in `src/web.ts` — doesn't currently surface in `fieldDrift`,
|
|
86
|
+
only a value change to a field chant *does* declare does. Worth a follow-up
|
|
87
|
+
issue on the chant side; noted here rather than papered over.)
|
|
88
|
+
|
|
89
|
+
**Unobserved.** `chant.config.ts` binds environment `local` to kubectl context
|
|
90
|
+
`k3d-behold-k3d-demo` (chant#1100) — every read/apply checks that binding
|
|
91
|
+
against whatever context is actually ambient, and refuses rather than reading
|
|
92
|
+
the wrong cluster. Switch away from it for a moment —
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
kubectl config use-context <anything-else>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
— and `/api/overlay` flips every node to `_status: neutral` with
|
|
99
|
+
`unobservedReason: "read-failed"`, Pods and all: an honest "did not look",
|
|
100
|
+
never a false "these are all gone." Switch back
|
|
101
|
+
(`kubectl config use-context k3d-behold-k3d-demo`) and the next refresh
|
|
102
|
+
recovers cleanly.
|
|
103
|
+
|
|
104
|
+
## Cleanup
|
|
105
|
+
|
|
106
|
+
`Ctrl-C` on `npm run demo:k8s` runs `scripts/local/local-down.sh`
|
|
107
|
+
automatically: deletes the `behold-k3d-demo` cluster and restores whatever
|
|
108
|
+
kubectl context was current before `local-up.sh` ran. Safe to run by hand too:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
bash scripts/local/local-down.sh
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Note for chant#1179
|
|
115
|
+
|
|
116
|
+
This Op's `delete: "owned-only"` exercises the same typed, marker-scoped
|
|
117
|
+
prune path chant#1179 wants a live k3d E2E for. This demo isn't that test —
|
|
118
|
+
it's a manual walkthrough, not an automated assertion of "exactly the orphan
|
|
119
|
+
and nothing else was deleted" — but it does confirm the path runs against a
|
|
120
|
+
real cluster, not just `fakeCluster`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ChantConfig } from "@intentius/chant";
|
|
2
|
+
import type { K8sChantConfig } from "@intentius/chant-lexicon-k8s";
|
|
3
|
+
|
|
4
|
+
// Bound to the demo's own cluster (scripts/local/local-up.sh creates it, k3d
|
|
5
|
+
// names the context "k3d-behold-k3d-demo") rather than whatever kubectl
|
|
6
|
+
// context happens to be ambient — chant#1100. A declared binding is checked
|
|
7
|
+
// against the ambient context on every live read/apply; a mismatch refuses
|
|
8
|
+
// loudly instead of silently reading the wrong cluster. The walkthrough's
|
|
9
|
+
// "unobserved" step (README.md) is exactly that refusal, produced on purpose
|
|
10
|
+
// by switching away from this context for a moment.
|
|
11
|
+
export default {
|
|
12
|
+
lexicons: ["k8s", "temporal"],
|
|
13
|
+
sourceDir: "src",
|
|
14
|
+
environments: ["local"],
|
|
15
|
+
ownership: { stack: "behold-k3d-demo", env: "local" },
|
|
16
|
+
k8s: {
|
|
17
|
+
profiles: {
|
|
18
|
+
local: { context: "k3d-behold-k3d-demo" },
|
|
19
|
+
},
|
|
20
|
+
} satisfies K8sChantConfig,
|
|
21
|
+
} satisfies ChantConfig;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ApplyOp } from "@intentius/chant-lexicon-temporal";
|
|
2
|
+
|
|
3
|
+
// code → local k3d (chant#704), no cloud account. scripts/local/local-up.sh
|
|
4
|
+
// (run by `npm run demo:k8s` before behold starts serving) brings the cluster
|
|
5
|
+
// up; behold's Run button on this Op deploys the declared app to it —
|
|
6
|
+
// build → plan (live diff) → server-side apply, field manager
|
|
7
|
+
// "chant:behold-k3d-demo" (chant#1074/#1075). Deletes are owned-only: a
|
|
8
|
+
// marker-scoped prune that never touches anything chant didn't declare — the
|
|
9
|
+
// same path chant#1179 wants a live E2E for; this Op exercises it, but isn't
|
|
10
|
+
// that test.
|
|
11
|
+
const { op } = ApplyOp({
|
|
12
|
+
name: "k3d-apply",
|
|
13
|
+
env: "local",
|
|
14
|
+
target: "kubectl",
|
|
15
|
+
output: "app.yaml",
|
|
16
|
+
delete: "owned-only",
|
|
17
|
+
});
|
|
18
|
+
export default op;
|