v0.32.0 lwconf engine update: config by path, schema from code, wasm
This commit is contained in:
1 parent
c2e3d669da
commit
a1e9d21d06
33 files changed
+1278
-421
No files matched your search
@@ -0,0 +1,91 @@
|
||||
// LogWisp's configuration engine for a page: one dependency-free ES module
|
||||
// beside lwconf.wasm, which make wasm writes with it. It knows the engine's
|
||||
// calls and the shapes of its schema and pipelines, never a plugin, a key or
|
||||
// a rule, so the page draws whatever the engine it loads describes.
|
||||
|
||||
// load starts the engine: the toolchain's wasm_exec.js by a script element
|
||||
// (outside a page the caller imports it first), the module fetched with its
|
||||
// integrity, then its main, which sets globalThis.lwconf
|
||||
export async function load({ execURL, execSRI, wasmURL, wasmSRI }) {
|
||||
if (!globalThis.Go) await script(execURL, execSRI);
|
||||
const go = new globalThis.Go();
|
||||
const fetched = async () => {
|
||||
const res = await fetch(wasmURL, { integrity: wasmSRI });
|
||||
if (!res.ok) throw new Error(`${wasmURL}: ${res.status}`);
|
||||
return res;
|
||||
};
|
||||
const { instance } = await WebAssembly.instantiateStreaming(fetched(), go.importObject)
|
||||
.catch(async () => WebAssembly.instantiate(await (await fetched()).arrayBuffer(), go.importObject)); // a server's wrong type
|
||||
go.run(instance);
|
||||
if (!globalThis.lwconf) throw new Error("lwconf.wasm did not start");
|
||||
return new Engine(globalThis.lwconf);
|
||||
}
|
||||
|
||||
function script(src, integrity) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = Object.assign(document.createElement("script"), { src, onload: resolve });
|
||||
s.onerror = () => reject(new Error(`${src} did not load`));
|
||||
if (integrity) s.integrity = integrity;
|
||||
document.head.append(s);
|
||||
});
|
||||
}
|
||||
|
||||
// Engine's calls take objects or JSON text and return the engine's answer,
|
||||
// {value} or {error, path?, pipeline?, node?, key?}; dirs answers whether a
|
||||
// preset path is a directory, {"/var/log/app.log": false}
|
||||
export class Engine {
|
||||
#api;
|
||||
constructor(api) { this.#api = api; }
|
||||
#call(name, ...args) {
|
||||
return JSON.parse(this.#api[name](...args.map(a => (typeof a === "string" ? a : JSON.stringify(a)))));
|
||||
}
|
||||
schema() { return this.#call("schema"); }
|
||||
preset(name, values, dirs = {}) { return this.#call("preset", name, values, dirs); }
|
||||
parse(text, form, dirs = {}) { return this.#call("parse", text, form, dirs); }
|
||||
validate(pipelines) { return this.#call("validate", pipelines); }
|
||||
emit(pipelines, form) { return this.#call("emit", pipelines, form); }
|
||||
edit(pipelines, op) { return this.#call("edit", pipelines, op); }
|
||||
}
|
||||
|
||||
// fields are the rows a form draws for a node, by its role (and a source's
|
||||
// or sink's type): a value row a key, with what leaving it unset means, and a
|
||||
// group a table, holding its own rows; paths are dotted as edit takes them
|
||||
export function fields(schema, role, type) {
|
||||
const plugin = schema[`${role}s`]?.find(p => p.type === type);
|
||||
const rows = (keys, prefix) => keys.map(key => {
|
||||
const path = prefix + key.key;
|
||||
if (Array.isArray(key.keys)) return { path, key, rows: rows(key.keys, path + ".") };
|
||||
return { path, key, placeholder: key.zero ? `(${key.zero})` : (key.default ?? key.hint ?? "") };
|
||||
});
|
||||
return rows(plugin?.keys ?? schema.flow.find(s => s.key === role)?.keys ?? [], "");
|
||||
}
|
||||
|
||||
// value is a node's value at a dotted path, undefined while unset; a node is
|
||||
// {role, id, index} as the engine returns it, a list stage's by its index
|
||||
export function value(pipelines, pipeline, node, path = "") {
|
||||
const p = pipelines[pipeline];
|
||||
const list = { source: p?.plugin_sources, sink: p?.plugin_sinks }[node.role];
|
||||
const stage = p?.flow?.[node.role];
|
||||
let v = list ? list.find(s => s.id === node.id)?.config : Array.isArray(stage) ? stage[node.index] : stage;
|
||||
for (const part of path ? path.split(".") : []) v = v?.[part];
|
||||
return v;
|
||||
}
|
||||
|
||||
// at names where problems lie: a pipeline's node, however value takes it, the
|
||||
// pipeline, or nowhere
|
||||
export function at(pipeline, node) {
|
||||
if (pipeline === undefined) return "";
|
||||
return node ? `${pipeline}/${node.role}/${node.id ?? ""}/${node.index ?? 0}` : `${pipeline}`;
|
||||
}
|
||||
|
||||
// group sorts validate's problems by at(pipeline, node), then by key, "" for
|
||||
// the node or pipeline itself
|
||||
export function group(problems) {
|
||||
const out = new Map();
|
||||
for (const p of problems) {
|
||||
const keys = out.get(at(p.pipeline, p.node)) ?? new Map();
|
||||
keys.set(p.key ?? "", [...(keys.get(p.key ?? "") ?? []), p]);
|
||||
out.set(at(p.pipeline, p.node), keys);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+161
-43
@@ -2,63 +2,181 @@ import { after, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { basename, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
// Built by make wasm and started as the page starts it: the toolchain's loader
|
||||
// beside the module, main running until the page closes. The make run is its
|
||||
// own: a parent GNU make's flags would stop a BSD one.
|
||||
const dir = mkdtempSync(join(tmpdir(), "lwconf-"));
|
||||
after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const made = execFileSync("make", ["-s", "wasm", `BIN_DIR=${dir}`, "VERSION=v0.0.0-node"], {
|
||||
cwd: join(import.meta.dirname, "../.."), encoding: "utf8",
|
||||
env: { ...process.env, MAKEFLAGS: "", MFLAGS: "", MAKELEVEL: "" },
|
||||
// The drop-in directory make wasm writes over an earlier one's, served as a
|
||||
// site serves it (?type= another content type) and loaded through lwconf.js;
|
||||
// wasm_exec.js is imported first, as a page's script element runs it. make runs
|
||||
// on its own, with make verify's toolchain: a GNU make's flags stop a BSD one.
|
||||
const root = join(import.meta.dirname, "../..");
|
||||
const tmp = mkdtempSync(join(tmpdir(), "lwconf-"));
|
||||
const dir = join(tmp, "lwconf");
|
||||
const go = process.env.GO ?? "go";
|
||||
const env = { ...process.env, MAKEFLAGS: "", MFLAGS: "", MAKELEVEL: "" };
|
||||
const wasm = (bin, ...vars) => execFileSync("make", ["-s", "wasm", `BIN_DIR=${bin}`, "VERSION=v0.0.0-node", `GO=${go}`, ...vars],
|
||||
{ cwd: root, env, stdio: "ignore" });
|
||||
mkdirSync(dir);
|
||||
writeFileSync(join(dir, "stale.js"), "");
|
||||
wasm(tmp);
|
||||
const server = createServer((req, res) => {
|
||||
const { pathname, searchParams } = new URL(req.url, "http://localhost");
|
||||
const file = join(dir, basename(pathname));
|
||||
if (!existsSync(file)) return res.writeHead(404).end();
|
||||
res.setHeader("content-type", searchParams.get("type") ?? (file.endsWith(".wasm") ? "application/wasm" : "text/javascript"));
|
||||
res.end(readFileSync(file));
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
after(() => { server.close(); rmSync(tmp, { recursive: true, force: true }); });
|
||||
const url = file => `http://127.0.0.1:${server.address().port}/${file}`;
|
||||
const sri = file => "sha256-" + createHash("sha256").update(readFileSync(join(dir, file))).digest("base64");
|
||||
|
||||
const { load, fields, value, at, group } = await import(pathToFileURL(join(dir, "lwconf.js")));
|
||||
await import(pathToFileURL(join(dir, "wasm_exec.js")));
|
||||
const go = new globalThis.Go();
|
||||
const { instance } = await WebAssembly.instantiate(readFileSync(join(dir, "lwconf.wasm")), go.importObject);
|
||||
go.run(instance);
|
||||
const call = (name, ...args) => JSON.parse(globalThis.lwconf[name](...args));
|
||||
const engine = await load({ wasmURL: url("lwconf.wasm"), wasmSRI: sri("lwconf.wasm") });
|
||||
const schema = engine.schema().value;
|
||||
|
||||
test("make wasm stamps the version and prints both checksums", () => {
|
||||
for (const file of ["lwconf.wasm", "wasm_exec.js"]) {
|
||||
const sum = createHash("sha256").update(readFileSync(join(dir, file))).digest("hex");
|
||||
assert.match(made, new RegExp(`^${sum} +${file.replace(".", "\\.")}$`, "m"));
|
||||
test("make wasm writes one directory, which its manifest describes", () => {
|
||||
const manifest = JSON.parse(readFileSync(join(dir, "lwconf.json"), "utf8"));
|
||||
const files = readdirSync(dir).filter(f => f !== "lwconf.json").sort();
|
||||
assert.deepEqual(Object.keys(manifest.sha256).sort(), files);
|
||||
for (const file of files) {
|
||||
assert.equal(manifest.sha256[file], createHash("sha256").update(readFileSync(join(dir, file))).digest("hex"), file);
|
||||
}
|
||||
assert.equal(call("schema").value.version, "v0.0.0-node");
|
||||
const version = execFileSync(go, ["env", "GOVERSION"], { cwd: root, encoding: "utf8" }).trim();
|
||||
assert.deepEqual([manifest.version, schema.version, manifest.go], ["v0.0.0-node", "v0.0.0-node", version]);
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(dir, "schema.json"), "utf8")), schema);
|
||||
});
|
||||
|
||||
// Off the host a directory path ends in '/'
|
||||
test("a preset's pipelines, written as a command line, parse and validate back to themselves", () => {
|
||||
const { value: pipelines } = call("preset", "tail", JSON.stringify({ path: "/var/log/" }));
|
||||
assert.equal(pipelines[0].plugin_sources[0].config.directory, "/var/log/");
|
||||
const { value: line } = call("emit", JSON.stringify(pipelines), "command");
|
||||
assert.match(line, /^lw \\\n {2}--pipeline tail/);
|
||||
assert.deepEqual(call("parse", line).value, pipelines);
|
||||
assert.deepEqual(call("validate", JSON.stringify(pipelines)).value, pipelines);
|
||||
assert.match(call("emit", JSON.stringify(pipelines), "file").value, /^\[\[pipelines\]\]/m);
|
||||
test("make wasm writes a commit's files the same at any time", () => {
|
||||
wasm(join(tmp, "again"), "BUILD_TIME=another");
|
||||
assert.equal(readFileSync(join(tmp, "again/lwconf/lwconf.json"), "utf8"), readFileSync(join(dir, "lwconf.json"), "utf8"));
|
||||
});
|
||||
|
||||
test("a refused call answers why, and the engine keeps answering", () => {
|
||||
test("load refuses an engine its integrity does not match, or a server does not have", async () => {
|
||||
await assert.rejects(load({ wasmURL: url("lwconf.wasm"), wasmSRI: sri("schema.json") }));
|
||||
await assert.rejects(load({ wasmURL: url("missing.wasm") }), { message: `${url("missing.wasm")}: 404` });
|
||||
});
|
||||
|
||||
test("load takes an engine a server sends as plain bytes", async () => {
|
||||
const api = globalThis.lwconf;
|
||||
delete globalThis.lwconf;
|
||||
try {
|
||||
const bytes = await load({ wasmURL: url("lwconf.wasm?type=application/octet-stream"), wasmSRI: sri("lwconf.wasm") });
|
||||
assert.deepEqual(bytes.schema().value, schema);
|
||||
} finally {
|
||||
globalThis.lwconf = api;
|
||||
}
|
||||
});
|
||||
|
||||
// The environment holds one pipeline, which a preset makes
|
||||
test("a preset's pipeline, a single file answered by dirs, reads back from every form", () => {
|
||||
const path = { path: "/var/log/app.log" }, dirs = { "/var/log/app.log": false };
|
||||
assert.deepEqual(engine.preset("tail", path), { error: "path: a directory or a file pattern?", path: "path" });
|
||||
const { value: pipelines } = engine.preset("serve", { ...path, allow: ["192.0.2.0/24", "198.51.100.7"] }, dirs);
|
||||
assert.deepEqual(value(pipelines, 0, { role: "source", id: "file" }), { directory: "/var/log", pattern: "app.log", from: "end" });
|
||||
assert.deepEqual(value(pipelines, 0, { role: "sink", id: "http" }, "acl.allow"), ["192.0.2.0/24", "198.51.100.7"]);
|
||||
assert.deepEqual(engine.parse("lw -p serve:path=/var/log/app.log,allow=192.0.2.0/24,allow=198.51.100.7", "command", dirs).value, pipelines);
|
||||
for (const { name } of schema.forms) {
|
||||
assert.deepEqual(engine.parse(engine.emit(pipelines, name).value, name).value, pipelines, name);
|
||||
}
|
||||
assert.deepEqual(engine.validate(pipelines).value, { pipelines, problems: [] });
|
||||
});
|
||||
|
||||
// A value of the wrong kind is a problem as any other is, and the parts after
|
||||
// it are still read
|
||||
test("validate places every problem at its pipeline, node and key", () => {
|
||||
const { value: { problems } } = engine.validate([
|
||||
{ name: "a", flow: { rate_limit: { policy: "bogus" }, filters: [{ patterns: ["("] }] }, plugin_sources: [{ type: "file" }],
|
||||
plugin_sinks: [{ id: "out", type: "tcp", config: { port: 9000, tls: { enabled: true } } }, { type: "http", config: { port: "x" } }] },
|
||||
{ name: "a", plugin_sinks: [{ type: "tcp" }] },
|
||||
]);
|
||||
const node = (role, id, index = 0) => ({ role, id, index });
|
||||
assert.deepEqual(problems.map(({ path, pipeline, node, key }) => ({ path, pipeline, node, key })), [
|
||||
{ path: "pipelines[0].flow.rate_limit.policy", pipeline: 0, node: node("rate_limit", ""), key: "policy" },
|
||||
{ path: "pipelines[0].flow.filters[0].patterns[0]", pipeline: 0, node: node("filters", "", 0), key: "patterns" },
|
||||
{ path: "pipelines[0].plugin_sources[file].config.directory", pipeline: 0, node: node("source", "file"), key: "directory" },
|
||||
{ path: "pipelines[0].plugin_sinks[out].config.tls", pipeline: 0, node: node("sink", "out"), key: "tls" },
|
||||
{ path: "pipelines[0].plugin_sinks[http].config.port", pipeline: 0, node: node("sink", "http"), key: "port" },
|
||||
{ path: "pipelines[1].name", pipeline: 1, node: undefined, key: "name" },
|
||||
{ path: "pipelines[1].plugin_sources", pipeline: 1, node: undefined, key: "plugin_sources" },
|
||||
{ path: "pipelines[1].plugin_sinks[tcp].config.port", pipeline: 1, node: node("sink", "tcp"), key: "port" },
|
||||
]);
|
||||
assert.match(problems[3].text, /^listeners need cert_file/);
|
||||
assert.equal(problems[4].text, "not an integer");
|
||||
const placed = group(problems);
|
||||
assert.deepEqual(placed.get(at(0, { role: "sink", id: "out" })).get("tls"), [problems[3]]);
|
||||
assert.deepEqual(placed.get(at(0, { role: "rate_limit" })).get("policy"), [problems[0]]);
|
||||
assert.deepEqual([...placed.get(at(1)).keys()], ["name", "plugin_sources"]);
|
||||
});
|
||||
|
||||
test("edits keep the engine's rules: free ids, retype's carry-over, a stage turned on", () => {
|
||||
let pipelines = [], node;
|
||||
const edit = op => {
|
||||
const r = engine.edit(pipelines, { pipeline: 0, ...op });
|
||||
assert.equal(r.error, undefined, JSON.stringify(op));
|
||||
({ pipelines, node } = r.value);
|
||||
return node;
|
||||
};
|
||||
edit({ op: "add_pipeline", name: "p" });
|
||||
edit({ op: "add", role: "source", type: "null" });
|
||||
assert.deepEqual(edit({ op: "add", role: "source", type: "null" }), { role: "source", id: "null_2", index: 0 });
|
||||
const tcp = edit({ op: "add", role: "sink", type: "tcp" });
|
||||
edit({ op: "set", node: tcp, key: "port", values: ["9000"] });
|
||||
edit({ op: "set", node: tcp, key: "tls.enabled", values: ["true"] });
|
||||
assert.deepEqual(edit({ op: "retype", node: tcp, type: "http" }), { role: "sink", id: "http", index: 0 });
|
||||
assert.deepEqual(value(pipelines, 0, node), { port: 9000, tls: { enabled: true } });
|
||||
edit({ op: "unset", node, key: "tls.enabled" });
|
||||
edit({ op: "set", node: { role: "rate_limit" }, key: "entries_per_second", values: ["5"] });
|
||||
assert.deepEqual(value(pipelines, 0, { role: "rate_limit" }, "policy"), schema.flow[0].on.policy);
|
||||
edit({ op: "add", role: "filters", type: "exclude" });
|
||||
edit({ op: "add", role: "filters", type: "include" });
|
||||
assert.deepEqual(edit({ op: "move_filter", from: 1, to: 0 }), { role: "filters", id: "", index: 0 });
|
||||
assert.deepEqual([value(pipelines, 0, node, "type"), value(pipelines, 0, { role: "filters", index: 1 }, "type")], ["include", "exclude"]);
|
||||
edit({ op: "remove", node: { role: "source", id: "null" } });
|
||||
edit({ op: "rename_pipeline", name: "q" });
|
||||
assert.deepEqual([pipelines[0].name, pipelines[0].plugin_sources.map(s => s.id), value(pipelines, 0, { role: "sink", id: "http" })],
|
||||
["q", ["null_2"], { port: 9000 }]);
|
||||
edit({ op: "remove_pipeline" });
|
||||
assert.deepEqual(pipelines, []);
|
||||
});
|
||||
|
||||
test("fields are a node's keys, its tables' of the plugin's side as groups", () => {
|
||||
assert.deepEqual(schema.sources.find(p => p.type === "null").keys, []);
|
||||
const rows = fields(schema, "sink", "tcp_chain");
|
||||
const tls = rows.find(r => r.path === "tls");
|
||||
assert.ok(tls.rows.some(r => r.path === "tls.pin_sha256") && !tls.rows.some(r => r.path === "tls.self_signed"));
|
||||
assert.deepEqual(rows.find(r => r.path === "port").key.max, 65535);
|
||||
assert.equal(fields(schema, "rate_limit").find(r => r.path === "entries_per_second").placeholder, "(off)");
|
||||
assert.equal(fields(schema, "heartbeat").find(r => r.path === "interval_ms").placeholder, "1000");
|
||||
});
|
||||
|
||||
test("a refused call answers why and where, and the engine keeps answering", () => {
|
||||
const refused = [
|
||||
[["parse", "lw --nope x"], /not a pipeline flag/],
|
||||
[["parse", "lw"], /no pipeline flags/],
|
||||
[["parse"], /^usage: lwconf\.parse\(line\)/],
|
||||
[["parse", 5], /^usage: /],
|
||||
[["preset", "tail", JSON.stringify({ path: "/var/log" })], /path/],
|
||||
[["validate", JSON.stringify([{ name: "p" }])], /no sources/],
|
||||
[["validate", JSON.stringify(["a", "b"].map((name, i) => ({
|
||||
name, plugin_sources: [{ type: "tcp_chain", config: { port: [9000, "x"][i] } }], plugin_sinks: [{ type: "null" }],
|
||||
})))], /^pipelines\[1\]\.plugin_sources\[tcp_chain\]\.config\.port: not an integer$/],
|
||||
[["emit", JSON.stringify([{ name: "p" }]), "command"], /no sources/],
|
||||
[["emit", "[]", "yaml"], /no form "yaml"/],
|
||||
[["parse", "lw --nope x", "command", "{}"], { error: /not a pipeline flag/ }],
|
||||
[["parse", "x=1", "environment", "{}"], { error: /^no LOGWISP_ pipeline variables$/ }],
|
||||
[["parse", "", "file", "{}"], { error: /no \[\[pipelines\]\]/ }],
|
||||
[["parse", "lw"], { error: /^usage: lwconf\.parse\(text, form, dirs\)/ }],
|
||||
[["parse", 5, "command", "{}"], { error: /^usage: / }],
|
||||
[["preset", "serve", JSON.stringify({ allow: 5 }), "{}"], { error: /^allow: takes a string/, path: "allow" }],
|
||||
[["edit", JSON.stringify([{ name: "p", plugin_sinks: [{ type: "tcp" }] }]),
|
||||
JSON.stringify({ op: "set", pipeline: 0, node: { role: "sink", id: "tcp" }, key: "port", values: ["x"] })],
|
||||
{ error: /port: not an integer$/, path: "pipelines[0].plugin_sinks[tcp].config.port", pipeline: 0, key: "port" }],
|
||||
[["emit", JSON.stringify([{ name: "p" }]), "command"], { error: /no sources defined/, path: "pipelines[0].plugin_sources" }],
|
||||
[["emit", "[]", "yaml"], { error: /no form "yaml"/ }],
|
||||
[["edit", "[]", JSON.stringify({ op: "add_pipeline", nmae: "p" })], { error: /unknown field "nmae"/ }],
|
||||
[["edit", "[]", JSON.stringify({ op: "rename" })], { error: /^no op "rename" \(valid: add, add_pipeline/ }],
|
||||
];
|
||||
for (const [args, want] of refused) {
|
||||
const got = call(...args);
|
||||
const got = JSON.parse(globalThis.lwconf[args[0]](...args.slice(1)));
|
||||
assert.equal(got.value, undefined, `${args}`);
|
||||
assert.match(got.error, want, `${args}`);
|
||||
for (const [field, w] of Object.entries(want)) {
|
||||
if (w instanceof RegExp) assert.match(got[field], w, `${args}`);
|
||||
else assert.equal(got[field], w, `${args}`);
|
||||
}
|
||||
}
|
||||
assert.ok(call("schema").value.sources.length > 0);
|
||||
assert.ok(engine.schema().value.sources.length > 0);
|
||||
});
|
||||
+180
-20
@@ -1,19 +1,24 @@
|
||||
//go:build js && wasm
|
||||
|
||||
// Command lwconf is the configuration engine as WebAssembly, for the website.
|
||||
// It sets globalThis.lwconf, whose calls take strings and return a JSON object:
|
||||
// {"value": ...}, or {"error": "..."} for a call refused. Pipelines travel in
|
||||
// compose's JSON form; off the host a preset's directory path ends in '/'.
|
||||
// Command lwconf is the configuration engine as WebAssembly: it sets
|
||||
// globalThis.lwconf for lwconf.js, calls taking strings and returning JSON,
|
||||
// {"value"} or {"error"} with its path, one in a pipeline placed on its node
|
||||
// and key, any other a preset's or flag's key. Pipelines are compose's JSON
|
||||
// form; dirs answers config.IsDir by path, {} leaving '/' and globs to tell.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall/js"
|
||||
|
||||
"github.com/lixenwraith/logwisp/internal/compose"
|
||||
"github.com/lixenwraith/logwisp/internal/config"
|
||||
)
|
||||
|
||||
// calls are lwconf's, by name, with the string arguments each takes
|
||||
@@ -22,34 +27,187 @@ var calls = map[string]struct {
|
||||
call func(args []string) (any, error)
|
||||
}{
|
||||
"schema": {nil, func([]string) (any, error) { return compose.NewSchema(), nil }},
|
||||
"preset": {[]string{"name", "values"}, func(a []string) (any, error) {
|
||||
var values map[string]string
|
||||
if err := json.Unmarshal([]byte(a[1]), &values); err != nil {
|
||||
return nil, fmt.Errorf("values: %w", err)
|
||||
"preset": {[]string{"name", "values", "dirs"}, func(a []string) (any, error) {
|
||||
values, err := presetValues(a[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pipelines(compose.FromPreset(a[0], values, nil))
|
||||
isDir, err := answers(a[2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pipelines(compose.FromPreset(a[0], values, isDir))
|
||||
}},
|
||||
"parse": {[]string{"line"}, func(a []string) (any, error) {
|
||||
return pipelines(compose.FromCommandLine(a[0], nil))
|
||||
"parse": {[]string{"text", "form", "dirs"}, func(a []string) (any, error) {
|
||||
f, err := form(a[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isDir, err := answers(a[2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pipelines(f.Read(a[0], isDir))
|
||||
}},
|
||||
"validate": {[]string{"pipelines"}, func(a []string) (any, error) {
|
||||
c, err := compose.FromJSON([]byte(a[0]))
|
||||
if err == nil {
|
||||
err = c.Validate()
|
||||
if c == nil {
|
||||
return nil, err
|
||||
}
|
||||
return pipelines(c, err)
|
||||
value, err := pipelines(c, nil) // a value of the wrong kind is a problem Validate names
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
problems := []problem{}
|
||||
for _, p := range compose.Problems(c.Validate()) {
|
||||
problems = append(problems, place(p))
|
||||
}
|
||||
return map[string]any{"pipelines": value, "problems": problems}, nil
|
||||
}},
|
||||
"emit": {[]string{"pipelines", "form"}, func(a []string) (any, error) {
|
||||
c, err := compose.FromJSON([]byte(a[0]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
forms := compose.Forms()
|
||||
if i := slices.IndexFunc(forms, func(f compose.Form) bool { return f.Name == a[1] }); i >= 0 {
|
||||
return forms[i].Write(c)
|
||||
f, err := form(a[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("no form %q", a[1])
|
||||
return f.Write(c)
|
||||
}},
|
||||
"edit": {[]string{"pipelines", "op"}, func(a []string) (any, error) {
|
||||
c, err := compose.FromJSON([]byte(a[0]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var o op
|
||||
in := json.NewDecoder(bytes.NewReader([]byte(a[1])))
|
||||
in.DisallowUnknownFields()
|
||||
if err := in.Decode(&o); err != nil {
|
||||
return nil, fmt.Errorf("op: %w", err)
|
||||
}
|
||||
edit, ok := edits[o.Op]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no op %q (valid: %s)", o.Op, strings.Join(slices.Sorted(maps.Keys(edits)), ", "))
|
||||
}
|
||||
n, err := edit(c, o)
|
||||
if _, ok := errors.AsType[*config.KeyError](err); ok && o.Node.Role != "" {
|
||||
err = config.At(o.Node.Path(o.Pipeline), err) // a key set or unset in the node
|
||||
}
|
||||
value, err := pipelines(c, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"pipelines": value, "node": n}, nil
|
||||
}},
|
||||
}
|
||||
|
||||
// op is one edit: the compose method Op names, and its arguments
|
||||
type op struct {
|
||||
Op string `json:"op"`
|
||||
Pipeline int `json:"pipeline"`
|
||||
Node compose.Node `json:"node"`
|
||||
Role string `json:"role"`
|
||||
Type string `json:"type"`
|
||||
Key string `json:"key"`
|
||||
Values []string `json:"values"`
|
||||
Name string `json:"name"`
|
||||
From int `json:"from"`
|
||||
To int `json:"to"`
|
||||
}
|
||||
|
||||
// edits call compose's edit methods, each returning the node it leaves chosen
|
||||
var edits = map[string]func(*compose.Composition, op) (compose.Node, error){
|
||||
"add_pipeline": func(c *compose.Composition, o op) (compose.Node, error) {
|
||||
return compose.Node{}, c.AddPipeline(o.Name)
|
||||
},
|
||||
"rename_pipeline": func(c *compose.Composition, o op) (compose.Node, error) {
|
||||
return compose.Node{}, c.RenamePipeline(o.Pipeline, o.Name)
|
||||
},
|
||||
"remove_pipeline": func(c *compose.Composition, o op) (compose.Node, error) {
|
||||
return compose.Node{}, c.RemovePipeline(o.Pipeline)
|
||||
},
|
||||
"add": func(c *compose.Composition, o op) (compose.Node, error) { return c.Add(o.Pipeline, o.Role, o.Type) },
|
||||
"retype": func(c *compose.Composition, o op) (compose.Node, error) {
|
||||
return c.Retype(o.Pipeline, o.Node, o.Type)
|
||||
},
|
||||
"remove": func(c *compose.Composition, o op) (compose.Node, error) {
|
||||
return compose.Node{}, c.Remove(o.Pipeline, o.Node)
|
||||
},
|
||||
"move_filter": func(c *compose.Composition, o op) (compose.Node, error) {
|
||||
return compose.Node{Role: "filters", Index: o.To}, c.MoveFilter(o.Pipeline, o.From, o.To)
|
||||
},
|
||||
"set": func(c *compose.Composition, o op) (compose.Node, error) {
|
||||
return o.Node, c.Set(o.Pipeline, o.Node, o.Key, o.Values...)
|
||||
},
|
||||
"unset": func(c *compose.Composition, o op) (compose.Node, error) {
|
||||
return o.Node, c.Unset(o.Pipeline, o.Node, o.Key)
|
||||
},
|
||||
}
|
||||
|
||||
// problem is where a problem, or a call's refusal, lies as far as the engine
|
||||
// knows: its path, and the pipeline, node and key Locate reads from it
|
||||
type problem struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Pipeline *int `json:"pipeline,omitempty"`
|
||||
Node *compose.Node `json:"node,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
func place(err error) problem {
|
||||
p := problem{Text: err.Error()}
|
||||
if ke, ok := errors.AsType[*config.KeyError](err); ok {
|
||||
p.Path = ke.Key
|
||||
}
|
||||
if pi, n, key, text, ok := compose.Locate(err); ok {
|
||||
p.Pipeline, p.Key, p.Text = &pi, key, text
|
||||
if n.Role != "" {
|
||||
p.Node = &n
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// presetValues reads a preset's values: a string each, or a list key's
|
||||
// strings, which the preset takes ',' between them
|
||||
func presetValues(data string) (map[string]string, error) {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(data), &raw); err != nil {
|
||||
return nil, fmt.Errorf("values: %w", err)
|
||||
}
|
||||
values := map[string]string{}
|
||||
for k, v := range raw {
|
||||
var one string
|
||||
var many []string
|
||||
switch {
|
||||
case json.Unmarshal(v, &one) == nil:
|
||||
values[k] = one
|
||||
case json.Unmarshal(v, &many) == nil:
|
||||
values[k] = strings.Join(many, ",")
|
||||
default:
|
||||
return nil, &config.KeyError{Key: k, Err: errors.New("takes a string, or a list of strings")}
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// answers reads dirs: whether each path a preset names is a directory
|
||||
func answers(dirs string) (config.IsDir, error) {
|
||||
var m map[string]bool
|
||||
if err := json.Unmarshal([]byte(dirs), &m); err != nil {
|
||||
return nil, fmt.Errorf("dirs: %w", err)
|
||||
}
|
||||
return config.Answers(m), nil
|
||||
}
|
||||
|
||||
func form(name string) (compose.Form, error) {
|
||||
forms := compose.Forms()
|
||||
if i := slices.IndexFunc(forms, func(f compose.Form) bool { return f.Name == name }); i >= 0 {
|
||||
return forms[i], nil
|
||||
}
|
||||
return compose.Form{}, fmt.Errorf("no form %q", name)
|
||||
}
|
||||
|
||||
// pipelines is a composition's value: its pipelines settled, ids given
|
||||
@@ -81,9 +239,11 @@ func answer(name string, args []js.Value) string {
|
||||
}
|
||||
value, err = c.call(strs)
|
||||
}
|
||||
out := map[string]any{"value": value}
|
||||
var out any = map[string]any{"value": value}
|
||||
if err != nil {
|
||||
out = map[string]any{"error": err.Error()}
|
||||
p := place(err)
|
||||
p.Error, p.Text = err.Error(), ""
|
||||
out = p
|
||||
}
|
||||
data, _ := json.Marshal(out)
|
||||
return string(data)
|
||||
|
||||
Reference in new issue
Block a user