diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..76e22be --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "maven" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "daily" diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 0000000..9c853a8 --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,26 @@ +# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven + +name: Java CI with Maven + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up JDK 17 + uses: actions/setup-java@v2 + with: + java-version: '17' + distribution: 'adopt' + cache: maven + - name: Build with Maven + run: mvn -B package --file pom.xml diff --git a/.gitignore b/.gitignore index 25b79c6..11b5092 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # IDE's **/.idea **/.vscode +**/*.swp # Other files **/*.dia~ diff --git a/README.md b/README.md index ac5e379..0326e6c 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,253 @@ # microstart -CLI utility to start processes. +CLI utility to start various processes in parallel with a start sequence. -If you are using a microservice architecture, this may come handy as it can various microservices -with just some simple lines. - -This improves developer experience because you'll no longer need to open multiple terminals 🚀. +It improves developer experience because **you'll no longer need to open multiple terminals**. It is similar to docker compose, but these are the main differences: -- This does not require a docker container to be built. Any command you run from command line can be run here -- It supports microservice chains (see below) +- Doesn't need docker installed. Any command you run from command line can be run by this program +- It supports process groups and dependencies1 +- Can generate [graphviz code](https://graphviz.org/doc/info/lang.html) based on the configuration file + +**It is intended to be used in development environment preferably**, since in production you have more pro stuff like +kubernetes. + +1 docker compose also supports dependencies via +[`depends_on`](https://docs.docker.com/compose/compose-file/#depends_on), but that works slightly different. + +## Feature overview + +- Start **group processes** defined in configuration +- Configure dependencies in order to **start a process after** another has notified **successful startup** +- Start **single processes** defined in configuration +- Generate [**graphviz code**](https://graphviz.org/doc/info/lang.html) from configuration to get an overview of the + dependency graph between your application microservices (or processes) + +Here is a small demonstration + +![Example](./microstart-example.gif) + +Configuration file used in GIF is [example.json](./example.json) or [example.yml](./example.yml). + +[schema.json](src/main/resources/schema.json) contains all configurable properties. + +Notice that _Search engine_ service is run before anything else, then both _users_ and _crypto_ services run +concurrently, and, after both have notified successful startup, _Landing page_ is started. + +Generated dependency graph image: + +![Dependency graph for example](./example.svg) ## Concepts -### Microservice chain +### Process groups Suppose your application has the following dependency graph -Bla bla bla... \ No newline at end of file +![Dependency graph](dependency-graph-example.svg) + +Then, we can define 4 groups: + +1. Databases (Redis, Solr, PostgreSQL) +2. Microservices (Users, Login, Catalogs) +3. Gateway (Gateway) +4. Frontend (WebApp, AndroidApp, IOSApp) + +Group 4 depends on group 3, which in turn depends on group 2, which in turn depends on group 1. +And it can be described with the following YAML configuration (JSON can be used too): + +```yaml +services: [ ] +groups: + - name: Frontend + aliases: + - frontend + - front + services: + - angular + - android-client + - ios-client + dependencies: + - gateway + + - name: Gateway + aliases: + - gate + - gateway + services: + - gateway + dependencies: + - microservices + + - name: Microservices + aliases: + - microservices + services: + - users + - login + - catalogs + dependencies: + - databases + + - name: Databases + aliases: + - databases + - db + - dbs + services: + - solr + - redis + - postgres + +ignoreErrrors: true +``` + +As you can see, each group has: + +- `name`: **required**. Name of the group. Can be used to start/stop the service group. +- `aliases`: Aliases for the name. Can be used to start/stop the service group. +- `services`: **required**. List of services that should run with this group. **references services defined in + the `services` array** (see below). +- `dependencies`: List of groups that should be started before this group is started. + The group will not start1 unless its dependencies have successfully notified they have started + +1 You can modify this behaviour with the `ignoreErrors` key. + +It is allowed to have a group and a service with the same name or alias, but not 2 groups or 2 services with the +same name or alias + +### Process + +The property `services` is where you define the actual processes that will run when a group starts. +Example: + +```yaml +services: + - name: Web App + aliases: + - web-app + - angular + start: npm run start + color: 0xff0000 + workDir: super-project/frontend/web + startedPatterns: + - '(service|server) is listening on https?://' + errorPatterns: + - Error (happened|occurred|in) + stdin: web-app-stdin.txt + stop: SIGTERM + + - name: Android App + aliases: + - android-app + - android-client + start: echo Starting android client... Done. + color: 0x00ff00 + workDir: super-project/frontend/android + startedPatterns: + - done + + - name: IOS App + aliases: + - ios-app + - ios-client + start: echo Starting ios client... Done. + workDir: super-project/frontend/ios + startedPatterns: + - done + + - name: Gateway + aliases: + - gateway + start: echo Starting gateway... Gateway is up and running. + workDir: super-project/frontend/android + startedPatterns: + - is up and running + stop: systemctl stop custom-gateway-service + stopTimeout: 1 + + - name: Users + aliases: + - users + start: node main.js + workDir: super-project/backend/users + startedPatterns: + - (service|server) is listening + +groups: + - name: Frontend + aliases: + - frontend + - front + services: + - angular + - android-client + - ios-client + dependencies: + - gateway + + - name: Gateway + aliases: + - gate + - gateway + services: + - gateway + dependencies: + - microservices + + - name: Microservices + aliases: + - microservices + services: + - users + - login + - catalogs + dependencies: + - databases + + - name: Databases + aliases: + - databases + - db + - dbs + services: + - solr + - redis + - postgres + +ignoreErrors: true +``` + +For a full and detailed list of configurable properties of a service please see +[schema.json](src/main/resources/schema.json) + +**Aliases and names are case-sensitive**, that's why *Gateway* has an alias *gateway* + +**patterns** (`startedPatterns` and `errorPatterns`) **are case-insensitive**, and please note these patterns are +regular expressions (so, `inactive (dead)` is NOT the same as `inactive \(dead\)`) + +**Once processes have started it is recommended to manage them inside microstart**. Don't manage processes externally +(e.g. manually sending signals with `kill` command), because you may end up with orphan processes. +A concrete example of this is when you use `npm start` as the start command. Killing the npm process may not kill the +nodejs process! + +## YAML/JSON config properties + +For all available JSON/YAML properties, description and constraints see [schema.json](src/main/resources/schema.json) + +## Installation + +[`install.sh`](install.sh) script is provided, simply execute it + +## Dependencies + +- [PicoCLI](https://picocli.info/): Parse CLI args and colorize output +- [Jetbrains Annotations](https://www.jetbrains.com/help/idea/annotating-source-code.html): Better code documentation +- [JUnit 5](https://junit.org/junit5/): Test framework +- [JSON parser](https://mvnrepository.com/artifact/org.json/json): Parse JSON +- [JSON schema validator](https://github.com/everit-org/json-schema/): Validate JSON with a defined schema +- [Snake YAML](https://bitbucket.org/asomov/snakeyaml/src): Convert from YAML (to `Map`) to JSON + +## License + +![GPLv3](gplv3.png) diff --git a/dependency-graph-example.dot b/dependency-graph-example.dot new file mode 100644 index 0000000..e177a8a --- /dev/null +++ b/dependency-graph-example.dot @@ -0,0 +1,6 @@ +digraph Example { + {WebApp[color=blue] AndroidApp[color=blue] IOSApp[color=blue]} -> Gateway + Gateway -> {Login[color=green] Users[color=green] Catalogs[color=green]} + {Login Users} -> {Redis[color=red] Solr[color=red]} + Catalogs -> {PostgreSQL[color=red]} +} \ No newline at end of file diff --git a/dependency-graph-example.svg b/dependency-graph-example.svg new file mode 100644 index 0000000..ac58c9b --- /dev/null +++ b/dependency-graph-example.svg @@ -0,0 +1,140 @@ + + + + + + + Example + + + + WebApp + + WebApp + + + + Gateway + + Gateway + + + + WebApp->Gateway + + + + + + AndroidApp + + AndroidApp + + + + + AndroidApp->Gateway + + + + + + IOSApp + + IOSApp + + + + IOSApp->Gateway + + + + + + Login + + Login + + + + Gateway->Login + + + + + + Users + + Users + + + + Gateway->Users + + + + + + Catalogs + + Catalogs + + + + Gateway->Catalogs + + + + + + Redis + + Redis + + + + Login->Redis + + + + + + Solr + + Solr + + + + Login->Solr + + + + + + Users->Redis + + + + + + Users->Solr + + + + + + PostgreSQL + + PostgreSQL + + + + Catalogs->PostgreSQL + + + + + diff --git a/docs/class-diagram.dia b/docs/class-diagram.dia deleted file mode 100644 index 3b17f7b..0000000 Binary files a/docs/class-diagram.dia and /dev/null differ diff --git a/example.dot b/example.dot new file mode 100644 index 0000000..a72bcd4 --- /dev/null +++ b/example.dot @@ -0,0 +1,23 @@ +digraph { + compound=true; + rank=same; + ranksep=1; + subgraph cluster_1661228684 { + label="Frontend services"; + color=blue; + "Landing page1661228684" [label=landing>]; + } + subgraph cluster_866315194 { + label="Search Engine"; + color=blue; + "Search engine866315194" [label=search, google>]; + } + subgraph cluster_135615626 { + label="Backend services"; + color=blue; + "Crypto135615626" [label=crypto>]; + "Users135615626" [label=rpc-users, users>]; + } + "Crypto135615626" -> "Landing page1661228684" [lhead=cluster_1661228684, ltail=cluster_135615626]; + "Search engine866315194" -> "Crypto135615626" [lhead=cluster_135615626, ltail=cluster_866315194]; +} diff --git a/example.json b/example.json index 745a900..c0f9325 100644 --- a/example.json +++ b/example.json @@ -3,51 +3,97 @@ "services": [ { "name": "Search engine", - "start": "ping -c 2 duckduckgo.com", - "aliases": ["search", "duckduckgo"], + "start": "ping -c 2 google.com", + "aliases": [ + "search", + "google" + ], "color": "0x00ff00", - "startedPatterns": ["0% packet loss"], - "errorPatterns": ["([1-9][0-9]?|100)% packet loss"] + "startedPatterns": [ + "[^0-9]0% packet loss" + ], + "errorPatterns": [ + "([1-9][0-9]?|100)% packet loss" + ] }, { "name": "Users", "start": "echo \"Loading service...\" && sleep 1 && echo \"Config loaded\" && sleep 1 && echo \"RPC server is listening on port 1111\"", - "aliases": ["rpc-users", "users"], + "aliases": [ + "rpc-users", + "users" + ], "color": "0xff00ff", - "startedPatterns": ["(Service|Server) is listening"] + "startedPatterns": [ + "(Service|Server) is listening" + ], + "errorPatterns": [ + "error" + ] }, { "name": "Crypto", - "aliases": ["crypto"], + "aliases": [ + "crypto" + ], "start": "echo \"...\" && sleep 1 && echo \"Done.\"", "color": "0x0000ff", - "startedPatterns": ["done"] + "startedPatterns": [ + "done" + ], + "errorPatterns": [ + "error" + ] }, { "name": "Landing page", - "aliases": ["landing"], + "aliases": [ + "landing" + ], "start": "sleep 5 && echo \"Compilation finished.\"", "color": "0xf0f0f0", - "startedPatterns": ["Compilation finished"] + "startedPatterns": [ + "Compilation finished" + ], + "errorPatterns": [ + "error" + ] } ], "groups": [ { "name": "Frontend services", - "aliases": ["frontend"], - "services": ["landing"], - "dependencies": ["backend"] + "aliases": [ + "frontend" + ], + "services": [ + "landing" + ], + "dependencies": [ + "backend" + ] }, { "name": "Backend services", - "aliases": ["backend"], - "services": ["crypto", "users"], - "dependencies": ["databases"] + "aliases": [ + "backend" + ], + "services": [ + "crypto", + "users" + ], + "dependencies": [ + "search" + ] }, { - "name": "Databases", - "aliases": ["databases"], - "services": ["duckduckgo"] + "name": "Search Engine", + "aliases": [ + "search" + ], + "services": [ + "google" + ] } ] -} \ No newline at end of file +} diff --git a/example.svg b/example.svg new file mode 100644 index 0000000..7fa0a56 --- /dev/null +++ b/example.svg @@ -0,0 +1,77 @@ + + + + + + + + + cluster_1661228684 + + Frontend + services + + + + cluster_866315194 + + Search Engine + + + + cluster_135615626 + + Backend + services + + + + + Landing page1661228684 + + Landing page + + landing + + + + Search engine866315194 + + Search engine + + search, google + + + + + Crypto135615626 + + Crypto + crypto + + + + Search engine866315194->Crypto135615626 + + + + + + Crypto135615626->Landing page1661228684 + + + + + + Users135615626 + + Users + rpc-users, + users + + + + diff --git a/example.yml b/example.yml new file mode 100644 index 0000000..c939fa2 --- /dev/null +++ b/example.yml @@ -0,0 +1,68 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/BenjaminGuzman/microstart/main/src/main/resources/schema.json +services: + - name: Search engine + start: ping -c 2 google.com + aliases: + - search + - google + color: 0x00ff00 + startedPatterns: + - "[^0-9]0% packet loss" + errorPatterns: + - "([1-9][0-9]?|100)% packet loss" + + - name: Users + start: echo "Loading service..." && sleep 1 && echo "Config loaded" && sleep 1 && echo "RPC server is listening on port 1111" + aliases: + - rpc-users + - users + color: 0xff00ff + startedPatterns: + - (Service|Server) is listening + errorPatterns: + - error + + - name: Crypto + aliases: + - crypto + start: echo "Compiling..." && sleep 1 && echo "Done." + color: 0x0000ff + startedPatterns: + - done + errorPatterns: + - error + + - name: Landing page + aliases: + - landing + start: echo "Starting compilation" && sleep 5 && echo "Compilation finished." + color: 0xf0f0f0 + startedPatterns: + - Compilation finished + errorPatterns: + - error + +groups: + - name: Frontend services + aliases: + - frontend + services: + - landing + dependencies: + - backend + + + - name: Backend services + aliases: + - backend + services: + - crypto + - users + dependencies: + - search + + - name: Search Engine + aliases: + - search + services: + - google diff --git a/gplv3.png b/gplv3.png new file mode 100644 index 0000000..c837da0 Binary files /dev/null and b/gplv3.png differ diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..89e96ac --- /dev/null +++ b/install.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +echo "Installing microstart..." + +function select_jar() { + available_jars=(target/microstart*with-dependencies.jar) + jar="${available_jars[0]}" + if [[ -f "$jar" ]]; then + echo "$jar" + else + echo "nil" + fi +} + +jar=$(select_jar) +if [[ "$jar" == "nil" ]]; then + echo -e "\tJar doesn't exist. Building it now..." + mvn clean package + jar=$(select_jar) +fi + +# TODO: let the user decide installation directory +# TODO: let the user decide if copying whole jar or simply creating a symlink +INSTALLATION_DIR="$HOME/bin" +echo "Copying jar to $INSTALLATION_DIR..." + +if [[ ! -d "$INSTALLATION_DIR" ]]; then + echo -e "\t$INSTALLATION_DIR doesnt' exist. Creating directory..." + mkdir "$INSTALLATION_DIR" +fi + +cp "$jar" "$INSTALLATION_DIR/microstart.jar" # Copy jar file + +echo "Copying microstart bash script..." +cp microstart.sh "$INSTALLATION_DIR/microstart" # Copy bash script to avoid typing java -jar ... +chmod u+x "$INSTALLATION_DIR/microstart" # Grant execute privileges on bash script + +# replace INSTALLATION_DIR variable from the microstart bash script with the actual value from this script +# | as separator is needed because $INSTALLATION_DIR could potentially contain forward slashes (/) +sed -i '' "s|INSTALLATION_DIR=\".*\"|INSTALLATION_DIR=\"$INSTALLATION_DIR\"|" "$INSTALLATION_DIR/microstart" + +echo "Done." diff --git a/microstart-example.gif b/microstart-example.gif new file mode 100644 index 0000000..c80d662 Binary files /dev/null and b/microstart-example.gif differ diff --git a/microstart.dot b/microstart.dot new file mode 100644 index 0000000..b00471d --- /dev/null +++ b/microstart.dot @@ -0,0 +1,10 @@ +digraph { +subgraph Group1 { + "Admin service" -> "Editor service" +} + +subgraph "Group 2" { + fillcolor=green + "hola" -> "mundo" +} +} \ No newline at end of file diff --git a/microstart.sh b/microstart.sh new file mode 100755 index 0000000..adf788f --- /dev/null +++ b/microstart.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# Check install.sh before modifying this file +# this is just a bash script to avoid typing java -jar ... + +INSTALLATION_DIR="REPLACE ME PLEASE!!" + +java -jar "$INSTALLATION_DIR/microstart.jar" "$@" diff --git a/microstart.svg b/microstart.svg new file mode 100644 index 0000000..18a7e78 --- /dev/null +++ b/microstart.svg @@ -0,0 +1,50 @@ + + + + + + + + + + Admin service + + Admin service + + + + + Editor service + + Editor service + + + + + Admin service->Editor service + + + + + + hola + + hola + + + + mundo + + mundo + + + + hola->mundo + + + + + diff --git a/pom.xml b/pom.xml index 32e1b6a..4c3d9db 100644 --- a/pom.xml +++ b/pom.xml @@ -4,13 +4,13 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - groupId + net.benjaminguzman microstart - 0.3 + 1.2.2 UTF-8 - 11 - 11 + 17 + 17 @@ -18,17 +18,17 @@ org.apache.maven.plugins maven-compiler-plugin - 3.8.1 + 3.11.0 - 11 - 11 + 17 + 17 org.apache.maven.plugins maven-assembly-plugin - 3.2.0 + 3.5.0 @@ -56,7 +56,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.0.0-M5 + 3.0.0 @@ -82,34 +82,34 @@ org.jetbrains annotations - 21.0.1 - - - commons-cli - commons-cli - 1.4 - - - com.diogonunes - JColor - 5.0.1 + 24.0.1 org.json json - 20210307 + 20231013 com.github.everit-org.json-schema org.everit.json.schema - 1.13.0 + 1.14.2 org.junit.jupiter junit-jupiter - 5.8.0-M1 + 5.9.3 test + + org.yaml + snakeyaml + 2.2 + + + info.picocli + picocli + 4.7.3 + @@ -119,4 +119,4 @@ https://jitpack.io - \ No newline at end of file + diff --git a/src/main/java/net/benjaminguzman/CLI.java b/src/main/java/net/benjaminguzman/CLI.java index 8f4552b..7823ed8 100644 --- a/src/main/java/net/benjaminguzman/CLI.java +++ b/src/main/java/net/benjaminguzman/CLI.java @@ -22,16 +22,21 @@ import net.benjaminguzman.exceptions.GroupNotFoundException; import net.benjaminguzman.exceptions.MaxDepthExceededException; import net.benjaminguzman.exceptions.ServiceNotFoundException; +import org.everit.json.schema.ValidationException; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import picocli.CommandLine; import javax.management.InstanceAlreadyExistsException; import java.io.*; -import java.util.Arrays; -import java.util.LinkedList; -import java.util.Queue; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.*; +import java.util.function.Consumer; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Collectors; public class CLI implements Runnable { private static final Logger LOGGER = Logger.getLogger(CLI.class.getName()); @@ -52,22 +57,19 @@ public class CLI implements Runnable { * {@code start editor} */ private final Queue cmdsQueue = new LinkedList<>(); - - /** - * Line with the initial commands. This is, the commands to be executed first, before reading directly from - * stdin - */ - @Nullable - private String initialLineInput = null; - private final String waitingSymbol = "⏳"; private final String readySymbol = "✔"; private final String byeSymbol = "👋"; private final String cmdSeparator = "&"; - private final PromptOutputStream customStdout = new PromptOutputStream(System.out) .setPrompt(">>> ") .setStatusIcon(waitingSymbol); + /** + * Line with the initial commands. This is, the commands to be executed first, before reading directly from + * stdin + */ + @Nullable + private String initialLineInput = null; public CLI() throws InstanceAlreadyExistsException { if (instantiated) @@ -83,7 +85,8 @@ public CLI() throws InstanceAlreadyExistsException { System.setOut(customOutput); System.setErr(customOutput); - printHelp(); + // printHelp(); + System.out.println("For help, type \"help\""); setPromptWaiting(false); } @@ -102,13 +105,16 @@ public void run() { boolean should_quit = false; try { if (initialLineInput != null) // process the initial line - processInputLine(initialLineInput); + should_quit = processInputLine(initialLineInput); while (!should_quit && (input = stdinReader.readLine()) != null) should_quit = processInputLine(input); } catch (IOException e) { - LOGGER.log(Level.SEVERE, "😱 Some really weird exception happened while reading from stdin", - e); + LOGGER.log( + Level.SEVERE, + "😱 Some really weird exception happened while reading from stdin", + e + ); } finally { customStdout.printPrompt(byeSymbol); } @@ -147,54 +153,205 @@ private boolean processInputLine(@NotNull String line) { } private boolean processSingleCommand(@NotNull String cmd) { - cmd = cmd.toLowerCase().strip(); + cmd = cmd.strip(); + String cmdLower = cmd.toLowerCase(); - switch (cmd) { - case "q": - case "exit": - case "quit": + switch (cmdLower) { + case "q", "exit", "quit" -> { return true; - case "h": - case "help": + } + case "h", "help" -> { printHelp(); - return false; + return false; + } + case "reload" -> { + reload(); + return false; + } + case "load" -> { + loadAllServices(); + return false; + } + case "" -> { + return false; + } } - // deal with group commands - if (cmd.startsWith("start group") || cmd.startsWith("group start")) { - String groupName = cmd.substring("group start".length()).stripLeading(); - startGroupByName(groupName); + // store the keys in a tree map sorting the keys by its length (if equal, use lexicographical order) + // this way longer command names will be processed before shorter command names + // e.g. "stop group" will be processed before "stop" + Map> cmdsWithArg = new TreeMap<>((String a, String b) -> { + if (a.length() == b.length()) + return a.compareTo(b); + return b.length() - a.length(); + }); + + // group commands + cmdsWithArg.put("start group", this::startGroupByName); + cmdsWithArg.put("group start", this::startGroupByName); + cmdsWithArg.put("stop group", this::stopGroupByName); + cmdsWithArg.put("group stop", this::stopGroupByName); + cmdsWithArg.put("group status", this::groupStatusByName); + cmdsWithArg.put("status group", this::groupStatusByName); + + // singleton service commands + cmdsWithArg.put("start", this::startServiceByName); + cmdsWithArg.put("stop", this::stopServiceByName); + cmdsWithArg.put("status", this::serviceStatusByName); + + // print command + cmdsWithArg.put("print", (String filename) -> { + try { + assert ConfigLoader.getInstance() != null; + printDot(filename, ConfigLoader.getInstance().load()); + } catch (ServiceNotFoundException + | FileNotFoundException + | MaxDepthExceededException + | GroupNotFoundException + | CircularDependencyException e) { + LOGGER.log(Level.SEVERE, "Config file is invalid", e); + } + }); + + // Process a single command that requires an argument + // (or not, in which case the consumer should handle empty strings) + Optional cmdNameOptional = cmdsWithArg.keySet() + .stream() + .filter(cmdLower::startsWith) + .findFirst(); + if (cmdNameOptional.isPresent()) { // means cmdNameOptional is actually a key of the map (therefore, valid) + String cmdName = cmdNameOptional.get(); + String cmdArg = cmd.substring(cmdName.length()).stripLeading(); + Consumer cmdConsumer = cmdsWithArg.get(cmdName); + + if (cmdArg.isBlank()) // process command that accepts empty argument + cmdConsumer.accept(cmdArg); + else // process each command argument + splitBySpaces(cmdArg).forEach(cmdConsumer); return false; } - // deal with singleton service commands - if (cmd.startsWith("start")) { - // String serviceName = cmd.substring("start".length()).stripLeading(); - // startServiceByName(serviceName); - return false; + System.out.println("Forwarding command \"" + cmd + "\" to OS..."); + try { + new ProcessBuilder() + .command( + Microstart.IS_WINDOWS ? "cmd" : "sh", + Microstart.IS_WINDOWS ? "/c" : "-c", + cmd + ) + .inheritIO() + .start() + .waitFor(); + } catch (InterruptedException | IOException e) { + LOGGER.log(Level.WARNING, "Exception encountered while executing: " + cmdLower, e); } - System.out.println("Command \"" + cmd + "\" was not understood. Type \"help\" or \"h\" to print help"); - return false; } - private void startGroupByName(@NotNull String groupName) { - ServiceGroup group = ServiceGroup.forName(groupName); + private void loadAllServices() { + try { + ConfigLoader configLoader = ConfigLoader.getInstance(); + if (configLoader == null) + return; - if (group == null) // group has not been loaded. Load it - try { - assert ConfigLoader.getInstance() != null; - group = new ServiceGroup( - ConfigLoader.getInstance().loadGroupConfig(groupName) - ); - } catch (MaxDepthExceededException | GroupNotFoundException | CircularDependencyException | FileNotFoundException | ServiceNotFoundException e) { - System.out.println(e.getMessage()); + configLoader.load() + .getServices() + .keySet() + .stream() + .filter(serviceName -> Service.forName(serviceName) == null) // only load not loaded services, otherwise an exception will be produced + .forEach(this::loadServiceByName); + } catch (ServiceNotFoundException | FileNotFoundException | MaxDepthExceededException | + GroupNotFoundException | CircularDependencyException e) { + printError(e.getMessage()); + } + } + + /** + * Reload configuration + */ + private void reload() { + boolean notSafe = Service.getServices() + .stream() + .anyMatch(service -> service.getStatus().isRunning()); + + if (notSafe) { + printError("It is not safe to reload since there are services running"); + return; + } + + try { + ConfigLoader configLoader = ConfigLoader.getInstance(); + if (configLoader == null) return; - } catch (InstanceAlreadyExistsException e) { - LOGGER.log(Level.SEVERE, "Programming error❗", e); + + configLoader.refresh(); + Group.clear(); // remove loaded groups + Service.clear(); // remove loaded services + + printSuccess("Configuration successfully reloaded"); + } catch (FileNotFoundException | NoSuchFileException e) { + printError("Configuration file was deleted"); + } catch (ValidationException e) { + ConfigLoader configLoader = ConfigLoader.getInstance(); + if (configLoader == null) + printError("Configuration file contains the following errors:"); + else + printError("Configuration file " + + configLoader.getConfigFile().getAbsolutePath() + + " contains the following errors:"); + + printError(e.getMessage()); + e.getCausingExceptions() + .stream() + .map(ValidationException::getMessage) + .forEach(CLI::printError); + } catch (MaxDepthExceededException | GroupNotFoundException | IOException | + CircularDependencyException | + InstanceAlreadyExistsException | ServiceNotFoundException e) { + printError(e.getMessage()); + } + } + + /** + * Loads a NOT loaded yet group by its name. + * Call this method only if {@link Group#forName(String)} returned null + * + * @param groupName group name + * @return the group or null in case an exception was encountered + */ + @Nullable + private Group loadGroupByName(@NotNull String groupName) { + if (isGroupNameBlank(groupName)) + return null; + + Group group = null; + try { + assert ConfigLoader.getInstance() != null; + group = new Group( + ConfigLoader.getInstance().loadGroupConfig(groupName) + ); + } catch (MaxDepthExceededException | GroupNotFoundException | CircularDependencyException | + FileNotFoundException | ServiceNotFoundException e) { + printError("Couldn't load group \"" + groupName + "\""); + printError(e.getMessage()); + } catch (InstanceAlreadyExistsException e) { + LOGGER.log(Level.SEVERE, "Programming error❗", e); + } + return group; + } + + private void startGroupByName(@NotNull String groupName) { + if (isGroupNameBlank(groupName)) + return; + + Group group = Group.forName(groupName); + + if (group == null) {// group has not been loaded. Load it + group = loadGroupByName(groupName); + if (group == null) // group couldn't be successfully loaded return; - } + } // by now, the group has been loaded if (!group.isUp()) // if the group is not up, try to start it @@ -204,55 +361,416 @@ private void startGroupByName(@NotNull String groupName) { LOGGER.log(Level.SEVERE, "Programming error❗", e); } else - System.out.println("Group \"" + groupName + "\" is already running"); + printWarning("Group \"" + groupName + "\" is already running"); + } + + private void stopGroupByName(@NotNull String groupName) { + if (isGroupNameBlank(groupName)) + return; + + Group group = Group.forName(groupName); + + if (group == null) { // group has not been loaded, there is nothing to do + printError("Group " + groupName + " has not been loaded"); + return; + } + + // the group has been loaded + if (group.isUp()) // if the group is up, stop it + group.stop(); + else + printWarning("Group \"" + groupName + "\" has been loaded but it is not running"); + } + + private void groupStatusByName(@NotNull String groupName) { + if (isGroupNameBlank(groupName)) + return; + + Group group = getGroupByName(groupName); + + if (group == null) { // group has not been loaded, there is nothing to do + printError("Group \"" + groupName + "\" has not been loaded"); + return; + } + + GroupConfig groupConfig = group.getConfig(); + + // print status for services directly on this group + System.out.println("Group \"" + groupName + "\" status:"); + group.getConfig() + .getServicesConfigs() + .stream() + .map(ServiceConfig::getName) + .forEach(this::serviceStatusByName); + + // print status for this group dependencies + String dependenciesStr = groupConfig.getDependenciesConfigs() + .stream() + .map(GroupConfig::getName) + .collect(Collectors.joining(", ")); + if (dependenciesStr.isEmpty()) + return; + + System.out.println("Group \"" + groupName + "\" depends on: " + dependenciesStr); + groupConfig.getDependenciesConfigs().forEach(g -> groupStatusByName(g.getName())); + } + + /** + * Loads a NOT loaded yet service by its name. + * Call this method only if {@link Service#forName(String)} returned null + * + * @param serviceName service name + * @return the service or null in case an exception was encountered + */ + @Nullable + private Service loadServiceByName(@NotNull String serviceName) { + if (isServiceNameBlank(serviceName)) + return null; + + Service service = null; + try { + assert ConfigLoader.getInstance() != null; + ServiceConfig serviceConfig = + ConfigLoader.getInstance().loadServiceConfig(serviceName); + service = new Service(serviceConfig, new HashMap<>(), (s, e) -> { + }); + } catch (FileNotFoundException | ServiceNotFoundException e) { + printError(e.getMessage()); + } catch (InstanceAlreadyExistsException e) { + LOGGER.log(Level.SEVERE, "Programming error❗", e); + } + return service; } private void startServiceByName(@NotNull String serviceName) { - Service service = Service.forName(serviceName); - throw new UnsupportedOperationException("You can't start singleton services with this version"); + if (isServiceNameBlank(serviceName)) + return; - /*if (service == null) // service has not been loaded. Load it - try { - assert ConfigLoader.getInstance() != null; - service = new Service( - ConfigLoader.getInstance().loadServiceConfig(serviceName), + Service service = Service.forName(serviceName); - ); - } catch (MaxDepthExceededException | GroupNotFoundException | CircularDependencyException | FileNotFoundException | ServiceNotFoundException e) { - System.out.println(e.getMessage()); + if (service == null) { // service has not been loaded. Load it + service = loadServiceByName(serviceName); + if (service == null) // service couldn't be successfully loaded return; - } catch (InstanceAlreadyExistsException e) { - LOGGER.log(Level.SEVERE, "Programming error❗", e); + System.out.println("Service " + service.getConfig().getColorizedName() + " successfully loaded"); + } + + // by now, the service has been loaded + if (service.getStatus().isRunning()) { + System.out.println( + "Service " + service.getConfig().getColorizedName() + " can't be run now." + + " Current status: " + service.getStatus() + ); + return; + } + + System.out.println("Starting " + service.getConfig().getColorizedName() + " asynchronously..."); + new DaemonThreadFactory().newThread(service).start(); + } + + private void stopServiceByName(@NotNull String serviceName) { + if (isServiceNameBlank(serviceName)) + return; + + Service service = Service.forName(serviceName); + + if (service == null) { // service has not been loaded, therefore it is not running + printError("Service \"" + serviceName + "\" hasn't been loaded"); + return; + } + + if (service.getStatus().ordinal() < ServiceStatus.STOPPING.ordinal()) { + service.stop(); + return; + } + + System.out.println( + "Service " + service.getConfig().getColorizedName() + " can't be requested to be stopped" + + " because it hasn't been started. Current status: " + service.getStatus() + ); + } + + private void serviceStatusByName(@NotNull String serviceName) { + if (serviceName.isBlank()) { // show status for all services + if (Service.getServices().isEmpty()) { + printError("No service has been loaded"); + System.out.println("Try the load command"); return; } - // by now, the group has been loaded - try { - service.start(); // start and block until it has started - } catch (InstanceAlreadyExistsException e) { - LOGGER.log(Level.SEVERE, "Programming error❗", e); - }*/ + // maximum width for all service names + int max_width = Service.getServices().stream() + .map(service -> service.getConfig().getName()) + .mapToInt(String::length) + .max() + .orElse(30); + + // pretty print all service names and their status + StringBuilder strBuilder = new StringBuilder(); + for (Service service : Service.getServices()) { + ServiceStatus serviceStatus = service.getStatus(); + int serviceNameLength = service.getConfig().getName().length(); + String spaces = " ".repeat(max_width - serviceNameLength); + strBuilder.append(service.getConfig().getColorizedName()) + .append(spaces) + .append(" ") + .append(serviceStatus); + + if (serviceStatus == ServiceStatus.STARTED) { + assert service.getProc() != null; + strBuilder.append(" (pid: ") + .append(service.getProc().pid()) + .append(")"); + } + + strBuilder.append('\n'); + } + strBuilder.deleteCharAt(strBuilder.length() - 1); // prompt output library will add the linefeed + System.out.println(strBuilder); + + // %-15s -> left aligned 15 char-width string + /*String format = "%-" + max_width + "s - %S%n"; + + Service.getServices().forEach(service -> System.out.printf( + format, + service.getConfig().getColorizedName(), + service.getStatus() + )); + // This actually doesn't work. Probably because printf has problems handling the ascii escape codes + */ + + return; + } + + Service service = Service.forName(serviceName); + + if (service == null) { // service has not been loaded, therefore it is not running + printError("Service \"" + serviceName + "\" hasn't been loaded"); + return; + } + + System.out.println(service.getConfig().getColorizedName() + " " + service.getStatus()); + } + + /** + * Check the given service name is blank and print error message if needed + * + * @param serviceName string to be validated + * @return true if service name is blank, false otherwise + */ + private boolean isServiceNameBlank(@NotNull String serviceName) { + if (serviceName.isBlank()) { + printError("You must provide a service name or alias"); + System.out.println("To see a list of available services use the status command"); + return true; + } + return false; + } + + /** + * Check the given group name is blank and print error message if needed + * + * @param groupName string to be validated + * @return true if group name is blank, false otherwise + */ + private boolean isGroupNameBlank(@NotNull String groupName) { + if (groupName.isBlank()) { + printError("You must provide a group name or alias"); + return true; + } + return false; + } + + /** + * Check if group exists and return it if it does exist + * If it doesn't exist, show error + * @param groupName group name + * @return null if group doesn't exist or, if it does exist, the actual group related to that group name + */ + private Group getGroupByName(@NotNull String groupName) { + Group group = Group.forName(groupName); + if (group == null) { + printError("Group \"" + groupName + "\" has not been loaded"); + return null; + } + return group; + } + + private void printDot(@NotNull String filename, @NotNull Config config) { + if (filename.startsWith("\"")) // remove "" from the filename if they exist + filename = filename.substring(1, filename.length() - 1); + + String dotCode = ""; + try { + dotCode = new ConfigToDot(new ConfigToDot.Builder(config)).convert(); + if (filename.equals("-")) // write to stdout + System.out.println(dotCode); + else { // write to file + Files.writeString(Path.of(filename), dotCode); + String cmd = CommandLine.Help.Ansi.AUTO.string("@|white,bold dot -Tsvg " + filename + "|@"); + System.out.println( + "Dot code has been written to " + filename + + "\nRun " + cmd + " to obtain a nice svg image" + ); + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Couldn't save generated dot code into " + filename, e); + System.out.println("Generated dot code is:\n" + dotCode); + } } private void printHelp() { - String help = "CLI prompt statuses:\n" + - " - " + readySymbol + ": Service has started and can read commands\n" + - " - " + waitingSymbol + ": Waiting a service or group to be started. Can't execute " + - "commands\n" + - " - " + byeSymbol + ": Exiting the application. Bye bye\n" + - '\n' + - "Available commands:\n" + - " - (group start|start group) . Start a group service.\n" + - " The group name is the one you defined in config file\n" + - " - (start|stop|restart) . Start, stop or restart a singleton service\n" + - " - status []. Query the status of a particular service\n" + - " or all services if service name is not provided\n" + - " - (quit|exit|q). Exit the application (all started processes will be stopped)\n" + - " - (help|h). Print this help\n" + - "You can input multiple commands if you separate them by '&'.\n" + - "Example: \"group start & status \"\n" + - "They'll execute sequentially"; - System.out.println(help); + String promptStatuses = CommandLine.Help.Ansi.AUTO.string( + String.format( + """ + @|white,bold CLI prompt statuses:|@ + • %s: Ready to read commands + • %s: Waiting a service or group to start. Can't read commands + • %s: Exiting the application. Bye, bye + """, readySymbol, waitingSymbol, byeSymbol + ) + ); + String availableCommands = CommandLine.Help.Ansi.AUTO.string( + """ + @|white,bold Available commands:|@ + @|white,underline Group commands:|@ + • @|blue,bold group|@ (@|blue,bold start|@ | @|blue,bold stop|@) | (@|blue,bold start|@ | @|blue,bold stop|@) @|blue,bold group|@ @|cyan,underline |@ @|cyan ...|@ + Start or stop a group service. + Group's dependencies will be started first. + • @|blue,bold group status|@ | @|blue,bold status group|@ @|cyan,underline |@ @|cyan ...|@ + Show the status of a group service. + Status will also be shown for group's dependencies + + @|white,underline Singleton service commands:|@ + • @|blue,bold start|@ | @|blue,bold stop|@ @|cyan,underline |@ @|cyan ...|@ + Start or stop a singleton service. + • @|blue,bold status|@ @|cyan,underline []|@ @|cyan ...|@ + Show the status of a service. + If service name or alias is not provided, all services' status will be shown. + + @|white,underline Configuration commands:|@ + • @|blue,bold load|@ + Load all services. Useful to validate config. + It may produce duplicated output, but that's normal. + • @|blue,bold reload|@ + Reload configuration from configuration file. + • @|blue,bold print|@ @|cyan,underline |@ + Convert configuration to dot (graphviz) code and write it to the specified file. + If "-" is used as file, output will be printed to standard output. + Useful to obtain an overview of the microservices dependency graph. + + @|white,underline Miscellaneous commands:|@ + • @|blue,bold quit|@ | @|blue,bold exit|@ | @|blue,bold q|@ + Exit the application. + Will first try to gracefully stop any started process. + • @|blue,bold help|@ | @|blue,bold h |@ + Print this help. + + If service name or alias contains a space, enclose it in double-quotes + """ + ); + String extraInfo = String.format( + """ + You can input multiple commands if you separate them by '%s'. + Example: "start %s status " + They'll execute sequentially. + + You can also execute any command your OS is capable to handle. + Example: "bash" + Control will be forwarded to that command.""", + cmdSeparator, + cmdSeparator + ); + System.out.println(promptStatuses + "\n" + availableCommands + "\n" + extraInfo); + } + + /** + * Split a string by space but not splitting if substring is double-quoted + *

