Add remote configuration of hosts

Implement remote editing of the host config in a similar fasion to how
the remote editing of the controller config works.
This commit is contained in:
Hornwitser committed 2024-01-15 20:28:44 +01:00
1 parent 705fb90740
commit 707c0bb453
10 files changed
+325 -9

No files matched your search

+144 -1
View File
@@ -58,7 +58,7 @@ async function configToKeyVal(data: string) {
function serializedConfigToString(
serializedConfig: lib.ConfigSchema,
configGroup: typeof lib.ControllerConfig | typeof lib.InstanceConfig,
configGroup: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig,
disallowedList: Record<string, unknown>,
) {
let allConfigElements = "";
@@ -289,6 +289,149 @@ hostCommands.add(new lib.Command({
},
}));
const hostConfigCommands = new lib.CommandTree({
name: "config", alias: ["c"], description: "Host config management",
});
hostConfigCommands.add(new lib.Command({
definition: ["list <host>", "List configuration for a host", (yargs) => {
yargs.positional("host", { describe: "Host to list config for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
}
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["set <host> <field> [value]", "Set field in host config", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { host: string, field: string, value?: string, stdin?: boolean },
control: Control,
) {
let hostId = await lib.resolveHost(control, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(args.field, args.value));
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["set-prop <host> <field> <prop> [value]", "Set property of field in host config", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { host: string, field: string, prop: string, value?: string, stdin?: boolean },
control: Control
) {
let hostId = await lib.resolveHost(control, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let value;
try {
if (args.value !== undefined) {
value = JSON.parse(args.value);
}
} catch (err: any) {
// See note for the instance version of set-prop
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
value = args.value;
}
await control.sendTo({ hostId }, new lib.HostConfigSetPropRequest(args.field, args.prop, value));
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["edit <host> [editor]", "Edit host configuration", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args: { host: string, editor: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl controller config edit <editor of choice>"`);
}
let disallowedList = {"host.id": 0};
let allConfigElements = serializedConfigToString(
config,
lib.HostConfig,
disallowedList
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
let doneEmitter = new events.EventEmitter();
editorSpawn.on("exit", async (exit) => {
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
}
}
doneEmitter.emit("dot_on_done");
});
await events.once(doneEmitter, "dot_on_done");
try {
await fs.unlink(tmpFile);
} catch (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
},
}));
hostCommands.add(hostConfigCommands);
const instanceCommands = new lib.CommandTree({
name: "instance", alias: ["i"], description: "Instance management",
+22
View File
@@ -341,6 +341,9 @@ export default class Host extends lib.Link {
});
}
this.handle(lib.HostConfigGetRequest, this.handleHostConfigGetRequest.bind(this));
this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
this.handle(lib.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this));
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.handleAdminlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.handleBanlistUpdateEvent.bind(this));
@@ -423,6 +426,25 @@ export default class Host extends lib.Link {
}
}
async handleHostConfigGetRequest() {
return this.config.toRemote("control");
}
async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) {
if (request.field === "host.id") {
// Changing host.id at runtime is not worth the effort to
// support and will break a lot of things if it is allowed.
throw new lib.RequestError("Setting 'host.id' while host is running is not supported");
}
this.config.set(request.field as keyof lib.HostConfigFields, request.value, "control");
}
async handleHostConfigSetPropRequest(request: lib.HostConfigSetPropRequest) {
let { field, prop, value } = request;
this.config.setProp(field as keyof lib.HostConfigFields, prop, value, "control");
}
async handleSyncUserListsEvent(event: lib.SyncUserListsEvent) {
let updateList = (list: Set<string>, updatedList: Set<string>, Event: lib.EventClass<unknown>) => {
let added = new Set(updatedList);
+57 -1
View File
@@ -1,6 +1,62 @@
import { Type, Static } from "@sinclair/typebox";
import { jsonArray, StringEnum } from "./composites";
import { jsonArray, plainJson, StringEnum } from "./composites";
import { CollectorResultSerialized } from "../prometheus";
import { HostConfig } from "../config/definitions";
export class HostConfigGetRequest {
declare ["constructor"]: typeof HostConfigGetRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "host" as const;
static permission = "core.host.get_config" as const;
static Response = plainJson(HostConfig.jsonSchema);
}
export class HostConfigSetFieldRequest {
declare ["constructor"]: typeof HostConfigSetFieldRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "host" as const;
static permission = "core.host.update_config" as const;
constructor(
public field: string,
public value: string,
) { }
static jsonSchema = Type.Object({
"field": Type.String(),
"value": Type.String(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.field, json.value);
}
}
export class HostConfigSetPropRequest {
declare ["constructor"]: typeof HostConfigSetPropRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "host" as const;
static permission = "core.host.update_config" as const;
constructor(
public field: string,
public prop: string,
public value?: unknown,
) { }
static jsonSchema = Type.Object({
"field": Type.String(),
"prop": Type.String(),
"value": Type.Optional(Type.Unknown()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.field, json.prop, json.value);
}
}
export class HostDetails {
constructor(
+3
View File
@@ -32,6 +32,9 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
subscriptions.SubscriptionRequest,
host.HostConfigGetRequest,
host.HostConfigSetFieldRequest,
host.HostConfigSetPropRequest,
host.HostListRequest,
host.HostUpdatesEvent,
host.HostMetricsRequest,
+10
View File
@@ -136,6 +136,16 @@ definePermission({
"Subscribe to be notified when the system metrics detailing cpu, memory and disk usage for the " +
"controller and hosts are updated.",
});
definePermission({
name: "core.host.get_config",
title: "Get host configs",
description: "Get the config of hosts.",
});
definePermission({
name: "core.host.update_config",
title: "Modify host configs",
description: "Modify the host config or entries of the host config.",
});
definePermission({
name: "core.host.list",
title: "List hosts",
@@ -26,12 +26,13 @@ function getInitialValues(config: lib.Config<any>, props: BaseConfigTreeProps) {
}
type BaseConfigTreeProps = {
ConfigClass: typeof lib.ControllerConfig | typeof lib.InstanceConfig;
ConfigClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig;
retrieveConfig: () => Promise<lib.ConfigSchema>;
setField: (field:string, value:any) => Promise<void>;
setProp: (field:string, prop:string, value:any) => Promise<void>;
id?: number;
onApply?: () => void;
available?: boolean;
};
export default function BaseConfigTree(props: BaseConfigTreeProps) {
let [config, setConfig] = useState<lib.Config<any>|null>(null);
@@ -39,6 +40,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
let [changedFields, setChangedFields] = useState<Set<string>>(new Set());
let [errorFields, setErrorFields] = useState(new Map());
let [applying, setApplying] = useState(false);
const available = props.available ?? true;
async function updateConfig() {
let serializedConfig = await props.retrieveConfig();
@@ -47,10 +49,12 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
return newConfig;
}
useEffect(() => {
updateConfig().then(updatedConfig => {
form.setFieldsValue(getInitialValues(updatedConfig, props));
});
}, [props.id]);
if (available) {
updateConfig().then(updatedConfig => {
form.setFieldsValue(getInitialValues(updatedConfig, props));
});
}
}, [props.id, available]);
function renderInput(def: lib.FieldDefinition) {
if (def.type === "boolean") {
@@ -66,8 +70,22 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
return `Unknown type ${def.type}`;
}
if (!available) {
return <>
<Title level={5} style={{ marginTop: 16 }}>Config</Title>
<Card size="small" className="config-container">
Config is currently unavailable.
</Card>
</>;
}
if (config === null) {
return <Spin/>;
return <>
<Title level={5} style={{ marginTop: 16 }}>Config</Title>
<Card size="small" className="config-container">
<Spin size="large" />;
</Card>
</>;
}
let initialValues: any = {};
@@ -0,0 +1,39 @@
import React, { useContext } from "react";
import * as lib from "@clusterio/lib";
import BaseConfigTree from "./BaseConfigTree";
import ControlContext from "./ControlContext";
export default function HostConfigTree(props: { id: number, available: boolean }) {
let control = useContext(ControlContext);
async function retrieveConfig() {
return await control.sendTo({ hostId: props.id }, new lib.HostConfigGetRequest());
}
async function setField(field: string, value: any) {
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetFieldRequest(field, value));
}
async function setProp(field: string, prop: string, value: any) {
if (value) {
try {
value = JSON.parse(value);
} catch (err) {
return;
}
}
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetPropRequest(field, prop, value));
}
return <BaseConfigTree
ConfigClass={lib.HostConfig}
retrieveConfig={retrieveConfig}
setField={setField}
setProp={setProp}
id={props.id}
available={props.available}
/>;
}
@@ -5,6 +5,7 @@ import { useParams } from "react-router-dom";
import * as lib from "@clusterio/lib";
import notify, { notifyErrorHandler } from "../util/notify";
import ControlContext from "./ControlContext";
import HostConfigTree from "./HostConfigTree";
import InstanceList from "./InstanceList";
import LogConsole from "./LogConsole";
import { useAccount } from "../model/account";
@@ -100,6 +101,7 @@ export default function HostViewPage() {
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<LogConsole hosts={[hostId]} />
</>}
{account.hasPermission("core.host.get_config") && <HostConfigTree id={hostId} available={host.connected} />}
<PluginExtra component="HostViewPage" host={host} />
</PageLayout>;
}
+1 -1
View File
@@ -287,7 +287,7 @@ body {
color: #000;
}
.config-container {
.config-container, .config-container .ant-tree {
background-color: #111111;
}
+23
View File
@@ -230,6 +230,29 @@ describe("Integration of Clusterio", function() {
await execCtl("host list");
});
});
describe("host config", function() {
it("changes host config", async function() {
await execCtl("host config set 4 host.name My-Host");
const result = await execCtl("host config list 4");
assert(/My-Host/.test(result.stdout), "New host name not in config output");
});
it("should not allow setting host.controller_token", async function() {
await assert.rejects(
execCtl("host config set 4 host.controller_token xyz"),
/Field 'host\.controller_token' is not accessible/,
);
});
it("should not allow setting host.id", async function() {
await assert.rejects(
execCtl("host config set 4 host.id 200"),
/Setting 'host\.id' while host is running is not supported/,
);
});
it("should not leak host.controller_token", async function() {
let result = await getControl().sendTo({ hostId: 4 }, new lib.HostConfigGetRequest());
assert.equal(Object.prototype.hasOwnProperty.call(result, "host.controller_token"), false);
});
});
describe("host generate-token", function() {
it("runs", async function() {
await execCtl("host generate-token --id 42");