@gojsop/cli 0.0.0-stage → 0.2.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 +17 -2
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +90 -0
- package/dist/build.d.ts +7 -0
- package/dist/build.js +46 -0
- package/dist/bundle.d.ts +27 -0
- package/dist/bundle.js +94 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +28 -0
- package/dist/platforms.d.ts +7 -0
- package/dist/platforms.js +7 -0
- package/dist/scaffold.d.ts +6 -0
- package/dist/scaffold.js +127 -0
- package/dist/workspace.d.ts +30 -0
- package/dist/workspace.js +59 -0
- package/package.json +50 -4
package/README.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @gojsop/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The [gojsop](https://github.com/efuturetoday/gojsop) CLI. It runs hooks and
|
|
4
|
+
policies on your machine, in the engine the operator uses, against a cluster
|
|
5
|
+
held in memory.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install --save-dev @gojsop/cli
|
|
9
|
+
npx gojsop run policy.yaml --request request.yaml --trace
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
npm installs the binary for your platform as an optional dependency
|
|
13
|
+
(`@gojsop/cli-<os>-<arch>`): macOS and Linux on x64 and arm64, Windows on
|
|
14
|
+
x64. Set `GOJSOP_BIN` to use another binary.
|
|
15
|
+
|
|
16
|
+
To test hooks and policies with vitest, use
|
|
17
|
+
[`@gojsop/testing`](https://www.npmjs.com/package/@gojsop/testing); it
|
|
18
|
+
depends on this package.
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { build } from "./build.js";
|
|
8
|
+
import { bundle, isModule, scriptBeside } from "./bundle.js";
|
|
9
|
+
import { binaryPath, notFoundHint } from "./index.js";
|
|
10
|
+
import { create, remove, rename } from "./scaffold.js";
|
|
11
|
+
const usage = `usage:
|
|
12
|
+
gojsop new <policy|hook> <name> add a policy or hook with a passing test
|
|
13
|
+
gojsop rn <name> <new-name> rename one
|
|
14
|
+
gojsop rm <name> [--yes] remove one
|
|
15
|
+
gojsop build write dist/ for kubectl apply -f dist/
|
|
16
|
+
gojsop run <manifest> (--request <file> | --event <file>) [--cluster <file>] [--trace]
|
|
17
|
+
gojsop serve --stdio
|
|
18
|
+
`;
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
// These commands run here, in Node: they edit the workspace or bundle with
|
|
21
|
+
// the project's Vite. run and serve are the gojsop binary's.
|
|
22
|
+
const OWN = new Set([undefined, "help", "-h", "--help", "new", "rm", "remove", "rn", "rename", "build"]);
|
|
23
|
+
async function workspaceCommand() {
|
|
24
|
+
const [cmd, ...rest] = args;
|
|
25
|
+
if (!OWN.has(cmd))
|
|
26
|
+
return false;
|
|
27
|
+
const { values, positionals } = parseArgs({
|
|
28
|
+
args: rest,
|
|
29
|
+
allowPositionals: true,
|
|
30
|
+
options: { yes: { type: "boolean", short: "y" } },
|
|
31
|
+
});
|
|
32
|
+
const want = (n) => {
|
|
33
|
+
if (positionals.length !== n)
|
|
34
|
+
throw new Error(usage);
|
|
35
|
+
};
|
|
36
|
+
switch (cmd) {
|
|
37
|
+
case undefined:
|
|
38
|
+
case "help":
|
|
39
|
+
case "-h":
|
|
40
|
+
case "--help":
|
|
41
|
+
console.log(usage);
|
|
42
|
+
return true;
|
|
43
|
+
case "new":
|
|
44
|
+
want(2);
|
|
45
|
+
create(positionals[0], positionals[1]);
|
|
46
|
+
return true;
|
|
47
|
+
case "rm":
|
|
48
|
+
case "remove":
|
|
49
|
+
want(1);
|
|
50
|
+
await remove(positionals[0], values.yes ?? false);
|
|
51
|
+
return true;
|
|
52
|
+
case "rn":
|
|
53
|
+
case "rename":
|
|
54
|
+
want(2);
|
|
55
|
+
rename(positionals[0], positionals[1]);
|
|
56
|
+
return true;
|
|
57
|
+
case "build":
|
|
58
|
+
want(0);
|
|
59
|
+
await build();
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
if (await workspaceCommand())
|
|
66
|
+
process.exit(0);
|
|
67
|
+
// gojsop run on a TypeScript script: bundle it here and hand the result
|
|
68
|
+
// to the binary with --source, as @gojsop/testing does for every call.
|
|
69
|
+
if (args[0] === "run" && args[1] && !args.includes("--source")) {
|
|
70
|
+
const st = fs.statSync(args[1], { throwIfNoEntry: false });
|
|
71
|
+
const entry = st && scriptBeside(st.isDirectory() ? args[1] : path.dirname(args[1]));
|
|
72
|
+
if (entry && isModule(entry)) {
|
|
73
|
+
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "gojsop-")), "script.js");
|
|
74
|
+
fs.writeFileSync(file, (await bundle(entry)).code);
|
|
75
|
+
args.push("--source", file);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
console.error(e instanceof Error ? e.message : e);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
const bin = binaryPath();
|
|
84
|
+
const r = spawnSync(bin, args, { stdio: "inherit" });
|
|
85
|
+
if (r.error) {
|
|
86
|
+
const code = r.error.code;
|
|
87
|
+
console.error(code === "ENOENT" ? notFoundHint(bin) : `cannot start "${bin}": ${r.error.message}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
process.exit(r.status ?? 1);
|
package/dist/build.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gojsop build: writes every hook and policy of the workspace to
|
|
3
|
+
* dist/<policy|hook>-<name>.yaml, with its script bundled into
|
|
4
|
+
* spec.source.inline, ready for kubectl apply -f dist/. Files it wrote
|
|
5
|
+
* before for hooks and policies that are gone are removed.
|
|
6
|
+
*/
|
|
7
|
+
export declare function build(out?: string): Promise<number>;
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseDocument, Scalar } from "yaml";
|
|
4
|
+
import { bundle, isModule, scriptBeside } from "./bundle.js";
|
|
5
|
+
import { entries } from "./workspace.js";
|
|
6
|
+
/** The most spec.source.inline may hold. */
|
|
7
|
+
const INLINE_LIMIT = 512 * 1024;
|
|
8
|
+
const OWN = /^(policy|hook)-.+\.yaml$/;
|
|
9
|
+
/**
|
|
10
|
+
* gojsop build: writes every hook and policy of the workspace to
|
|
11
|
+
* dist/<policy|hook>-<name>.yaml, with its script bundled into
|
|
12
|
+
* spec.source.inline, ready for kubectl apply -f dist/. Files it wrote
|
|
13
|
+
* before for hooks and policies that are gone are removed.
|
|
14
|
+
*/
|
|
15
|
+
export async function build(out = "dist") {
|
|
16
|
+
const all = entries();
|
|
17
|
+
fs.mkdirSync(out, { recursive: true });
|
|
18
|
+
for (const f of fs.readdirSync(out))
|
|
19
|
+
if (OWN.test(f))
|
|
20
|
+
fs.rmSync(path.join(out, f));
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
for (const e of all) {
|
|
23
|
+
const file = `${e.kind}-${e.name}.yaml`;
|
|
24
|
+
if (seen.has(file))
|
|
25
|
+
throw new Error(`two ${e.kind === "hook" ? "hooks" : "policies"} are named ${e.name}`);
|
|
26
|
+
seen.add(file);
|
|
27
|
+
const doc = parseDocument(fs.readFileSync(e.manifest, "utf8"));
|
|
28
|
+
if (!doc.hasIn(["spec", "source", "inline"]) && !doc.hasIn(["spec", "source", "configMapRef"])) {
|
|
29
|
+
const entry = scriptBeside(e.dir);
|
|
30
|
+
if (!entry)
|
|
31
|
+
throw new Error(`${e.dir}: no script next to ${path.basename(e.manifest)}`);
|
|
32
|
+
const code = isModule(entry) ? (await bundle(entry)).code : fs.readFileSync(entry, "utf8");
|
|
33
|
+
if (Buffer.byteLength(code) > INLINE_LIMIT) {
|
|
34
|
+
throw new Error(`${entry}: the bundled script has ${Buffer.byteLength(code)} bytes, more than 512 KiB`);
|
|
35
|
+
}
|
|
36
|
+
const inline = new Scalar(code);
|
|
37
|
+
inline.type = Scalar.BLOCK_LITERAL;
|
|
38
|
+
doc.setIn(["spec", "source"], doc.createNode({ inline }));
|
|
39
|
+
}
|
|
40
|
+
fs.writeFileSync(path.join(out, file), doc.toString({ lineWidth: 0, flowCollectionPadding: false }));
|
|
41
|
+
console.log(`built ${path.join(out, file)}`);
|
|
42
|
+
}
|
|
43
|
+
if (all.length === 0)
|
|
44
|
+
console.log("no hooks or policies found");
|
|
45
|
+
return all.length;
|
|
46
|
+
}
|
package/dist/bundle.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The script next to a manifest: policy.ts or hook.ts (or .mts, .mjs, .js)
|
|
3
|
+
* named like the manifest, else the one script file of the directory that
|
|
4
|
+
* is not a test. Other files are modules the script may import.
|
|
5
|
+
* Undefined when there is none.
|
|
6
|
+
*/
|
|
7
|
+
export declare function scriptBeside(manifestDir: string): string | undefined;
|
|
8
|
+
/** Whether a script is a module that bundle() must turn into a plain script. */
|
|
9
|
+
export declare function isModule(file: string): boolean;
|
|
10
|
+
/** A bundled script, the files it was built from, and its source map. */
|
|
11
|
+
export interface Bundle {
|
|
12
|
+
code: string;
|
|
13
|
+
files: string[];
|
|
14
|
+
/** Source map (v3) with absolute paths in sources. */
|
|
15
|
+
map?: {
|
|
16
|
+
version: number;
|
|
17
|
+
sources: string[];
|
|
18
|
+
mappings: string;
|
|
19
|
+
names: string[];
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Bundles a TypeScript or ES-module script, with its imports, into the one
|
|
24
|
+
* plain script gojsop runs: ES2023, no modules, the exports as globals.
|
|
25
|
+
* Uses the project's Vite.
|
|
26
|
+
*/
|
|
27
|
+
export declare function bundle(file: string): Promise<Bundle>;
|
package/dist/bundle.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// Names the IIFE that holds the module's exports while it runs.
|
|
4
|
+
const EXPORTS = "__gojsop_exports";
|
|
5
|
+
/** Script files gojsop bundles; a .js file is a plain script and runs as it is. */
|
|
6
|
+
const MODULE = /\.(ts|mts|mjs)$/;
|
|
7
|
+
const NOT_A_SCRIPT = /\.(test|spec)\.[cm]?[jt]s$|\.d\.ts$/;
|
|
8
|
+
/**
|
|
9
|
+
* The script next to a manifest: policy.ts or hook.ts (or .mts, .mjs, .js)
|
|
10
|
+
* named like the manifest, else the one script file of the directory that
|
|
11
|
+
* is not a test. Other files are modules the script may import.
|
|
12
|
+
* Undefined when there is none.
|
|
13
|
+
*/
|
|
14
|
+
export function scriptBeside(manifestDir) {
|
|
15
|
+
for (const base of ["policy", "hook"]) {
|
|
16
|
+
if (!fs.existsSync(path.join(manifestDir, `${base}.yaml`)))
|
|
17
|
+
continue;
|
|
18
|
+
for (const ext of [".ts", ".mts", ".mjs", ".js"]) {
|
|
19
|
+
const f = path.join(manifestDir, base + ext);
|
|
20
|
+
if (fs.existsSync(f))
|
|
21
|
+
return f;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const found = fs
|
|
25
|
+
.readdirSync(manifestDir)
|
|
26
|
+
.filter((f) => /\.([cm]?[jt]s)$/.test(f) && !NOT_A_SCRIPT.test(f))
|
|
27
|
+
.sort();
|
|
28
|
+
if (found.length > 1) {
|
|
29
|
+
throw new Error(`${manifestDir}: several scripts (${found.join(", ")}); name the entry policy.ts or hook.ts`);
|
|
30
|
+
}
|
|
31
|
+
return found[0] && path.join(manifestDir, found[0]);
|
|
32
|
+
}
|
|
33
|
+
/** Whether a script is a module that bundle() must turn into a plain script. */
|
|
34
|
+
export function isModule(file) {
|
|
35
|
+
return MODULE.test(file);
|
|
36
|
+
}
|
|
37
|
+
// After the bundle runs, its exports become globals: gojsop calls
|
|
38
|
+
// validate, mutate and handle as properties of globalThis.
|
|
39
|
+
function exportsToGlobals() {
|
|
40
|
+
return {
|
|
41
|
+
name: "gojsop:exports-to-globals",
|
|
42
|
+
generateBundle(_options, bundle) {
|
|
43
|
+
for (const chunk of Object.values(bundle)) {
|
|
44
|
+
if (chunk.type !== "chunk" || !chunk.isEntry)
|
|
45
|
+
continue;
|
|
46
|
+
// Region markers name local paths, and the script ends up in the
|
|
47
|
+
// cluster. Blank them but keep the lines, so the source map holds.
|
|
48
|
+
chunk.code = chunk.code.replace(/^[ \t]*\/\/#(end)?region.*$/gm, "");
|
|
49
|
+
chunk.code += `\n${chunk.exports.map((n) => `globalThis.${n} = ${EXPORTS}.${n};\n`).join("")}`;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Vite writes sources relative to its output directory, dist/ of root.
|
|
55
|
+
function absoluteSources(map, root) {
|
|
56
|
+
return { ...map, sources: map.sources.map((s) => path.resolve(root, "dist", s)) };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Bundles a TypeScript or ES-module script, with its imports, into the one
|
|
60
|
+
* plain script gojsop runs: ES2023, no modules, the exports as globals.
|
|
61
|
+
* Uses the project's Vite.
|
|
62
|
+
*/
|
|
63
|
+
export async function bundle(file) {
|
|
64
|
+
const { build } = await import("vite");
|
|
65
|
+
const entry = path.resolve(file); // vite resolves a relative entry against root
|
|
66
|
+
const out = await build({
|
|
67
|
+
configFile: false,
|
|
68
|
+
logLevel: "silent",
|
|
69
|
+
root: path.dirname(entry),
|
|
70
|
+
plugins: [exportsToGlobals()],
|
|
71
|
+
build: {
|
|
72
|
+
write: false,
|
|
73
|
+
target: "es2023",
|
|
74
|
+
minify: false,
|
|
75
|
+
sourcemap: "hidden",
|
|
76
|
+
emptyOutDir: false,
|
|
77
|
+
lib: { entry, formats: ["iife"], name: EXPORTS, fileName: "script" },
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
const outputs = Array.isArray(out) ? out : [out];
|
|
81
|
+
for (const o of outputs) {
|
|
82
|
+
if ("output" in o) {
|
|
83
|
+
const chunk = o.output.find((c) => c.type === "chunk" && c.isEntry);
|
|
84
|
+
if (chunk && chunk.type === "chunk") {
|
|
85
|
+
return {
|
|
86
|
+
code: chunk.code,
|
|
87
|
+
files: chunk.moduleIds.filter((id) => path.isAbsolute(id)),
|
|
88
|
+
map: chunk.map ? absoluteSources(JSON.parse(chunk.map.toString()), path.dirname(entry)) : undefined,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw new Error(`${entry}: vite produced no script`);
|
|
94
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The path of the gojsop binary: GOJSOP_BIN if set, else the binary of the
|
|
3
|
+
* platform package npm installed, else "gojsop" from PATH.
|
|
4
|
+
*/
|
|
5
|
+
export declare function binaryPath(): string;
|
|
6
|
+
/** Explains how to get the binary when binaryPath() points nowhere. */
|
|
7
|
+
export declare function notFoundHint(bin: string): string;
|
|
8
|
+
export { type Bundle, bundle, isModule, scriptBeside } from "./bundle.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { platforms } from "./platforms.js";
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
/**
|
|
5
|
+
* The path of the gojsop binary: GOJSOP_BIN if set, else the binary of the
|
|
6
|
+
* platform package npm installed, else "gojsop" from PATH.
|
|
7
|
+
*/
|
|
8
|
+
export function binaryPath() {
|
|
9
|
+
if (process.env.GOJSOP_BIN)
|
|
10
|
+
return process.env.GOJSOP_BIN;
|
|
11
|
+
const p = platforms[`${process.platform}-${process.arch}`];
|
|
12
|
+
if (p) {
|
|
13
|
+
const exe = process.platform === "win32" ? "gojsop.exe" : "gojsop";
|
|
14
|
+
try {
|
|
15
|
+
return require.resolve(`${p.pkg}/bin/${exe}`);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// not installed (unsupported platform, --omit=optional); fall back to PATH
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return "gojsop";
|
|
22
|
+
}
|
|
23
|
+
/** Explains how to get the binary when binaryPath() points nowhere. */
|
|
24
|
+
export function notFoundHint(bin) {
|
|
25
|
+
return (`cannot start the gojsop binary "${bin}": not found. ` +
|
|
26
|
+
`Reinstall @gojsop/cli without --omit=optional, put gojsop on PATH, or set GOJSOP_BIN to its path.`);
|
|
27
|
+
}
|
|
28
|
+
export { bundle, isModule, scriptBeside } from "./bundle.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const platforms = {
|
|
2
|
+
"darwin-arm64": { pkg: "@gojsop/cli-darwin-arm64", goos: "darwin", goarch: "arm64" },
|
|
3
|
+
"darwin-x64": { pkg: "@gojsop/cli-darwin-x64", goos: "darwin", goarch: "amd64" },
|
|
4
|
+
"linux-arm64": { pkg: "@gojsop/cli-linux-arm64", goos: "linux", goarch: "arm64" },
|
|
5
|
+
"linux-x64": { pkg: "@gojsop/cli-linux-x64", goos: "linux", goarch: "amd64" },
|
|
6
|
+
"win32-x64": { pkg: "@gojsop/cli-win32-x64", goos: "windows", goarch: "amd64" },
|
|
7
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** gojsop new <policy|hook> <name>: a new directory with a passing test. */
|
|
2
|
+
export declare function create(kind: string, name: string): void;
|
|
3
|
+
/** gojsop rm <name>: removes the directory of a hook or policy. */
|
|
4
|
+
export declare function remove(nameOrDir: string, yes: boolean): Promise<void>;
|
|
5
|
+
/** gojsop rn <name> <new-name>: renames a hook or policy and its directory. */
|
|
6
|
+
export declare function rename(nameOrDir: string, to: string): void;
|
package/dist/scaffold.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { parseDocument } from "yaml";
|
|
5
|
+
import { checkName, find, KINDS } from "./workspace.js";
|
|
6
|
+
// What gojsop new writes: a manifest, a script and a test that passes.
|
|
7
|
+
const FILES = {
|
|
8
|
+
policy: (name) => ({
|
|
9
|
+
"policy.yaml": `apiVersion: core.gojsop.io/v1alpha1
|
|
10
|
+
kind: JSAdmission
|
|
11
|
+
metadata:
|
|
12
|
+
name: ${name}
|
|
13
|
+
spec:
|
|
14
|
+
type: validating
|
|
15
|
+
rules:
|
|
16
|
+
- apiGroups: [""]
|
|
17
|
+
apiVersions: ["v1"]
|
|
18
|
+
resources: ["pods"]
|
|
19
|
+
operations: ["CREATE", "UPDATE"]
|
|
20
|
+
# Audit records what the policy would deny and blocks nothing; switch to
|
|
21
|
+
# Deny once kubectl describe jsadmission ${name} shows no surprises.
|
|
22
|
+
enforcement: Audit
|
|
23
|
+
`,
|
|
24
|
+
"policy.ts": `import type { Request, Response } from "@gojsop/types";
|
|
25
|
+
|
|
26
|
+
export function validate(req: Request): Response {
|
|
27
|
+
return { allowed: true };
|
|
28
|
+
}
|
|
29
|
+
`,
|
|
30
|
+
"policy.test.ts": `import { expect, test } from "vitest";
|
|
31
|
+
import { pod, policy } from "@gojsop/testing";
|
|
32
|
+
|
|
33
|
+
const ${camel(name)} = policy("./policy.yaml");
|
|
34
|
+
|
|
35
|
+
test("allows a pod", async () => {
|
|
36
|
+
const result = await ${camel(name)}.review({ object: pod() });
|
|
37
|
+
expect(result.allowed).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
`,
|
|
40
|
+
}),
|
|
41
|
+
hook: (name) => ({
|
|
42
|
+
"hook.yaml": `apiVersion: core.gojsop.io/v1alpha1
|
|
43
|
+
kind: JSHook
|
|
44
|
+
metadata:
|
|
45
|
+
name: ${name}
|
|
46
|
+
spec:
|
|
47
|
+
bindings:
|
|
48
|
+
- name: pods
|
|
49
|
+
apiGroups: [""]
|
|
50
|
+
apiVersions: ["v1"]
|
|
51
|
+
resources: ["pods"]
|
|
52
|
+
# What kube.* may do besides reading the bindings, for example:
|
|
53
|
+
# permissions:
|
|
54
|
+
# - apiGroups: [""]
|
|
55
|
+
# resources: ["configmaps"]
|
|
56
|
+
# verbs: ["get", "create", "patch"]
|
|
57
|
+
`,
|
|
58
|
+
"hook.ts": `import type { Event } from "@gojsop/types";
|
|
59
|
+
|
|
60
|
+
export function handle(event: Event) {
|
|
61
|
+
console.log(event.type, event.object.metadata?.name);
|
|
62
|
+
}
|
|
63
|
+
`,
|
|
64
|
+
"hook.test.ts": `import { expect, test } from "vitest";
|
|
65
|
+
import { hook, pod } from "@gojsop/testing";
|
|
66
|
+
|
|
67
|
+
const ${camel(name)} = hook("./hook.yaml");
|
|
68
|
+
|
|
69
|
+
test("logs the pod", async () => {
|
|
70
|
+
const result = await ${camel(name)}.handle({ object: pod({ name: "web" }) });
|
|
71
|
+
expect(result.console.map((l) => l.text)).toEqual(["Added web"]);
|
|
72
|
+
});
|
|
73
|
+
`,
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
function camel(name) {
|
|
77
|
+
return name.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
78
|
+
}
|
|
79
|
+
/** gojsop new <policy|hook> <name>: a new directory with a passing test. */
|
|
80
|
+
export function create(kind, name) {
|
|
81
|
+
if (kind !== "policy" && kind !== "hook")
|
|
82
|
+
throw new Error(`gojsop new: give policy or hook, not "${kind}"`);
|
|
83
|
+
checkName(name);
|
|
84
|
+
const dir = path.join(KINDS[kind].dir, name);
|
|
85
|
+
if (fs.existsSync(dir))
|
|
86
|
+
throw new Error(`${dir} exists already`);
|
|
87
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
88
|
+
for (const [file, text] of Object.entries(FILES[kind](name)))
|
|
89
|
+
fs.writeFileSync(path.join(dir, file), text);
|
|
90
|
+
console.log(`created ${dir}: ${Object.keys(FILES[kind](name)).join(", ")}`);
|
|
91
|
+
}
|
|
92
|
+
async function confirm(question) {
|
|
93
|
+
if (!process.stdin.isTTY)
|
|
94
|
+
return false;
|
|
95
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
96
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
97
|
+
rl.close();
|
|
98
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
99
|
+
}
|
|
100
|
+
function stillDeployed(kind, name) {
|
|
101
|
+
return `If it is deployed, it stays in the cluster until: kubectl delete ${KINDS[kind].resource} ${name}`;
|
|
102
|
+
}
|
|
103
|
+
/** gojsop rm <name>: removes the directory of a hook or policy. */
|
|
104
|
+
export async function remove(nameOrDir, yes) {
|
|
105
|
+
const e = find(nameOrDir);
|
|
106
|
+
if (!yes && !(await confirm(`Remove ${e.dir}?`))) {
|
|
107
|
+
throw new Error(`not removed; confirm, or pass --yes`);
|
|
108
|
+
}
|
|
109
|
+
fs.rmSync(e.dir, { recursive: true });
|
|
110
|
+
console.log(`removed ${e.dir}\n${stillDeployed(e.kind, e.name)}`);
|
|
111
|
+
}
|
|
112
|
+
/** gojsop rn <name> <new-name>: renames a hook or policy and its directory. */
|
|
113
|
+
export function rename(nameOrDir, to) {
|
|
114
|
+
checkName(to);
|
|
115
|
+
const e = find(nameOrDir);
|
|
116
|
+
const doc = parseDocument(fs.readFileSync(e.manifest, "utf8"));
|
|
117
|
+
doc.setIn(["metadata", "name"], to);
|
|
118
|
+
fs.writeFileSync(e.manifest, doc.toString({ lineWidth: 0, flowCollectionPadding: false }));
|
|
119
|
+
let dir = e.dir;
|
|
120
|
+
if (path.basename(e.dir) === e.name) {
|
|
121
|
+
dir = path.join(path.dirname(e.dir), to);
|
|
122
|
+
if (fs.existsSync(dir))
|
|
123
|
+
throw new Error(`${dir} exists already`);
|
|
124
|
+
fs.renameSync(e.dir, dir);
|
|
125
|
+
}
|
|
126
|
+
console.log(`renamed ${e.name} to ${to} (${dir})\n${stillDeployed(e.kind, e.name)}`);
|
|
127
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare const KINDS: {
|
|
2
|
+
readonly policy: {
|
|
3
|
+
readonly file: "policy.yaml";
|
|
4
|
+
readonly kind: "JSAdmission";
|
|
5
|
+
readonly dir: "policies";
|
|
6
|
+
readonly resource: "jsadmission";
|
|
7
|
+
};
|
|
8
|
+
readonly hook: {
|
|
9
|
+
readonly file: "hook.yaml";
|
|
10
|
+
readonly kind: "JSHook";
|
|
11
|
+
readonly dir: "hooks";
|
|
12
|
+
readonly resource: "jshook";
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export type Kind = keyof typeof KINDS;
|
|
16
|
+
/** A hook or policy of the workspace. */
|
|
17
|
+
export interface Entry {
|
|
18
|
+
kind: Kind;
|
|
19
|
+
/** metadata.name */
|
|
20
|
+
name: string;
|
|
21
|
+
dir: string;
|
|
22
|
+
manifest: string;
|
|
23
|
+
}
|
|
24
|
+
/** Reads the hook or policy of a directory; undefined when it has none. */
|
|
25
|
+
export declare function entryIn(dir: string): Entry | undefined;
|
|
26
|
+
/** Every hook and policy below root, in a stable order. */
|
|
27
|
+
export declare function entries(root?: string): Entry[];
|
|
28
|
+
/** The one hook or policy a name or directory names; throws otherwise. */
|
|
29
|
+
export declare function find(nameOrDir: string): Entry;
|
|
30
|
+
export declare function checkName(name: string): void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseDocument } from "yaml";
|
|
4
|
+
// The layout of a workspace: one directory per hook or policy, holding
|
|
5
|
+
// hook.yaml or policy.yaml, the script and its tests.
|
|
6
|
+
export const KINDS = {
|
|
7
|
+
policy: { file: "policy.yaml", kind: "JSAdmission", dir: "policies", resource: "jsadmission" },
|
|
8
|
+
hook: { file: "hook.yaml", kind: "JSHook", dir: "hooks", resource: "jshook" },
|
|
9
|
+
};
|
|
10
|
+
const SKIP = new Set(["node_modules", "dist"]);
|
|
11
|
+
/** Reads the hook or policy of a directory; undefined when it has none. */
|
|
12
|
+
export function entryIn(dir) {
|
|
13
|
+
for (const kind of Object.keys(KINDS)) {
|
|
14
|
+
const manifest = path.join(dir, KINDS[kind].file);
|
|
15
|
+
if (!fs.existsSync(manifest))
|
|
16
|
+
continue;
|
|
17
|
+
const doc = parseDocument(fs.readFileSync(manifest, "utf8"));
|
|
18
|
+
const name = doc.getIn(["metadata", "name"]);
|
|
19
|
+
return { kind, name: typeof name === "string" && name ? name : path.basename(dir), dir, manifest };
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
/** Every hook and policy below root, in a stable order. */
|
|
24
|
+
export function entries(root = ".") {
|
|
25
|
+
const found = [];
|
|
26
|
+
const walk = (dir) => {
|
|
27
|
+
const e = entryIn(dir);
|
|
28
|
+
if (e)
|
|
29
|
+
found.push(e);
|
|
30
|
+
for (const d of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
31
|
+
if (d.isDirectory() && !SKIP.has(d.name) && !d.name.startsWith("."))
|
|
32
|
+
walk(path.join(dir, d.name));
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
walk(root);
|
|
36
|
+
return found.sort((a, b) => a.dir.localeCompare(b.dir));
|
|
37
|
+
}
|
|
38
|
+
/** The one hook or policy a name or directory names; throws otherwise. */
|
|
39
|
+
export function find(nameOrDir) {
|
|
40
|
+
if (fs.existsSync(nameOrDir) && fs.statSync(nameOrDir).isDirectory()) {
|
|
41
|
+
const e = entryIn(nameOrDir);
|
|
42
|
+
if (e)
|
|
43
|
+
return e;
|
|
44
|
+
}
|
|
45
|
+
const hits = entries().filter((e) => e.name === nameOrDir || path.basename(e.dir) === nameOrDir);
|
|
46
|
+
if (hits.length === 1)
|
|
47
|
+
return hits[0];
|
|
48
|
+
if (hits.length === 0)
|
|
49
|
+
throw new Error(`no hook or policy named ${nameOrDir}`);
|
|
50
|
+
throw new Error(`${nameOrDir} names ${hits.map((h) => h.dir).join(" and ")}; give the directory instead`);
|
|
51
|
+
}
|
|
52
|
+
// metadata.name becomes part of ServiceAccount and webhook names, so a
|
|
53
|
+
// DNS label keeps every one of them valid.
|
|
54
|
+
const NAME = /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/;
|
|
55
|
+
export function checkName(name) {
|
|
56
|
+
if (!NAME.test(name)) {
|
|
57
|
+
throw new Error(`"${name}" is not a valid name: use lowercase letters, digits and "-", at most 63 characters`);
|
|
58
|
+
}
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gojsop/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The gojsop CLI: run and serve gojsop hooks and policies locally, in the engine the operator uses.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/efuturetoday/gojsop.git",
|
|
9
|
+
"directory": "sdk/cli"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"gojsop": "dist/bin.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.12"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.json",
|
|
32
|
+
"test": "vitest run"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^22.0.0",
|
|
36
|
+
"typescript": "^7.0.2",
|
|
37
|
+
"vite": "^8.0.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"vite": ">=7"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"yaml": "^2.9.1"
|
|
44
|
+
},
|
|
45
|
+
"optionalDependencies": {
|
|
46
|
+
"@gojsop/cli-darwin-arm64": "0.2.0",
|
|
47
|
+
"@gojsop/cli-darwin-x64": "0.2.0",
|
|
48
|
+
"@gojsop/cli-linux-arm64": "0.2.0",
|
|
49
|
+
"@gojsop/cli-linux-x64": "0.2.0",
|
|
50
|
+
"@gojsop/cli-win32-x64": "0.2.0"
|
|
51
|
+
}
|
|
52
|
+
}
|