+ * Example: + * 'hello world "hola mundo" ...' will be split into: + *

    + *
  1. hello
  2. + *
  3. world
  4. + *
  5. hola mundo
  6. + *
  7. ...
  8. + *
+ * @param str string to be split + * @return list of tokens extracted + */ + @NotNull + public static List splitBySpaces(String str) { + if (str.isBlank()) + return Collections.emptyList(); + + List tokens = new ArrayList<>(); + int tokenStart = 0; + int tokenEnd = 0; + boolean insideQuotes = false; // tells if index i is inside double quotes + for (int i = 0; i < str.length(); ++i) { // O(n) + if (insideQuotes) { + tokenStart = i; + + // advance i until we see the final '"' + // also ignore any escaped '"', i.e. \" + while (i < str.length() && (str.charAt(i) != '"' && str.charAt(i - 1) != '\\')) + ++i; + + if (i == str.length()) { + printError("Malformed string. Missing end double quote '\"'"); + return Collections.emptyList(); + } + + tokenEnd = i; + insideQuotes = false; + + tokens.add(str.substring(tokenStart, tokenEnd)); + continue; + } + + // outside quotes + switch (str.charAt(i)) { + case '"' -> insideQuotes = true; + case ' ' -> { + tokenEnd = i; + tokens.add(str.substring(tokenStart, tokenEnd)); + tokenStart = tokenEnd + 1; + } + } + } + + // add substring at the end + if (str.charAt(str.length() - 1) != '"') // if last token is double-quoted, then it is already in list + tokens.add(str.substring(tokenStart)); + + return tokens; + } + + /** + * Same as println method from {@link System#out} but using green coloured output + * @param msg message to be printed + */ + public static void printSuccess(@NotNull String msg) { + String out = CommandLine.Help.Ansi.AUTO.string("@|green,bold " + msg + "|@"); + System.out.println(out); + } + + /** + * Same as println method from {@link System#out} but using red coloured output + * @param msg message to be printed + */ + public static void printError(@NotNull String msg) { + String out = CommandLine.Help.Ansi.AUTO.string("@|red " + msg + "|@"); + System.out.println(out); + } + + /** + * Same as println method from {@link System#out} but using yellow coloured output + * @param msg message to be printed + */ + public static void printWarning(@NotNull String msg) { + String out = CommandLine.Help.Ansi.AUTO.string("@|yellow " + msg + "|@"); + System.out.println(out); } } diff --git a/src/main/java/net/benjaminguzman/Config.java b/src/main/java/net/benjaminguzman/Config.java new file mode 100644 index 0000000..4592b0f --- /dev/null +++ b/src/main/java/net/benjaminguzman/Config.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2021. Benjamín Antonio Velasco Guzmán + * Author: Benjamín Antonio Velasco Guzmán + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.benjaminguzman; + +import org.jetbrains.annotations.NotNull; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Config { + @NotNull + private static final Config config = new Config(); + + @NotNull + public final Map services = new HashMap<>(); + + @NotNull + public final Map groups = new HashMap<>(); + + public int maxDepth = ConfigDefaults.MAX_DEPTH; + public boolean continueAfterError = ConfigDefaults.IGNORE_ERRORS; + + public static Config getInstance() { + return config; + } + + /** + * Clear (reset) all configuration values + */ + public void clear() { + services.clear(); + groups.clear(); + maxDepth = ConfigDefaults.MAX_DEPTH; + continueAfterError = ConfigDefaults.IGNORE_ERRORS; + } + + /** + * Adds a single service configuration to the services array (hashmap) + * + * @param config the config for the service to be added + * @return this + */ + public Config addService(@NotNull ServiceConfig config) { + services.putIfAbsent(config.getName(), config); + return this; + } + + /** + * Adds multiple services configuration to the services array (hashmap) + * + * @param configs list of services to be added + * @return this + */ + public Config addAllServices(@NotNull List configs) { + configs.forEach(this::addService); + return this; + } + + /** + * Adds a single group configuration to the service groups array (hashmap) + * + * @param config the service group to be added + * @return this + */ + public Config addGroup(@NotNull GroupConfig config) { + groups.putIfAbsent(config.getName(), config); + return this; + } + + /** + * Adds multiple groups configuration to the service groups array (hashmap) + * + * @param configs list of service groups to be added + * @return this + */ + public Config addAllGroups(@NotNull List configs) { + configs.forEach(this::addGroup); + return this; + } + + /** + * Set the max depth of the dependency graph + * + * @param maxDepth max depth + * @return this + */ + public Config setMaxDepth(int maxDepth) { + this.maxDepth = maxDepth; + return this; + } + + /** + * Set the "continue after error" flag + * + * @param continueAfterError value of the flag + * @return this + */ + public Config setContinueAfterError(boolean continueAfterError) { + this.continueAfterError = continueAfterError; + return this; + } + + @NotNull + public Map getServices() { + return services; + } + + @NotNull + public Map getGroups() { + return groups; + } + + public int getMaxDepth() { + return maxDepth; + } + + public boolean isContinueAfterError() { + return continueAfterError; + } +} diff --git a/src/main/java/net/benjaminguzman/ConfigDefaults.java b/src/main/java/net/benjaminguzman/ConfigDefaults.java new file mode 100644 index 0000000..5e61770 --- /dev/null +++ b/src/main/java/net/benjaminguzman/ConfigDefaults.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2021. Benjamín Antonio Velasco Guzmán + * Author: Benjamín Antonio Velasco Guzmán + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.benjaminguzman; + +public final class ConfigDefaults { + public static final int MAX_DEPTH = 5; + public static final boolean IGNORE_ERRORS = true; + + private ConfigDefaults() { + throw new UnsupportedOperationException("Cannot instantiate this class"); + } +} diff --git a/src/main/java/net/benjaminguzman/ConfigLoader.java b/src/main/java/net/benjaminguzman/ConfigLoader.java index f0ea691..142773d 100644 --- a/src/main/java/net/benjaminguzman/ConfigLoader.java +++ b/src/main/java/net/benjaminguzman/ConfigLoader.java @@ -27,10 +27,12 @@ import org.everit.json.schema.loader.SchemaLoader; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.json.JSONTokener; +import org.yaml.snakeyaml.Yaml; import javax.management.InstanceAlreadyExistsException; import java.awt.*; @@ -45,27 +47,48 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; -import static com.diogonunes.jcolor.Attribute.TEXT_COLOR; - public class ConfigLoader { private static volatile ConfigLoader instance = null; + @NotNull + private final File configFile; private static final Logger LOGGER = Logger.getLogger(ConfigLoader.class.getName()); private final JSONObject rootNode; /** * @param file configuration will be read from this file - * @throws IOException if there is an error while reading file contents (e.g. file doesn't exist) - * @throws ValidationException if the given config file doesn't comply with the schema specification + * @throws IOException if there is an error while reading file contents (e.g. file doesn't + * exist) + * @throws ValidationException if the given config file doesn't comply with the schema specification * @throws InstanceAlreadyExistsException if there already exists an instance of this class. Use - * {@link #getInstance()} to use it instead of creating a new one + * {@link #getInstance()} to use it instead of creating a new one */ - public ConfigLoader(@NotNull File file) throws IOException, ValidationException, InstanceAlreadyExistsException { + public ConfigLoader(@NotNull File file) throws IOException, ValidationException, + InstanceAlreadyExistsException { if (instance != null) throw new InstanceAlreadyExistsException( "Cannot instantiate " + ConfigLoader.class.getName() + " more than once" ); - this.rootNode = new JSONObject(Files.readString(file.toPath())); + + if (file.exists() && file.length() < 10) // if file doesn't exist exception will be thrown later + LOGGER.warning(file.getName() + " has less than 10 bytes, that doesn't seem good"); + + if (file.getName().endsWith("json")) + this.rootNode = new JSONObject(Files.readString(file.toPath())); + else if (file.getName().endsWith("yaml") || file.getName().endsWith("yml")) { + // read is as yaml + Yaml yaml = new Yaml(); + Map map = yaml.load(Files.readString(file.toPath())); + + // convert it to json + this.rootNode = new JSONObject(map); + } else { + LOGGER.warning( + file.getName() + " doesn't have any of the following extensions: yaml, yml, json.\n" + + " I'll try to read it as JSON" + ); + this.rootNode = new JSONObject(Files.readString(file.toPath())); + } // validate the config file complies with the specification in schema.json try (InputStream inputStream = getClass().getResourceAsStream("/resources/schema.json")) { @@ -74,6 +97,7 @@ public ConfigLoader(@NotNull File file) throws IOException, ValidationException, Schema schema = SchemaLoader.load(rawSchema); schema.validate(this.rootNode); } + configFile = file; instance = this; } @@ -93,11 +117,93 @@ public static ConfigLoader getInstance() { return instance; } + @NotNull + public File getConfigFile() { + return configFile; + } + + /** + * Loads ALL the configuration in the configuration file + * + * @return the loaded configuration + */ + public Config load() throws ServiceNotFoundException, FileNotFoundException, MaxDepthExceededException, + GroupNotFoundException, CircularDependencyException { + Config conf = Config.getInstance(); + + // load single values + conf.setContinueAfterError(shouldIgnoreErrors()) + .setMaxDepth(maxDepth()); + + // load all services + JSONArray servicesArr = rootNode.getJSONArray("services"); + for (Object service : servicesArr) + conf.addService(loadServiceConfig(((JSONObject) service).getString("name"))); + + // load all groups + JSONArray groupsArr = rootNode.getJSONArray("groups"); + for (Object group : groupsArr) + conf.addGroup(loadGroupConfig(((JSONObject) group).getString("name"))); + + return conf; + } + + /** + * Equivalent to executing {@link #refresh()} and {@link #load()} sequentially + * + * @return the new configuration + */ + public Config reload() throws MaxDepthExceededException, ServiceNotFoundException, InstanceAlreadyExistsException, GroupNotFoundException, IOException, CircularDependencyException { + refresh(); + return load(); + } + + /** + * Read configuration file again + *

+ * The return value of the next call to {@link #load()} should contain the new configuration + * + * @see #reload() + * @return itself + */ + public ConfigLoader refresh() throws InstanceAlreadyExistsException, IOException, MaxDepthExceededException, ServiceNotFoundException, GroupNotFoundException, CircularDependencyException, ValidationException { + // NOTE: at this point of time there are at least 2 references to this ConfigLoader object: + // 1. the static reference instance + // 2. this itself + // Thus, we can get all its fields by either using instance.field or this.field + // and that's why no NPE is thrown when passing the config file as argument to the config loader constructor + + instance = null; // invalidate reference + // GC will collect the real object later, after the "this" reference is no longer in use. + // Probably after this method exits + + new ConfigLoader(configFile); // create a new object and reassign the reference null-ed previously + assert instance != null; + + // NOTE 2: In theory this code should be synchronized + // Since it is possible that instance reference is used at the same time this code is being executed + // and because instance is null, bad things may happen + // But, synchronizing the code is somewhat an overhead given the actual use of this method + // and that what was explained above is the only reason for synchronization + // So, just let's cross fingers and hope there are no synchronization errors 🤞 + + Config.getInstance().clear(); // finally clear the current loaded configuration + return instance; + } + + /** + * @return the value of the key continueAfterError in the JSON file or + * {@link ConfigDefaults#IGNORE_ERRORS} if not defined + */ + public boolean shouldIgnoreErrors() { + return rootNode.optBoolean("ignoreErrors", ConfigDefaults.IGNORE_ERRORS); + } + /** - * @return the value of the key continueAfterError in the JSON file or true if it is not defined + * @return the value of the key maxDepth in the JSON file or {@link ConfigDefaults#MAX_DEPTH} if not defined */ - public boolean shouldContinueAfterError() { - return rootNode.optBoolean("continueAfterError", true); + public int maxDepth() { + return rootNode.optInt("maxDepth", ConfigDefaults.MAX_DEPTH); } /** @@ -116,19 +222,19 @@ public ServiceConfig loadServiceConfig(@NotNull String name) throws FileNotFound } /** - * Loads a {@link ServiceGroup} for the service group with the given name or alias + * Loads a {@link Group} for the service group with the given name or alias * * @param name name or alias to be searched in the array of group services defined in configuration file - * @return the corresponding {@link ServiceGroup} or null if not found + * @return the corresponding {@link Group} or null if not found * @throws JSONException if the configuration has invalid format * @throws FileNotFoundException if the specified working directory for a service is not found * @throws ServiceNotFoundException if the service configuration was not found */ @NotNull - public ServiceGroupConfig loadGroupConfig(@NotNull String name) throws JSONException, + public GroupConfig loadGroupConfig(@NotNull String name) throws JSONException, MaxDepthExceededException, GroupNotFoundException, CircularDependencyException, FileNotFoundException, ServiceNotFoundException { - return loadServiceGroupConfigJSON(name); + return loadGroupConfigJSON(name); } /** @@ -186,9 +292,10 @@ private static List list2StringList(@NotNull List list) { * @throws ServiceNotFoundException if the service configuration was not found */ @NotNull - private ServiceConfig loadServiceConfigJSON(@NotNull String name) throws FileNotFoundException, JSONException, ServiceNotFoundException { - JSONObject serviceJSONConfig = getItemByNameOrAlias(name, rootNode.getJSONArray("services")); - if (serviceJSONConfig == null) + private ServiceConfig loadServiceConfigJSON(@NotNull String name) throws FileNotFoundException, JSONException, + ServiceNotFoundException { + JSONObject jsonConfig = getItemByNameOrAlias(name, rootNode.getJSONArray("services")); + if (jsonConfig == null) throw new ServiceNotFoundException("\"" + name + "\"" + " service was not found"); // by now we know the current json node is the one the user wants @@ -196,74 +303,115 @@ private ServiceConfig loadServiceConfigJSON(@NotNull String name) throws FileNot ServiceConfig config = new ServiceConfig(); // set required fields - config.setName(serviceJSONConfig.getString("name")); - config.setStartCmd(serviceJSONConfig.getString("start")); + config.setName(jsonConfig.getString("name")); + config.setStartCmd(jsonConfig.getString("start")); // set optional fields - if (serviceJSONConfig.has("aliases")) - config.setAliases(list2StringList(serviceJSONConfig.getJSONArray("aliases").toList())); + if (jsonConfig.has("aliases")) + config.setAliases(list2StringList(jsonConfig.getJSONArray("aliases").toList())); + + if (jsonConfig.has("stop")) + config.setStopCmd(jsonConfig.getString("stop")); + + if (jsonConfig.has("stopTimeout")) + config.setStopTimeout(jsonConfig.getInt("stopTimeout")); + + if (jsonConfig.has("stdin")) { + File stdinFile = new File(jsonConfig.getString("stdin")); + if (!stdinFile.exists() || !stdinFile.canRead() || !stdinFile.isFile()) + throw new FileNotFoundException( + "\"" + stdinFile.getAbsolutePath() + "\" either " + + "doesn't exist, is not a regular file, or can't be read" + ); - if (serviceJSONConfig.has("color")) { - Color color = Color.decode(serviceJSONConfig.getString("color")); - config.setAsciiColor(TEXT_COLOR(color.getRed(), color.getGreen(), color.getBlue())); + config.setStdin(stdinFile); } - if (serviceJSONConfig.has("workDir")) { - File workDir = new File(serviceJSONConfig.getString("workDir")); + if (jsonConfig.has("stopStdin")) { + File stdinFile = new File(jsonConfig.getString("stopStdin")); + if (!stdinFile.exists() || !stdinFile.canRead() || !stdinFile.isFile()) + throw new FileNotFoundException( + "\"" + stdinFile.getAbsolutePath() + "\" either " + + "doesn't exist, is not a regular file, or can't be read" + ); + + config.setStopStdin(stdinFile); + } + + if (jsonConfig.has("color")) { + Color color; + if (jsonConfig.get("color") instanceof String) // color is given as string + color = Color.decode(jsonConfig.getString("color")); + else // color is given as an integer + color = new Color(jsonConfig.getInt("color")); + + config.setColor(color); + } + + if (jsonConfig.has("workDir")) { + File workDir = new File(jsonConfig.getString("workDir")); if (!workDir.exists() || !workDir.isDirectory() || !workDir.canRead()) throw new FileNotFoundException( - "\"" + workDir.getAbsolutePath() + "\" either doesn't exists," + - " isn't a directory, or you can't read from it" + "\"" + workDir.getAbsolutePath() + "\" either doesn't exist," + + " is not a directory, or can't be read" ); config.setWorkingDirectory(workDir); } - if (serviceJSONConfig.has("startedPatterns")) + if (jsonConfig.has("startedPatterns")) config.setStartedPatterns( - list2StringList(serviceJSONConfig.getJSONArray("startedPatterns").toList()) + list2StringList(jsonConfig.getJSONArray("startedPatterns").toList()) .stream() .map(patternStr -> Pattern.compile(patternStr, Pattern.CASE_INSENSITIVE)) .collect(Collectors.toList()) ); else LOGGER.warning( - "⚠ Not configuring started patterns for " + + "Not configuring started patterns for " + config.getColorizedName() + - " may result on the application hanging up indefinitely" + " may result on microstart hanging up indefinitely" ); - if (serviceJSONConfig.has("errorPatterns")) + if (jsonConfig.has("errorPatterns")) config.setErrorPatterns( - list2StringList(serviceJSONConfig.getJSONArray("errorPatterns").toList()) + list2StringList(jsonConfig.getJSONArray("errorPatterns").toList()) .stream() .map(patternStr -> Pattern.compile(patternStr, Pattern.CASE_INSENSITIVE)) .collect(Collectors.toList()) ); else LOGGER.warning( - "⚠ Not configuring error patterns for " + + "Not configuring error patterns for " + config.getColorizedName() + - " may result on the application hanging up indefinitely if an error" + - " occurs" + " may result on microstart hanging up indefinitely" ); + // show more warnings if optional prop value were defined for a required prop that was not defined + List optionalProps = List.of("stopTimeout", "stopStdin"); + if (!jsonConfig.has("stop") && optionalProps.stream().anyMatch(jsonConfig::has)) + optionalProps.stream().filter(jsonConfig::has).forEach(optionalProp -> { + LOGGER.warning("\"" + optionalProp + "\" property was defined but no" + + " \"stop\" command was given"); + }); + return config; } /** - * Loads a {@link ServiceGroupConfig} for the service group with the given name or alias assuming the contents + * Loads a {@link GroupConfig} for the service group with the given name or alias assuming the contents * in config file have json format * * @param name name or alias to be looked in the array of groups defined in configuration file - * @return the corresponding {@link ServiceGroupConfig} or null if not found + * @return the corresponding {@link GroupConfig} or null if not found * @throws JSONException if the configuration has invalid format * @throws CircularDependencyException if it is not a DAG, and therefore it has circular dependencies * @throws GroupNotFoundException if a group name is not found in the configuration * @throws MaxDepthExceededException if the graph depth is greater than the maximum depth allowed */ @NotNull - private ServiceGroupConfig loadServiceGroupConfigJSON(@NotNull String name) throws JSONException, MaxDepthExceededException, GroupNotFoundException, CircularDependencyException, FileNotFoundException, ServiceNotFoundException { + private GroupConfig loadGroupConfigJSON(@NotNull String name) throws JSONException, MaxDepthExceededException, + GroupNotFoundException, CircularDependencyException, FileNotFoundException, ServiceNotFoundException { JSONObject groupJSONConfig = getItemByNameOrAlias(name, rootNode.getJSONArray("groups")); if (groupJSONConfig == null) throw new GroupNotFoundException("\"" + name + "\" was not found in the groups array"); @@ -271,7 +419,7 @@ private ServiceGroupConfig loadServiceGroupConfigJSON(@NotNull String name) thro // check dependencies are ok for this group // this check is important because dependencies will be loaded recursively, so, this avoids infinite // recursion - int max_depth = rootNode.has("maxDepth") ? rootNode.getInt("maxDepth") : 5; + int max_depth = rootNode.optInt("maxDepth", ConfigDefaults.MAX_DEPTH); checkGroupDependencies( groupJSONConfig.getString("name"), rootNode.getJSONArray("groups"), @@ -282,7 +430,8 @@ private ServiceGroupConfig loadServiceGroupConfigJSON(@NotNull String name) thro } @NotNull - private ServiceGroupConfig loadGroupDependencyConfigJSON(@NotNull String name) throws JSONException, GroupNotFoundException, FileNotFoundException, ServiceNotFoundException { + private GroupConfig loadGroupDependencyConfigJSON(@NotNull String name) throws JSONException, + GroupNotFoundException, FileNotFoundException, ServiceNotFoundException { JSONObject groupJSONConfig = getItemByNameOrAlias(name, rootNode.getJSONArray("groups")); if (groupJSONConfig == null) // if checkGroupDependencies is called before this, this will never happen throw new GroupNotFoundException( @@ -291,7 +440,7 @@ private ServiceGroupConfig loadGroupDependencyConfigJSON(@NotNull String name) t // by now we know the current json node is the one the user wants // load all its configuration - ServiceGroupConfig config = new ServiceGroupConfig(); + GroupConfig config = new GroupConfig(); config.setName(groupJSONConfig.getString("name")); // set other config properties @@ -311,7 +460,7 @@ private ServiceGroupConfig loadGroupDependencyConfigJSON(@NotNull String name) t List dependencies = list2StringList( groupJSONConfig.getJSONArray("dependencies").toList() ); - List groupsConfigs = new ArrayList<>(dependencies.size()); + List groupsConfigs = new ArrayList<>(dependencies.size()); for (String dependencyName : dependencies) groupsConfigs.add(loadGroupDependencyConfigJSON(dependencyName)); @@ -345,7 +494,7 @@ public void checkGroupDependencies( @NotNull JSONArray groupsArray, int max_depth ) throws CircularDependencyException, GroupNotFoundException, MaxDepthExceededException { - Map visitedNodes = new HashMap<>(); // contains the names of the visited nodes + //Map visitedNodes = new HashMap<>(); // contains the names of the visited nodes // use a stack to traverse the graph using DFS Stack pendingNodes = new Stack<>(); // contains the names of the unvisited nodes @@ -353,19 +502,23 @@ public void checkGroupDependencies( pendingNodes.push(rootGroupName); int depth = 0; + int rootGroupNameOccurrences = 0; // traverse the graph to detect cycles while (!pendingNodes.isEmpty()) { String current = pendingNodes.pop(); + if (current.equalsIgnoreCase(rootGroupName)) + ++rootGroupNameOccurrences; + // if current node has been visited, a cycle has been found - if (visitedNodes.containsKey(current)) + if (rootGroupNameOccurrences >= 2) throw new CircularDependencyException( "Group \"" + rootGroupName + "\" depends on itself (circular dependency was found)" ); - visitedNodes.put(current, true); // mark the current node as visited + //visitedNodes.put(current, true); // mark the current node as visited // obtain dependencies for current group JSONObject groupConfig = getItemByNameOrAlias(current, groupsArray); @@ -396,4 +549,14 @@ public void checkGroupDependencies( depth -= 2; // decrement by 2 to undo the first increment and to "go back" in the graph } } + + @TestOnly + public static void deleteInstance() { + instance = null; + } + + @TestOnly + public JSONObject getRoot() { + return rootNode; + } } diff --git a/src/main/java/net/benjaminguzman/ConfigToDot.java b/src/main/java/net/benjaminguzman/ConfigToDot.java new file mode 100644 index 0000000..fe9ac27 --- /dev/null +++ b/src/main/java/net/benjaminguzman/ConfigToDot.java @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2021. Benjamín Antonio Velasco Guzmán + * Author: Benjamín Antonio Velasco Guzmán + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.benjaminguzman; + +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; + +public class ConfigToDot { + @NotNull + private final Builder opts; + + /** + * Indentation string of level 1 + */ + private final String indentStr; + + /** + * Indentation string of level 2 + */ + private final String indentStr2; + + public ConfigToDot(@NotNull Builder options) { + opts = options; + indentStr = String.valueOf(opts.indentChar).repeat(opts.indentSize); + indentStr2 = String.valueOf(opts.indentChar).repeat(2 * opts.indentSize); + } + + /** + * Convert the JSON configuration to dot code + * + * @return the dot code + */ + public String convert() { + StringBuilder builder = new StringBuilder(); + + // open a directed graph + builder.append("digraph {").append(System.lineSeparator()); + + // compound true is important to connect subgraphs + builder.append(indentStr).append("compound=true;").append(System.lineSeparator()); + + // add separation between subgraphs + builder.append(indentStr).append("rank=same;").append(System.lineSeparator()); + builder.append(indentStr).append("ranksep=1;").append(System.lineSeparator()); + + // print all the subgraphs + Collection groups = opts.config.getGroups().values(); + for (GroupConfig group : groups) + printSubgraph(group, builder); + + // connect all the subgraphs + for (GroupConfig group : groups) + connectSubgraphs(group, builder); + + // close the directed graph + builder.append("}").append(System.lineSeparator()); + + return builder.toString(); + } + + /** + * Convert and save the JSON configuration into dot code and save it to the specified location + * + * @param outPath output path where the dot code will be written + * @return the dot code + * @throws IOException any exception thrown by {@link Files#writeString(Path, CharSequence, OpenOption...)} + */ + public String convertAndSave(@NotNull Path outPath) throws IOException { + String dot = this.convert(); + Files.writeString(outPath, dot); + return dot; + } + + /** + * "Prints" a single group config as a subgraph + * + * @param groupConfig the group configuration to be converted into dot + * @param builder the string builder in which the dot code will be added + */ + private void printSubgraph(@NotNull GroupConfig groupConfig, @NotNull StringBuilder builder) { + int group_hash_code = groupConfig.getName().hashCode() & 0x7fff_ffff; // remove sign bit + + // add subgraph + builder.append(indentStr) + .append("subgraph cluster_") + .append(group_hash_code) + .append(" {") + .append(System.lineSeparator()); + + // add label to subgraph + builder.append(indentStr2).append("label=\"") + .append(groupConfig.getName()) + .append("\";") + .append(System.lineSeparator()) + .append(indentStr2).append("color=blue;") // add border color to subgraph + .append(System.lineSeparator()); + + // add group's services + groupConfig.getServicesConfigs().forEach(serviceConfig -> { + builder.append(indentStr2) + .append('"') + .append(serviceConfig.getName()).append(group_hash_code) + .append("\"") + .append(" [label=<") + .append(serviceConfig.getName()); + + if (serviceConfig.getAliases().size() > 0) + builder.append("
") + .append(String.join(", ", serviceConfig.getAliases())) + .append(""); + + builder.append(">, style=filled];") + .append(System.lineSeparator()); + } + ); + + // close the subgraph + builder.append(indentStr).append("}").append(System.lineSeparator()); + } + + /** + * Connects all subgraph. This method should be called after {@link #printSubgraph(GroupConfig, StringBuilder)} + * + * @param groupConfig the group configuration + * @param builder the string builder in which the dot code will be added + */ + private void connectSubgraphs(@NotNull GroupConfig groupConfig, @NotNull StringBuilder builder) { + int group_hash_code = groupConfig.getName().hashCode() & 0x7fff_ffff; + + List deps = groupConfig.getDependenciesConfigs(); + if (deps.isEmpty()) // this group doesn't have dependencies + return; + + // connection must be made between nodes in cluster, not between clusters + // that's how dot works 😕 + String serviceName = groupConfig.getServicesConfigs().get(0).getName(); + + // add all dependencies + int dep_hash_code; + for (GroupConfig dep : deps) { + dep_hash_code = dep.getName().hashCode() & 0x7fff_ffff; + builder.append(indentStr) + .append('"') + .append(dep.getServicesConfigs().get(0).getName()) + .append(dep_hash_code) + .append('"') + .append(" -> ") + .append('"') + .append(serviceName) + .append(group_hash_code) + .append('"') + .append(" [lhead=cluster_") + .append(group_hash_code) // remove sign bit + .append(", ltail=cluster_") + .append(dep_hash_code) // remove sign bit + .append("];") + .append(System.lineSeparator()); + } + } + + public static class Builder { + @NotNull + private Config config; + + private char indentChar = ' '; + private int indentSize = 4; + + public Builder(@NotNull Config config) { + this.config = config; + } + + /** + * @see #setConfig(Config) + */ + @NotNull + public Config getConfig() { + return config; + } + + /** + * Set the configuration for which the dot file will be generated + * + * @param config the configuration + */ + public Builder setConfig(Config config) { + this.config = config; + return this; + } + + /** + * @see #setIndentChar(char) + */ + public char getIndentChar() { + return indentChar; + } + + /** + * Set the indentation character + *

