diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index a2bd2d08c8..25d7e11ff2 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -329,6 +329,7 @@ "Find / Replace", "Regular expression", "Fuzzy Match", + "Generate Systemd Unit", "Offset checker", "Hamming Distance", "Levenshtein Distance", diff --git a/src/core/operations/GenerateSystemdUnit.mjs b/src/core/operations/GenerateSystemdUnit.mjs new file mode 100644 index 0000000000..102e9e97df --- /dev/null +++ b/src/core/operations/GenerateSystemdUnit.mjs @@ -0,0 +1,126 @@ +/** + * @author 0xff1ce [github.com/0xff1ce] + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; + +const RESTART_OPTIONS = ["no", "on-sucess", "on-failure", "on-abnormal", "on-watchdog", "on-abort", "always"]; +const TYPE_OPTIONS = ["simple", "exec", "forking", "oneshot", "dbus", "notify", "idle"]; + +/** + * GenerateSystemdUnit operation + */ +class GenerateSystemdUnit extends Operation { + + /** + * GenerateSystemdUnit constructor + */ + constructor() { + super(); + + this.name = "Generate Systemd Unit"; + this.module = "Default"; + this.description = "Generates a systemd unit file based on provided inputs"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + "name": "Description", + "type": "string", + "value": "", + }, + { + "name": "After", + "type": "string", + "value": "network.target", + }, + { + "name": "Wants", + "type": "string", + "value": "network-online.target", + }, + { + "name": "Restart", + "type": "option", + "value": RESTART_OPTIONS, + }, + { + "name": "Type", + "type": "option", + "value": TYPE_OPTIONS, + }, + { + "name": "ExecStart", + "type": "string", + "value": "", + }, + { + "name": "Environment", + "type": "string", + "value": "KEY=VALUE", + }, + { + "name": "User", + "type": "string", + "value": "", + }, + { + "name": "WorkingDirectory", + "type": "string", + "value": "", + }, + { + "name": "WantedBy", + "type": "string", + "value": "multi-user.target", + }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(_, args) { + const description = args[0]; + const after = args[1]; + const wants = args[2]; + const restart = args[3]; + const type = args[4]; + const execStart = args[5]; + const environment = args[6]; + const user = args[7]; + const workingDirectory = args[8]; + const wantedBy = args[9]; + + const lines = []; + + // [Unit] section + lines.push("[Unit]"); + if (description) lines.push(`Description=${description}`); + if (after) lines.push(`After=${after}`); + if (wants) lines.push(`Wants=${wants}`); + + // [Service] section + lines.push(""); + lines.push("[Service]"); + if (restart) lines.push(`Restart=${restart}`); + if (type) lines.push(`Type=${type}`); + if (execStart) lines.push(`ExecStart=${execStart}`); + if (environment) lines.push(`Environment=${environment}`); + if (user) lines.push(`User=${user}`); + if (workingDirectory) lines.push(`WorkingDirectory=${workingDirectory}`); + + // [Install] section + lines.push(""); + lines.push("[Install]"); + if (wantedBy) lines.push(`WantedBy=${wantedBy}`); + + return lines.join("\n"); + } +} + +export default GenerateSystemdUnit; diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index f030349d2a..1221308b1d 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -80,6 +80,7 @@ import "./tests/GenerateAllChecksums.mjs"; import "./tests/GenerateAllHashes.mjs"; import "./tests/GenerateDeBruijnSequence.mjs"; import "./tests/GenerateQRCode.mjs"; +import "./tests/GenerateSystemdUnit.mjs"; import "./tests/GetAllCasings.mjs"; import "./tests/GOST.mjs"; import "./tests/Gunzip.mjs"; diff --git a/tests/operations/tests/GenerateSystemdUnit.mjs b/tests/operations/tests/GenerateSystemdUnit.mjs new file mode 100644 index 0000000000..d0cd869182 --- /dev/null +++ b/tests/operations/tests/GenerateSystemdUnit.mjs @@ -0,0 +1,100 @@ +/** + * GenerateSystemdUnit tests. + * + * @author 0xff1ce + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import TestRegister from "../../lib/TestRegister.mjs"; + +TestRegister.addTests([ + { + name: "Generate minimal systemd unit with required defaults", + input: "", + expectedMatch: /\[Unit\]\nAfter=network\.target\nWants=network-online\.target\n\n\[Service\]\nEnvironment=KEY=VALUE\n\n\[Install\]\nWantedBy=multi-user\.target/, + recipeConfig: [ + { + op: "Generate Systemd Unit", + args: [ + "", // Description + "network.target", // After + "network-online.target", // Wants + "", // Restart + "", // Type + "", // ExecStart + "KEY=VALUE", // Environment + "", // User + "", // WorkingDirectory + "multi-user.target" // WantedBy + ] + }, + ], + }, + { + name: "Generate full systemd unit with all fields populated", + input: "", + expectedMatch: /\[Unit\]\nDescription=My Service\nAfter=network\.target\nWants=network-online\.target\n\n\[Service\]\nRestart=always\nType=simple\nExecStart=\/usr\/bin\/node app\.js\nEnvironment=NODE_ENV=production\nUser=www-data\nWorkingDirectory=\/var\/www\n\n\[Install\]\nWantedBy=multi-user\.target/, + recipeConfig: [ + { + op: "Generate Systemd Unit", + args: [ + "My Service", + "network.target", + "network-online.target", + "always", + "simple", + "/usr/bin/node app.js", + "NODE_ENV=production", + "www-data", + "/var/www", + "multi-user.target" + ] + }, + ], + }, + { + name: "Generate unit without optional Service fields", + input: "", + expectedMatch: /\[Unit\]\nDescription=Test\nAfter=network\.target\nWants=network-online\.target\n\n\[Service\]\nEnvironment=KEY=VALUE\n\n\[Install\]\nWantedBy=multi-user\.target/, + recipeConfig: [ + { + op: "Generate Systemd Unit", + args: [ + "Test", + "network.target", + "network-online.target", + "", // Restart + "", // Type + "", // ExecStart + "KEY=VALUE", + "", // User + "", // WorkingDirectory + "multi-user.target" + ] + }, + ], + }, + { + name: "Generate unit with custom dependencies and install target", + input: "", + expectedMatch: /\[Unit\]\nDescription=Custom\nAfter=docker\.service\nWants=network\.target\n\n\[Service\]\nRestart=on-failure\nType=exec\nExecStart=\/bin\/bash start\.sh\nEnvironment=ENV=dev\n\n\[Install\]\nWantedBy=graphical\.target/, + recipeConfig: [ + { + op: "Generate Systemd Unit", + args: [ + "Custom", + "docker.service", + "network.target", + "on-failure", + "exec", + "/bin/bash start.sh", + "ENV=dev", + "", // User + "", // WorkingDirectory + "graphical.target" + ] + }, + ], + } +]);