mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
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:
1 parent
705fb90740
commit
707c0bb453
10 files changed
+325
-9
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -287,7 +287,7 @@ body {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.config-container {
|
||||
.config-container, .config-container .ant-tree {
|
||||
background-color: #111111;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user