diff --git a/README.md b/README.md index a35dab63..e6e45f3e 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ master server. In order for slaves to connect to the master server they need a valid authentication token, you can create a slave config with a valid token with the following command. - node clusterctl create-slave-config --name Local --generate-token + node clusterctl slave create-config --name Local --generate-token This will write a new `config-slave.json` file in the current directory (you can change the location with the `--output` option) with the name, @@ -253,9 +253,9 @@ clusterctl control-config set` command: The basic operations to start a new instance is the following - node clusterctl create-instance --name "My instance" - node clusterctl assign-instance --instance "My instance" --slave "Local" - node clusterctl start-instance --instance "My instance" + node clusterctl instance create "My instance" + node clusterctl instance assign "My instance" "Local" + node clusterctl instance start "My instance" The first line creates the instance configuration on the master server. The second assigns the instance to a slave which creates the instance diff --git a/clusterctl.js b/clusterctl.js index fe3ddc9b..bf8eca3b 100644 --- a/clusterctl.js +++ b/clusterctl.js @@ -14,6 +14,8 @@ const events = require("events"); const link = require("lib/link"); const errors = require("lib/errors"); const config = require("lib/config"); +const plugin = require("lib/plugin"); +const command = require("lib/command"); /** @@ -54,141 +56,17 @@ function formatOutputColored(output) { return time + info + output.message; } -/** - * Represents a command that can be runned by clusterctl - */ -class Command { - constructor({ definition, handler }) { - this.name = definition[0]; - this._definition = definition; - this._handler = handler; - } - - register(yargs) { - yargs.command(...this._definition); - } - - async run(args, control) { - await this._handler(args, control); - } -} - -/** - * Resolve a string to an instance ID - * - * Resolves a string with either an instance name or an id into an integer - * with the instance ID. - * - * @param {module:lib/link.Link} client - link to master server to query instance on. - * @param {string} instanceName - string with name or id of instance. - * @returns {number} instance ID. - * @private - */ -async function resolveInstance(client, instanceName) { - let instanceId; - if (/^-?\d+$/.test(instanceName)) { - instanceId = parseInt(instanceName, 10); - } else { - let response = await link.messages.listInstances.send(client); - for (let instance of response.list) { - if (instance.name === instanceName) { - instanceId = instance.id; - break; - } - } - - if (instanceId === undefined) { - throw new errors.CommandError(`No instance named ${instanceName}`); - } - } - - return instanceId; -} - -/** - * Resolve a string into a slave ID - * - * Resolves a string with either an slave name or an id into an integer with - * the slave ID. - * - * @param {module:lib/link.Link} client - link to master server to query slave on. - * @param {string} slaveName - string with name or id of slave. - * @returns {number} slave ID. - * @private - */ -async function resolveSlave(client, slaveName) { - let slaveId; - if (/^-?\d+$/.test(slaveName)) { - slaveId = parseInt(slaveName, 10); - } else { - let response = await link.messages.listSlaves.send(client); - for (let slave of response.list) { - if (slave.name === slaveName) { - slaveId = slave.id; - break; - } - } - - if (slaveId === undefined) { - throw new errors.CommandError(`No slave named ${slaveName}`); - } - } - - return slaveId; -} - -/** - * Resolve a string into a role object - * - * Resolves a string with either a role name or an id into an object - * representing the role. - * - * @param {module:lib/link.Link} client - link to master server to query role on. - * @param {string} roleName - string with name or id of role. - * @returns {Object} Role info. - * @private - */ -async function resolveRole(client, roleName) { - let response = await link.messages.listRoles.send(client); - - let resolvedRole; - if (/^-?\d+$/.test(roleName)) { - let roleId = parseInt(roleName, 10); - for (let role of response.list) { - if (role.id === roleId) { - resolvedRole = role; - break; - } - } - - } else { - for (let role of response.list) { - if (role.name === roleName) { - resolvedRole = role; - break; - } - } - } - - if (!resolvedRole) { - throw new errors.CommandError(`No role named ${roleName}`); - } - - return resolvedRole; -} - - -let commands = []; -commands.push(new Command({ - definition: ["list-slaves", "List slaves connected to the master"], +const slaveCommands = new command.CommandTree({ name: "slave", description: "Slave management" }); +slaveCommands.add(new command.Command({ + definition: [["list", "l"], "List slaves connected to the master"], handler: async function(args, control) { let response = await link.messages.listSlaves.send(control); console.log(asTable(response.list)); }, })); -commands.push(new Command({ - definition: ["generate-slave-token", "Generate token for a slave", (yargs) => { +slaveCommands.add(new command.Command({ + definition: ["generate-token", "Generate token for a slave", (yargs) => { yargs.option("id", { type: "number", nargs: 1, describe: "Slave id", demandOption: true }); }], handler: async function(args, control) { @@ -197,8 +75,8 @@ commands.push(new Command({ }, })); -commands.push(new Command({ - definition: ["create-slave-config", "Create slave config", (yargs) => { +slaveCommands.add(new command.Command({ + definition: ["create-config", "Create slave config", (yargs) => { yargs.option("id", { type: "number", nargs: 1, describe: "Slave id", default: null }); yargs.option("name", { type: "string", nargs: 1, describe: "Slave name", default: null }); yargs.option("generate-token", { @@ -231,34 +109,23 @@ commands.push(new Command({ })); -commands.push(new Command({ - definition: ["list-instances", "List instances known to the master"], +const instanceCommands = new command.CommandTree({ + name: "instance", alias: ["i"], description: "Instance management", +}); +instanceCommands.add(new command.Command({ + definition: [["list", "l"], "List instances known to the master"], handler: async function(args, control) { let response = await link.messages.listInstances.send(control); console.log(asTable(response.list)); }, })); -commands.push(new Command({ - definition: ["create-instance", "Create an instance", (yargs) => { +instanceCommands.add(new command.Command({ + definition: ["create ", "Create an instance", (yargs) => { // XXX TODO: set any specific options? - yargs.option("name", { - type: "string", - nargs: 1, - describe: "Instance name", - group: "Instance Config:", - }); - yargs.option("id", { - type: "number", - nargs: 1, - describe: "Instance ID", - group: "Instance Config:", - }); - yargs.option("base", { - type: "boolean", - nargs: 0, - describe: "Specify this is a base config", - group: "Instance Config:", + yargs.positional("name", { describe: "Instance name", type: "string" }); + yargs.options({ + "id": { type: "number", nargs: 1, describe: "Instance id" }, }); }], handler: async function(args, control) { @@ -267,22 +134,21 @@ commands.push(new Command({ if (args.id) { instanceConfig.set("instance.id", args.id); } - if (args.name) { - instanceConfig.set("instance.name", args.name); - } + instanceConfig.set("instance.name", args.name); let serialized_config = instanceConfig.serialize(); let response = await link.messages.createInstance.send(control, { serialized_config }); }, })); -commands.push(new Command({ - definition: ["list-instance-config", "List configuration for an instance", (yargs) => { - yargs.option({ - "instance": { describe: "Instance to list config for", nargs: 1, type: "string", demandOption: true }, - }); +const instanceConfigCommands = new command.CommandTree({ + name: "config", alias: ["c"], description: "Instance config management", +}); +instanceConfigCommands.add(new command.Command({ + definition: ["list ", "List configuration for an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to list config for", type: "string" }); }], handler: async function(args, control) { - let instanceId = await resolveInstance(control, args.instance); + let instanceId = await command.resolveInstance(control, args.instance); let response = await link.messages.getInstanceConfig.send(control, { instance_id: instanceId }); for (let group of response.serialized_config.groups) { @@ -293,16 +159,14 @@ commands.push(new Command({ }, })); -commands.push(new Command({ - definition: ["set-instance-config", "Set field in instance config", (yargs) => { - yargs.option({ - "instance": { describe: "Instance to set config on", nargs: 1, type: "string", demandOption: true }, - "field": { describe: "Field to set", nargs: 1, type: "string", demandOption: true }, - "value": { describe: "Value to set", nargs: 1, type: "string", demandOption: true }, - }); +instanceConfigCommands.add(new command.Command({ + definition: ["set ", "Set field in instance config", (yargs) => { + yargs.positional("instance", { describe: "Instance to set config on", type: "string" }); + yargs.positional("field", { describe: "Field to set", type: "string" }); + yargs.positional("value", { describe: "Value to set", type: "string" }); }], handler: async function(args, control) { - let instanceId = await resolveInstance(control, args.instance); + let instanceId = await command.resolveInstance(control, args.instance); await link.messages.setInstanceConfigField.send(control, { instance_id: instanceId, field: args.field, @@ -311,17 +175,15 @@ commands.push(new Command({ }, })); -commands.push(new Command({ - definition: ["set-instance-config-prop", "Set property of field in instance config", (yargs) => { - yargs.option({ - "instance": { describe: "Instance to set config on", nargs: 1, type: "string", demandOption: true }, - "field": { describe: "Field to set", nargs: 1, type: "string", demandOption: true }, - "prop": { describe: "Property to set", nargs: 1, type: "string", demandOption: true }, - "value": { describe: "JSON parsed value to set", nargs: 1, type: "string", demandOption: true }, - }); +instanceConfigCommands.add(new command.Command({ + definition: ["set-prop ", "Set property of field in instance config", (yargs) => { + yargs.positional("instance", { describe: "Instance 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" }); }], handler: async function(args, control) { - let instanceId = await resolveInstance(control, args.instance); + let instanceId = await command.resolveInstance(control, args.instance); await link.messages.setInstanceConfigProp.send(control, { instance_id: instanceId, field: args.field, @@ -330,17 +192,16 @@ commands.push(new Command({ }); }, })); +instanceCommands.add(instanceConfigCommands); -commands.push(new Command({ - definition: ["assign-instance", "Assign instance to a slave", (yargs) => { - yargs.options({ - "instance": { describe: "Instance to assign", nargs: 1, type: "string", demandOption: true }, - "slave": { describe: "Slave to assign to", nargs: 1, type: "string", demandOption: true }, - }); +instanceCommands.add(new command.Command({ + definition: ["assign ", "Assign instance to a slave", (yargs) => { + yargs.positional("instance", { describe: "Instance to assign", type: "string" }); + yargs.positional("slave", { describe: "Slave to assign to", type: "string" }); }], handler: async function(args, control) { - let instanceId = await resolveInstance(control, args.instance); - let slaveId = await resolveSlave(control, args.slave); + let instanceId = await command.resolveInstance(control, args.instance); + let slaveId = await command.resolveSlave(control, args.slave); await link.messages.assignInstanceCommand.send(control, { instance_id: instanceId, slave_id: slaveId, @@ -348,14 +209,12 @@ commands.push(new Command({ }, })); -commands.push(new Command({ - definition: ["create-save", "Create a new save on an instance", (yargs) => { - yargs.options({ - "instance": { describe: "Instance to create on", nargs: 1, type: "string", demandOption: true }, - }); +instanceCommands.add(new command.Command({ + definition: ["create-save ", "Create a new save on an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to create on", type: "string" }); }], handler: async function(args, control) { - let instanceId = await resolveInstance(control, args.instance); + let instanceId = await command.resolveInstance(control, args.instance); await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] }); let response = await link.messages.createSave.send(control, { instance_id: instanceId, @@ -363,14 +222,12 @@ commands.push(new Command({ }, })); -commands.push(new Command({ - definition: ["export-data", "Export item icons and locale from instance", (yargs) => { - yargs.options({ - "instance": { describe: "Instance to export from", nargs: 1, type: "string", demandOption: true }, - }); +instanceCommands.add(new command.Command({ + definition: ["export-data ", "Export item icons and locale from instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to export from", type: "string" }); }], handler: async function(args, control) { - let instanceId = await resolveInstance(control, args.instance); + let instanceId = await command.resolveInstance(control, args.instance); await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] }); let response = await link.messages.exportData.send(control, { instance_id: instanceId, @@ -378,16 +235,16 @@ commands.push(new Command({ }, })); -commands.push(new Command({ - definition: ["start-instance", "Start instance", (yargs) => { +instanceCommands.add(new command.Command({ + definition: ["start ", "Start instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to start", type: "string" }); yargs.options({ - "instance": { describe: "Instance to start", nargs: 1, type: "string", demandOption: true }, "save": { describe: "Save load, defaults to latest", nargs: 1, type: "string" }, "keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false }, }); }], handler: async function(args, control) { - let instanceId = await resolveInstance(control, args.instance); + let instanceId = await command.resolveInstance(control, args.instance); await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] }); let response = await link.messages.startInstance.send(control, { instance_id: instanceId, @@ -396,16 +253,16 @@ commands.push(new Command({ }, })); -commands.push(new Command({ - definition: ["load-scenario", "Start instance by loading a scenario", (yargs) => { +instanceCommands.add(new command.Command({ + definition: ["load-scenario ", "Start instance by loading a scenario", (yargs) => { + yargs.positional("instance", { describe: "Instance to start", type: "string" }); + yargs.positional("scenario", { describe: "Scenario to load", type: "string" }); yargs.options({ - "instance": { describe: "Instance to start", nargs: 1, type: "string", demandOption: true }, - "scenario": { describe: "Scenario to load", nargs: 1, type: "string" }, "keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false }, }); }], handler: async function(args, control) { - let instanceId = await resolveInstance(control, args.instance); + let instanceId = await command.resolveInstance(control, args.instance); await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] }); let response = await link.messages.loadScenario.send(control, { instance_id: instanceId, @@ -414,14 +271,12 @@ commands.push(new Command({ }, })); -commands.push(new Command({ - definition: ["stop-instance", "Stop instance", (yargs) => { - yargs.options({ - "instance": { describe: "Instance to stop", nargs: 1, type: "string", demandOption: true }, - }); +instanceCommands.add(new command.Command({ + definition: ["stop ", "Stop instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to stop", type: "string" }); }], handler: async function(args, control) { - let instanceId = await resolveInstance(control, args.instance); + let instanceId = await command.resolveInstance(control, args.instance); await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] }); let response = await link.messages.stopInstance.send(control, { instance_id: instanceId, @@ -429,29 +284,25 @@ commands.push(new Command({ }, })); -commands.push(new Command({ - definition: ["delete-instance", "Delete instance", (yargs) => { - yargs.options({ - "instance": { describe: "Instance to delete", nargs: 1, type: "string", demandOption: true }, - }); +instanceCommands.add(new command.Command({ + definition: ["delete ", "Delete instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to delete", type: "string" }); }], handler: async function(args, control) { let response = await link.messages.deleteInstance.send(control, { - instance_id: await resolveInstance(control, args.instance), + instance_id: await command.resolveInstance(control, args.instance), }); }, })); -commands.push(new Command({ - definition: ["send-rcon", "Send RCON command", (yargs) => { - yargs.options({ - "instance": { describe: "Instance to sent to", nargs: 1, type: "string", demandOption: true }, - "command": { describe: "command to send", nargs: 1, type: "string", demandOption: true }, - }); +instanceCommands.add(new command.Command({ + definition: ["send-rcon ", "Send RCON command", (yargs) => { + yargs.positional("instance", { describe: "Instance to send to", type: "string" }); + yargs.positional("command", { describe: "command to send", type: "string" }); }], handler: async function(args, control) { let response = await link.messages.sendRcon.send(control, { - instance_id: await resolveInstance(control, args.instance), + instance_id: await command.resolveInstance(control, args.instance), command: args.command, }); @@ -460,26 +311,29 @@ commands.push(new Command({ }, })); -commands.push(new Command({ - definition: ["list-permissions", "List permissions in the cluster"], +const permissionCommands = new command.CommandTree({ name: "permission", description: "Permission inspection" }); +permissionCommands.add(new command.Command({ + definition: [["list", "l"], "List permissions in the cluster"], handler: async function(args, control) { let response = await link.messages.listPermissions.send(control); console.log(asTable(response.list)); }, })); -commands.push(new Command({ - definition: ["list-roles", "List roles in the cluster"], + +const roleCommands = new command.CommandTree({ name: "role", description: "Role management" }); +roleCommands.add(new command.Command({ + definition: [["list", "l"], "List roles in the cluster"], handler: async function(args, control) { let response = await link.messages.listRoles.send(control); console.log(asTable(response.list)); }, })); -commands.push(new Command({ - definition: ["create-role", "Create a new role", (yargs) => { +roleCommands.add(new command.Command({ + definition: ["create ", "Create a new role", (yargs) => { + yargs.positional("name", { describe: "Name of role to create", type: "string" }); yargs.options({ - "name": { describe: "Name of role to create", nargs: 1, type: "string", demandOption: true }, "description": { describe: "Description for role", nargs: 1, type: "string", default: "" }, "permissions": { describe: "Permissions role grants", nargs: 1, array: true, type: "string", default: [] }, }); @@ -494,10 +348,10 @@ commands.push(new Command({ }, })); -commands.push(new Command({ - definition: ["edit-role", "Edit existing role", (yargs) => { +roleCommands.add(new command.Command({ + definition: ["edit ", "Edit existing role", (yargs) => { + yargs.positional("role", { describe: "Role to edit", type: "string" }); yargs.options({ - "role": { describe: "Role to edit", nargs: 1, type: "string", demandOption: true }, "name": { describe: "New name for role", nargs: 1, type: "string" }, "description": { describe: "New description for role", nargs: 1, type: "string" }, "permissions": { describe: "New permissions for role", array: true, type: "string" }, @@ -505,7 +359,7 @@ commands.push(new Command({ }); }], handler: async function(args, control) { - let role = await resolveRole(control, args.role); + let role = await command.retrieveRole(control, args.role); if (args.name !== undefined) { role.name = args.name; @@ -524,44 +378,39 @@ commands.push(new Command({ }, })); -commands.push(new Command({ - definition: ["delete-role", "Delete role", (yargs) => { - yargs.options({ - "role": { describe: "Role to delete", nargs: 1, type: "string", demandOption: true }, - }); +roleCommands.add(new command.Command({ + definition: ["delete ", "Delete role", (yargs) => { + yargs.positional("role", { describe: "Role to delete", type: "string" }); }], handler: async function(args, control) { - let role = await resolveRole(control, args.role); + let role = await command.retrieveRole(control, args.role); await link.messages.deleteRole.send(control, { id: role.id }); }, })); -commands.push(new Command({ - definition: ["list-users", "List user in the cluster"], +const userCommands = new command.CommandTree({ name: "user", alias: ["u"], description: "User management" }); +userCommands.add(new command.Command({ + definition: [["list", "l"], "List user in the cluster"], handler: async function(args, control) { let response = await link.messages.listUsers.send(control); console.log(asTable(response.list)); }, })); -commands.push(new Command({ - definition: ["create-user", "Create a user", (yargs) => { - yargs.options({ - "name": { describe: "Name of user to create", nargs: 1, type: "string", demandOption: true }, - }); +userCommands.add(new command.Command({ + definition: ["create ", "Create a user", (yargs) => { + yargs.positional("name", { describe: "Name of user to create", type: "string" }); }], handler: async function(args, control) { await link.messages.createUser.send(control, { name: args.name }); }, })); -commands.push(new Command({ - definition: ["edit-user-roles", "Edit user roles", (yargs) => { - yargs.options({ - "name": { describe: "Name of user to change roles for", nargs: 1, type: "string", demandOption: true }, - "roles": { describe: "roles to assign", array: true, type: "string", demandOption: true }, - }); +userCommands.add(new command.Command({ + definition: ["set-roles [roles...]", "Replace user roles", (yargs) => { + yargs.positional("user", { describe: "Name of user to change roles for", type: "string" }); + yargs.positional("roles", { describe: "roles to assign", type: "string" }); }], handler: async function(args, control) { let response = await link.messages.listRoles.send(control); @@ -588,32 +437,28 @@ commands.push(new Command({ } } - await link.messages.updateUserRoles.send(control, { name: args.name, roles: resolvedRoles }); + await link.messages.updateUserRoles.send(control, { name: args.user, roles: resolvedRoles }); }, })); -commands.push(new Command({ - definition: ["delete-user", "Delete user", (yargs) => { - yargs.options({ - "name": { describe: "Name of user to delete", nargs: 1, type: "string", demandOption: true }, - }); +userCommands.add(new command.Command({ + definition: ["delete ", "Delete user", (yargs) => { + yargs.positional("user", { describe: "Name of user to delete", type: "string" }); }], handler: async function(args, control) { - await link.messages.deleteUser.send(control, { name: args.name }); + await link.messages.deleteUser.send(control, { name: args.user }); }, })); -commands.push(new Command({ - definition: ["debug-dump-ws", "Dump WebSocket messages sent and received by master", (yargs) => { }], +const debugCommands = new command.CommandTree({ name: "debug", description: "Debugging utilities" }); +debugCommands.add(new command.Command({ + definition: ["dump-ws", "Dump WebSocket messages sent and received by master", (yargs) => { }], handler: async function(args, control) { await link.messages.debugDumpWs.send(control); return new Promise(() => {}); }, })); -// Convert to mapping from name to command instance -commands = new Map([...commands.map(command => [command.name, command])]); - /** * Connector for control connection to master server @@ -639,14 +484,22 @@ class ControlConnector extends link.WebSocketClientConnector { * Handles running the control * * Connects to the master server over WebSocket and sends commands to it. + * @static */ class Control extends link.Link { - // I don't like God classes, but the alternative of putting all this state - // into global variables is not much better. - constructor(connector) { + constructor(connector, controlPlugins) { super("control", "master", connector); link.attachAllMessages(this); + + /** + * Mapping of plugin names to their instance for loaded plugins. + * @type {Map} + */ + this.plugins = controlPlugins; + for (let controlPlugin of controlPlugins.values()) { + plugin.attachPluginMessages(this, controlPlugin.info, controlPlugin); + } } async instanceOutputEventHandler(message) { @@ -687,18 +540,42 @@ async function startControl() { }) .command("control-config", "Manage Control config", config.configCommand) .wrap(yargs.terminalWidth()) + .strict() ; - for (let command of commands.values()) { - command.register(yargs); + const rootCommands = new command.CommandTree({ name: "clusterctl", description: "Manage cluster" }); + rootCommands.add(slaveCommands); + rootCommands.add(instanceCommands); + rootCommands.add(permissionCommands); + rootCommands.add(roleCommands); + rootCommands.add(userCommands); + rootCommands.add(debugCommands); + + console.log("Loading Plugin info"); + let pluginInfos = await plugin.loadPluginInfos("plugins"); + config.registerPluginConfigGroups(pluginInfos); + config.finalizeConfigs(); + + let controlPlugins = new Map(); + for (let pluginInfo of pluginInfos) { + if (!pluginInfo.controlEntrypoint) { + continue; + } + + let { ControlPlugin } = require(`./plugins/${pluginInfo.name}/${pluginInfo.controlEntrypoint}`); + let controlPlugin = new ControlPlugin(pluginInfo); + controlPlugins.set(pluginInfo.name, controlPlugin); + await controlPlugin.init(); + await controlPlugin.addCommands(rootCommands); } - const args = yargs.demandCommand(1, "You need to specify a command to run") - .strict() - .argv - ; + for (let [name, command] of rootCommands.subCommands) { + if (name === command.name) { + command.register(yargs); + } + } - config.finalizeConfigs(); + const args = yargs.argv; console.log(`Loading config from ${args.config}`); let controlConfig = new config.ControlConfig(); @@ -715,12 +592,24 @@ async function startControl() { } } - let commandName = args._[0]; - if (commandName === "control-config") { + if (args._.length === 0) { + yargs.showHelp(); + yargs.exit(); + } + + // Handle the control-config command before trying to connect. + if (args._[0] === "control-config") { await config.handleConfigCommand(args, controlConfig, args.config); return; } + // Determine which command is being executed. + let commandPath = [...args._]; + let targetCommand = rootCommands; + while (commandPath.length && targetCommand instanceof command.CommandTree) { + targetCommand = targetCommand.get(commandPath.shift()); + } + // The remaining commands require connecting to the master server. if (!controlConfig.get("control.master_url") || !controlConfig.get("control.master_token")) { console.error("Missing URL and/or token to connect with. See README.md for setting up access."); @@ -733,7 +622,7 @@ async function startControl() { controlConfig.get("control.reconnect_delay"), controlConfig.get("control.master_token") ); - let control = new Control(controlConnector); + let control = new Control(controlConnector, controlPlugins); try { await controlConnector.connect(); } catch(err) { @@ -751,42 +640,36 @@ async function startControl() { }); }); - if (commands.has(commandName)) { - let command = commands.get(commandName); + let keepOpen = Boolean(args.keepOpen); + try { + await targetCommand.run(args, control); - let keepOpen = Boolean(args.keepOpen); - try { - await command.run(args, control); + } catch (err) { + keepOpen = false; + if (err instanceof errors.CommandError) { + console.error(`Error running command: ${err.message}`); + process.exitCode = 1; - } catch (err) { - keepOpen = false; - if (err instanceof errors.CommandError) { - console.error(`Error running command: ${err.message}`); - process.exitCode = 1; + } else if (err instanceof errors.RequestError) { + console.error(`Error sending request: ${err.message}`); + process.exitCode = 1; - } else if (err instanceof errors.RequestError) { - console.error(`Error sending request: ${err.message}`); - process.exitCode = 1; + } else { + throw err; + } - } else { - throw err; - } - - } finally { - if (!keepOpen) { - await control.shutdown(); - } + } finally { + if (!keepOpen) { + await control.shutdown(); } } } module.exports = { + Control, + // for testing only _formatOutputColored: formatOutputColored, - _resolveInstance: resolveInstance, - _Control: Control, - - _commands: commands, }; diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 83b7fdf4..b1ba341d 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -19,6 +19,7 @@ Contents - [Sending Link Messages](#sending-link-messages) - [Handling connection events](#handling-connection-events) - [Collecting Statistics](#collecting-statistics) +- [Adding Custom Commands to clusterctl](#adding-custom-commands-to-clusterctl) Plugin Structure @@ -31,6 +32,7 @@ The basic file structure of a plugin is the following. +- package.json +- master.js +- instance.js + +- control.js +- test/ | +- plugin.js +- module/ @@ -94,6 +96,12 @@ The following properties are recognized: configuration fields for this plugin. See [Plugin Configuration](#plugin-configuration) +**controlEntrypoint**: + Path to a Node.js module relative to the plugin directory which + contains the ControlPlugin class definition for this plugin. This + is an optional paramater. A plugin can be made that only runs on + the clusterctl side. + **messages**: Object with link messages definitions for this plugin. See guide for [defining link messages](#defining-link-messages) below. @@ -137,8 +145,9 @@ code can be used: } For the instance plugin it's exactly the same except "Master" is -replaced with "Instance". The available hooks that you can override are -documented in the base class [in lib/plugin.js](../lib/plugin.js). +replaced with "Instance", and for the clusterctl plugin "Control" is +used. The available hooks that you can override are documented in the +base class [in lib/plugin.js](../lib/plugin.js). It's best to avoid defining a constructor, but if you insist on defining one, forward all arguments to the base class. E.g.: @@ -318,46 +327,60 @@ the event, for example the following could be defined in `info.js`: }, This specifies an event that can be sent from the master to a slave, -and from a slave to an instance. It also specifies that the event must contain the -property `frobnication_type`, with a string value in the data payload. -It will also be forwarded by slaves to a specific instance. +and from a slave to an instance. It also specifies that the event must +contain the property `frobnication_type`, with a string value in the +data payload. It will also be forwarded by slaves to a specific +instance. The following properties are recognized by the Event constructor: -**type**: - The message type sent over the wire. This must start with the name - of the plugin followed by colon and and be unique for the plugin. - The type of the message sent over the socket will have the suffix - `_event` appended to it. +#### type -**links**: - An array of strings describing which links this event can be sent - over. Direction matters, `'master-slave'` means the event can be - sent from the master to the slave, but can't be sent back the other way, - unless `'slave-master'` is also present in the links array. +The message type sent over the wire. This must start with the name of +the plugin followed by colon and and be unique for the plugin. The type +of the message sent over the socket will have the suffix `_event` +appended to it. -**forwardTo**: - Target to forward an event to. Can either be `'master'`, to indicate a - slave should forward it to the master server, or `'instance'`, to - indicate it should be forwarded to the instances specified by the - `instance_id` event property. This works by using a default handler - for the event at the links that forward it. +#### links -**broadcastTo**: - Target to broadcast this message towards. A value of "instance" means - the event will be broadcast to all instances downstream of the target - it's sent to, but not back from where it came from. Currently, only - "instance" is supported. This means that sending an event to a slave - that slave except for the instance it came from. - from an instance will cause it to be broadcast to all instances of +An array of strings describing which links this event can be sent over. +Direction matters, `'master-slave'` means the event can be sent from the +master to the slave, but can't be sent back the other way, unless +`'slave-master'` is also present in the links array. -**eventProperties**: - Object with properties mapping to a JSON schema of that property - that specifies what's valid to send in the event. This is - equivalent to using the `properties` keyword in JSON schema, except - that the properties specified are implicitly required and additional - properties are not allowed. See [this guide][guide] for an - introduction to writing JSON schemas. +The available endpoints are `master`, `slave`, `instance`, and +`control`. Master talks with slave and control, and slave talks to +instance. The full chain must be specified as the individual links in +order for a message to travers multiple hops, (i.e., for a message to go +from master to instance it must have both `'master-slave'` and +`'slave-instance'` in the links array). See `forwardTo` and +`broadcastTo` for ways to forward an event to the next link in a chain. + +#### forwardTo + +Target to forward an event to. Can either be `'master'`, to indicate a +slave should forward it to the master server, or `'instance'`, to +indicate it should be forwarded to the instances specified by the +`instance_id` event property. This works by using a default handler for +the event at the links that forward it. + +#### broadcastTo + +Target to broadcast this message towards. A value of "instance" means +the event will be broadcast to all instances downstream of the target +it's sent to, but not back from where it came from. Currently, only +"instance" is supported. This means that sending an event to a slave +that slave except for the instance it came from. from an instance will +cause it to be broadcast to all instances of + +#### eventProperties + +Object with properties mapping to a JSON schema of that property that +specifies what's valid to send in the event. This is equivalent to +using the `properties` keyword in JSON schema, except that the +properties specified are implicitly required and additional properties +are not allowed. See [this guide][guide] for an introduction to writing +JSON schemas. The forwardTo and broadcastTo can be combined such that specifying `'master'` as the forwardTo value and `'instance'` as the broadcastTo @@ -404,39 +427,51 @@ instance specified by `instance_id`. The following properties are recognized by the Request constructor: -**type**: - The message type sent over the wire. This must start with the name - of the plugin followed by colon and and be unique for the plugin. - The type of the message sent over the socket will have the suffix - `_request` appended to it for the request and `_response` appended - to it for the response. +#### type -**links**: - An array of strings describing which links this request can be sent - over. Direction matters; `'master-slave'` means the request can be - sent from the master to the slave and the slave can reply to the master, - but the slave can't send a request to the master unless - `'slave-master'` is also present in the links array. +The message type sent over the wire. This must start with the name of +the plugin followed by colon and and be unique for the plugin. The type +of the message sent over the socket will have the suffix `_request` +appended to it for the request and `_response` appended to it for the +response. -**forwardTo**: - Target to forward the request to. Can either be `'master'` to indicate - a slave should forward it to the master server when receiving it - from an instance, or `'instance'` to indicate it should be forwarded - to the instances specified by the `instance_id` request property. - This works by using a default handler for the request by the links - that forward it. +#### links -**requestProperties**: - Object with properties mapping to a JSON schema of that property - that specifies what's valid to send in the request. This is - equivalent to using the `properties` keyword in JSON schema, except - that the properties specified are implicitly required and additional - properties are not allowed. See [this guide][guide] for an - introduction to writing JSON schemas +An array of strings describing which links this request can be sent +over. Direction matters; `'master-slave'` means the request can be sent +from the master to the slave and the slave can reply to the master, but +the slave can't send a request to the master unless `'slave-master'` is +also present in the links array. -**responseProperties**: - Same as the requestProperties only for the response sent back by the - target. +The available endpoints are `master`, `slave`, `instance`, and +`control`. Master talks with slave and control, and slave talks to +instance. The full chain must be specified as the individual links in +order for a message to travers multiple hops, (i.e., for a message to go +from master to instance it must have both `'master-slave'` and +`'slave-instance'` in the links array). See `forwardTo` for ways to +forward a request to the next link in a chain. + +#### forwardTo + +Target to forward the request to. Can either be `'master'` to indicate +a slave should forward it to the master server when receiving it from an +instance, or `'instance'` to indicate it should be forwarded to the +instances specified by the `instance_id` request property. This works +by using a default handler for the request by the links that forward it. + +#### requestProperties + +Object with properties mapping to a JSON schema of that property that +specifies what's valid to send in the request. This is equivalent to +using the `properties` keyword in JSON schema, except that the +properties specified are implicitly required and additional properties +are not allowed. See [this guide][guide] for an introduction to writing +JSON schemas + +#### responseProperties + +Same as the requestProperties only for the response sent back by the +target. Sending Link Messages @@ -554,3 +589,66 @@ label matches the id of the instance shut down. For statistics you need to update on collection there's an `onMetrics` hook on both master and instance plugins that is run before the metrics in the default registry are collected. + + +Adding Custom Commands to clusterctl +------------------------------------ + +The control entrypoint for plugins allows you to extend clustectl with +your own commands. The creation of custom commands typically starts +with defining a command tree for the plugin: + + const { Command, CommandTree } = require("lib/command"); + const fooFrobberCommands = new CommandTree({ + name: "foo-frobber", description: "Foo Frobber Plugin commands" + }); + +Then commands are added to the the plugin's command tree: + + const info = require("./info"); + + fooFrobberCommands.add(new Command({ + definition: ["frobnicate ", "Do frobnications", (yargs) => { + yargs.positional("level", { + describe: "type of frobnication", type: "string" + }); + }], + handler: async function(args, control) { + await info.messages.frobnicate.send(control, { + instance_name: "Console", + content: args.message, + }); + }, + })); + +For a command the `definition` is the arguments to pass to +[yargs.command](http://yargs.js.org/docs/#api-reference-commandcmd-desc-builder-handler) +(see also +[yargs.positional](http://yargs.js.org/docs/#api-reference-positionalkey-opt) +and +[yargs.options](http://yargs.js.org/docs/#api-reference-optionskey-opt) +for setting up positional and optional arguments to commands). The +`handler` is an async function that's invoked when the command is +executed and it's passed the parsed command line arguments and a +reference to the `Control` class of clusterctl. It's possible to optain +a reference to the plugin class with `control.plugins.get(info.name)`. + +Note that messages sent from clusterctl needs to have +`"control-master"` as a part of the links array for it to be accepted by +the master server, see [Defining Link Messages](#defining-link-messages) +for how to define the messages that can be sent to the master. + +To have the command tree become part of clusterctl it needs to be added +to the rootCommand tree in `addCommands` callback of the Control plugin: + + const plugin = require("lib/plugin"); + + class ControlPlugin extends plugin.BaseControlPlugin { + async addCommands(rootCommand) { + rootCommand.add(fooFrobberCommands); + } + } + + module.exports = { + ControlPlugin, + } diff --git a/lib/command.js b/lib/command.js new file mode 100644 index 00000000..691d99f9 --- /dev/null +++ b/lib/command.js @@ -0,0 +1,248 @@ +/** + * Command library + * + * Data types and utilites for commands to clusterctl's CLI. + * + * @author Hornwitser + * @module + */ +"use strict"; +const link = require("lib/link"); +const errors = require("lib/errors"); + +/** + * Represents a command that can be runned by clusterctl + * @static + */ +class Command { + /** + * Define an executable command. + * + * @param {Object} cmd - Command definiton. + * @param {Array} cmd.definiton - + * Arguments to pass to yargs .command method to define this + * command. + * @param {function(args, control)} handler - + * Async function invoked when the command is executed. Is given + * the parsed args and a reference to the {@link + * module:clusterctl.Control} instance. + */ + constructor({ definition, handler }) { + if (definition[0] instanceof Array) { + this.name = definition[0][0].split(" ")[0]; + this.alias = definition[0].slice(1); + } else { + this.name = definition[0].split(" ")[0]; + this.alias = []; + } + this._definition = definition; + this._handler = handler; + } + + register(yargs) { + yargs.command(...this._definition); + } + + async run(args, control) { + await this._handler(args, control); + } +} + +/** + * A node in the command tree that can hold commands and sub trees + * + * Container which can contain {@link module:lib/command.Command}s and other + * CommandTrees. This is used by clusterctl to hold the full tree of + * available commands, you may extend this tree by using a control plugin, + * see {@link module:lib/plugin.BaseControlPlugin#addCommands} + * + * @static + */ +class CommandTree { + /** + * Define a command containing sub commands. + * + * @param {Object} cmd - Command definition + * @param {string} cmd.name - Name of the command. + * @param {Array} cmd.alias - Aliases for this tree. + * @param {description} cmd.description - + * Descripton to provide for this command tree node. + */ + constructor({ name, alias, description }) { + if (typeof name !== "string") { + throw new Error("name must be a string"); + } + + this.name = name; + this.alias = alias || []; + this.description = description; + this.subCommands = new Map(); + } + + /** + * Add a command or command tree to this tree + * + * @param {module:lib/command.Command|module:lib/command.CommandTree} command - + * The command to add to this command tree. + */ + add(command) { + if (this.subCommands.has(command.name)) { + throw new Error(`Command ${command.name} already exists`); + } + this.subCommands.set(command.name, command); + for (let alias of command.alias) { + if (this.subCommands.has(alias)) { + throw new Error(`Alias ${alias} for command ${command.name} already exists`); + } + this.subCommands.set(alias, command); + } + } + + /** + * Get a command or command tree from this tree + * + * @param {string} name - + * The name of the command or command tree to retrieve. + * @return {?module:lib/command.Command|module:lib/command.CommandTree} + * The command to add to this command tree. + */ + get(name) { + return this.subCommands.get(name) || null; + } + + /** + * Insert the registered commands into the given yargs parser + * + * Traverses the the tree of sub-commands recursively and adds all of + * them to the given yargs parser. + * + * @param {Object} yargs - yargs parser to register with. + */ + register(yargs) { + yargs.command([this.name].concat(this.alias), this.description, (yargs) => { + for (let [name, command] of this.subCommands) { + // Check if the entry is not an alias. + if (name === command.name) { + command.register(yargs); + } + } + }, () => { + yargs.showHelp(); + yargs.exit(); + }); + } +} + + +/** + * Resolve a string into a slave ID + * + * Resolves a string with either an slave name or an id into an integer with + * the slave ID. + * + * @param {module:lib/link.Link} client - link to master server to query slave on. + * @param {string} slaveName - string with name or id of slave. + * @returns {number} slave ID. + * @static + */ +async function resolveSlave(client, slaveName) { + let slaveId; + if (/^-?\d+$/.test(slaveName)) { + slaveId = parseInt(slaveName, 10); + } else { + let response = await link.messages.listSlaves.send(client); + for (let slave of response.list) { + if (slave.name === slaveName) { + slaveId = slave.id; + break; + } + } + + if (slaveId === undefined) { + throw new errors.CommandError(`No slave named ${slaveName}`); + } + } + + return slaveId; +} + +/** + * Resolve a string to an instance ID + * + * Resolves a string with either an instance name or an id into an integer + * with the instance ID. + * + * @param {module:lib/link.Link} client - link to master server to query instance on. + * @param {string} instanceName - string with name or id of instance. + * @returns {number} instance ID. + * @static + */ +async function resolveInstance(client, instanceName) { + let instanceId; + if (/^-?\d+$/.test(instanceName)) { + instanceId = parseInt(instanceName, 10); + } else { + let response = await link.messages.listInstances.send(client); + for (let instance of response.list) { + if (instance.name === instanceName) { + instanceId = instance.id; + break; + } + } + + if (instanceId === undefined) { + throw new errors.CommandError(`No instance named ${instanceName}`); + } + } + + return instanceId; +} + +/** + * Retrieve role object from string + * + * Resolves a string with either a role name or an id into an object + * representing the role. + * + * @param {module:lib/link.Link} client - link to master server to query role on. + * @param {string} roleName - string with name or id of role. + * @returns {Object} Role info. + * @static + */ +async function retrieveRole(client, roleName) { + let response = await link.messages.listRoles.send(client); + + let resolvedRole; + if (/^-?\d+$/.test(roleName)) { + let roleId = parseInt(roleName, 10); + for (let role of response.list) { + if (role.id === roleId) { + resolvedRole = role; + break; + } + } + + } else { + for (let role of response.list) { + if (role.name === roleName) { + resolvedRole = role; + break; + } + } + } + + if (!resolvedRole) { + throw new errors.CommandError(`No role named ${roleName}`); + } + + return resolvedRole; +} + +module.exports = { + Command, + CommandTree, + + resolveSlave, + resolveInstance, + retrieveRole, +}; diff --git a/lib/plugin.js b/lib/plugin.js index 867e4048..84e4cbd5 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -11,11 +11,26 @@ const errors = require("lib/errors"); /** * Conceptual base for master and instance plugins. - * @typedef {module:lib/plugin.BaseMasterPlugin|module:lib/plugin.BaseInstancePlugin} module:lib/plugin.BasePlugin + * @typedef { + * module:lib/plugin.BaseMasterPlugin + * |module:lib/plugin.BaseInstancePlugin + * |module:lib/plugin.BaseControlPlugin + * } module:lib/plugin.BasePlugin */ /** * Base class for instance plugins + * + * Instance plugins are subclasses of this class which get instantiated by + * the slave when it brings up an instance with the plugin enabled in the + * config. To be discovered the class must be exported under the name + * `InstancePlugin` in the module specified by the `instanceEntrypoint` in + * the plugin's info.js file. + * + * Instances may be started and stopped many times, and many instances may + * be running at the same time, each of which will have their own instance + * of the InstancePlugin class. + * * @static */ class BaseInstancePlugin { @@ -185,6 +200,13 @@ class BaseInstancePlugin { /** * Base class for master plugins + * + * Master plugins are subclasses of this class which get instantiated by + * the master server on startup when the plugin is enabled in the config. + * To be discovered the class must be exported under the name `MasterPlugin` + * in the module specified by the `masterEntrypoint` in the plugin's info.js + * file. + * * @static */ class BaseMasterPlugin { @@ -316,6 +338,42 @@ class BaseMasterPlugin { } } +/** + * Base class for clusterctl plugins + * + * Control plugins are subclasses of this class which get instantiated by + * clusterctl in order to extend its functionallity. To be discovered the + * class must be exported under the name `ControlPlugin` in the module + * specified by the `controlEntrypoint` in the plugin's info.js file. + * + * @static + */ +class BaseControlPlugin { + constructor(info) { + /** + * The plugin's own info module + */ + this.info = info; + } + + /** + * Called immediately after the class is instantiated + */ + async init() { } + + /** + * Called to add commands to the command line interface. + * + * Invoked by clusterctl to let plugins add commands. `rootCommand` is + * the top level command node which the plugin should add its own {@link + * module:lib/command.CommandTree} to. + * + * @param {module:lib/command.CommandTree} rootCommand - + * Root of the clusterctl command tree. + */ + async addCommands(rootCommand) { } +} + /** * Load plugin information * @@ -413,6 +471,7 @@ async function invokeHook(plugins, hook, ...args) { module.exports = { BaseInstancePlugin, BaseMasterPlugin, + BaseControlPlugin, loadPluginInfos, attachPluginMessages, diff --git a/test/clusterctl.js b/test/clusterctl.js index acb6c5b0..ade97724 100644 --- a/test/clusterctl.js +++ b/test/clusterctl.js @@ -2,10 +2,8 @@ const assert = require("assert").strict; const chalk = require("chalk"); -const errors = require("lib/errors"); const { testLines } = require("./lib/factorio/lines"); const clusterctl = require("../clusterctl.js"); -const mock = require("./mock"); describe("clusterctl", function() { @@ -23,33 +21,4 @@ describe("clusterctl", function() { chalk.level = old; }); }); - - let mockConnector = new mock.MockConnector(); - mockConnector.on("send", function(message) { - if (message.type === "list_instances_request") { - this.emit("message", { - seq: 1, type: "list_instances_response", - data: { - seq: message.seq, - list: [{ id: 57, assigned_slave: 4, name: "Test Instance" }], - }, - }); - } - }); - let testControl = new clusterctl._Control(mockConnector); - - describe("resolveInstance", function() { - it("should pass an integer like string back", async function() { - assert.equal(await clusterctl._resolveInstance(null, "123"), 123); - }); - it("should resolve an instance name with the master server", async function() { - assert.equal(await clusterctl._resolveInstance(testControl, "Test Instance"), 57); - }); - it("should throw if instance is not found", async function() { - await assert.rejects( - clusterctl._resolveInstance(testControl, "invalid"), - new errors.CommandError("No instance named invalid") - ); - }); - }); }); diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 73b5ee4e..e6a95e4b 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -32,24 +32,24 @@ describe("Integration of Clusterio", function() { describe("clusterctl", function() { describe("list-slaves", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} list-slaves`); + await exec(`node clusterctl --config ${controlConfigPath} slave list`); }); }); describe("list-instances", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} list-instances`); + await exec(`node clusterctl --config ${controlConfigPath} instance list`); }); }); describe("create-instances", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} create-instance --id 44 --name test`); + await exec(`node clusterctl --config ${controlConfigPath} instance create test --id 44`); }); }); describe("assign-instance", function() { it("creates the instance files", async function() { - await exec(`node clusterctl --config ${controlConfigPath} assign-instance --instance test --slave 4`); + await exec(`node clusterctl --config ${controlConfigPath} instance assign test 4`); assert(await fs.exists(path.join(instancesDir, "test")), "Instance was not created"); }); }); @@ -57,14 +57,14 @@ describe("Integration of Clusterio", function() { describe("create-save", function() { it("creates a save", async function() { slowTest(this); - await exec(`node clusterctl --config ${controlConfigPath} create-save --instance test`); + await exec(`node clusterctl --config ${controlConfigPath} instance create-save test`); }); }); describe("export-data", function() { it("exports the data", async function() { slowTest(this); - await exec(`node clusterctl --config ${controlConfigPath} export-data --instance test`); + await exec(`node clusterctl --config ${controlConfigPath} instance export-data test`); assert(await fs.exists(path.join("static", "export", "locale.json")), "Export was not created"); }); }); @@ -72,7 +72,7 @@ describe("Integration of Clusterio", function() { describe("start-instance", function() { it("starts the instance", async function() { slowTest(this); - await exec(`node clusterctl --config ${controlConfigPath} start-instance --instance test`); + await exec(`node clusterctl --config ${controlConfigPath} instance start test`); // TODO check that the instance actually started }); }); @@ -80,7 +80,7 @@ describe("Integration of Clusterio", function() { describe("send-rcon", function() { it("sends the command", async function() { slowTest(this); - await exec(`node clusterctl --config ${controlConfigPath} send-rcon --instance test --command test`); + await exec(`node clusterctl --config ${controlConfigPath} instance send-rcon test test`); // TODO check that the command was received }); }); @@ -158,8 +158,8 @@ describe("Integration of Clusterio", function() { for (let [prop, value, ,] of testConfigs) { value = `"'${JSON.stringify(value).replace(/"/g, process.platform === "win32" ? '""' : '\\"')}'"`; - let args = `--instance test --field factorio.settings --prop ${prop} --value ${value}`; - await exec(`node clusterctl --config ${controlConfigPath} set-instance-config-prop ${args}`); + let args = `test factorio.settings ${prop} ${value}`; + await exec(`node clusterctl --config ${controlConfigPath} instance config set-prop ${args}`); } // Do this afterwards to leave enough to time for the @@ -173,7 +173,7 @@ describe("Integration of Clusterio", function() { describe("stop-instance", function() { it("stops the instance", async function() { slowTest(this); - await exec(`node clusterctl --config ${controlConfigPath} stop-instance --instance test`); + await exec(`node clusterctl --config ${controlConfigPath} instance stop test`); // TODO check that the instance actually stopped }); }); @@ -181,26 +181,27 @@ describe("Integration of Clusterio", function() { describe("delete-instance", function() { it("deletes the instance", async function() { slowTest(this); - await exec(`node clusterctl --config ${controlConfigPath} delete-instance --instance test`); + await exec(`node clusterctl --config ${controlConfigPath} instance delete test`); assert(!await fs.exists(path.join(instancesDir, "test")), "Instance was not deleted"); }); }); describe("list-permissions", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} list-permissions`); + await exec(`node clusterctl --config ${controlConfigPath} permission list`); }); }); describe("list-roles", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} list-roles`); + await exec(`node clusterctl --config ${controlConfigPath} role list`); }); }); describe("create-role", function() { it("should create the given role", async function() { - await exec(`node clusterctl --config ${controlConfigPath} create-role --name temp --description "A temp role" --permissions core.control.connect`); + let args = "--description \"A temp role\" --permissions core.control.connect"; + await exec(`node clusterctl --config ${controlConfigPath} role create temp ${args}`); let result = await link.messages.listRoles.send(getControl()); let role = result.list.find(role => role.name === "temp"); assert.deepEqual(role, { id: 5, name: "temp", description: "A temp role", permissions: ["core.control.connect"] }); @@ -209,7 +210,8 @@ describe("Integration of Clusterio", function() { describe("edit-role", function() { it("should modify the given role", async function() { - await exec(`node clusterctl --config ${controlConfigPath} edit-role --role temp --name new --description "A new role" --permissions`); + let args = "--name new --description \"A new role\" --permissions"; + await exec(`node clusterctl --config ${controlConfigPath} role edit temp ${args}`); let result = await link.messages.listRoles.send(getControl()); let role = result.list.find(role => role.name === "new"); assert.deepEqual(role, { id: 5, name: "new", description: "A new role", permissions: [] }); @@ -218,7 +220,7 @@ describe("Integration of Clusterio", function() { describe("delete-role", function() { it("should modify the given role", async function() { - await exec(`node clusterctl --config ${controlConfigPath} delete-role --role new`); + await exec(`node clusterctl --config ${controlConfigPath} role delete new`); let result = await link.messages.listRoles.send(getControl()); let role = result.list.find(role => role.name === "new"); assert(!role, "Role was not deleted"); @@ -227,13 +229,13 @@ describe("Integration of Clusterio", function() { describe("list-users", function() { it("runs", async function() { - await exec(`node clusterctl --config ${controlConfigPath} list-users`); + await exec(`node clusterctl --config ${controlConfigPath} user list`); }); }); describe("create-user", function() { it("should create the given user", async function() { - await exec(`node clusterctl --config ${controlConfigPath} create-user --name temp`); + await exec(`node clusterctl --config ${controlConfigPath} user create temp`); let result = await link.messages.listUsers.send(getControl()); let user = result.list.find(user => user.name === "temp"); assert(user, "user was not created"); @@ -242,7 +244,7 @@ describe("Integration of Clusterio", function() { describe("edit-user-roles", function() { it("should set the roles on the user", async function() { - await exec(`node clusterctl --config ${controlConfigPath} edit-user-roles --name temp --roles Admin`); + await exec(`node clusterctl --config ${controlConfigPath} user set-roles temp Admin`); let result = await link.messages.listUsers.send(getControl()); let user = result.list.find(user => user.name === "temp"); assert.deepEqual(user.roles, [0]); @@ -251,7 +253,7 @@ describe("Integration of Clusterio", function() { describe("delete-user", function() { it("should delete the user", async function() { - await exec(`node clusterctl --config ${controlConfigPath} delete-user --name temp`); + await exec(`node clusterctl --config ${controlConfigPath} user delete temp`); let result = await link.messages.listUsers.send(getControl()); let user = result.list.find(user => user.name === "temp"); assert(!user, "user was note deleted"); diff --git a/test/integration/index.js b/test/integration/index.js index c7305c0a..1c9fdf64 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -123,7 +123,8 @@ before(async function() { masterProcess = await spawn("master:", `node master --config ${masterConfigPath} run`, "All plugins loaded"); - await exec(`node clusterctl --config ${controlConfigPath} create-slave-config --id 4 --name slave --generate-token --output ${slaveConfigPath}`); + let createArgs = `--id 4 --name slave --generate-token --output ${slaveConfigPath}`; + await exec(`node clusterctl --config ${controlConfigPath} slave create-config ${createArgs}`); await exec(`node slave --config ${slaveConfigPath} config set slave.instances_directory ${instancesDir}`); slaveProcess = await spawn("slave:", `node slave --config ${slaveConfigPath} run`, "SOCKET | received ready from master"); diff --git a/test/lib/command.js b/test/lib/command.js new file mode 100644 index 00000000..bc612782 --- /dev/null +++ b/test/lib/command.js @@ -0,0 +1,88 @@ +"use strict"; +const assert = require("assert").strict; + +const command = require("lib/command"); +const errors = require("lib/errors"); +const link = require("lib/link"); +const mock = require("../mock"); + + +describe("lib/command", function() { + let testRole = { id: 28, name: "Test Role", description: "Test", permissions: [] }; + let mockConnector = new mock.MockConnector(); + mockConnector.on("send", function(message) { + if (message.type === "list_slaves_request") { + this.emit("message", { + seq: 1, type: "list_slaves_response", + data: { + seq: message.seq, + list: [{ agent: "test", version: "0.1", id: 11, name: "Test Slave", connected: false }], + }, + }); + } else if (message.type === "list_instances_request") { + this.emit("message", { + seq: 1, type: "list_instances_response", + data: { + seq: message.seq, + list: [{ id: 57, assigned_slave: 4, name: "Test Instance" }], + }, + }); + } else if (message.type === "list_roles_request") { + this.emit("message", { + seq: 1, type: "list_roles_response", + data: { + seq: message.seq, + list: [testRole], + }, + }); + } + }); + + let testControl = new mock.MockControl(mockConnector); + link.messages.listSlaves.attach(testControl); + link.messages.listInstances.attach(testControl); + link.messages.listRoles.attach(testControl); + + describe("resolveSlave", function() { + it("should pass an integer like string back", async function() { + assert.equal(await command.resolveSlave(null, "123"), 123); + }); + it("should resolve a slave name with the master server", async function() { + assert.equal(await command.resolveSlave(testControl, "Test Slave"), 11); + }); + it("should throw if slave is not found", async function() { + await assert.rejects( + command.resolveSlave(testControl, "invalid"), + new errors.CommandError("No slave named invalid") + ); + }); + }); + describe("resolveInstance", function() { + it("should pass an integer like string back", async function() { + assert.equal(await command.resolveInstance(null, "123"), 123); + }); + it("should resolve an instance name with the master server", async function() { + assert.equal(await command.resolveInstance(testControl, "Test Instance"), 57); + }); + it("should throw if instance is not found", async function() { + await assert.rejects( + command.resolveInstance(testControl, "invalid"), + new errors.CommandError("No instance named invalid") + ); + }); + }); + describe("retrieveRole", function() { + it("should retrieve a role by id from an integer like string", async function() { + assert.deepEqual(await command.retrieveRole(testControl, "28"), testRole); + }); + it("should retrieve a role by name from a string", async function() { + assert.deepEqual(await command.retrieveRole(testControl, "Test Role"), testRole); + }); + it("should throw if role is not found", async function() { + await assert.rejects( + command.retrieveRole(testControl, "invalid"), + new errors.CommandError("No role named invalid") + ); + }); + }); +}); diff --git a/test/mock.js b/test/mock.js index bc77c051..6fd759ec 100644 --- a/test/mock.js +++ b/test/mock.js @@ -81,10 +81,17 @@ class MockSlave extends link.Link { } } +class MockControl extends link.Link { + constructor(connector) { + super("control", "master", connector); + } +} + module.exports = { MockSocket, MockConnector, MockInstance, MockSlave, + MockControl, };