+ * Default: ' ' + * + * @param indentChar indentation character + */ + public Builder setIndentChar(char indentChar) { + this.indentChar = indentChar; + return this; + } + + /** + * @see #setIndentSize(int) + */ + public int getIndentSize() { + return indentSize; + } + + /** + * Set the indent size, i.e. how many {@link #indentChar} will be printed per indent + *

+ * Default: 4 + * + * @param indentSize number, must be non-negative + * @throws IllegalArgumentException if the given indent size is negative + */ + public Builder setIndentSize(int indentSize) { + if (indentSize < 0) + throw new IllegalArgumentException("Can't have an indentation size of " + indentSize); + + this.indentSize = indentSize; + return this; + } + } +} diff --git a/src/main/java/net/benjaminguzman/ServiceThreadFactory.java b/src/main/java/net/benjaminguzman/DaemonThreadFactory.java similarity index 56% rename from src/main/java/net/benjaminguzman/ServiceThreadFactory.java rename to src/main/java/net/benjaminguzman/DaemonThreadFactory.java index 9612c9d..64cd585 100644 --- a/src/main/java/net/benjaminguzman/ServiceThreadFactory.java +++ b/src/main/java/net/benjaminguzman/DaemonThreadFactory.java @@ -22,7 +22,43 @@ import java.util.concurrent.ThreadFactory; -public class ServiceThreadFactory implements ThreadFactory { +/** + * Factory for daemon threads + * @see Thread#setDaemon(boolean) + */ +public class DaemonThreadFactory implements ThreadFactory { + @NotNull + private final String name; + + private final int priority; + + /** + * Create a thread factory with "Service-Thread" as thread name + * and default priority + * @see DaemonThreadFactory(String) + */ + public DaemonThreadFactory() { + this("Service-Thread"); + } + + /** + * Create a thread factory with thread having the given name and {@link Thread#NORM_PRIORITY} + * @param threadName thread name + */ + public DaemonThreadFactory(@NotNull String threadName) { + this(threadName, Thread.NORM_PRIORITY); + } + + /** + * Create a thread factory with thread having the given name and priority + * @param threadName thread name + * @param priority thread priority + */ + public DaemonThreadFactory(@NotNull String threadName, int priority) { + this.name = threadName; + this.priority = priority; + } + /** * Constructs a new {@code Thread}. Implementations may also initialize * priority, name, daemon status, {@code ThreadGroup}, etc. @@ -33,9 +69,9 @@ public class ServiceThreadFactory implements ThreadFactory { */ @Override public Thread newThread(@NotNull Runnable r) { - Thread t = new Thread(r, "Service-Thread"); + Thread t = new Thread(r, name); t.setDaemon(true); - t.setPriority(Thread.NORM_PRIORITY); + t.setPriority(priority); return t; } } diff --git a/src/main/java/net/benjaminguzman/ServiceGroup.java b/src/main/java/net/benjaminguzman/Group.java similarity index 54% rename from src/main/java/net/benjaminguzman/ServiceGroup.java rename to src/main/java/net/benjaminguzman/Group.java index b240862..bde1b99 100644 --- a/src/main/java/net/benjaminguzman/ServiceGroup.java +++ b/src/main/java/net/benjaminguzman/Group.java @@ -22,24 +22,19 @@ import org.jetbrains.annotations.Nullable; import javax.management.InstanceAlreadyExistsException; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.*; +import java.util.concurrent.*; import java.util.function.BiConsumer; import java.util.logging.Level; import java.util.logging.Logger; /** * Class containing a group of services to run - * + *

* This class can also start the list group and execute it */ -public class ServiceGroup { - private static final Logger LOGGER = Logger.getLogger(ServiceGroup.class.getName()); +public class Group { + private static final Logger LOGGER = Logger.getLogger(Group.class.getName()); /** * Map of names and service groups @@ -49,13 +44,13 @@ public class ServiceGroup { * This contains all the available service groups throughout the application */ @NotNull - private final static Map serviceGroups = new HashMap<>(); + private final static Map serviceGroups = new HashMap<>(); /** * Configuration for this service group */ @NotNull - private final ServiceGroupConfig config; + private final GroupConfig config; /** * Latch to be counted down each time a service in the group has started @@ -63,7 +58,7 @@ public class ServiceGroup { * If count is 0, all services should have started */ @NotNull - private final CountDownLatch servicesLatch; + private CountDownLatch servicesLatch; @NotNull private final Map> defaultServiceHooks = new HashMap<>(); @@ -86,9 +81,9 @@ public class ServiceGroup { * * @param config configuration for the service group * @throws InstanceAlreadyExistsException if a service group with the same name or alias has already been - * instantiated previously + * instantiated previously */ - public ServiceGroup(@NotNull ServiceGroupConfig config) throws InstanceAlreadyExistsException { + public Group(@NotNull GroupConfig config) throws InstanceAlreadyExistsException { // ensure there is no other service loaded with the same name or alias if (forName(config.getName()) != null // using stream is probably not efficient, but it is easy || config.getAliases().stream().anyMatch(alias -> forName(alias) != null)) @@ -98,7 +93,7 @@ public ServiceGroup(@NotNull ServiceGroupConfig config) throws InstanceAlreadyEx this.servicesLatch = new CountDownLatch(this.config.getServicesConfigs().size()); this.executorService = Executors.newFixedThreadPool( this.config.getServicesConfigs().size(), - new ServiceThreadFactory() + new DaemonThreadFactory() ); defaultServiceHooks.put(ServiceStatus.ERROR, this::onServiceError); @@ -106,28 +101,58 @@ public ServiceGroup(@NotNull ServiceGroupConfig config) throws InstanceAlreadyEx // register service in singleton map serviceGroups.put(config.getName(), this); - for (String alias : config.getAliases()) - serviceGroups.put(alias, this); + config.getAliases().forEach(alias -> serviceGroups.put(alias, this)); } /** - * Get a {@link ServiceGroup} by its name or alias + * Get a {@link Group} by its name or alias * * @param name the name or alias of the service * @return the service with the given name or null if not found (maybe it has not been loaded) */ @Nullable - public static ServiceGroup forName(@NotNull String name) { + public static Group forName(@NotNull String name) { return serviceGroups.get(name); } + /** + * Remove all loaded groups + */ + public static void clear() { + serviceGroups.clear(); + } + /** * @return list of loaded service groups */ - public static Collection getGroups() { + public static Collection getGroups() { return serviceGroups.values(); } + /** + * @return list of loaded service groups that have no dependencies + */ + public static List getRoots() { + return getGroups().stream() + .filter(group -> group.getConfig().getDependenciesConfigs().isEmpty()) + .toList(); + } + + /** + * @return list of groups that depend on this group + */ + public List getDependants() { + // TODO do we really need to compute this on-the-fly? + // For now this is ok, although caching (and cache invalidation) should be considered + return getGroups().stream() + .filter(group -> group.getConfig() + .getDependenciesConfigs() + .stream() + .map(GroupConfig::getName) + .anyMatch(groupName -> groupName.equals(this.config.getName())) + ).toList(); + } + /** * Starts a service group. *

@@ -138,24 +163,43 @@ public static Collection getGroups() { * been instantiated */ public void start() throws InstanceAlreadyExistsException { - if (this.isUp()) + if (isUp()) return; + else { // is not up + // reset the latch (currently latch count should be 0) + servicesLatch = new CountDownLatch(config.getServicesConfigs().size()); + + // reset count down times + countDownTimes.keySet().forEach(k -> countDownTimes.put(k, 0)); + } - for (ServiceGroupConfig groupConfig : config.getDependenciesConfigs()) { - ServiceGroup dependency; + for (GroupConfig groupConfig : config.getDependenciesConfigs()) { + Group dependency; if ((dependency = forName(groupConfig.getName())) == null) // dependency has not been loaded - dependency = new ServiceGroup(groupConfig); + dependency = new Group(groupConfig); if (!dependency.isUp()) dependency.start(); // start dependency services (and dependencies if they exist) } for (ServiceConfig serviceConfig : config.getServicesConfigs()) { + boolean submit2Executor = true; + Service service; if ((service = Service.forName(serviceConfig.getName())) == null) service = new Service(serviceConfig, defaultServiceHooks, this::onException); + else if (service.getStatus().isRunning()) { // service has already been loaded, and is running + CLI.printWarning(service.getConfig().getColorizedName() + + " has already started"); + + // countdown the latch and don't submit the service to execution + // because it is already running + submit2Executor = false; + servicesLatch.countDown(); + } - executorService.submit(service); + if (submit2Executor) + executorService.submit(service); } // wait until all services are up @@ -167,19 +211,60 @@ public void start() throws InstanceAlreadyExistsException { } /** - * @return true if both {@link #servicesLatch} count is 0. It'll be 0 if all dependencies are up and all - * services are up too + * @return true if all the services in the group have running ({@link ServiceStatus#isRunning()}) status */ public boolean isUp() { - return servicesLatch.getCount() == 0; + return config.getServicesConfigs() + .stream() + .map(serviceConfig -> Service.forName(serviceConfig.getName())) + // if service is null, it hasn't been loaded probably + // it's not possible that it is null because name is not valid + .allMatch(service -> service != null && service.getStatus().isRunning()); + } + + /** + * @return {@link GroupConfig} object used to configure this service group + */ + @NotNull + public GroupConfig getConfig() { + return config; + } + + /** + * Tries to stop all processes started by the services that were run in this group + */ + public void stop() { + for (ServiceConfig serviceConfig : config.getServicesConfigs()) { + Service service = Service.forName(serviceConfig.getName()); + assert service != null; + service.stop(); + } + } + + /** + * Calls {@link #stop()} and then shuts down the underlying executor service + *

+ * Use it only when the application is about to shut down + *

+ * Once this method is called, any subsequent call to {@link #start()} may fail because executor service is + * shut down and can't accept more tasks + */ + public void shutdownNow() { + if (executorService.isShutdown()) + return; + + stop(); + executorService.shutdownNow(); } - /*** - * Calls {@link ExecutorService#shutdownNow()} on the executor service used to run services inside this group - * @return same as {@link ExecutorService#shutdownNow()} + /** + * Calls {@link ExecutorService#awaitTermination(long, TimeUnit)} on the executor service used to run services + * inside this group + * + * @return same as {@link ExecutorService#awaitTermination(long, TimeUnit)} */ - public List shutdownNow() { - return executorService.shutdownNow(); + public boolean awaitTermination(long timeout, @NotNull TimeUnit unit) throws InterruptedException { + return executorService.awaitTermination(timeout, unit); } private void onServiceStarted(Service service, ServiceStatus started) { @@ -187,9 +272,7 @@ private void onServiceStarted(Service service, ServiceStatus started) { if (countDownTimes.getOrDefault(service, 0) > 0) { LOGGER.info( "Service " + service.getConfig().getColorizedName() + - " has notified again it has started. Ignoring that notification 🤷.\n" + - "Total times it has notified this (excluding this occasion): " + - countDownTimes.get(service) + " has again notified it has started " + countDownTimes.get(service) + " times" ); countDownTimes.put(service, countDownTimes.get(service) + 1); return; @@ -202,12 +285,12 @@ private void onServiceStarted(Service service, ServiceStatus started) { private void onServiceError(Service service, ServiceStatus error) { LOGGER.severe( "🔥 Error has been produced inside " + service.getConfig().getColorizedName() + " service 🔥\n" - + (Microstart.CONTINUE_AFTER_ERROR + + (Microstart.IGNORE_ERRORS ? "💥 Services will continue execution 💥" : "Next service group in the graph will not be executed") ); - if (Microstart.CONTINUE_AFTER_ERROR) { + if (Microstart.IGNORE_ERRORS) { servicesLatch.countDown(); countDownTimes.put(service, 1); } @@ -216,8 +299,17 @@ private void onServiceError(Service service, ServiceStatus error) { private void onException(Service service, Exception e) { LOGGER.log( Level.SEVERE, - "Exception produced while starting service " + service.getConfig().getColorizedName(), + "Exception produced while managing service " + service.getConfig().getColorizedName() + + ". Service status: " + service.getStatus(), e ); } + + @Override + public String toString() { + return "Group{" + + "config=" + config + + ", countDownTimes=" + countDownTimes + + '}'; + } } diff --git a/src/main/java/net/benjaminguzman/ServiceGroupConfig.java b/src/main/java/net/benjaminguzman/GroupConfig.java similarity index 78% rename from src/main/java/net/benjaminguzman/ServiceGroupConfig.java rename to src/main/java/net/benjaminguzman/GroupConfig.java index 13c25ca..c8783b7 100644 --- a/src/main/java/net/benjaminguzman/ServiceGroupConfig.java +++ b/src/main/java/net/benjaminguzman/GroupConfig.java @@ -23,7 +23,7 @@ import java.util.Collections; import java.util.List; -public class ServiceGroupConfig { +public class GroupConfig { @NotNull private String name = "Unnamed group"; @@ -31,7 +31,7 @@ public class ServiceGroupConfig { private List aliases = Collections.emptyList(); @NotNull - private List dependenciesConfigs = Collections.emptyList(); + private List dependenciesConfigs = Collections.emptyList(); @NotNull private List servicesConfigs = Collections.emptyList(); @@ -41,7 +41,7 @@ public String getName() { return name; } - public ServiceGroupConfig setName(@NotNull String name) { + public GroupConfig setName(@NotNull String name) { this.name = name; return this; } @@ -51,17 +51,17 @@ public List getAliases() { return aliases; } - public ServiceGroupConfig setAliases(@NotNull List aliases) { + public GroupConfig setAliases(@NotNull List aliases) { this.aliases = aliases; return this; } @NotNull - public List getDependenciesConfigs() { + public List getDependenciesConfigs() { return dependenciesConfigs; } - public ServiceGroupConfig setDependenciesConfigs(@NotNull List dependenciesConfigs) { + public GroupConfig setDependenciesConfigs(@NotNull List dependenciesConfigs) { this.dependenciesConfigs = dependenciesConfigs; return this; } @@ -71,7 +71,7 @@ public List getServicesConfigs() { return servicesConfigs; } - public ServiceGroupConfig setServicesConfigs(@NotNull List servicesConfigs) { + public GroupConfig setServicesConfigs(@NotNull List servicesConfigs) { this.servicesConfigs = servicesConfigs; return this; } diff --git a/src/main/java/net/benjaminguzman/Microstart.java b/src/main/java/net/benjaminguzman/Microstart.java index 8354bcf..c51c5d5 100644 --- a/src/main/java/net/benjaminguzman/Microstart.java +++ b/src/main/java/net/benjaminguzman/Microstart.java @@ -18,65 +18,116 @@ package net.benjaminguzman; -import org.apache.commons.cli.*; import org.everit.json.schema.ValidationException; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; +import picocli.CommandLine; import javax.management.InstanceAlreadyExistsException; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.nio.file.NoSuchFileException; +import java.util.*; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Collectors; +import java.util.stream.Stream; -public class Microstart { +@CommandLine.Command( + name = "microstart", + description = "Start processes groups with dependencies in a single command", + version = "microstart v1.2.2", + header = """ + Copyright (c) 2021-2023. Benjamín Antonio Velasco Guzmán + This program comes with ABSOLUTELY NO WARRANTY. + This is free software, and you are welcome to redistribute it + under certain conditions. + License GPLv3: GNU GPL version 3 + """, + mixinStandardHelpOptions = true +) +public class Microstart implements Runnable { public static final Logger LOGGER = Logger.getLogger(Microstart.class.getName()); - public static final boolean IS_WINDOWS = System.getProperty("os.name").contains("win"); + public static final boolean IS_WINDOWS = System.getProperty("os.name").toLowerCase().contains("win"); + + @CommandLine.Option( + names = {"-c", "--config", "-f"}, + description = "Path to the configuration file", + defaultValue = "microstart.yml" + ) + private String configFile; + + @CommandLine.Option( + names = {"-i", "--input"}, + description = "Command(s) to be executed by microstart CLI. Example: \"start \"" + ) + private String initialInput; + + @CommandLine.Option( + names = {"-e", "--ignore-errors"}, + description = "Tells if execution should be stopped when a service notifies an error has " + + "happened. Overrides ignoreErrors key in config file" + ) + private boolean ignoreErrors; + + @CommandLine.Option( + names = {"--no-colors"}, + description = "Don't use coloured output", + defaultValue = "false" + ) + private boolean noColors; /** - * If true, and an error occurred while running a service or group - *

- * The application should continue execution + * If true, and an error occurred while running a service or group, the application will continue execution */ - public static boolean CONTINUE_AFTER_ERROR = true; - - public static final String DEFAULT_CONFIG_FILE = "microstart.json"; + public static boolean IGNORE_ERRORS; public static void main(String... args) { - System.out.println( - "Micro start version: " + Microstart.class.getPackage().getImplementationVersion() + "\n" + - "Copyright (c) 2021. Benjamín Antonio Velasco Guzmán\n" + - "License GPLv3: GNU GPL version 3 \n" + - "This is free software: you are free to change and redistribute it.\n" - ); - - CommandLine cli = parseCLIArgs(args); - if (cli == null) - return; + System.setProperty("java.util.logging.SimpleFormatter.format", "[%4$-7s] [%1$tF %1$tT] %5$s%6$s%n"); - if (cli.hasOption("help")) { - HelpFormatter helpFormatter = new HelpFormatter(); - helpFormatter.printHelp("java -jar ", getCLIOptions()); - return; - } + // when jvm is shutting down, kill all its children processes. + // Recall this processes will be the ones started by running groups or singleton services. + // Killing all children recursively is good because this way it is ensured there are no remaining + // orphan/dangling processes that may have been started by other service, e.g. + // microstart -> process 1 (defined in config) -> process 2 -> process 3 + // with this line of code process 3, process 2 and process 1 will be stopped + // instead of just stopping the direct child process 1, which would happen normally if jvm exits and no + // shutdown hook is configured + Runtime.getRuntime().addShutdownHook(new Thread( + () -> ProcessHandle.current().children().forEach(Microstart::destroyChildrenProcesses) + )); + + CommandLine commandLine = new CommandLine(new Microstart()); + commandLine.execute(args); + } + + @Override + public void run() { + System.setProperty("picocli.ansi", noColors ? "false" : "true"); - String configFile = cli.hasOption("config") ? cli.getOptionValue("config") : DEFAULT_CONFIG_FILE; + System.out.println(""" + Copyright (c) 2021-2023. Benjamín Antonio Velasco Guzmán + This program comes with ABSOLUTELY NO WARRANTY. + This is free software, and you are welcome to redistribute it + under certain conditions. + License GPLv3: GNU GPL version 3 + """); // load configuration try { new ConfigLoader(configFile); } catch (ValidationException e) { - System.out.println("Configuration file is invalid. Errors are these:"); + CLI.printError("Configuration file contains the following errors:"); + CLI.printError(e.getMessage()); e.getCausingExceptions() .stream() .map(ValidationException::getMessage) - .forEach(System.out::println); + .forEach(CLI::printError); return; } catch (FileNotFoundException | NoSuchFileException e) { - System.out.println( + CLI.printError( "Config file " + configFile + " doesn't exist. Absolute path: " + new File(configFile).getAbsolutePath() ); @@ -92,95 +143,125 @@ public static void main(String... args) { LOGGER.severe(e.getMessage()); return; } + assert ConfigLoader.getInstance() != null; - CONTINUE_AFTER_ERROR = ConfigLoader.getInstance().shouldContinueAfterError(); + IGNORE_ERRORS = ConfigLoader.getInstance().shouldIgnoreErrors(); + + if (ignoreErrors) // override value in config + IGNORE_ERRORS = true; // start command line try { - new CLI(cli.getOptionValue("input")).run(); + if (initialInput != null) + new CLI(initialInput).run(); + else + new CLI().run(); } catch (InstanceAlreadyExistsException e) { LOGGER.log(Level.SEVERE, "Shouldn't instantiate CLI more than once!", e); } finally { // the application is exiting due to breakage of the cli loop - ServiceGroup.getGroups().forEach(ServiceGroup::shutdownNow); - } - } + // This won't execute if SIGINT is received, therefore it is convenient to ask if + // this should be inside a shutdown hook? + // it seems the JVM successfully handles child process destruction when SIGINT is received + // so let's hope it is true for any architecture and 🤞 there are no dangling process after + // exit - @NotNull - private static Options getCLIOptions() { - Options options = new Options(); - options.addOption( - "c", - "config", - true, - "Path to the json config file. Default: " + DEFAULT_CONFIG_FILE - ); - options.addOption( - "i", - "input", - true, - "Command(s) to be executed by microstart CLI. Example: \"start group \"" - ); - options.addOption("h", "help", false, "Print this message"); - - return options; - } + // stop groups. Start with those that don't have dependants but do have dependencies, + // i.e., the ones at the deepest levels (or the end of the list) + // to do so, we use level order traversal + List groupsOrdered = Group.getRoots() + .stream() + .map(Microstart::levelOrderTraversal) + //.peek(System.out::println) // check traversal is indeed working + .flatMap(Collection::stream) + .flatMap(Collection::stream) + .distinct() + .collect(Collectors.toList()); + Collections.reverse(groupsOrdered); + groupsOrdered + // .stream().peek(group -> System.out.println(group.getConfig().getName())) + .forEach(group -> { - @Nullable - private static CommandLine parseCLIArgs(String... args) { - Options options = getCLIOptions(); + group.shutdownNow(); + try { + group.awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + LOGGER.warning( + "Group " + + group.getConfig().getName() + + " couldn't be gracefully shut down" + ); + } + }); - try { - return new DefaultParser().parse(options, args); - } catch (ParseException e) { - if (e instanceof UnrecognizedOptionException) - System.out.println(e.getMessage()); - else if (e instanceof MissingArgumentException) - System.out.println(e.getMessage()); - else - LOGGER.log(Level.SEVERE, "😱 Exception while parsing CLI options", e); + Service.getServices().forEach(Service::stop); } - return null; } /** - * Tries to stop all running process that may have been run before + * @see #levelOrderTraversal(Group, List, int) + */ + public static List> levelOrderTraversal(@NotNull Group group) { + List> levels = new ArrayList<>(); + levelOrderTraversal(group, levels, 0); + + // levels may contain duplicates + return levels.stream() + .map(Collection::stream) + .map(Stream::distinct) + .map(Stream::toList) + .toList(); + } + + /** + * Perform level order traversal on the given group * - * @param forcibly if true, {@link Process#destroyForcibly()} will be used to destroy the process, if false, - * {@link Process#destroy()} will be used + * @param group group (root node) + * @param levels list of lists. first list corresponds to groups at level 0, + * second list corresponds to groups at level 1, and so on... + *

+ * Level 0 contains the groups that don't have any dependency, + * level 1 contains groups dependent on level 0, and so on... + *

+ * It may be possible that lists contain duplicated groups since a group (node) can have + * more than 2 parents. + *

+ * If this list is flattened, you'd get a level-ordered list of nodes + * @param level current level. 0 is the first level */ - public static void destroyProcesses(boolean forcibly) { - Service.services().forEach(service -> { - Process proc = service.getProc(); - if (proc != null && proc.isAlive()) // stop all processes that are alive - if (forcibly) - proc.destroyForcibly(); - else - proc.destroy(); - }); + private static void levelOrderTraversal(@NotNull Group group, @NotNull List> levels, int level) { + // if we're at a new level, grow the array + if (level == levels.size()) + levels.add(new ArrayList<>()); + + // add the group to its corresponding level + levels.get(level).add(group); + + // add the group's children + group.getDependants() + .stream() + .filter(Objects::nonNull) + .forEach(dependency -> levelOrderTraversal(dependency, levels, level + 1)); } /** - * Blocks until all processes have finished. + * Stop all children processes for the given process recursively *

- * This is a complement to {@link #destroyProcesses(boolean)}. You may want to call that first + * Once all children have been stopped, parent process is also stopped * - * @see #destroyProcesses(boolean) + * @param parentProc parent process whose children will be tried to be stopped */ - public static void waitForProcesses() { - Service.services().forEach(service -> { - Process proc = service.getProc(); - if (proc != null) { // stop all processes that are alive - try { - proc.waitFor(5, TimeUnit.SECONDS); - } catch (InterruptedException e) { - LOGGER.log( - Level.WARNING, - "Exception produced while waiting for process in service " + service.getConfig() - .getColorizedName(), - e - ); - } - } - }); + public static void destroyChildrenProcesses(@NotNull ProcessHandle parentProc) { + // stop all children + parentProc.children().forEach(Microstart::destroyChildrenProcesses); + + // stop parent process + parentProc.destroy(); + // should destroyForcibly() be used? let's hope the user knows what he/she is doing and there is no + // need to use destroyForcibly() which will send SIGKILL or something similar to the process, + // which is no good because process won't clean resources or handle shutdown hooks + + // update: sometimes in windows that is required 😤😡 + if (Microstart.IS_WINDOWS) + parentProc.destroyForcibly(); } } diff --git a/src/main/java/net/benjaminguzman/Service.java b/src/main/java/net/benjaminguzman/Service.java index 799e938..f03ecb8 100644 --- a/src/main/java/net/benjaminguzman/Service.java +++ b/src/main/java/net/benjaminguzman/Service.java @@ -24,11 +24,10 @@ import javax.management.InstanceAlreadyExistsException; import java.io.IOException; import java.io.InputStream; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -49,6 +48,11 @@ public class Service implements Runnable { @NotNull private final static Map services = new HashMap<>(); + /** + * Contains the same values of {@link #services} but without any duplicates + */ + private final static HashSet uniqueServices = new HashSet<>(); + /** * Service configuration */ @@ -73,7 +77,7 @@ public class Service implements Runnable { /** * Queue to store a history of the statuses this service has had */ - @Nullable + @NotNull private final BlockingQueue statusesQueue; /** @@ -104,14 +108,14 @@ public class Service implements Runnable { * not when the service process reports an error, see * {@link ServiceStatus#ERROR} and {@link ServiceConfig#setErrorPatterns(List)} * @throws InstanceAlreadyExistsException if a service with the same name or alias has already been - * instantiated previously + * instantiated previously */ public Service( @NotNull ServiceConfig config, @NotNull Map> hooks, @NotNull BiConsumer onException ) throws InstanceAlreadyExistsException { - this(config, hooks, onException, null); + this(config, hooks, onException, new LinkedBlockingQueue<>()); } /** @@ -127,13 +131,13 @@ public Service( * {@link ServiceStatus#STARTED}. Adding elements to the queue doesn't affect this class * behaviour, this class is a producer, not a consumer * @throws InstanceAlreadyExistsException if a service with the same name or alias has already been - * instantiated previously + * instantiated previously */ public Service( @NotNull ServiceConfig config, @NotNull Map> hooks, @NotNull BiConsumer onException, - @Nullable BlockingQueue statusesQueue + @NotNull BlockingQueue statusesQueue ) throws InstanceAlreadyExistsException { // ensure there is no other service loaded with the same name or alias if (forName(config.getName()) != null // using stream is probably not efficient, but it is easy @@ -147,6 +151,7 @@ public Service( // register service in singleton map services.put(config.getName(), this); + uniqueServices.add(this); for (String alias : config.getAliases()) services.put(alias, this); @@ -164,11 +169,19 @@ public static Service forName(@NotNull String name) { return services.get(name); } + /** + * Remove all loaded services + */ + public static void clear() { + services.clear(); + uniqueServices.clear(); + } + /** * @return a list of all loaded services */ - public static Collection services() { - return services.values(); + public static Collection getServices() { + return uniqueServices; } /** @@ -192,11 +205,17 @@ public void run() { } // start the service process - ProcessBuilder processBuilder = new ProcessBuilder(config.getStartCmd()) + ProcessBuilder startProcBuilder = new ProcessBuilder(config.getStartCmd()) .directory(config.getWorkingDirectory()); + // if provided, redirect file contents to stdin + if (config.getStdin() != null) { + startProcBuilder = startProcBuilder.redirectInput(config.getStdin()); + LOGGER.config(config.getStdin().getAbsolutePath() + " will serve as stdin for " + config.getColorizedName()); + } + try { - proc = processBuilder.start(); + proc = startProcBuilder.start(); LOGGER.info(() -> config.getColorizedName() + " PID: " + proc.pid()); } catch (IOException e) { LOGGER.log( @@ -216,14 +235,14 @@ public void run() { // set the hooks to be executed when service notifies it has successfully started Map> startUpHooks = new HashMap<>(); - if (hooks.get(ServiceStatus.STARTED) != null && !config.getStartedPatterns().isEmpty()) + if (!config.getStartedPatterns().isEmpty()) config.getStartedPatterns().forEach(pattern -> { startUpHooks.put(pattern, s -> changeStatusSync(ServiceStatus.STARTED)); }); // set the hooks to be executed when service notifies an error has happened Map> errorHooks = new HashMap<>(); - if (hooks.get(ServiceStatus.ERROR) != null && !config.getErrorPatterns().isEmpty()) + if (!config.getErrorPatterns().isEmpty()) config.getErrorPatterns().forEach(pattern -> { errorHooks.put(pattern, s -> changeStatusSync(ServiceStatus.ERROR)); }); @@ -257,8 +276,12 @@ public void run() { try (procStdout; procStderr) { stdoutThread.join(); stderrThread.join(); - } catch (InterruptedException ignored) { + } catch (InterruptedException e) { // thread interruption is expected, e.g. when you request service stop + proc.destroy(); + + // status is changed just for safety, + // in case the thread won't continue (because it has been requested to stop) changeStatusSync(ServiceStatus.STOPPED); } catch (IOException e) { this.onException.accept(this, e); @@ -271,8 +294,11 @@ public void run() { try { exit_code = proc.waitFor(); // wait until the process finishes } catch (InterruptedException ignored) { - exit_code = Integer.MIN_VALUE; // thread interruption is expected, e.g. when you request service stop + exit_code = Integer.MIN_VALUE; + + // status is changed just for safety, + // in case the thread won't continue (because it has been requested to stop) changeStatusSync(ServiceStatus.STOPPED); } @@ -288,11 +314,11 @@ public void run() { } String exitMessage = "Service " + config.getColorizedName() + " exited"; - if (exit_code == Integer.MIN_VALUE) // if thread has been interrupted this may never be executed anyway + if (exit_code == Integer.MIN_VALUE) // if thread has been interrupted this may not be executed anyway exitMessage += " because thread was interrupted"; else exitMessage += " with status code " + exit_code + " " + - (exit_code == 0 ? "(Good)" : "(Bad?)"); + (exit_code == 143 ? "(SIGTERM)" : (exit_code == 0 ? "(Good 👍)" : "(Bad 🥴?)")); System.out.println(exitMessage); } @@ -320,6 +346,159 @@ public Process getProc() { return proc; } + /** + * Run the stop cmd (see {@link ServiceConfig}) + */ + public void stop() { + if (proc == null || !proc.isAlive()) + return; + + ServiceConfig config = getConfig(); + String[] stopCmd = config.getStopCmd(); + int stopTimeout = getConfig().getStopTimeout(); + + if (stopCmd[0].startsWith("SIG")) { // cmd is actually not a command but a signal name + String signalName = stopCmd[0]; + if (Microstart.IS_WINDOWS) { + LOGGER.finer("Windows 😠..."); + } else { + LOGGER.info("Sending " + signalName + " to " + + getConfig().getColorizedName() + " (pid: " + proc.pid() + ") " + + "and all subprocesses"); + sendSignal(signalName); + } + + // destroy the process anyway, if the signal was processed correctly, + // then this should be a no-op + destroyProc(); + } else { // cmd is really a command + LOGGER.info("Executing stop command for " + getConfig().getColorizedName() + " (pid: " + proc.pid() + ")"); + ProcessBuilder stopProcBuilder = new ProcessBuilder(config.getStopCmd()) + .directory(config.getWorkingDirectory()) + .redirectOutput(ProcessBuilder.Redirect.INHERIT) + .redirectError(ProcessBuilder.Redirect.INHERIT); + + if (config.getStopStdin() != null) { + stopProcBuilder.redirectInput(config.getStopStdin()); + //LOGGER.info(config.getStopStdin().getAbsolutePath() + " will serve as stdin for " + config.getColorizedName()); + } + + /*Thread t = new Thread(() -> waitForStoppedStatus(stopTimeout)); + t.start();*/ + var waiterForStopped = Executors.newSingleThreadExecutor( + new DaemonThreadFactory("Waiter-For-Stopped-Status") + ) + .submit(() -> waitForStoppedStatus(stopTimeout)); + try { + stopProcBuilder + .start() + .waitFor(stopTimeout, TimeUnit.SECONDS); + } catch (InterruptedException | IOException e) { + LOGGER.log( + Level.SEVERE, + "Error while executing stop command \"" + + Arrays.toString(config.getStopCmd()) + "\"", + e + ); + } finally { + // cancel the thread. stop waiting + //t.interrupt(); + waiterForStopped.cancel(true); + + // at the end we need to be sure the process is destroyed + // if we call destroyProc method and STOPPED status was seen, + // then destroyProc is a no-op because the process doesn't exist anymore + destroyProc(); + } + } + } + + /** + * Wait for {@link ServiceStatus#STOPPED} status to appear on {@link #statusesQueue} + * or destroy the process if it is not seen before the timeout + *

+ * It is recommended to clear the queue before calling this method + *

+ * WARNING: This will block the thread + * + * @param timeout number of seconds to wait before destroying the process + */ + private void waitForStoppedStatus(int timeout) { + try { + long startWaitingAt = System.currentTimeMillis(); + + ServiceStatus currStatus; + while ((currStatus = statusesQueue.poll(timeout, TimeUnit.SECONDS)) != null) { + if (currStatus == ServiceStatus.STOPPED) // stopped status was seen in time + return; + + // received a status but not the STOPPED status + // the timeout should be updated + // it may not be guaranteed the timeout is precise because all these computations + // take time + long endWaitingAt = System.currentTimeMillis(); + long elapsedSeconds = (endWaitingAt - startWaitingAt) / 1_000; + timeout -= elapsedSeconds; + + startWaitingAt = endWaitingAt; + } + } catch (InterruptedException ignored) { + } + } + + /** + * @return the pids for all the subprocesses of {@link #proc} and itself (pid of {@link #proc} is also present + * in the returned list). List order is postorder + * (try to visualize the process hierarchy as a complete binary tree. + * Example: subsubproc subsubproc subproc subproc proc) + */ + private List pids(@NotNull ProcessHandle p, @NotNull List pidsList) { + p.children().forEach(childP -> pids(childP, pidsList)); + pidsList.add(p.pid()); + return pidsList; + } + + /** + * Send signal to {@link #proc} and all child processes + * @param signalName signal name to send + */ + private void sendSignal(@NotNull String signalName) { + if (proc == null || !proc.isAlive()) + return; + + // get a list of all process ids that should receive the signal + List allPids = pids(proc.toHandle(), new ArrayList<>()); + LOGGER.fine("Sending " + signalName + " to: " + allPids); + + // create kill command: kill --signal SIGNAME pid1 pid2 pid3... + String[] killCmd = new String[3 + allPids.size()]; + killCmd[0] = "kill"; + killCmd[1] = "--signal"; + killCmd[2] = signalName; + for (int i = 0; i < allPids.size(); ++i) + killCmd[i + 3] = String.valueOf(allPids.get(i)); + + try { + Runtime.getRuntime().exec(killCmd).waitFor(2, TimeUnit.SECONDS); + } catch (IOException | InterruptedException e) { + LOGGER.log( + Level.SEVERE, + "Error while sending " + signalName + " to " + allPids, + e + ); + } + } + + /** + * Tries to stop the current running process (if any) and all its children processes + */ + private void destroyProc() { + if (proc == null || !proc.isAlive()) + return; + + Microstart.destroyChildrenProcesses(proc.toHandle()); + } + /** * Runs the hook (if any) configured to run at the given service status * @@ -341,8 +520,7 @@ private void runHook(@NotNull ServiceStatus status) { private void changeStatus(@NotNull ServiceStatus newStatus) { status = newStatus; - if (statusesQueue != null) - statusesQueue.offer(newStatus); // use put instead? + statusesQueue.offer(newStatus); // use put instead? runHook(newStatus); // hooks shouldn't take too much time to finish } @@ -360,4 +538,19 @@ private void changeStatusSync(@NotNull ServiceStatus newStatus) { changeStatus(newStatus); } } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Service other = (Service) o; + return config.equals(other.config); // this will actually just check equality for service names + } + + @Override + public int hashCode() { + // this will actually return the hashcode for the service name, which should be unique throughout + // the application + return config.hashCode(); + } } diff --git a/src/main/java/net/benjaminguzman/ServiceConfig.java b/src/main/java/net/benjaminguzman/ServiceConfig.java index 130d193..ae5c9c9 100644 --- a/src/main/java/net/benjaminguzman/ServiceConfig.java +++ b/src/main/java/net/benjaminguzman/ServiceConfig.java @@ -18,9 +18,11 @@ package net.benjaminguzman; -import com.diogonunes.jcolor.Attribute; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import picocli.CommandLine; +import java.awt.*; import java.io.File; import java.nio.file.Paths; import java.util.Arrays; @@ -28,19 +30,22 @@ import java.util.List; import java.util.regex.Pattern; -import static com.diogonunes.jcolor.Ansi.colorize; - /** * Wrapper class for all the configurations needed to run a microservice */ public class ServiceConfig { + /** + * Multiply a 256 RGB color component by this factor to obtain it in scale from 0 to 5 + */ + public static float COLOR_NORM_FACTOR = 5f / 256; + /** * Service name - * + *

* It is the name that will be printed to stdout - * + *

* It should be unique throughout the whole application - * + *

* {@link Service} ensures that uniqueness */ @NotNull @@ -48,7 +53,7 @@ public class ServiceConfig { /** * Working directory for the service. - * + *

* Used when starting the service with {@link #startCmd} */ @NotNull @@ -64,18 +69,29 @@ public class ServiceConfig { private List aliases = Collections.emptyList(); /** - * ASCII color + * This color will be used to colorize the service name */ @NotNull - private Attribute asciiColor = Attribute.WHITE_TEXT(); + private Color color = Color.WHITE; @NotNull private String[] startCmd = {"npm", "run", "start"}; @NotNull - private String colorizedName = asciiColor + name + "\033[0m"; + private String[] stopCmd = {"SIGTERM"}; - private String colorizedErrorName = "\033[41mERROR " + colorizedName; + private int stopTimeout = 5; + + @Nullable + private File stdin; + + @Nullable + private File stopStdin; + + @NotNull + private String colorizedName = CommandLine.Help.Ansi.AUTO.string("@|white " + name + "|@"); + + private String colorizedErrorName = CommandLine.Help.Ansi.AUTO.string("@|red,blink,bold " + name + "|@"); /** * @return service name used to identify unequivocally the service within the application @@ -111,9 +127,10 @@ public ServiceConfig setWorkingDirectory(@NotNull File workingDirectory) { /** * Get the list of patterns to indicate the service has started (is up) - * + *

* If the text inside service (process) stdout matches one of these patterns, service will be considered to * have been started + * * @return the list of patterns * @see #setStartedPatterns(List) */ @@ -132,9 +149,10 @@ public ServiceConfig setStartedPatterns(@NotNull List startedPatterns) /** * Get the list of patterns to indicate an error occurred within the service - * + *

* If the text inside service (process) stdout matches one of these patterns, an error will be considered to * have happened + * * @return the list of patterns * @see #setErrorPatterns(List) */ @@ -153,7 +171,7 @@ public ServiceConfig setErrorPatterns(@NotNull List errorPatterns) { /** * Get the aliases for this service name - * + *

* Unlike service name, these aliases may not be unique throughout the application * * @see #getName() @@ -171,22 +189,22 @@ public ServiceConfig setAliases(@NotNull List aliases) { return this; } - public ServiceConfig setAsciiColor(Attribute asciiColor) { - this.asciiColor = asciiColor; + public ServiceConfig setColor(@NotNull Color color) { + this.color = color; this.setColorizedName(); return this; } /** * Get the start command (with arguments) - * + *

* Some start commands may be dependent on the {@link #workingDirectory} * (for example "npm", "run", "start" won't work if working directory is not right) * - * @see #setStartCmd(String) * @return the command to execute in order to start the service. It'll be system dependent, for example, in * windows platforms it may return {"cmd", "/c", real command}. You can pass this to * {@link ProcessBuilder#command(String...)} + * @see #setStartCmd(String) */ public String[] getStartCmd() { return startCmd; @@ -206,9 +224,100 @@ public ServiceConfig setStartCmd(@NotNull String startCmd) { } /** - * Same as {@link #getName()} but with {@link #asciiColor} at the beginning and - * "\033[0m" (reset ASCII sequence) at the end + * Get the stop command (with arguments) to be executed on exit or when service is stopped + *

+ * Some start commands may be dependent on the {@link #workingDirectory} + * (for example "npm", "run", "start" won't work if working directory is not right) + *

+ * Command MAY NOT be an actual command but a signal name (SIGHUP, SIGTERM, SIGKILL, SIGQUIT, SIGINT) which + * should be sent to the process * + * @return the command to execute in order to start the service. It'll be system dependent, for example, in + * windows platforms it may return {"cmd", "/c", real command}. You can pass this to + * {@link ProcessBuilder#command(String...)} + * @see #setStopCmd(String) + */ + public String[] getStopCmd() { + return stopCmd; + } + + /** + * @param stopCmd the command to execute or signal to send to stop a service + * @see #getStopCmd() + */ + public ServiceConfig setStopCmd(@NotNull String stopCmd) { + String normalizedStopCmd = stopCmd.strip().toUpperCase(); + switch (normalizedStopCmd) { + case "SIGINT": + case "SIGTERM": + case "SIGHUP": + case "SIGKILL": + case "SIGQUIT": + this.stopCmd[0] = normalizedStopCmd; + break; + default: + this.stopCmd = new String[]{ + Microstart.IS_WINDOWS ? "cmd" : "sh", + Microstart.IS_WINDOWS ? "/c" : "-c", + stopCmd + }; + } + return this; + } + + /** + * Get the stop timeout (in seconds) + *

+ * If service (process) hasn't been terminated after this number of seconds, destroy it + * + * @return timeout in seconds + * @see #setStopTimeout(int) + */ + public int getStopTimeout() { + return stopTimeout; + } + + /** + * @param timeout timeout in seconds + * @see #getStopTimeout() + */ + public ServiceConfig setStopTimeout(int timeout) { + if (timeout <= 0) + throw new IllegalArgumentException("stop timeout should be greater than 0"); + + this.stopTimeout = timeout; + return this; + } + + /** + * @param stdin the file containing the data that will be passed to the service process' stdin start command + */ + public ServiceConfig setStdin(@Nullable File stdin) { + this.stdin = stdin; + return this; + } + + @Nullable + public File getStdin() { + return stdin; + } + + /** + * @param stdin the file containing the data that will be passed to the service process' stdin stop command + */ + public ServiceConfig setStopStdin(@Nullable File stdin) { + this.stopStdin = stdin; + return this; + } + + @Nullable + public File getStopStdin() { + return stopStdin; + } + + /** + * Same as {@link #getName()} but with colorized with ANSI scape sequences + *

* You can safely print this to stdout */ @NotNull @@ -218,7 +327,7 @@ public String getColorizedName() { /** * Same as {@link #getColorizedName()} but with special format to indicate an error has happened - * + *

* You can safely print this to stdout */ @NotNull @@ -230,12 +339,21 @@ public String getColorizedErrorName() { * Set the colorized name */ private void setColorizedName() { - colorizedName = colorize(name, asciiColor); + // normalized rgb components in the scale 0 - 5 + // https://picocli.info/#_ansi_colors_and_styles + int[] rgbNorm = { + Math.round(COLOR_NORM_FACTOR * color.getRed()), + Math.round(COLOR_NORM_FACTOR * color.getGreen()), + Math.round(COLOR_NORM_FACTOR * color.getBlue()) + }; + + String normalizedColor = "fg(" + rgbNorm[0] + ";" + rgbNorm[1] + ";" + rgbNorm[2] + ")"; + colorizedName = CommandLine.Help.Ansi.AUTO.string("@|" + normalizedColor + " " + name + "|@"); setColorizedErrorName(); } private void setColorizedErrorName() { - colorizedErrorName = colorize(name, Attribute.SLOW_BLINK(), Attribute.RED_TEXT()); + colorizedErrorName = CommandLine.Help.Ansi.AUTO.string("@|red,blink,bold " + name + "|@"); } @Override @@ -260,7 +378,7 @@ public String toString() { ", startedPatterns=" + startedPatterns + ", errorPatterns=" + errorPatterns + ", aliases=" + aliases + - ", asciiColor='" + asciiColor + '\'' + + ", color=" + color + ", startCmd=" + Arrays.toString(startCmd) + ", colorizedName='" + colorizedName + '\'' + ", colorizedErrorName='" + colorizedErrorName + '\'' + diff --git a/src/main/java/net/benjaminguzman/ServiceStatus.java b/src/main/java/net/benjaminguzman/ServiceStatus.java index cbbef39..f2a03ad 100644 --- a/src/main/java/net/benjaminguzman/ServiceStatus.java +++ b/src/main/java/net/benjaminguzman/ServiceStatus.java @@ -64,11 +64,56 @@ public static boolean canServiceBeStarted(@NotNull ServiceStatus status) { /** * Tells if the service is currently running or is about to be running (is starting) + *

+ * A service is running if its status is one of {@link ServiceStatus#STARTING}, + * {@link ServiceStatus#STARTED}, {@link ServiceStatus#ERROR}, {@link ServiceStatus#STOPPING}. + *

+ * In other words, a service is running if it has been loaded and hasn't been * - * @param status current service status * @return true if the service is running, false otherwise */ - public static boolean isRunning(@NotNull ServiceStatus status) { - return status != LOADED && status != STOPPED; + public boolean isRunning() { + switch (this) { + case STARTING: + case STARTED: + case ERROR: + case STOPPING: + return true; + default: + return false; + } + } + + + /** + * Returns the name of this enum constant, as contained in the + * declaration. This method may be overridden, though it typically + * isn't necessary or desirable. An enum type should override this + * method when a more "programmer-friendly" string form exists. + * + * @return the name of this enum constant + */ + @Override + public String toString() { + String name = this.name().charAt(0) + this.name().substring(1).toLowerCase() + " "; + switch (this) { + case STARTING: + name += "🏃"; + break; + case STARTED: + name += "🏁"; + break; + case STOPPING: + name += "✋"; + break; + case STOPPED: + name += "🔴"; + break; + case ERROR: + name += "🥵"; + break; + } + + return name; } } diff --git a/src/main/resources/schema.json b/src/main/resources/schema.json index 2eba5e8..9343902 100644 --- a/src/main/resources/schema.json +++ b/src/main/resources/schema.json @@ -20,6 +20,16 @@ "type": "string", "description": "Command to start the service. You can add bash operators, e.g. \"npm run build && npm run start\" will be run as 'sh -c \"npm run build && npm run start\"' or 'cmd /c \"npm run build && npm run start\"' in windows (it may not work since cmd doesn't support && operator). Take into account that, the parent process for npm is sh (or cmd) and the parent process for sh is java, so java can handle its child process sh, but not the npm process" }, + "stop": { + "type": "string", + "description": "Command to execute on exit or when service is stopped, e.g. \"docker compose down\". If one a signal name (SIGINT, SIGKILL, SIGTERM, SIGQUIT, SIGHUP, ...) is provided, then such signal is sent to the process (ONLY *NIX SYSTEMS) and all its children subprocesses", + "default": "SIGTERM" + }, + "stopTimeout": { + "type": "number", + "description": "If service (process) is not stopped after executing the stop command and waiting this number of seconds, SIGINT or SIGTERM signal will be sent to the process", + "default": 5 + }, "aliases": { "type": "array", "description": "List of aliases for the name. With user interactive input these aliases can be useful", @@ -30,8 +40,11 @@ } }, "color": { - "type": "string", - "description": "Color in either hex (prefix 0x), octal (prefix 0) or decimal format. If your terminal supports that text color, the service name will have it" + "type": [ + "string", + "integer" + ], + "description": "Color in either hex (prefix 0x), octal (prefix 0) or decimal format. It will be normalized to one of the 216 colors in the ANSI palette https://en.wikipedia.org/wiki/ANSI_escape_code#Colors" }, "workDir": { "type": "string", @@ -52,6 +65,14 @@ "type": "string", "description": "Pattern that indicates the service has started. Example: errno is [0-9]{1, 2}. Pattern is compiled with CASE_INSENSITIVE option" } + }, + "stdin": { + "type": "string", + "description": "If you want to provide some data to the service process start command, put it inside a file and it will be redirected to the process" + }, + "stopStdin": { + "type": "string", + "description": "If you want to provide some data to the service process stop command, put it inside a file and it will be redirected to the process" } } } @@ -100,9 +121,9 @@ "minimum": 1, "description": "Maximum depth in the dependency graph. If it is detected a group has a dependency graph whose depth exceeds this maximum, an exception will be produced (and notified to user). Root node has depth=1" }, - "continueAfterError": { + "ignoreErrors": { "type": "boolean", - "default": true + "default": false } } -} \ No newline at end of file +} diff --git a/src/test/java/net/benjaminguzman/ConfigLoaderTest.java b/src/test/java/net/benjaminguzman/ConfigLoaderJSONTest.java similarity index 83% rename from src/test/java/net/benjaminguzman/ConfigLoaderTest.java rename to src/test/java/net/benjaminguzman/ConfigLoaderJSONTest.java index 081ffe5..e48c8fc 100644 --- a/src/test/java/net/benjaminguzman/ConfigLoaderTest.java +++ b/src/test/java/net/benjaminguzman/ConfigLoaderJSONTest.java @@ -35,11 +35,12 @@ import static org.junit.jupiter.api.Assertions.*; -class ConfigLoaderTest { +class ConfigLoaderJSONTest { @BeforeAll static void beforeAll() throws IOException, InstanceAlreadyExistsException { - if (ConfigLoader.getInstance() == null) - new ConfigLoader("src/test/test.json"); + if (ConfigLoader.getInstance() != null) + ConfigLoader.deleteInstance(); + new ConfigLoader("src/test/resources/test.json"); } @Test @@ -48,8 +49,11 @@ void loadConfig1() throws FileNotFoundException, ServiceNotFoundException { ServiceConfig test1Config = Objects.requireNonNull(ConfigLoader.getInstance()).loadServiceConfig("Test 1"); assertNotNull(test1Config); assertEquals("Test 1", test1Config.getName()); - assertEquals("echo -e \"Testing config loader...\nIt works!\"", test1Config.getStartCmd()[2]); + assertEquals("echo -e \"Testing config loader...\\nIt works!\"", test1Config.getStartCmd()[2]); + assertEquals("echo stopping service Test 1...", test1Config.getStopCmd()[2]); + assertEquals(1, test1Config.getStopTimeout()); assertEquals("/tmp", test1Config.getWorkingDirectory().toString()); + assertEquals("src/test/resources/mirror.stdin", test1Config.getStopStdin().toString()); assertEquals(Pattern.compile("Works", Pattern.CASE_INSENSITIVE).toString(), test1Config.getStartedPatterns().get(0).toString()); assertEquals(Pattern.compile("errno", Pattern.CASE_INSENSITIVE).toString(), test1Config.getErrorPatterns().get(1).toString()); @@ -64,7 +68,9 @@ void loadConfig2() throws FileNotFoundException, ServiceNotFoundException { ServiceConfig test2Config = Objects.requireNonNull(ConfigLoader.getInstance()).loadServiceConfig("Test 2"); assertNotNull(test2Config); assertEquals("Test 2", test2Config.getName()); - assertEquals("echo -e \"Testing config loader 2...\nIt works!\"", test2Config.getStartCmd()[2]); + assertEquals("echo -e \"Testing config loader 2...\\nIt works!\"", test2Config.getStartCmd()[2]); + assertEquals("SIGQUIT", test2Config.getStopCmd()[0]); + assertEquals(90, test2Config.getStopTimeout()); assertEquals("/tmp", test2Config.getWorkingDirectory().toString()); assertEquals(Pattern.compile("Works", Pattern.CASE_INSENSITIVE).toString(), test2Config.getStartedPatterns().get(0).toString()); assertEquals(Pattern.compile("errno", Pattern.CASE_INSENSITIVE).toString(), test2Config.getErrorPatterns().get(1).toString()); @@ -108,15 +114,24 @@ void loadGroupWWrongServiceName() { @Test @DisplayName("Testing config loader for good service group configuration") void loadGroupWGoodConfig() throws MaxDepthExceededException, ServiceNotFoundException, FileNotFoundException, GroupNotFoundException, CircularDependencyException { - ServiceGroupConfig config = Objects.requireNonNull(ConfigLoader.getInstance()).loadGroupConfig("good group"); + GroupConfig config = Objects.requireNonNull(ConfigLoader.getInstance()).loadGroupConfig("good group"); assertEquals("good group", config.getName()); assertEquals(List.of("pass", "good"), config.getAliases()); // check dependencies have been loaded correctly - ServiceGroupConfig deps = config.getDependenciesConfigs().get(0); + GroupConfig deps = config.getDependenciesConfigs().get(0); assertEquals(1, config.getDependenciesConfigs().size()); assertEquals("good group 2", deps.getName()); assertEquals("Test 2", deps.getServicesConfigs().get(0).getName()); assertEquals("/tmp", deps.getServicesConfigs().get(0).getWorkingDirectory().toString()); } -} \ No newline at end of file + + @Test + @DisplayName("Testing it loads ALL configuration") + void load() { + assertThrows( + MaxDepthExceededException.class, + () -> Objects.requireNonNull(ConfigLoader.getInstance()).load() + ); + } +} diff --git a/src/test/java/net/benjaminguzman/ConfigLoaderYAMLTest.java b/src/test/java/net/benjaminguzman/ConfigLoaderYAMLTest.java new file mode 100644 index 0000000..9061214 --- /dev/null +++ b/src/test/java/net/benjaminguzman/ConfigLoaderYAMLTest.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2021. Benjamín Antonio Velasco Guzmán + * Author: Benjamín Antonio Velasco Guzmán + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.benjaminguzman; + +import net.benjaminguzman.exceptions.CircularDependencyException; +import net.benjaminguzman.exceptions.GroupNotFoundException; +import net.benjaminguzman.exceptions.MaxDepthExceededException; +import net.benjaminguzman.exceptions.ServiceNotFoundException; +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.management.InstanceAlreadyExistsException; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * This class is identical to {@link ConfigLoaderJSONTest} and this is because JUnit 5 doesn't support parametrized + * tests at class level + * https://github.com/junit-team/junit5/issues/871 + */ +class ConfigLoaderYAMLTest { + @BeforeAll + static void beforeAll() throws IOException, InstanceAlreadyExistsException { + if (ConfigLoader.getInstance() != null) + ConfigLoader.deleteInstance(); + new ConfigLoader("src/test/resources/test.yml"); + } + + @Test + @DisplayName("Testing yaml config is equivalent to json config") + void equivalence() throws IOException { + JSONObject expected = new JSONObject(Files.readString(Path.of("src/test/resources/test.json"))); + JSONObject actual = Objects.requireNonNull(ConfigLoader.getInstance()).getRoot(); + + expected.remove("$schema"); + + // convert all color values to int + // naturally, this is not the best way of doing that, but for tests is ok + for (Object obj : expected.getJSONArray("services")) { + JSONObject service = (JSONObject) obj; + if (!service.has("color")) + continue; + service.put("color", Integer.decode(service.getString("color"))); + } + + assertTrue(expected.similar(actual)); + } + + @Test + @DisplayName("Testing config loader for service Test 1") + void loadConfig1() throws FileNotFoundException, ServiceNotFoundException { + ServiceConfig test1Config = Objects.requireNonNull(ConfigLoader.getInstance()) + .loadServiceConfig("Test 1"); + assertNotNull(test1Config); + assertEquals("Test 1", test1Config.getName()); + assertEquals("echo -e \"Testing config loader...\\nIt works!\"", test1Config.getStartCmd()[2]); + assertEquals("echo stopping service Test 1...", test1Config.getStopCmd()[2]); + assertEquals(1, test1Config.getStopTimeout()); + assertEquals("/tmp", test1Config.getWorkingDirectory().toString()); + assertEquals("src/test/resources/mirror.stdin", test1Config.getStopStdin().toString()); + assertEquals(Pattern.compile("Works", Pattern.CASE_INSENSITIVE) + .toString(), test1Config.getStartedPatterns().get(0).toString()); + assertEquals(Pattern.compile("errno", Pattern.CASE_INSENSITIVE) + .toString(), test1Config.getErrorPatterns().get(1).toString()); + + // test config can also be loaded by the service aliases + assertEquals(test1Config, ConfigLoader.getInstance().loadServiceConfig("test1")); + assertEquals(test1Config, ConfigLoader.getInstance().loadServiceConfig("first")); + } + + @Test + @DisplayName("Testing config loader for service Test 2") + void loadConfig2() throws FileNotFoundException, ServiceNotFoundException { + ServiceConfig test2Config = Objects.requireNonNull(ConfigLoader.getInstance()) + .loadServiceConfig("Test 2"); + assertNotNull(test2Config); + assertEquals("Test 2", test2Config.getName()); + assertEquals("echo -e \"Testing config loader 2...\\nIt works!\"", test2Config.getStartCmd()[2]); + assertEquals("SIGQUIT", test2Config.getStopCmd()[0]); + assertEquals(90, test2Config.getStopTimeout()); + assertEquals("/tmp", test2Config.getWorkingDirectory().toString()); + assertEquals(Pattern.compile("Works", Pattern.CASE_INSENSITIVE) + .toString(), test2Config.getStartedPatterns().get(0).toString()); + assertEquals(Pattern.compile("errno", Pattern.CASE_INSENSITIVE) + .toString(), test2Config.getErrorPatterns().get(1).toString()); + + // test config can also be loaded by the service aliases + assertEquals(test2Config, ConfigLoader.getInstance().loadServiceConfig("test2")); + assertEquals(test2Config, ConfigLoader.getInstance().loadServiceConfig("second")); + } + + @Test + @DisplayName("Testing config loader for group service with circular dependencies") + void loadGroupWCyclicDeps() { + assertThrows(CircularDependencyException.class, + () -> Objects.requireNonNull(ConfigLoader.getInstance()) + .loadGroupConfig("circular dependencies")); + } + + @Test + @DisplayName("Testing config loader for group service with max depth exceeded") + void loadGroupWMaxDepth() { + assertThrows(MaxDepthExceededException.class, () -> Objects.requireNonNull(ConfigLoader.getInstance()) + .loadGroupConfig("max depth")); + } + + @Test + @DisplayName("Testing config loader for group service with wrong dependency name") + void loadGroupWWrongDepName() { + assertThrows(GroupNotFoundException.class, () -> Objects.requireNonNull(ConfigLoader.getInstance()) + .loadGroupConfig("wrong dependency")); + } + + @Test + @DisplayName("Testing config loader for group service with wrong name") + void loadGroupWWrongName() { + assertThrows(GroupNotFoundException.class, () -> Objects.requireNonNull(ConfigLoader.getInstance()) + .loadGroupConfig("non existent group")); + } + + @Test + @DisplayName("Testing config loader for group service with wrong service name") + void loadGroupWWrongServiceName() { + assertThrows(ServiceNotFoundException.class, () -> Objects.requireNonNull(ConfigLoader.getInstance()) + .loadGroupConfig("wrong service")); + } + + @Test + @DisplayName("Testing config loader for good service group configuration") + void loadGroupWGoodConfig() throws MaxDepthExceededException, ServiceNotFoundException, FileNotFoundException, + GroupNotFoundException, CircularDependencyException { + GroupConfig config = Objects.requireNonNull(ConfigLoader.getInstance()).loadGroupConfig("good group"); + assertEquals("good group", config.getName()); + assertEquals(List.of("pass", "good"), config.getAliases()); + + // check dependencies have been loaded correctly + GroupConfig deps = config.getDependenciesConfigs().get(0); + assertEquals(1, config.getDependenciesConfigs().size()); + assertEquals("good group 2", deps.getName()); + assertEquals("Test 2", deps.getServicesConfigs().get(0).getName()); + assertEquals("/tmp", deps.getServicesConfigs().get(0).getWorkingDirectory().toString()); + } + + @Test + @DisplayName("Testing it loads ALL configuration") + void load() { + assertThrows( + MaxDepthExceededException.class, + () -> Objects.requireNonNull(ConfigLoader.getInstance()).load() + ); + } +} diff --git a/src/test/java/net/benjaminguzman/ConfigToDotTest.java b/src/test/java/net/benjaminguzman/ConfigToDotTest.java new file mode 100644 index 0000000..0f90efc --- /dev/null +++ b/src/test/java/net/benjaminguzman/ConfigToDotTest.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2021. Benjamín Antonio Velasco Guzmán + * Author: Benjamín Antonio Velasco Guzmán + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.benjaminguzman; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +class ConfigToDotTest { + Config config = new Config(); + File tmpOut = File.createTempFile("microstart", ".dot"); + + public ConfigToDotTest() throws IOException { + config.addAllServices(List.of( + new ServiceConfig().setName("Test 1").setStartCmd("hola").setAliases(List.of("alias 1", "ali" + )), + new ServiceConfig().setName("Test 2").setStartCmd("hola").setAliases(List.of("alias 2")), + new ServiceConfig().setName("Test 3").setStartCmd("hola").setAliases(List.of("alias 3")), + new ServiceConfig().setName("Test 4").setStartCmd("hola").setAliases(List.of("alias 4")), + new ServiceConfig().setName("Test 5").setStartCmd("hola").setAliases(List.of("alias 5")) + )); + GroupConfig group1 = new GroupConfig().setName("Group 1").setServicesConfigs(List.of( + config.getServices().get("Test 1"), + config.getServices().get("Test 2") + )); + GroupConfig group2 = new GroupConfig().setName("Group 2").setServicesConfigs(List.of( + config.getServices().get("Test 3") + )); + GroupConfig group3 = new GroupConfig().setName("Group 3").setServicesConfigs(List.of( + config.getServices().get("Test 4"), + config.getServices().get("Test 5") + )); + config.addAllGroups(List.of( + group1, + group2.setDependenciesConfigs(List.of(group1)), + group3.setDependenciesConfigs(List.of(group1, group2)) + )); + } + + @Test + @DisplayName("Test conversion with indentChar=' ' and indentSize=4") + void convert4space() throws IOException { + new ConfigToDot( + new ConfigToDot.Builder(config) + .setIndentChar(' ') + .setIndentSize(4) + ).convertAndSave(tmpOut.toPath()); + + // check contents are equal + BufferedInputStream actual = new BufferedInputStream(new FileInputStream(tmpOut)); + BufferedInputStream expected = new BufferedInputStream( + Objects.requireNonNull(this.getClass().getResourceAsStream("/4spaces.dot")) + ); + + byte[] actualBuff = new byte[2048]; + byte[] expectedBuff = new byte[2048]; + try (actual; expected) { + while (actual.read(actualBuff) != -1 && expected.read(expectedBuff) != -1) + assertArrayEquals(expectedBuff, actualBuff); + } + } + + /*@Test + @DisplayName("Test conversion with indentChar='\\t' and indentSize=2") + void convert2tabs() throws IOException { + new ConfigToDot( + new ConfigToDot.Builder(config) + .setIndentChar('\t') + .setIndentSize(2) + ).convertAndSave(tmpOut.toPath()); + + // check contents are equal + BufferedInputStream actual = new BufferedInputStream(new FileInputStream(tmpOut)); + BufferedInputStream expected = new BufferedInputStream( + Objects.requireNonNull(this.getClass().getResourceAsStream("/2tab.dot")) + ); + + byte[] actualBuff = new byte[2048]; + byte[] expectedBuff = new byte[2048]; + try (actual; expected) { + while (actual.read(actualBuff) != -1 && expected.read(expectedBuff) != -1) + assertArrayEquals(expectedBuff, actualBuff); + } + }*/ +} \ No newline at end of file diff --git a/src/test/java/net/benjaminguzman/ServiceGroupTest.java b/src/test/java/net/benjaminguzman/GroupTest.java similarity index 80% rename from src/test/java/net/benjaminguzman/ServiceGroupTest.java rename to src/test/java/net/benjaminguzman/GroupTest.java index 00fcba1..85d9874 100644 --- a/src/test/java/net/benjaminguzman/ServiceGroupTest.java +++ b/src/test/java/net/benjaminguzman/GroupTest.java @@ -28,21 +28,19 @@ import javax.management.InstanceAlreadyExistsException; import java.io.FileNotFoundException; import java.io.IOException; -import java.util.List; import java.util.Objects; -import static org.junit.jupiter.api.Assertions.*; - -class ServiceGroupTest { +class GroupTest { @BeforeAll static void beforeAll() throws IOException, InstanceAlreadyExistsException { - if (ConfigLoader.getInstance() == null) - new ConfigLoader("src/test/test.json"); + ConfigLoader.deleteInstance(); + new ConfigLoader("src/test/resources/test.json"); } @Test - void start() throws MaxDepthExceededException, ServiceNotFoundException, FileNotFoundException, GroupNotFoundException, CircularDependencyException, InstanceAlreadyExistsException { - ServiceGroupConfig config = Objects.requireNonNull(ConfigLoader.getInstance()).loadGroupConfig("good group"); + void start() throws MaxDepthExceededException, ServiceNotFoundException, FileNotFoundException, + GroupNotFoundException, CircularDependencyException { + GroupConfig config = Objects.requireNonNull(ConfigLoader.getInstance()).loadGroupConfig("good group"); //ServiceGroup group = new ServiceGroup(config); //group.start(); // uncomment to see results diff --git a/src/test/java/net/benjaminguzman/LevelOrderTraversalTest.java b/src/test/java/net/benjaminguzman/LevelOrderTraversalTest.java new file mode 100644 index 0000000..c08de9d --- /dev/null +++ b/src/test/java/net/benjaminguzman/LevelOrderTraversalTest.java @@ -0,0 +1,86 @@ +package net.benjaminguzman; + +import net.benjaminguzman.exceptions.CircularDependencyException; +import net.benjaminguzman.exceptions.GroupNotFoundException; +import net.benjaminguzman.exceptions.MaxDepthExceededException; +import net.benjaminguzman.exceptions.ServiceNotFoundException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.management.InstanceAlreadyExistsException; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +public class LevelOrderTraversalTest { + @BeforeAll + static void beforeAll() throws IOException, InstanceAlreadyExistsException { + if (ConfigLoader.getInstance() != null) + ConfigLoader.deleteInstance(); + new ConfigLoader("src/test/resources/tree.yml"); + } + + @Test + @DisplayName("Testing level order works") + void levelOrdering() throws FileNotFoundException, ServiceNotFoundException, MaxDepthExceededException, GroupNotFoundException, CircularDependencyException { + Config conf = Objects.requireNonNull(ConfigLoader.getInstance()).load(); + + // load all groups + conf.getGroups() + .values() + .stream() + .map(GroupConfig::getName) + .map(name -> { + try { + return ConfigLoader.getInstance().loadGroupConfig(name); + } catch (MaxDepthExceededException | GroupNotFoundException | + CircularDependencyException | FileNotFoundException | + ServiceNotFoundException e) { + //fail(e); + return null; + } + }) + .filter(Objects::nonNull) + .forEach(groupConfig -> { + try { + new Group(groupConfig); + } catch (InstanceAlreadyExistsException e) { + fail(e); + } + }); + + // get actual group level ordering + List groupNames = Group.getRoots() + .stream() + .map(Microstart::levelOrderTraversal) + // .peek(System.out::println) // check traversal is indeed working + .flatMap(Collection::stream) + .flatMap(Collection::stream) + //.distinct() // distinct is used in Microstart because we call shutdownNow only once + .map(Group::getConfig) + .map(GroupConfig::getName) + .toList(); + + List expected = Stream.of( + List.of( + List.of("Root 2"), + List.of("Deeper 1"), + List.of("Deepest 1") + ), + List.of( + List.of("Root 1"), + List.of("Deep 1"), + List.of("Deeper 1", "Deeper 2"), + List.of("Deepest 1") + ) + ).flatMap(Collection::stream).flatMap(Collection::stream).toList(); + + assertEquals(expected, groupNames); + } +} diff --git a/src/test/java/net/benjaminguzman/ServiceTest.java b/src/test/java/net/benjaminguzman/ServiceTest.java index f6c2999..8522135 100644 --- a/src/test/java/net/benjaminguzman/ServiceTest.java +++ b/src/test/java/net/benjaminguzman/ServiceTest.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test; import javax.management.InstanceAlreadyExistsException; +import java.io.File; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -189,4 +190,49 @@ void runMultipleUp() throws InterruptedException, InstanceAlreadyExistsException assertNotNull(Service.forName(serviceName)); // check the service was added to singleton map } -} \ No newline at end of file + + @Test + @DisplayName("Testing stdin redirection works") + void runStdinRedirection() throws InterruptedException, InstanceAlreadyExistsException { + String serviceName = "Mirror"; + + assertNull(Service.forName(serviceName)); + + List expectedStatuses = List.of( + ServiceStatus.LOADED, + ServiceStatus.STARTING, + ServiceStatus.STARTED, + ServiceStatus.STOPPING, + ServiceStatus.STOPPED + ); + + Map> hooks = new HashMap<>(); + + BlockingQueue queue = new ArrayBlockingQueue<>(expectedStatuses.size(), true); + + Service service = new Service( + new ServiceConfig() + .setName(serviceName) + .setStartedPatterns(List.of( + Pattern.compile("World!", Pattern.CASE_INSENSITIVE) + )) + .setStdin(new File("src/test/resources/mirror.stdin")) + .setStartCmd("python3 src/test/resources/mirror.py"), + hooks, + (s, e) -> fail(), + queue + ); + Thread t = new Thread(service); + t.start(); + + // check all expected statuses have been added to queue + for (ServiceStatus status : expectedStatuses) + assertEquals(status, queue.take()); + + t.join(); // wait for service to end + + assertTrue(queue.isEmpty()); // verify that no more statuses were added to the queue + + assertNotNull(Service.forName(serviceName)); // check the service was added to singleton map + } +} diff --git a/src/test/resources/4spaces.dot b/src/test/resources/4spaces.dot new file mode 100644 index 0000000..ac5e6c7 --- /dev/null +++ b/src/test/resources/4spaces.dot @@ -0,0 +1,25 @@ +digraph { + compound=true; + rank=same; + ranksep=1; + subgraph cluster_1958080177 { + label="Group 2"; + color=blue; + "Test 31958080177" [label=alias 3>, style=filled]; + } + subgraph cluster_1958080176 { + label="Group 1"; + color=blue; + "Test 11958080176" [label=alias 1, ali>, style=filled]; + "Test 21958080176" [label=alias 2>, style=filled]; + } + subgraph cluster_1958080178 { + label="Group 3"; + color=blue; + "Test 41958080178" [label=alias 4>, style=filled]; + "Test 51958080178" [label=alias 5>, style=filled]; + } + "Test 11958080176" -> "Test 31958080177" [lhead=cluster_1958080177, ltail=cluster_1958080176]; + "Test 11958080176" -> "Test 41958080178" [lhead=cluster_1958080178, ltail=cluster_1958080176]; + "Test 31958080177" -> "Test 41958080178" [lhead=cluster_1958080178, ltail=cluster_1958080177]; +} diff --git a/src/test/resources/mirror.py b/src/test/resources/mirror.py new file mode 100644 index 0000000..7f636f3 --- /dev/null +++ b/src/test/resources/mirror.py @@ -0,0 +1,3 @@ +for _ in range(10): + i = input() + print(i) \ No newline at end of file diff --git a/src/test/resources/mirror.stdin b/src/test/resources/mirror.stdin new file mode 100644 index 0000000..0cc3a6d --- /dev/null +++ b/src/test/resources/mirror.stdin @@ -0,0 +1,14 @@ +hello +hello +hola +hello +hello +hello +hello +World! +mundo +mundial +some +more +input +hehehe \ No newline at end of file diff --git a/src/test/test.json b/src/test/resources/test.json similarity index 69% rename from src/test/test.json rename to src/test/resources/test.json index ee15d40..0d82cc8 100644 --- a/src/test/test.json +++ b/src/test/resources/test.json @@ -2,7 +2,10 @@ "$schema": "https://raw.githubusercontent.com/BenjaminGuzman/microstart/main/src/main/resources/schema.json", "services": [{ "name": "Test 1", - "start": "echo -e \"Testing config loader...\nIt works!\"", + "start": "echo -e \"Testing config loader...\\nIt works!\"", + "stop": "echo stopping service Test 1...", + "stopTimeout": 1, + "stopStdin": "src/test/resources/mirror.stdin", "aliases": ["test1", "first"], "color": "0x00ff00", "workDir": "/tmp", @@ -10,12 +13,23 @@ "errorPatterns": ["error", "errno"] }, { "name": "Test 2", - "start": "echo -e \"Testing config loader 2...\nIt works!\"", + "start": "echo -e \"Testing config loader 2...\\nIt works!\"", + "stop": "SIGQUIT", + "stopTimeout": 90, "aliases": ["test2", "second"], "color": "0x00ff00", "workDir": "/tmp", "startedPatterns": ["Works", "(service|server) is up"], "errorPatterns": ["error", "errno"] + }, { + "name": "Mirror", + "start": "python src/test/resources/mirror.py", + "aliases": ["mirror"], + "color": "0x00ffff", + "workDir": "/tmp", + "startedPatterns": ["World!"], + "errorPatterns": ["error"], + "stdin": "src/test/resources/mirror.stdin" }], "groups": [{ "name": "good group", @@ -46,4 +60,4 @@ "services": ["non existent service"] }], "maxDepth": 2 -} \ No newline at end of file +} diff --git a/src/test/resources/test.yml b/src/test/resources/test.yml new file mode 100644 index 0000000..f5c58c7 --- /dev/null +++ b/src/test/resources/test.yml @@ -0,0 +1,89 @@ +services: + - name: Test 1 + start: 'echo -e "Testing config loader...\nIt works!"' + stop: 'echo stopping service Test 1...' + stopTimeout: 1 + stopStdin: src/test/resources/mirror.stdin + aliases: + - test1 + - first + color: 0x00ff00 + workDir: /tmp + startedPatterns: + - Works + - (service|server) is up + errorPatterns: + - error + - errno + + - name: Test 2 + start: 'echo -e "Testing config loader 2...\nIt works!"' + stop: SIGQUIT + stopTimeout: 90 + aliases: + - test2 + - second + color: 0x00ff00 + workDir: /tmp + startedPatterns: + - Works + - (service|server) is up + errorPatterns: + - error + - errno + + - name: Mirror + start: "python src/test/resources/mirror.py" + aliases: + - mirror + color: 0x00ffff + workDir: "/tmp" + startedPatterns: + - World! + errorPatterns: + - error + stdin: "src/test/resources/mirror.stdin" + +groups: + - name: good group + services: + - Test 1 + aliases: + - pass + - good + dependencies: + - good group 2 + + - name: good group 2 + services: + - Test 2 + + - name: max depth + services: + - Test 1 + dependencies: + - good group + + - name: circular dependencies + services: + - Test 1 + dependencies: + - circular dependencies 2 + + - name: circular dependencies 2 + services: + - Test 1 + dependencies: + - circular dependencies + + - name: wrong dependency + services: + - Test 1 + dependencies: + - non existent dependency + + - name: wrong service + services: + - non existent service + +maxDepth: 2 diff --git a/src/test/resources/tree.yml b/src/test/resources/tree.yml new file mode 100644 index 0000000..4d76d8b --- /dev/null +++ b/src/test/resources/tree.yml @@ -0,0 +1,47 @@ +services: + - name: Service 1 + start: 'echo -e "Testing config loader...\nIt works!"' + + - name: Service 2 + start: 'echo -e "Testing config loader 2...\nIt works!"' + + - name: Mirror + start: "python src/test/resources/mirror.py" + +groups: + - name: Deepest 1 + services: + - Service 1 + dependencies: + - Deeper 1 + - Deeper 2 + + - name: Deeper 2 + services: + - Service 2 + dependencies: + - Deep 1 + + - name: Deeper 1 + services: + - Service 1 + dependencies: + - Deep 1 + - Root 2 + + - name: Deep 1 + services: + - Service 1 + - Service 2 + dependencies: + - Root 1 + + - name: Root 1 + services: + - Mirror + + - name: Root 2 + services: + - Mirror + +maxDepth: 4