diff --git a/.github/workflows/jest.yml b/.github/workflows/jest.yml
index 2560713..c270c15 100644
--- a/.github/workflows/jest.yml
+++ b/.github/workflows/jest.yml
@@ -13,9 +13,10 @@ jobs:
test:
runs-on: ubuntu-latest
env:
- DATABASE_URL: 'mysql://test:test@localhost:3306/test'
- CONFIG_DOCKER_HOST: '///var/run/docker.sock'
- DEBUG: 'true'
+ DATABASE_URL: "mysql://test:test@localhost:3306/test"
+ CONFIG_DOCKER_HOST: "///var/run/docker.sock"
+ CONFIG_RESOURCES_PATH: "./resources"
+ DEBUG: "true"
steps:
- name: Checkout
uses: actions/checkout@v2
@@ -26,14 +27,20 @@ jobs:
echo "This PR is from the dev branch. Exiting..."
exit 0
fi
+ - name: Prepare resources folder for tests
+ run: |
+ # Create test template folder
+ mkdir -p ./resources/templates/test
+ # Copy test template files to the resources folder
+ cp -r ./dev/templates/test/* ./resources/templates/test/
- name: Shutdown default MySQL
run: sudo service mysql stop
- name: Setup MySQL
uses: mirromutth/mysql-action@v1.1
with:
- mysql database: 'test'
- mysql user: 'test'
- mysql password: 'test'
+ mysql database: "test"
+ mysql user: "test"
+ mysql password: "test"
- name: Install Node.js
uses: actions/setup-node@v6
with:
@@ -48,4 +55,4 @@ jobs:
run: npm test
- name: Publish Test Summary Results
run: npx github-actions-ctrf ctrf/ctrf-report.json
- if: always()
\ No newline at end of file
+ if: always()
diff --git a/.github/workflows/push-image.yml b/.github/workflows/push-image.yml
index 25c76e9..81787a9 100644
--- a/.github/workflows/push-image.yml
+++ b/.github/workflows/push-image.yml
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
tag:
- description: 'Tag to use for the image (optional, defaults to branch name and short SHA)'
+ description: "Tag to use for the image (optional, defaults to branch name and short SHA)"
required: false
jobs:
@@ -30,20 +30,20 @@ jobs:
run: |
IMAGE_OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
IMAGE_NAME=ghcr.io/$IMAGE_OWNER/node-server-manager
-
+
if [ "${{ github.event_name }}" = "release" ]; then
TAG=${{ github.event.release.tag_name }}
echo "IS_RELEASE=true" >> $GITHUB_ENV
-
+
elif [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.tag }}" ]; then
TAG=${{ github.event.inputs.tag }}
echo "IS_RELEASE=true" >> $GITHUB_ENV
-
+
else
TAG=${GITHUB_REF_NAME}-$(echo $GITHUB_SHA | cut -c1-7)
echo "IS_RELEASE=false" >> $GITHUB_ENV
fi
-
+
echo "IMAGE_NAME=$IMAGE_NAME" >> $GITHUB_ENV
echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV
@@ -66,4 +66,4 @@ jobs:
if: env.IS_RELEASE == 'true'
run: |
docker tag $IMAGE_NAME:$IMAGE_TAG $IMAGE_NAME:latest
- docker push $IMAGE_NAME:latest
\ No newline at end of file
+ docker push $IMAGE_NAME:latest
diff --git a/.gitignore b/.gitignore
index c45b6ef..36b7834 100644
--- a/.gitignore
+++ b/.gitignore
@@ -131,7 +131,6 @@ dist
.pnp.*
# Other
-templates
volumes
# Exclude all addons except the example one
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..1b8ac88
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,3 @@
+# Ignore artifacts:
+build
+coverage
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1 @@
+{}
diff --git a/Dockerfile b/Dockerfile
index 7cfc4b2..d3c8838 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,4 +23,4 @@ COPY index.ts ./
RUN npm run build
-CMD npx prisma migrate deploy && npm run start
\ No newline at end of file
+CMD npm start
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..1234982
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,34 @@
+.PHONY: build test up down restart logs shell ps
+
+ATTACH ?= 0
+
+all: build
+
+build:
+ docker compose build
+
+test:
+ifeq ($(ATTACH),1) # if ATTACH=1, run tests with debugger attached
+ docker compose run --rm -p 9229:9229 nsm \
+ sh -c 'npm run migrate && node --inspect-brk=0.0.0.0:9229 ./node_modules/.bin/jest --runInBand $(ARGS)'
+else
+ docker compose run --rm nsm npm run test $(ARGS)
+endif
+
+up:
+ docker compose up -d
+
+down:
+ docker compose down
+
+restart:
+ docker compose restart
+
+logs:
+ docker compose logs -f
+
+shell:
+ docker compose exec nsm sh
+
+ps:
+ docker compose ps
diff --git a/README.md b/README.md
index 1b2073f..0c8201c 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ NSM is a robust service manager built on Docker Engine. Its primary purpose is t
- **Resources usage management**: NSM provides ability to limit or extend resources limits and view current usage.
## API Specification
+
Specification is hosted on external repository here
## Prerequisites
@@ -27,16 +28,20 @@ Ensure you have the following installed before proceeding with the installation:
Follow these steps to install and set up NSM:
1. **Clone the Repository**
+
```sh
git clone https://github.com/ZorTik/node-server-manager
```
+
Alternatively, download the latest release from the [NSM repository](https://github.com/ZorTik/node-server-manager) and extract it.
2. **Configure Environment Variables**
Copy the example environment file and fill in the required values:
+
```sh
cp .env.example .env
```
+
Open the `.env` file and provide the necessary configuration values.
3. **Edit Configuration**
@@ -44,18 +49,21 @@ Follow these steps to install and set up NSM:
4. **Install Dependencies**
Install the required Node.js packages:
+
```sh
npm install
```
5. **Generate Prisma Client**
Generate the Prisma client for database interaction:
+
```sh
npx prisma generate
```
6. **Sync Database Schema**
Apply the database schema migrations:
+
```sh
npx prisma migrate deploy
```
diff --git a/addons/example_addon/index.ts b/addons/example_addon/index.ts
index 9c93283..3c2be3a 100644
--- a/addons/example_addon/index.ts
+++ b/addons/example_addon/index.ts
@@ -1,14 +1,14 @@
-import {Addon} from "@nsm/addon";
+import { Addon } from "@nsm/addon";
import winston from "winston";
async function initAfterLogger(ctx: { logger: winston.Logger }) {
- ctx.logger.info('Hello from example addon!');
+ ctx.logger.info("Hello from example addon!");
}
export default {
- name: 'example_addon',
- disabled: true,
- steps: {
- BEFORE_CONFIG: initAfterLogger,
- }
-} as Addon;
\ No newline at end of file
+ name: "example_addon",
+ disabled: true,
+ steps: {
+ BEFORE_CONFIG: initAfterLogger,
+ },
+} as Addon;
diff --git a/addons/example_addon/libraries.txt b/addons/example_addon/libraries.txt
index 828d07c..722f54a 100644
--- a/addons/example_addon/libraries.txt
+++ b/addons/example_addon/libraries.txt
@@ -1 +1 @@
-express=4.18.2
\ No newline at end of file
+express=5.2.1
\ No newline at end of file
diff --git a/babel.config.js b/babel.config.js
index 9127806..dd242dc 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -1,6 +1,6 @@
module.exports = {
- presets: [
- ['@babel/preset-env', {targets: {node: 'current'}}],
- '@babel/preset-typescript',
- ],
-};
\ No newline at end of file
+ presets: [
+ ["@babel/preset-env", { targets: { node: "current" } }],
+ "@babel/preset-typescript",
+ ],
+};
diff --git a/resources/template/example/example_nsmignore b/dev/templates/test/.nsmignore
similarity index 100%
rename from resources/template/example/example_nsmignore
rename to dev/templates/test/.nsmignore
diff --git a/resources/template/test/test_dockerfile b/dev/templates/test/Dockerfile
similarity index 100%
rename from resources/template/test/test_dockerfile
rename to dev/templates/test/Dockerfile
diff --git a/resources/template/test/test_settings.yml b/dev/templates/test/settings.yml
similarity index 75%
rename from resources/template/test/test_settings.yml
rename to dev/templates/test/settings.yml
index d8e6f1d..1b222b8 100644
--- a/resources/template/test/test_settings.yml
+++ b/dev/templates/test/settings.yml
@@ -1,5 +1,5 @@
-name: 'Test'
-description: 'A Test template'
+name: "Test"
+description: "A Test template"
port_range:
min: 22222
max: 33333
@@ -9,6 +9,6 @@ defaults:
disk: 2000000000 # bytes
meta:
# Stop command to be sent in stop signal endpoint
- stopCmd: 'stop'
+ stopCmd: "stop"
# Optional ENV vars, and their default values
-env: {}
\ No newline at end of file
+env: {}
diff --git a/docker-compose.yml b/docker-compose.yml
index 19fe4b6..11131ef 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,17 +2,22 @@ services:
nsm:
build: .
volumes:
- - '/var/run/docker.sock:/var/run/docker.sock'
+ - "./dev/templates/test:/data/resources/templates/test:ro"
+ - "./tests:/data/tests:ro"
ports:
- - '3000:3000'
+ - "3000:3000"
extra_hosts:
- - 'docker.host.internal:host-gateway'
+ - "docker.host.internal:host-gateway"
environment:
- - 'CONFIG_DOCKER_HOST=///var/run/docker.sock'
- - 'DATABASE_URL=mysql://root:test@db:3306/nsm'
+ - "CONFIG_DOCKER_HOST=http://docker:2375"
+ - "CONFIG_RESOURCES_PATH=/data/resources"
+ - "DATABASE_URL=mysql://root:test@db:3306/nsm"
+ - "DOCKER_HOST=tcp://docker:2375"
depends_on:
db:
condition: service_healthy
+ docker:
+ condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/ || exit 1"]
interval: 5s
@@ -28,14 +33,29 @@ services:
- ./logs
- ./node_packages
- ./package-lock.json
-
+ docker:
+ image: docker:29.1.3-dind
+ privileged: true
+ volumes:
+ - docker_data:/var/lib/docker
+ environment:
+ # in dev environment, we don't need TLS for Dind.
+ DOCKER_TLS_CERTDIR: ""
+ ports:
+ - "2377:2375"
+ healthcheck:
+ test: ["CMD", "docker", "info"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
db:
image: mariadb:10.4
environment:
MARIADB_ROOT_PASSWORD: test
MARIADB_DATABASE: nsm
ports:
- - '3306:3306'
+ - "3306:3306"
volumes:
- nsm_db:/var/lib/mysql
healthcheck:
@@ -46,4 +66,5 @@ services:
start_period: 10s
volumes:
- nsm_db:
\ No newline at end of file
+ nsm_db:
+ docker_data:
\ No newline at end of file
diff --git a/index.ts b/index.ts
index 4c48f2c..a458ae6 100644
--- a/index.ts
+++ b/index.ts
@@ -1,10 +1,10 @@
-import {init} from "@nsm/app";
-import {postInit} from "@nsm/cleanup";
+import { init } from "@nsm/app";
+import { postInit } from "@nsm/cleanup";
import server from "@nsm/server";
init(server)
- // Run some cleanup tasks and register handlers
- .then(postInit)
- .catch((e) => {
- console.log(e);
- });
+ // Run some cleanup tasks and register handlers
+ .then(postInit)
+ .catch((e) => {
+ console.log(e);
+ });
diff --git a/installTempDeps.js b/installTempDeps.js
index a93900e..a6afbb1 100644
--- a/installTempDeps.js
+++ b/installTempDeps.js
@@ -1,18 +1,20 @@
const fs = require("fs");
const npm = require("npm");
-console.log('Preinstalling dependencies for build...');
+console.log("Preinstalling dependencies for build...");
npm.load().then(() => {
- for (let addon of fs.readdirSync(process.cwd() + '/addons')) {
- const libFPath = process.cwd() + '/addons/' + addon + '/libraries.txt';
- if (!fs.existsSync(libFPath)) {
- continue;
- }
- const libs = fs.readFileSync(libFPath, 'utf8').split('\n')
- .map((lib) => lib.split('=')[0] + '@' + lib.split('=')[1]);
- npm.commands.install(libs, (err) => {
- console.log(err);
- });
+ for (let addon of fs.readdirSync(process.cwd() + "/addons")) {
+ const libFPath = process.cwd() + "/addons/" + addon + "/libraries.txt";
+ if (!fs.existsSync(libFPath)) {
+ continue;
}
-});
\ No newline at end of file
+ const libs = fs
+ .readFileSync(libFPath, "utf8")
+ .split("\n")
+ .map((lib) => lib.split("=")[0] + "@" + lib.split("=")[1]);
+ npm.commands.install(libs, (err) => {
+ console.log(err);
+ });
+ }
+});
diff --git a/jest.config.js b/jest.config.js
index 216f8fc..7e1d746 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,13 +1,20 @@
-const tsconfig = require("./tsconfig.json")
-const moduleNameMapper = require("tsconfig-paths-jest")(tsconfig)
+const tsconfig = require("./tsconfig.json");
+const moduleNameMapper = require("tsconfig-paths-jest")(tsconfig);
module.exports = {
- moduleNameMapper,
- transformIgnorePatterns: [
- "/node_modules/(?!(env-paths)/)",
- ],
- reporters: [
- 'default',
- ['jest-ctrf-json-reporter', {}],
- ],
-}
\ No newline at end of file
+ moduleNameMapper,
+ testPathIgnorePatterns: [
+ "/node_modules/",
+ "/dist/"
+ ],
+ modulePathIgnorePatterns: [
+ "/dist/"
+ ],
+ transformIgnorePatterns: [
+ "/node_modules/(?!(env-paths)/)"
+ ],
+ reporters: [
+ "default",
+ ["jest-ctrf-json-reporter", {}]
+ ],
+};
diff --git a/openapi.yml b/openapi.yml
index bed694d..2bc5b0b 100644
--- a/openapi.yml
+++ b/openapi.yml
@@ -142,10 +142,11 @@ components:
description: "The template ID used to create the service"
state:
type: string
- description: "The current state of the service. One of: 'RUNNING', 'BUILDING', 'STOPPED'."
+ description: "The current state of the service. One of: 'RUNNING', 'BUILDING', 'STOPPING', 'STOPPED'."
enum:
- "RUNNING"
- "BUILDING"
+ - "STOPPING"
- "STOPPED"
port:
type: "integer"
@@ -468,6 +469,11 @@ paths:
required: true
schema:
type: "string"
+ - name: "force"
+ in: "query"
+ required: false
+ schema:
+ type: boolean
responses:
"200":
description: "Successfully rebooted service."
@@ -550,4 +556,4 @@ paths:
# TODO: /v1/service/{serviceId}/sessions
# TODO: /v1/service/{serviceId}/logs
-# TODO: /v1/session/{sessionId}/logs
\ No newline at end of file
+# TODO: /v1/session/{sessionId}/logs
diff --git a/package.json b/package.json
index 6ad3e3b..7e4e146 100644
--- a/package.json
+++ b/package.json
@@ -5,8 +5,9 @@
"main": "index.js",
"scripts": {
"build": "node installTempDeps.js && tsc && tscp",
- "start": "cross-env TS_NODE_BASEURL=./dist node -r tsconfig-paths/register dist/index.js",
- "test": "jest"
+ "migrate": "prisma migrate deploy",
+ "start": "npm run migrate && cross-env TS_NODE_BASEURL=./dist node -r tsconfig-paths/register dist/index.js",
+ "test": "npm run migrate && jest"
},
"keywords": [],
"author": "ZorTik",
@@ -23,6 +24,7 @@
"check-disk-space": "^3.4.0",
"express-ws": "^5.0.2",
"jest-ctrf-json-reporter": "^0.0.9",
+ "prettier": "3.8.3",
"prisma": "^5.10.2",
"superagent": "^9.0.2",
"supertest": "^7.0.0",
@@ -55,11 +57,12 @@
"dockerode": "^4.0.2",
"dotenv": "^16.4.5",
"env-paths": "^3.0.0",
- "express": "^4.18.2",
+ "express": "^5.2.1",
"express-fileupload": "^1.5.0",
"folder-hash": "^4.1.1",
"ignore": "^5.3.1",
"jest": "^29.7.0",
+ "jest-mock-extended": "^4.0.1",
"npm": "^7.24.2",
"tar": "^6.2.0",
"tsconfig-paths-jest": "^0.0.1",
diff --git a/resources/config.yml b/resources/config.yml
index a610785..53b08fd 100644
--- a/resources/config.yml
+++ b/resources/config.yml
@@ -2,13 +2,13 @@
# with CONFIG_ format.
# ID of this node. Should be unique.
-node_id: 'main'
+node_id: "main"
# Listen port.
port: 3000
# Security
# Supported types: 'none', 'auth_token'
-auth: 'none'
-docker_host: 'unix:///var/run/docker.sock'
+auth: "none"
+docker_host: "unix:///var/run/docker.sock"
# Override resources path if needed.
# By default, an explicit system-specific data path is used.
-# resources_path: '/srv/resources'
\ No newline at end of file
+# resources_path: '/srv/resources'
diff --git a/resources/template/example/example_dockerfile b/resources/templates/example/example_dockerfile
similarity index 100%
rename from resources/template/example/example_dockerfile
rename to resources/templates/example/example_dockerfile
diff --git a/resources/template/test/test_nsmignore b/resources/templates/example/example_nsmignore
similarity index 100%
rename from resources/template/test/test_nsmignore
rename to resources/templates/example/example_nsmignore
diff --git a/resources/template/example/example_settings.yml b/resources/templates/example/example_settings.yml
similarity index 65%
rename from resources/template/example/example_settings.yml
rename to resources/templates/example/example_settings.yml
index 3b8ee85..f351972 100644
--- a/resources/template/example/example_settings.yml
+++ b/resources/templates/example/example_settings.yml
@@ -1,5 +1,5 @@
-name: 'Example'
-description: 'An Example template'
+name: "Example"
+description: "An Example template"
port_range:
min: 25565
max: 35565
@@ -10,8 +10,8 @@ defaults:
disk: 2000000000 # bytes
meta:
# Stop command to be sent in stop signal endpoint
- stopCmd: 'stop'
+ stopCmd: "stop"
# Optional ENV vars, and their default values
env:
- STARTUP_FILE: 'server.jar'
- JAVA_VERSION: '' # Required option
\ No newline at end of file
+ STARTUP_FILE: "server.jar"
+ JAVA_VERSION: "" # Required option
diff --git a/src/addon.ts b/src/addon.ts
index 9fbe55e..81f8435 100644
--- a/src/addon.ts
+++ b/src/addon.ts
@@ -1,112 +1,123 @@
import winston from "winston";
-import {AppContext} from "./app";
+import { AppContext } from "./app";
import * as fs from "fs";
import npm from "npm";
import * as http from "http";
-import {isDebug} from "./helpers";
-import {createLogger} from "./logger";
+import { isDebug } from "./helpers";
+import { createLogger } from "./logger";
type FunctionTypes = {
- 'BEFORE_CONFIG': (ctx: { logger: winston.Logger }) => Promise;
- 'BEFORE_DB': (ctx: { logger: winston.Logger, appConfig: any }) => Promise;
- 'BEFORE_ENGINE': (ctx: AppContext) => Promise;
- 'BEFORE_SECURITY': (ctx: AppContext) => Promise;
- 'BEFORE_ROUTES': (ctx: AppContext) => Promise;
- 'BEFORE_SERVER': (ctx: AppContext) => Promise;
- 'BOOT': (ctx: AppContext, srv: http.Server) => Promise;
- 'EXIT': (ctx: AppContext) => Promise;
-}
+ BEFORE_CONFIG: (ctx: { logger: winston.Logger }) => Promise;
+ BEFORE_DB: (ctx: { logger: winston.Logger; appConfig: any }) => Promise;
+ BEFORE_ENGINE: (ctx: AppContext) => Promise;
+ BEFORE_SECURITY: (ctx: AppContext) => Promise;
+ BEFORE_ROUTES: (ctx: AppContext) => Promise;
+ BEFORE_SERVER: (ctx: AppContext) => Promise;
+ BOOT: (ctx: AppContext, srv: http.Server) => Promise;
+ EXIT: (ctx: AppContext) => Promise;
+};
export type Moment = keyof FunctionTypes;
export type AddonSteps = {
- [key in Moment]: FunctionTypes[key];
+ [key in Moment]: FunctionTypes[key];
};
export type Addon = {
- name: string,
- briefName?: string,
- author?: string,
- version?: string,
- disabled?: boolean,
- steps: AddonSteps,
-}
+ name: string;
+ briefName?: string;
+ author?: string;
+ version?: string;
+ disabled?: boolean;
+ steps: AddonSteps;
+};
async function initNpm() {
- await npm.load();
- npm.config.set('save', false);
- npm.config.set('save-dev', false);
+ await npm.load();
+ npm.config.set("save", false);
+ npm.config.set("save-dev", false);
}
// Installs dependencies written in libraries.txt
-async function installLibs(logger: winston.Logger, libs: { [key: string]: string }) {
- const libsArray = Object.keys(libs).map((key) => key + '@' + libs[key]);
- logger.info(`Installing ${libsArray.join(', ')}`);
- await new Promise((resolve, reject) => {
- npm.commands.install(libsArray, (err) => {
- if (err) {
- reject(err);
- } else {
- resolve(true);
- }
- });
+async function installLibs(
+ logger: winston.Logger,
+ libs: { [key: string]: string },
+) {
+ const libsArray = Object.keys(libs).map((key) => key + "@" + libs[key]);
+ logger.info(`Installing ${libsArray.join(", ")}`);
+ await new Promise((resolve, reject) => {
+ npm.commands.install(libsArray, (err) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(true);
+ }
});
+ });
}
// Load addons
export default async function (logger: winston.Logger) {
- // Load NPM client
- await initNpm();
+ // Load NPM client
+ await initNpm();
- const addons: Addon[] = [];
- // Loop addon dirs
- for (const dir of (
- // Directories array
- fs.readdirSync(__dirname + '/../addons')
- .map((dir) => __dirname + '/../addons/' + dir)
- .filter((file) => fs.existsSync(file + '/index.js'))
- )) {
- if (dir.endsWith('example_addon')) {
- // Skip default example addon
- continue;
- }
- logger.info(`Loading addon from ${dir}`);
- if (fs.existsSync(dir + '/libraries.txt')) {
- await installLibs(logger, (
- // Libraries mapped
- fs.readFileSync(dir + '/libraries.txt', 'utf8')
- .split('\n')
- .filter((lib) => lib.includes("="))
- .map((lib) => lib.split('='))
- .reduce((acc, [name, version]) => {
- acc[name] = version.replace('\r', '');
- return acc;
- }, {} as { [key: string]: string })
- ));
- }
+ const addons: Addon[] = [];
+ // Loop addon dirs
+ // Directories array
+ for (const dir of fs
+ .readdirSync(__dirname + "/../addons")
+ .map((dir) => __dirname + "/../addons/" + dir)
+ .filter((file) => fs.existsSync(file + "/index.js"))) {
+ if (dir.endsWith("example_addon")) {
+ // Skip default example addon
+ continue;
+ }
+ logger.info(`Loading addon from ${dir}`);
+ if (fs.existsSync(dir + "/libraries.txt")) {
+ await installLibs(
+ logger,
+ // Libraries mapped
+ fs
+ .readFileSync(dir + "/libraries.txt", "utf8")
+ .split("\n")
+ .filter((lib) => lib.includes("="))
+ .map((lib) => lib.split("="))
+ .reduce(
+ (acc, [name, version]) => {
+ acc[name] = version.replace("\r", "");
+ return acc;
+ },
+ {} as { [key: string]: string },
+ ),
+ );
+ }
- const addon = require(dir + '/index.js').default as Addon;
- if (!addon.disabled) {
- addons.push(addon);
+ const addon = require(dir + "/index.js").default as Addon;
+ if (!addon.disabled) {
+ addons.push(addon);
- const { name, author, version } = addon;
+ const { name, author, version } = addon;
- logger.info(`Loaded addon ${name}${author ? ` by ${author}` : ``}${version ? ` (v${version})` : ``}`);
- }
+ logger.info(
+ `Loaded addon ${name}${author ? ` by ${author}` : ``}${version ? ` (v${version})` : ``}`,
+ );
}
- return (step: T, ctx: any, ...args: any[]) => {
- if (isDebug()) {
- logger.info(`Running step ${step}`);
- }
- addons
- .filter((addon) => addon.steps[step])
- .forEach(addon => {
- const f = addon.steps[step];
- // Make temporary duplicate
- const ctxAddon = { ...ctx };
- if (ctxAddon.logger) {
- // Make custom logger for each addon
- ctxAddon.logger = createLogger({ label: addon.briefName ?? addon.name });
- }
- f.apply(f, [ctxAddon, ...args])
- });
+ }
+ return (step: T, ctx: any, ...args: any[]) => {
+ if (isDebug()) {
+ logger.info(`Running step ${step}`);
}
-}
\ No newline at end of file
+ addons
+ .filter((addon) => addon.steps[step])
+ .forEach((addon) => {
+ const f = addon.steps[step];
+ // Make temporary duplicate
+ const ctxAddon = { ...ctx };
+ if (ctxAddon.logger) {
+ // Make custom logger for each addon
+ ctxAddon.logger = createLogger({
+ label: addon.briefName ?? addon.name,
+ });
+ }
+ f.apply(f, [ctxAddon, ...args]);
+ });
+ };
+}
diff --git a/src/app.ts b/src/app.ts
index a84cc8b..acf7da0 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,6 +1,10 @@
import dotenv from "dotenv";
-import {loadAppConfig} from "@nsm/config";
-import {init as initFileStructure, getResourcesTargetPath, prepareFolders} from "@nsm/filestructure";
+import { loadAppConfig } from "@nsm/config";
+import {
+ init as initFileStructure,
+ getResourcesPath,
+ prepareFolders,
+} from "@nsm/filestructure";
// Load .env
dotenv.config();
@@ -9,71 +13,78 @@ dotenv.config();
const appConfig = loadAppConfig();
initFileStructure(appConfig);
-import {Router} from 'express';
-import {Database} from "@nsm/database";
-import {ServiceManager} from "@nsm/engine";
+import { Router } from "express";
+import { Database } from "@nsm/database";
+import { ServiceManager } from "@nsm/engine";
import loadAddons from "./addon";
-import loadAppRoutes from '@nsm/router';
-import createDbManager from '@nsm/database';
+import loadAppRoutes from "@nsm/router";
+import createDbManager from "@nsm/database";
import loadSecurity from "@nsm/security";
import * as manager from "@nsm/engine/manager";
import * as sessionManager from "@nsm/engine/session";
import * as logging from "./logger";
import winston from "winston";
-import {Application} from "express-ws";
+import { Application } from "express-ws";
import fs from "fs";
-import isInsideContainer from "@nsm/lib/isInsideContainer";
-import {middleLayer} from "@nsm/engine/middle";
-import {SessionManager} from "@nsm/engine/session";
-import {mkdirResource, saveResource} from "@nsm/resources";
+import { middleLayer } from "@nsm/engine/middle";
+import { SessionManager } from "@nsm/engine/session";
+import { mkdirResource, saveResource } from "@nsm/resources";
import path from "path";
-import {AppConfig} from "@nsm/config";
+import { AppConfig } from "@nsm/config";
export type AppBootContext = AppContext & { steps: any };
// Passed context to the routes
export type AppContext = {
- router: Router;
- manager: ServiceManager;
- sessionManager: SessionManager;
- database: Database;
- appConfig: AppConfig;
- logger: winston.Logger;
- debug: boolean;
- workers: boolean;
+ router: Router;
+ manager: ServiceManager;
+ sessionManager: SessionManager;
+ database: Database;
+ appConfig: AppConfig;
+ logger: winston.Logger;
+ debug: boolean;
};
export type AppBootOptions = {
- test?: boolean;
- disableWorkers?: boolean;
-}
+ test?: boolean;
+};
export let currentContext: AppContext;
function initGlobalLogger() {
- logging.createLatestLogFile();
+ logging.createLatestLogFile();
- return logging.createLogger();
+ return logging.createLogger();
}
// Decorate all manager functions except those excluded to disallow using them
// before manager.engine is initialized. This is necessary as the manager is being
// used (mainly for expandEngine()) even before manager.init() is called.
function managerForUnsafeUse() {
- const excludeKeys: (keyof ServiceManager)[] = ["expandEngine", "initEngineForcibly", "engine"];
- //
- const managerRef = { ...manager };
- const handler: ProxyHandler = {
- get(target, prop, receiver) {
- // If it's key of base manager, not expanded object and is not excluded, deny access
- if ((Object.keys(managerRef) as any[]).includes(prop) && !(excludeKeys as any[]).includes(prop)) {
- throw new Error("ServiceManager is not initialized yet! " +
- "You can only access those members now: " + excludeKeys.join(", "));
- }
- return Reflect.get(target, prop, receiver);
- }
- }
- return new Proxy(manager, handler);
+ const excludeKeys: (keyof ServiceManager)[] = [
+ "expandEngine",
+ "initEngineForcibly",
+ "engine",
+ ];
+ //
+ const managerRef = { ...manager };
+ const handler: ProxyHandler = {
+ get(target, prop, receiver) {
+ // If it's key of base manager, not expanded object and is not excluded, deny access
+ if (
+ (Object.keys(managerRef) as any[]).includes(prop) &&
+ !(excludeKeys as any[]).includes(prop)
+ ) {
+ throw new Error(
+ "ServiceManager is not initialized yet! " +
+ "You can only access those members now: " +
+ excludeKeys.join(", "),
+ );
+ }
+ return Reflect.get(target, prop, receiver);
+ },
+ };
+ return new Proxy(manager, handler);
}
/**
@@ -82,80 +93,80 @@ function managerForUnsafeUse() {
* @param router The app router.
* @param options The optional boot options.
*/
-export const init = async (router: Application, options?: AppBootOptions): Promise => {
- // Prepare logging
- const logger = initGlobalLogger();
-
- prepareFolders();
-
- // Prepare templates folder
- mkdirResource("templates");
- if (options?.test === true) {
- prepareTestResources(); // Copy resources for test
- }
-
- // Load addon steps
- const steps = await loadAddons(logger);
-
- steps('BEFORE_CONFIG', { logger });
-
- // Database connection layer
- steps('BEFORE_DB', { logger, appConfig });
- const database = createDbManager();
-
- // Temporarily lock manager until it's initialized
- const ctx = currentContext = {
- router,
- manager: managerForUnsafeUse(),
- sessionManager,
- database,
- appConfig,
- logger,
- debug: process.env.DEBUG === 'true',
- workers: !options?.disableWorkers && !isInsideContainer()
- };
-
- // Service (virtualization) layer
- steps('BEFORE_ENGINE', ctx);
- await manager.init(database, appConfig, logger);
-
- // Bring back original manager
- ctx.manager = currentContext.manager = middleLayer(manager);
-
- // Load security
- steps('BEFORE_SECURITY', ctx);
- await loadSecurity(ctx);
-
- // Load HTTP routes
- steps('BEFORE_ROUTES', ctx);
- await loadAppRoutes(ctx);
-
- // Start the server
- steps('BEFORE_SERVER', ctx);
-
- if (isInsideContainer()) {
- logger.info('Running in container! Worker threads will be unavailable.');
- } else if(!ctx.workers) {
- logger.info('Worker threads are forcibly disabled.');
- }
-
- let srv = undefined;
- if (options?.test == undefined || options.test == false) {
- logger.info(`Starting server`);
- srv = router.listen(appConfig.getPort(), () => {
- logger.info(`Server started on port ${appConfig.getPort()}`);
- });
- }
- steps('BOOT', ctx, srv);
- return { ...ctx, steps };
-}
+export const init = async (
+ router: Application,
+ options?: AppBootOptions,
+): Promise => {
+ // Prepare logging
+ const logger = initGlobalLogger();
+ logging.setCurrentGlobalLogger(logger);
+
+ prepareFolders();
+
+ // Prepare templates folder
+ mkdirResource("templates");
+ if (options?.test === true) {
+ prepareTestResources(); // Copy resources for test
+ }
+
+ // Load addon steps
+ const steps = await loadAddons(logger);
+
+ steps("BEFORE_CONFIG", { logger });
+
+ // Database connection layer
+ steps("BEFORE_DB", { logger, appConfig });
+ const database = createDbManager();
+
+ // Temporarily lock manager until it's initialized
+ const ctx = (currentContext = {
+ router,
+ manager: managerForUnsafeUse(),
+ sessionManager,
+ database,
+ appConfig,
+ logger,
+ debug: process.env.DEBUG === "true",
+ });
+
+ // Service (virtualization) layer
+ steps("BEFORE_ENGINE", ctx);
+ await manager.init(database, appConfig, logger);
+
+ // Bring back original manager
+ ctx.manager = currentContext.manager = middleLayer(manager);
+
+ // Load security
+ steps("BEFORE_SECURITY", ctx);
+ await loadSecurity(ctx);
+
+ // Load HTTP routes
+ steps("BEFORE_ROUTES", ctx);
+ await loadAppRoutes(ctx);
+
+ // Start the server
+ steps("BEFORE_SERVER", ctx);
+
+ let srv = undefined;
+ if (options?.test == undefined || options.test == false) {
+ logger.info(`Starting server`);
+ srv = router.listen(appConfig.getPort(), () => {
+ logger.info(`Server started on port ${appConfig.getPort()}`);
+ });
+ }
+ steps("BOOT", ctx, srv);
+ return { ...ctx, steps };
+};
const prepareTestResources = () => {
- if (fs.existsSync(path.join(getResourcesTargetPath(), 'templates', 'test'))) {
- return;
- }
-
- saveResource('template/test/test_settings.yml', 'templates/test/settings.yml')
- saveResource('template/test/test_dockerfile', 'templates/test/Dockerfile')
- saveResource('template/test/test_nsmignore', 'templates/test/.nsmignore')
-}
\ No newline at end of file
+ if (fs.existsSync(path.join(getResourcesPath(), "templates", "test"))) {
+ return;
+ }
+
+ saveResource(
+ "template/test/test_settings.yml",
+ "templates/test/settings.yml",
+ );
+ saveResource("template/test/test_dockerfile", "templates/test/Dockerfile");
+ saveResource("template/test/test_nsmignore", "templates/test/.nsmignore");
+};
diff --git a/src/cleanup.ts b/src/cleanup.ts
index fd65777..7f8e095 100644
--- a/src/cleanup.ts
+++ b/src/cleanup.ts
@@ -1,47 +1,49 @@
-import {AppBootContext} from "@nsm/app";
-import {setStatus} from "@nsm/server";
-import {resolveSequentially} from "@nsm/util/promises";
-import {setStopping} from "@nsm/engine/asyncp";
+import { AppBootContext } from "@nsm/app";
+import { setStatus } from "@nsm/server";
+import { resolveSequentially } from "@nsm/util/promises";
+import { setStopping } from "@nsm/engine/asyncp";
let active = false;
const cleanup = (ctx: AppBootContext, exit?: boolean) => {
- const { manager, logger, steps } = ctx;
-
- if (active == true) {
- return;
- }
-
- active = true;
+ const { manager, logger, steps } = ctx;
+
+ if (active == true) {
+ return;
+ }
+
+ active = true;
+ if (exit == true) {
+ logger.info("SIGINT" + ": Executing stop sequence, please wait");
+ setStatus("stopping");
+ setStopping();
+ }
+
+ resolveSequentially(
+ ...(exit == true
+ ? [
+ // Those steps that should only be called on exit
+ () => steps("EXIT", ctx),
+ ]
+ : []),
+ () => manager.stopRunning(),
+ ).then(() => {
if (exit == true) {
- logger.info('SIGINT' + ': Executing stop sequence, please wait');
- setStatus("stopping");
- setStopping();
+ process.exit(0);
}
-
- resolveSequentially(
- ...(exit == true ? [
- // Those steps that should only be called on exit
- () => steps('EXIT', ctx)
- ] : []),
- () => manager.stopRunning()
- ).then(() => {
- if (exit == true) {
- process.exit(0);
- }
- });
-}
+ });
+};
export const postInit = (ctx: AppBootContext) => {
- // Cleanup on start
- cleanup(ctx);
-
- // Handle exit
- process.on('exit', () => {
- // Cleanup on exit
- cleanup(ctx, true);
- });
-
- // Debug info
- ctx.logger.debug('Signal handlers');
-}
\ No newline at end of file
+ // Cleanup on start
+ cleanup(ctx);
+
+ // Handle exit
+ process.on("exit", () => {
+ // Cleanup on exit
+ cleanup(ctx, true);
+ });
+
+ // Debug info
+ ctx.logger.debug("Signal handlers");
+};
diff --git a/src/config.ts b/src/config.ts
index a032dbd..af82545 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,8 +1,10 @@
-import {loadYamlFile} from "@nsm/util/yaml";
+import { loadYamlFile } from "@nsm/util/yaml";
import path from "path";
-import {currentPaths} from "@nsm/filestructure";
-import {saveResource} from "@nsm/resources";
+import { saveResource } from "@nsm/resources";
import z from "zod";
+import envPaths, {Paths} from "env-paths";
+
+export const currentPaths: Paths = envPaths("nsm");
export interface AppConfig {
getNodeId(): string;
@@ -13,7 +15,13 @@ export interface AppConfig {
getDockerHost(): string;
- getResourcesPath(): string|undefined;
+ getResourcesPath(): string;
+
+ getTemplatesPath(): string;
+
+ getTemplateBuildDir(template: string): string;
+
+ getTempPath(): string;
}
/**
@@ -22,14 +30,16 @@ export interface AppConfig {
* @author ZorTik
*/
export class YamlAppConfig implements AppConfig {
- private static readonly schema: z.ZodObject = z.object({
- node_id: z.string(),
- // Coerce port to auto-parse from env if overwritten
- port: z.coerce.number().int().positive(),
- auth: z.string(),
- docker_host: z.string(),
- resources_path: z.string().optional()
- }).strict();
+ private static readonly schema: z.ZodObject = z
+ .object({
+ node_id: z.string(),
+ // Coerce port to auto-parse from env if overwritten
+ port: z.coerce.number().int().positive(),
+ auth: z.string(),
+ docker_host: z.string(),
+ resources_path: z.string().optional(),
+ })
+ .strict();
private readonly data: any;
@@ -55,26 +65,40 @@ export class YamlAppConfig implements AppConfig {
return this.data["docker_host"];
}
- getResourcesPath(): string | undefined {
- return this.data["resources_path"];
+ getResourcesPath(): string {
+ const resourcesPath = this.data["resources_path"];
+
+ return resourcesPath ? path.resolve(resourcesPath) : path.join(currentPaths.data);
+ }
+
+ getTemplatesPath(): string {
+ return path.join(this.getResourcesPath(), "templates");
+ }
+
+ getTemplateBuildDir(template: string): string {
+ return path.join(this.getTemplatesPath(), template);
+ }
+
+ getTempPath(): string {
+ return currentPaths.temp;
}
private validate = () => {
const result = YamlAppConfig.schema.safeParse(this.data);
if (!result.success) {
- throw new Error('Invalid config file. ' + result.error.toString());
+ throw new Error("Invalid config file. " + result.error.toString());
}
- }
+ };
private static loadData = () => {
// Copy if it does not exist
- saveResource('config.yml', 'config.yml', true, currentPaths.config);
+ saveResource("config.yml", "config.yml", true, currentPaths.config);
- const config = loadYamlFile(path.join(currentPaths.config, 'config.yml'));
+ const config = loadYamlFile(path.join(currentPaths.config, "config.yml"));
for (let key in YamlAppConfig.schema.shape) {
// Overwrite with env variable if exists.
// Sync
- const envKey = 'CONFIG_' + key.toUpperCase();
+ const envKey = "CONFIG_" + key.toUpperCase();
if (process.env[envKey]) {
config[key] = process.env[envKey];
} else if (config[key]) {
@@ -82,9 +106,9 @@ export class YamlAppConfig implements AppConfig {
}
}
return config;
- }
+ };
}
export const loadAppConfig = (): AppConfig => {
return new YamlAppConfig();
-}
\ No newline at end of file
+};
diff --git a/src/database/image.ts b/src/database/image.ts
index 189a778..3c99fc8 100644
--- a/src/database/image.ts
+++ b/src/database/image.ts
@@ -1,12 +1,12 @@
-import {ImageRepository} from "@nsm/database/models";
-import {optionsDiffer} from "@nsm/engine/image";
-import {PrismaClient} from "@prisma/client";
+import { ImageRepository } from "@nsm/database/models";
+import { optionsDiffer } from "@nsm/engine/image";
+import { PrismaClient } from "@prisma/client";
let client: PrismaClient;
export const init = (client_: PrismaClient) => {
client = client_;
-}
+};
export const saveImage: ImageRepository["saveImage"] = async (info) => {
const { id, templateId, hash, buildOptions } = info;
@@ -19,24 +19,30 @@ export const saveImage: ImageRepository["saveImage"] = async (info) => {
hash,
buildOptions: {
deleteMany: {},
- create: Object.entries(buildOptions).map(([key, value]) => ({ key, value })),
- }
+ create: Object.entries(buildOptions).map(([key, value]) => ({
+ key,
+ value,
+ })),
+ },
},
create: {
id,
templateId,
hash,
buildOptions: {
- create: Object.entries(buildOptions).map(([key, value]) => ({ key, value })),
- }
- }
+ create: Object.entries(buildOptions).map(([key, value]) => ({
+ key,
+ value,
+ })),
+ },
+ },
});
return true;
} catch (e) {
console.log(e);
return false;
}
-}
+};
export const getImage: ImageRepository["getImage"] = async (id) => {
const image = await client.image.findUnique({
@@ -44,58 +50,63 @@ export const getImage: ImageRepository["getImage"] = async (id) => {
include: {
buildOptions: {
select: { key: true, value: true },
- }
- }
+ },
+ },
});
if (image) {
const buildOptions = {};
- image.buildOptions.forEach((option) => buildOptions[option.key] = option.value);
+ image.buildOptions.forEach(
+ (option) => (buildOptions[option.key] = option.value),
+ );
return {
...image,
buildOptions,
- }
+ };
} else {
return undefined;
}
-}
+};
export const deleteImage: ImageRepository["deleteImage"] = async (id) => {
try {
await client.image.delete({
- where: { id }
+ where: { id },
});
return true;
} catch (e) {
- if (e.code !== 'P2025') {
+ if (e.code !== "P2025") {
console.log(e);
}
return false;
}
-}
+};
-export const listImagesByOptions: ImageRepository["listImagesByOptions"] = async (templateId, buildOptions) => {
- return (
- client.image.findMany({
- include: {
- buildOptions: {
- select: { key: true, value: true },
- }
- }
- })
- ).then((images) => (
- images.map(image => ({
- ...image,
- buildOptions: image.buildOptions.reduce((acc, option) => {
- acc[option.key] = option.value;
- return acc;
- }, {})
- }))
- ))
- .then((images) => (
- images.filter(
- (image) => image.templateId === templateId && !optionsDiffer(image.buildOptions, buildOptions)
+export const listImagesByOptions: ImageRepository["listImagesByOptions"] =
+ async (templateId, buildOptions) => {
+ return client.image
+ .findMany({
+ include: {
+ buildOptions: {
+ select: { key: true, value: true },
+ },
+ },
+ })
+ .then((images) =>
+ images.map((image) => ({
+ ...image,
+ buildOptions: image.buildOptions.reduce((acc, option) => {
+ acc[option.key] = option.value;
+ return acc;
+ }, {}),
+ })),
)
- ));
-}
\ No newline at end of file
+ .then((images) =>
+ images.filter(
+ (image) =>
+ image.templateId === templateId &&
+ !optionsDiffer(image.buildOptions, buildOptions),
+ ),
+ );
+ };
diff --git a/src/database/index.ts b/src/database/index.ts
index e8bbdee..dd7dfaa 100644
--- a/src/database/index.ts
+++ b/src/database/index.ts
@@ -1,5 +1,5 @@
-import {Database} from "./models";
-import {PrismaClient} from "@prisma/client";
+import { Database } from "./models";
+import { PrismaClient } from "@prisma/client";
import * as permaRepository from "./perma";
import * as metaRepository from "./meta";
@@ -8,31 +8,31 @@ import * as imageRepository from "./image";
import * as sessionRepository from "./session";
import * as serviceLogRepository from "./serviceLog";
-export * from './models';
+export * from "./models";
export default function (client?: PrismaClient): Database {
- if (!client) {
- client = new PrismaClient();
- }
+ if (!client) {
+ client = new PrismaClient();
+ }
- // Propagate client
- (
- [
- permaRepository,
- metaRepository,
- serviceMetaRepository,
- imageRepository,
- sessionRepository,
- serviceLogRepository
- ] as unknown as { init: (client: PrismaClient) => void }[]
- ).forEach(repository => repository.init(client));
+ // Propagate client
+ (
+ [
+ permaRepository,
+ metaRepository,
+ serviceMetaRepository,
+ imageRepository,
+ sessionRepository,
+ serviceLogRepository,
+ ] as unknown as { init: (client: PrismaClient) => void }[]
+ ).forEach((repository) => repository.init(client));
- return {
- permaRepository,
- metaRepository,
- serviceMetaRepository,
- imageRepository,
- sessionRepository,
- serviceLogRepository
- }
-}
\ No newline at end of file
+ return {
+ permaRepository,
+ metaRepository,
+ serviceMetaRepository,
+ imageRepository,
+ sessionRepository,
+ serviceLogRepository,
+ };
+}
diff --git a/src/database/meta.ts b/src/database/meta.ts
index 738e840..5d50753 100644
--- a/src/database/meta.ts
+++ b/src/database/meta.ts
@@ -1,13 +1,16 @@
-import {PrismaClient} from "@prisma/client";
-import {MetaRepository} from "@nsm/database/models";
+import { PrismaClient } from "@prisma/client";
+import { MetaRepository } from "@nsm/database/models";
let client: PrismaClient;
export const init = (client_: PrismaClient) => {
client = client_;
-}
+};
-export const getMetaVal: MetaRepository["getMetaVal"] = async (key, defaultVal) => {
+export const getMetaVal: MetaRepository["getMetaVal"] = async (
+ key,
+ defaultVal,
+) => {
try {
let meta = await client.meta.findUnique({ where: { key } });
if (!meta) {
@@ -19,6 +22,6 @@ export const getMetaVal: MetaRepository["getMetaVal"] = async (key, defaultVal)
return meta.value;
} catch (e) {
console.log(e);
- return '';
+ return "";
}
-}
\ No newline at end of file
+};
diff --git a/src/database/models.ts b/src/database/models.ts
index c44f9a3..3666fa1 100644
--- a/src/database/models.ts
+++ b/src/database/models.ts
@@ -1,120 +1,135 @@
export interface Database {
- permaRepository: PermaRepository;
- metaRepository: MetaRepository;
- serviceMetaRepository: ServiceMetaRepository;
- imageRepository: ImageRepository;
- sessionRepository: SessionRepository;
- serviceLogRepository: ServiceLogRepository;
+ permaRepository: PermaRepository;
+ metaRepository: MetaRepository;
+ serviceMetaRepository: ServiceMetaRepository;
+ imageRepository: ImageRepository;
+ sessionRepository: SessionRepository;
+ serviceLogRepository: ServiceLogRepository;
}
export interface PermaRepository {
- savePerma(info: PermaModel): Promise;
- deletePerma(serviceId: string): Promise;
- getPerma(serviceId: string): Promise;
- listPerma(nodeId: string, page?: number, pageSize?: number, meta?: {[key: string]: any}): Promise;
- listPermaUsingImage(imageId: string): Promise;
- countPerma(nodeId: string): Promise;
+ savePerma(info: PermaModel): Promise;
+ deletePerma(serviceId: string): Promise;
+ getPerma(serviceId: string): Promise;
+ listPerma(
+ nodeId: string,
+ page?: number,
+ pageSize?: number,
+ meta?: { [key: string]: any },
+ ): Promise;
+ listPermaUsingImage(imageId: string): Promise;
+ countPerma(nodeId: string): Promise;
}
export interface MetaRepository {
- getMetaVal(key: string, defaultVal?: string): Promise;
+ getMetaVal(key: string, defaultVal?: string): Promise;
}
export interface ServiceMetaRepository {
- setServiceMeta(serviceId: string, key: string, value: any): Promise;
- getServiceMeta(serviceId: string, key: string): Promise;
+ setServiceMeta(serviceId: string, key: string, value: any): Promise;
+ getServiceMeta(serviceId: string, key: string): Promise;
}
export interface ImageRepository {
- saveImage(info: ImageModel): Promise;
- getImage(id: string): Promise;
- deleteImage(id: string): Promise;
- listImagesByOptions(templateId: string, buildOptions: {[key: string]: string}): Promise;
+ saveImage(info: ImageModel): Promise;
+ getImage(id: string): Promise;
+ deleteImage(id: string): Promise;
+ listImagesByOptions(
+ templateId: string,
+ buildOptions: { [key: string]: string },
+ ): Promise;
}
export interface SessionRepository {
- createSession(serviceId: string): Promise;
+ createSession(serviceId: string): Promise;
- listSessions(args: ListSessionsArgs): Promise;
+ listSessions(
+ args: ListSessionsArgs,
+ ): Promise;
}
export type ListSessionsArgs = {
- filter?: {
- serviceId?: string;
- }
- sort?: {
- by?: 'startedAt'
- direction?: 'asc' | 'desc'
- }
- page?: {
- index: number;
- size: number;
- }
-}
+ filter?: {
+ serviceId?: string;
+ };
+ sort?: {
+ by?: "startedAt";
+ direction?: "asc" | "desc";
+ };
+ page?: {
+ index: number;
+ size: number;
+ };
+};
export interface ServiceLogRepository {
- createRecords(records: CreateLogRecordArgs[]): Promise;
+ createRecords(records: CreateLogRecordArgs[]): Promise;
- listRecords(args: ListRecordsArgs): Promise;
+ listRecords(
+ args: ListRecordsArgs,
+ ): Promise;
}
-export type CreateLogRecordArgs = Omit;
+export type CreateLogRecordArgs = Omit<
+ ServiceLogRecordModel,
+ "id" | "timestamp"
+>;
export type ListRecordsArgs = {
- filter?: {
- sessionId?: string;
- }
- sort?: {
- by?: 'timestamp',
- direction?: 'asc' | 'desc'
- }
- page?: {
- index: number;
- size: number;
- }
-}
+ filter?: {
+ sessionId?: string;
+ };
+ sort?: {
+ by?: "timestamp";
+ direction?: "asc" | "desc";
+ };
+ page?: {
+ index: number;
+ size: number;
+ };
+};
export type PermaModel = {
- serviceId: string;
- template: string;
- nodeId: string;
- imageId?: string;
- port: number;
- options: {
- [key: string]: any;
- };
- meta?: {
- stopCmd?: string;
- };
- env: {
- [key: string]: string;
- };
- network?: {
- address: string;
- portsOnly: boolean;
- }
+ serviceId: string;
+ template: string;
+ nodeId: string;
+ imageId?: string;
+ port: number;
+ options: {
+ [key: string]: any;
+ };
+ meta?: {
+ stopCmd?: string;
+ };
+ env: {
+ [key: string]: string;
+ };
+ network?: {
+ address: string;
+ portsOnly: boolean;
+ };
};
export type ImageModel = {
- id: string;
- templateId: string;
- hash: string;
- buildOptions: {
- [key: string]: string;
- }
-}
+ id: string;
+ templateId: string;
+ hash: string;
+ buildOptions: {
+ [key: string]: string;
+ };
+};
export type ServiceSessionModel = {
- id: string;
- serviceId: string;
- startedAt: Date;
-}
+ id: string;
+ serviceId: string;
+ startedAt: Date;
+};
export type ServiceLogRecordModel = {
- id: bigint;
- sessionId: string;
- source: 'ENGINE' | 'CONTAINER'
- timestamp: Date;
- logLevel: string;
- message: string;
-}
\ No newline at end of file
+ id: bigint;
+ sessionId: string;
+ source: "ENGINE" | "CONTAINER";
+ timestamp: Date;
+ logLevel: string;
+ message: string;
+};
diff --git a/src/database/perma.ts b/src/database/perma.ts
index 642b5bd..0daa297 100644
--- a/src/database/perma.ts
+++ b/src/database/perma.ts
@@ -1,11 +1,11 @@
-import {PrismaClient} from "@prisma/client";
-import {PermaModel, PermaRepository} from "@nsm/database/models";
+import { PrismaClient } from "@prisma/client";
+import { PermaModel, PermaRepository } from "@nsm/database/models";
let client: PrismaClient;
export const init = (client_: PrismaClient) => {
client = client_;
-}
+};
export const savePerma: PermaRepository["savePerma"] = async (data) => {
const { serviceId } = data;
@@ -13,32 +13,30 @@ export const savePerma: PermaRepository["savePerma"] = async (data) => {
await client.service.upsert({
where: { serviceId },
update: data,
- create: data
+ create: data,
});
return true;
} catch (e) {
console.log(e);
return false;
}
-}
+};
export const deletePerma: PermaRepository["deletePerma"] = async (
- serviceId
+ serviceId,
) => {
try {
await client.service.delete({ where: { serviceId } });
return true;
} catch (e) {
- if (e.code !== 'P2025') {
+ if (e.code !== "P2025") {
console.log(e);
}
return false;
}
-}
+};
-export const getPerma: PermaRepository["getPerma"] = async (
- serviceId
-) => {
+export const getPerma: PermaRepository["getPerma"] = async (serviceId) => {
try {
const service = await client.service.findUnique({ where: { serviceId } });
if (!service) {
@@ -49,13 +47,13 @@ export const getPerma: PermaRepository["getPerma"] = async (
console.log(e);
return undefined;
}
-}
+};
export const listPerma: PermaRepository["listPerma"] = async (
nodeId,
page,
pageSize,
- meta
+ meta,
) => {
try {
// SELECT * FROM Service WHERE JSON_EXTRACT(Meta, "$.tag1") IS NOT NULL;
@@ -86,24 +84,27 @@ export const listPerma: PermaRepository["listPerma"] = async (
}
}
return client
- .$queryRawUnsafe(`SELECT * FROM Service${where}${pg};`, ...values)
- .then(result => result as PermaModel[]);
+ .$queryRawUnsafe<
+ PermaModel[]
+ >(`SELECT * FROM Service${where}${pg};`, ...values)
+ .then((result) => result as PermaModel[]);
} catch (e) {
console.log(e);
return [];
}
-}
+};
-export const listPermaUsingImage: PermaRepository["listPermaUsingImage"] = async (
- imageId
-) => {
- try {
- return await client.service.findMany({ where: { imageId } }) as PermaModel[];
- } catch (e) {
- console.log(e);
- return [];
- }
-}
+export const listPermaUsingImage: PermaRepository["listPermaUsingImage"] =
+ async (imageId) => {
+ try {
+ return (await client.service.findMany({
+ where: { imageId },
+ })) as PermaModel[];
+ } catch (e) {
+ console.log(e);
+ return [];
+ }
+ };
export const countPerma: PermaRepository["countPerma"] = async (nodeId) => {
try {
@@ -112,4 +113,4 @@ export const countPerma: PermaRepository["countPerma"] = async (nodeId) => {
console.log(e);
return -1;
}
-}
+};
diff --git a/src/database/serviceLog.ts b/src/database/serviceLog.ts
index f79c6d7..64bb6bb 100644
--- a/src/database/serviceLog.ts
+++ b/src/database/serviceLog.ts
@@ -1,14 +1,14 @@
-import {Prisma, PrismaClient} from "@prisma/client";
-import {ServiceLogRepository} from "@nsm/database/models";
+import { Prisma, PrismaClient } from "@prisma/client";
+import { ServiceLogRepository } from "@nsm/database/models";
let client: PrismaClient;
export const init = (client_: PrismaClient) => {
client = client_;
-}
+};
export const createRecords: ServiceLogRepository["createRecords"] = async (
- records
+ records,
) => {
try {
await client.serviceLogRecord.createMany({ data: records });
@@ -18,21 +18,19 @@ export const createRecords: ServiceLogRepository["createRecords"] = async (
return false;
}
-}
+};
-export const listRecords: ServiceLogRepository["listRecords"] = async (args) => {
- const {
- filter,
- sort,
- page
- } = args;
+export const listRecords: ServiceLogRepository["listRecords"] = async (
+ args,
+) => {
+ const { filter, sort, page } = args;
const query: Prisma.ServiceLogRecordFindManyArgs = {};
if (filter?.sessionId) {
query.where = filter;
}
query.orderBy = {
- [sort?.by ?? "timestamp"]: sort?.direction ?? "desc"
+ [sort?.by ?? "timestamp"]: sort?.direction ?? "desc",
};
if (page) {
query.skip = page.index * page.size;
@@ -46,4 +44,4 @@ export const listRecords: ServiceLogRepository["listRecords"] = async (args) =>
return undefined;
}
-}
\ No newline at end of file
+};
diff --git a/src/database/serviceMeta.ts b/src/database/serviceMeta.ts
index 8799340..3db62cf 100644
--- a/src/database/serviceMeta.ts
+++ b/src/database/serviceMeta.ts
@@ -1,38 +1,40 @@
-import {PrismaClient} from "@prisma/client";
-import {ServiceMetaRepository} from "@nsm/database/models";
+import { PrismaClient } from "@prisma/client";
+import { ServiceMetaRepository } from "@nsm/database/models";
let client: PrismaClient;
export const init = (client_: PrismaClient) => {
client = client_;
-}
+};
export const setServiceMeta: ServiceMetaRepository["setServiceMeta"] = async (
serviceId,
key,
- value
+ value,
) => {
try {
await client.serviceMeta.upsert({
where: { serviceId },
update: { serviceId, key, value },
- create: { serviceId, key, value }
+ create: { serviceId, key, value },
});
return true;
} catch (e) {
console.log(e);
return false;
}
-}
+};
export const getServiceMeta: ServiceMetaRepository["getServiceMeta"] = async (
serviceId,
- key
+ key,
) => {
- const meta = await client.serviceMeta.findUnique({ where: { serviceId, key } });
+ const meta = await client.serviceMeta.findUnique({
+ where: { serviceId, key },
+ });
if (meta) {
return meta.value;
} else {
return undefined;
}
-}
\ No newline at end of file
+};
diff --git a/src/database/session.ts b/src/database/session.ts
index 2d33d6e..ae3db43 100644
--- a/src/database/session.ts
+++ b/src/database/session.ts
@@ -1,15 +1,17 @@
-import {Prisma, PrismaClient} from "@prisma/client";
-import {SessionRepository} from "@nsm/database/models";
+import { Prisma, PrismaClient } from "@prisma/client";
+import { SessionRepository } from "@nsm/database/models";
let client: PrismaClient;
export const init = (client_: PrismaClient) => {
client = client_;
-}
+};
-export const createSession: SessionRepository["createSession"] = async (serviceId) => {
+export const createSession: SessionRepository["createSession"] = async (
+ serviceId,
+) => {
const data: Prisma.ServiceSessionUncheckedCreateInput = {
- serviceId
+ serviceId,
};
try {
@@ -19,21 +21,17 @@ export const createSession: SessionRepository["createSession"] = async (serviceI
return undefined;
}
-}
+};
export const listSessions: SessionRepository["listSessions"] = async (args) => {
- const {
- filter,
- sort,
- page
- } = args;
+ const { filter, sort, page } = args;
const query: Prisma.ServiceSessionFindManyArgs = {};
if (filter?.serviceId) {
query.where = filter;
}
query.orderBy = {
- [sort?.by ?? "startedAt"]: sort?.direction ?? "desc"
+ [sort?.by ?? "startedAt"]: sort?.direction ?? "desc",
};
if (page) {
query.skip = page.index * page.size;
@@ -47,4 +45,4 @@ export const listSessions: SessionRepository["listSessions"] = async (args) => {
return undefined;
}
-}
\ No newline at end of file
+};
diff --git a/src/depend.ts b/src/depend.ts
index 63264c3..7acaf0d 100644
--- a/src/depend.ts
+++ b/src/depend.ts
@@ -1,15 +1,15 @@
const deps: { [id: string]: any } = {};
-export type RegType = 'engine'; // Registration types
+export type RegType = "engine"; // Registration types
export function setSingleton(key: RegType, obj: any) {
- deps[key] = obj;
+ deps[key] = obj;
}
-export function getSingleton(key: RegType): T|undefined {
- return deps[key];
+export function getSingleton(key: RegType): T | undefined {
+ return deps[key];
}
export function getSingletonOrDef(key: RegType, def: T): T {
- return deps[key] ?? def;
-}
\ No newline at end of file
+ return deps[key] ?? def;
+}
diff --git a/src/engine/asyncp.ts b/src/engine/asyncp.ts
index 2f94191..e745c7e 100644
--- a/src/engine/asyncp.ts
+++ b/src/engine/asyncp.ts
@@ -1,3 +1,5 @@
+import {ServicePendingActionError} from "@nsm/engine/error";
+
export type UnlockObserver = (id: string, status?: string, err?: any) => void;
const statuses = {};
@@ -15,69 +17,74 @@ let stopping = false;
* @returns The unlock function
*/
export function lockBusyAction(id: string, tp: string) {
- reqNotPending(id);
- statuses[id] = true;
- status_types[id] = tp; // type of action
-
- return (err?: any) => {
- delete statuses[id];
- delete status_types[id];
+ reqNotPending(id);
+ statuses[id] = true;
+ status_types[id] = tp; // type of action
- (obs.get(id) ?? []).forEach(o => o(id, tp, err));
- obs.delete(id);
-
- if (pendingCount() == 0) {
- obsAll.forEach(o => o());
- obsAll.splice(0, obsAll.length);
- }
+ return (err?: any) => {
+ if (getActionType(id) !== tp) {
+ throw new Error(
+ `Unlocking action type ${tp} does not match the current action type ${getActionType(id)} for service ${id}`,
+ );
}
-}
-export function whenUnlocked(id: string, cb: UnlockObserver) {
- if (isServicePending(id)) {
- obs.set(id, obs.get(id) ?? []);
- obs.get(id).push(cb);
- } else {
- cb(id, undefined, undefined);
- }
+ unlockBusyAction(id, err);
+ };
}
-export function whenUnlockedAll(cb: () => void) {
- if (pendingCount() > 0) {
- obsAll.push(cb);
- } else {
- cb();
- }
+export function unlockBusyAction(id: string, err?: any) {
+ const tp = getActionType(id);
+ if (!tp) {
+ throw new Error("No busy action in process");
+ }
+
+ delete statuses[id];
+ delete status_types[id];
+
+ (obs.get(id) ?? []).forEach((o) => o(id, tp, err));
+ obs.delete(id);
+
+ if (pendingCount() == 0) {
+ obsAll.forEach((o) => o());
+ obsAll.splice(0, obsAll.length);
+ }
}
-export function lckStatusTp(id: string, tp: string) {
- status_types[id] = tp;
+export function whenUnlocked(id: string, cb: UnlockObserver) {
+ if (isServicePending(id)) {
+ obs.set(id, obs.get(id) ?? []);
+ obs.get(id).push(cb);
+ } else {
+ cb(id, undefined, undefined);
+ }
}
-export function ulckStatusTp(id: string) {
- delete status_types[id];
+export function whenUnlockedAll(cb: () => void) {
+ if (pendingCount() > 0) {
+ obsAll.push(cb);
+ } else {
+ cb();
+ }
}
export function isServicePending(id: string): boolean {
- return statuses[id] || false;
+ return statuses[id] || false;
}
-export function getActionType(id: string): string|undefined {
- return status_types[id] || undefined;
+export function getActionType(id: string): string | undefined {
+ return status_types[id] || undefined;
}
export function reqNotPending(id: string) {
- if (stopping == false && isServicePending(id)) {
- throw new Error('Service is pending another action.');
- }
+ if (stopping == false && isServicePending(id)) {
+ throw new ServicePendingActionError(id, getActionType(id));
+ }
}
export function setStopping() {
- stopping = true;
+ stopping = true;
}
export function pendingCount() {
- return Object.keys(statuses)
- .filter(k => statuses[k])
- .length;
-}
\ No newline at end of file
+ return Object.keys(statuses).filter((k) => statuses[k]).length;
+}
diff --git a/src/engine/docker/action/build.ts b/src/engine/docker/action/build.ts
index 370e4eb..125f565 100644
--- a/src/engine/docker/action/build.ts
+++ b/src/engine/docker/action/build.ts
@@ -2,139 +2,123 @@ import DockerClient from "dockerode";
import fs from "fs";
import path from "path";
import tar from "tar";
-import {currentContext, currentContext as ctx} from "../../../app";
-import {MessageListener, ServiceEngine} from "@nsm/engine";
-import {clock} from "@nsm/util/clock";
-import {Worker} from "worker_threads";
-import {getRootFilesFiltered} from "@nsm/engine/ignore";
-import {Paths} from "env-paths";
-import {getTempPath} from "@nsm/filestructure";
+import { MessageListener, ServiceEngine } from "@nsm/engine";
+import { clock } from "@nsm/util/clock";
+import { getRootFilesFiltered } from "@nsm/engine/ignore";
+import { mkdirTemp } from "@nsm/filestructure";
+import { currentContext } from "@nsm/app";
-async function prepareImage(
- args: {
- imageName: string|undefined,
- client: DockerClient,
- arDir: string,
- buildDir: string,
- env: any,
- messageListener?: MessageListener
- }
-): Promise {
- let {
- imageName,
- client,
- arDir,
- buildDir,
- env,
- messageListener
- } = args;
+async function prepareImage(args: {
+ imageName: string | undefined;
+ client: DockerClient;
+ arDir: string;
+ buildDir: string;
+ env: any;
+ messageListener?: MessageListener;
+}): Promise {
+ let { imageName, client, arDir, buildDir, env, messageListener } = args;
- if (!imageName) {
- // Generate an unique image name
- imageName = "nsm-template-" + path.basename(buildDir) + '-' + Date.now() + ':latest'; // TODO: better unique name generation, maybe hash of the build context?
- }
+ if (!imageName) {
+ // Generate an unique image name
+ imageName =
+ "nsm-template-" + path.basename(buildDir) + "-" + Date.now() + ":latest"; // TODO: better unique name generation, maybe hash of the build context?
+ }
- // temp archive
- const archive = path.join(arDir, imageName + '.tar');
- try {
- // try to delete if there is already a file
- fs.unlinkSync(archive);
- } catch (e) {
- if (!e.message.includes('ENOENT')) {
- throw e;
- }
+ // temp archive
+ const archive = path.join(arDir, imageName + ".tar");
+ try {
+ // try to delete if there is already a file
+ fs.unlinkSync(archive);
+ } catch (e) {
+ if (!e.message.includes("ENOENT")) {
+ throw e;
}
+ }
- await tar.c({
- gzip: false,
- file: archive,
- cwd: buildDir
- }, [...getRootFilesFiltered(buildDir)]);
+ await tar.c(
+ {
+ gzip: false,
+ file: archive,
+ cwd: buildDir,
+ },
+ [...getRootFilesFiltered(buildDir)],
+ );
- const imageTag = imageName;
- const logs = [];
- return (
- new Promise((resolve, reject) => {
- const msgHandler = (msg: any) => {
- if (Array.isArray(msg)) {
- msg.forEach(m => {
- // Push service log record
- // TODO: publish log record using messageListener
- });
- } else {
- // Final message, resolve the promise with the image tag.
- resolve(msg);
- }
- }
- /*if (ctx.workers) {
- // Build using workers
- const w = new Worker(__dirname + path.sep + 'build.worker.js', {
- workerData: {
- archive,
- imageTag,
- env,
- appConfig: ctx.appConfig,
- debug: ctx.debug
- }
- });
- w.on('message', msgHandler);
+ const imageTag = imageName;
+ const logs = [];
+ return new Promise((resolve, reject) => {
+ const msgHandler = (msg: any) => {
+ if (Array.isArray(msg)) {
+ msg.forEach((m) => {
+ // Push service log record
+ // TODO: publish log record using messageListener
+ });
+ } else {
+ // Final message, resolve the promise with the image tag.
+ resolve(msg);
+ }
+ };
+ // In container, worker threads are not supported. Or they
+ // are disabled.
+ client
+ .buildImage(archive, { t: imageTag, buildargs: env })
+ .then((stream) => {
+ logs.push("--------- Begin Build Log ---------");
+ client.modem.followProgress(stream, (err, res) => {
+ if (err) {
+ console.error(err);
} else {
- // Here comes the normal build
- }*/
- // In container, worker threads are not supported. Or they
- // are disabled.
- client.buildImage(archive, { t: imageTag, buildargs: env }).then(stream => {
- logs.push('--------- Begin Build Log ---------');
- client.modem.followProgress(stream, (err, res) => {
- if (err) {
- console.error(err);
- } else {
- let errorOccurred = false;
- res.forEach(r => {
- if (r.errorDetail) {
- errorOccurred = true;
+ let errorOccurred = false;
+ res.forEach((r) => {
+ if (r.errorDetail) {
+ errorOccurred = true;
- reject(r.errorDetail);
- } else {
- const msg = r.stream?.trim();
+ reject(r.errorDetail);
+ } else {
+ const msg = r.stream?.trim();
- logs.push(msg);
- }
- });
- if (errorOccurred) {
- return;
+ logs.push(msg);
}
- logs.push('--------- End Of Build Log ---------\n');
- fs.unlinkSync(archive);
- msgHandler(logs);
- msgHandler(imageTag);
+ });
+ if (errorOccurred) {
+ return;
}
- });
- });
- }).finally(() => {
- // Clean up archive file if it still exists
- try {
- fs.unlinkSync(archive);
- } catch (e) {
- if (!e.message.includes('ENOENT')) {
- console.error('Error cleaning up archive file:', e);
- }
+ logs.push("--------- End Of Build Log ---------\n");
+ fs.unlinkSync(archive);
+ msgHandler(logs);
+ msgHandler(imageTag);
}
- })
- );
+ });
+ });
+ }).finally(() => {
+ // Clean up archive file if it still exists
+ try {
+ fs.unlinkSync(archive);
+ } catch (e) {
+ if (!e.message.includes("ENOENT")) {
+ console.error("Error cleaning up archive file:", e);
+ }
+ }
+ });
}
-export default function (client: DockerClient, paths: Paths): ServiceEngine['build'] {
- const arDir = path.join(getTempPath(), "archives");
- if (!fs.existsSync(arDir)) {
- fs.mkdirSync(arDir, { recursive: true });
- }
+export default function (client: DockerClient): ServiceEngine["build"] {
+ const arDir = mkdirTemp("archives");
- return async (imageId, buildDir, options, messageListener) => {
- const imageBuildClock = clock();
- const imageTag = await prepareImage({imageName: imageId, client, arDir, buildDir, env: options, messageListener});
- currentContext.logger.info('Image built in ' + imageBuildClock.durFromCreation() + 'ms');
+ return async (imageId, buildDir, options, messageListener) => {
+ const imageBuildClock = clock();
+ const imageTag = await prepareImage({
+ imageName: imageId,
+ client,
+ arDir,
+ buildDir,
+ env: options,
+ messageListener,
+ });
+ currentContext.logger.info(
+ "Image built in " + imageBuildClock.durFromCreation() + "ms",
+ );
- return imageTag;
- }
-}
\ No newline at end of file
+ return imageTag;
+ };
+}
diff --git a/src/engine/docker/action/build.worker.ts b/src/engine/docker/action/build.worker.ts
deleted file mode 100644
index 9e45d67..0000000
--- a/src/engine/docker/action/build.worker.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import {workerData, parentPort} from "worker_threads";
-import fs from "fs";
-import {initDockerClient} from "../client";
-
-const appConfig = workerData['appConfig'] as any;
-const archive = workerData['archive'] as string;
-const tag = workerData['imageTag'] as string;
-const env = workerData['env'] as any;
-const debug = workerData['debug'] as boolean;
-
-const client = initDockerClient(appConfig);
-const logs = [];
-
-if (debug) {
- console.log("Running image build inside worker.");
-}
-
-client.buildImage(archive, { t: tag, buildargs: env }).then(stream => {
- logs.push('--------- Begin Build Log ---------');
- client.modem.followProgress(stream, (err, res) => {
- if (err) {
- console.error(err);
- } else {
- res.forEach(r => {
- if (r.errorDetail) {
- console.error(new Error(r.errorDetail));
- } else {
- const msg = r.stream?.trim();
- //ctx.logger.info(msg);
- logs.push(msg);
- }
- });
- logs.push('--------- End Of Build Log ---------\n');
- fs.unlinkSync(archive);
- parentPort.postMessage(logs);
- parentPort.postMessage(tag);
- }
- });
-});
-
-if (debug) {
- console.log("End of worker.");
-}
\ No newline at end of file
diff --git a/src/engine/docker/action/calcHostUsage.ts b/src/engine/docker/action/calcHostUsage.ts
index 4031091..b8b4fd0 100644
--- a/src/engine/docker/action/calcHostUsage.ts
+++ b/src/engine/docker/action/calcHostUsage.ts
@@ -7,7 +7,7 @@ export default function calcHostUsage(client: DockerClient) {
let free_ = 0;
let size_ = 0;
for (const vol of Volumes) {
- if (!vol.Labels || !('nsm' in vol.Labels)) {
+ if (!vol.Labels || !("nsm" in vol.Labels)) {
// Not a NSM volume.
continue;
}
@@ -16,5 +16,5 @@ export default function calcHostUsage(client: DockerClient) {
size_ += size;
}
return [free_, size_];
- }
-}
\ No newline at end of file
+ };
+}
diff --git a/src/engine/docker/action/cmd.ts b/src/engine/docker/action/cmd.ts
index 39e4984..40477d2 100644
--- a/src/engine/docker/action/cmd.ts
+++ b/src/engine/docker/action/cmd.ts
@@ -1,15 +1,18 @@
-import {DockerServiceEngine, ServiceEngine} from "@nsm/engine";
+import { DockerServiceEngine, ServiceEngine } from "@nsm/engine";
import DockerClient from "dockerode";
-export default function (self: ServiceEngine, _: DockerClient): ServiceEngine['cmd'] {
- return async (id, cmd) => {
- const watchers = (self as DockerServiceEngine).rws;
- //
- if (id in watchers) {
- watchers[id].write(cmd + '\n');
- return true;
- } else {
- return false;
- }
+export default function (
+ self: ServiceEngine,
+ _: DockerClient,
+): ServiceEngine["cmd"] {
+ return async (id, cmd) => {
+ const watchers = (self as DockerServiceEngine).rws;
+ //
+ if (id in watchers) {
+ watchers[id].write(cmd + "\n");
+ return true;
+ } else {
+ return false;
}
-}
\ No newline at end of file
+ };
+}
diff --git a/src/engine/docker/action/deletei.ts b/src/engine/docker/action/deletei.ts
index 0eceaa1..835e91c 100644
--- a/src/engine/docker/action/deletei.ts
+++ b/src/engine/docker/action/deletei.ts
@@ -1,10 +1,12 @@
import DockerClient from "dockerode";
-import {ServiceEngine} from "@nsm/engine";
+import { ServiceEngine } from "@nsm/engine";
-export default function deleteImage(client: DockerClient): ServiceEngine["deleteImage"] {
+export default function deleteImage(
+ client: DockerClient,
+): ServiceEngine["deleteImage"] {
return async (id) => {
const image = client.getImage(id);
await image.remove();
- }
-}
\ No newline at end of file
+ };
+}
diff --git a/src/engine/docker/action/deletev.ts b/src/engine/docker/action/deletev.ts
index 12f1604..773496c 100644
--- a/src/engine/docker/action/deletev.ts
+++ b/src/engine/docker/action/deletev.ts
@@ -1,15 +1,18 @@
import DockerClient from "dockerode";
-import {ServiceEngine} from "../../engine";
-import {currentContext} from "../../../app";
+import { ServiceEngine } from "../../engine";
+import { currentContext } from "../../../app";
-export default function (self: ServiceEngine, client: DockerClient): ServiceEngine['deleteVolume'] {
- return async (id) => {
- try {
- await client.getVolume(id).remove();
- return true;
- } catch (e) {
- currentContext.logger.error(e);
- return false;
- }
+export default function (
+ self: ServiceEngine,
+ client: DockerClient,
+): ServiceEngine["deleteVolume"] {
+ return async (id) => {
+ try {
+ await client.getVolume(id).remove();
+ return true;
+ } catch (e) {
+ currentContext.logger.error(e);
+ return false;
}
-}
\ No newline at end of file
+ };
+}
diff --git a/src/engine/docker/action/getLabels.ts b/src/engine/docker/action/getLabels.ts
index 52f3205..c9d1ae0 100644
--- a/src/engine/docker/action/getLabels.ts
+++ b/src/engine/docker/action/getLabels.ts
@@ -1,12 +1,12 @@
import DockerClient from "dockerode";
-import {ServiceEngine} from "@nsm/engine";
+import { ServiceEngine } from "@nsm/engine";
-export default function (client: DockerClient): ServiceEngine['getLabels'] {
+export default function (client: DockerClient): ServiceEngine["getLabels"] {
return async (id) => {
const container = client.getContainer(id);
const inspect = await container.inspect();
return inspect.Config.Labels;
- }
-}
\ No newline at end of file
+ };
+}
diff --git a/src/engine/docker/action/kill.ts b/src/engine/docker/action/kill.ts
index f625b28..8651ea4 100644
--- a/src/engine/docker/action/kill.ts
+++ b/src/engine/docker/action/kill.ts
@@ -1,20 +1,20 @@
-import {ServiceEngine} from "@nsm/engine";
+import { ServiceEngine } from "@nsm/engine";
import DockerClient from "dockerode";
-export default function (client: DockerClient): ServiceEngine['kill'] {
- return async (id) => {
- try {
- const list = await client.listContainers();
- if (list.map(c => c.Id).includes(id)) {
- await client.getContainer(id).kill();
- }
+export default function (client: DockerClient): ServiceEngine["kill"] {
+ return async (id) => {
+ try {
+ const list = await client.listContainers();
+ if (list.map((c) => c.Id).includes(id)) {
+ await client.getContainer(id).kill();
+ }
- return true;
- } catch (e) {
- if (!e.message.includes('container is not running')) {
- console.log(e);
- }
- return false;
- }
+ return true;
+ } catch (e) {
+ if (!e.message.includes("container is not running")) {
+ console.log(e);
+ }
+ return false;
}
-}
\ No newline at end of file
+ };
+}
diff --git a/src/engine/docker/action/listRunning.ts b/src/engine/docker/action/listRunning.ts
index d91fff3..d110ab5 100644
--- a/src/engine/docker/action/listRunning.ts
+++ b/src/engine/docker/action/listRunning.ts
@@ -1,15 +1,13 @@
import DockerClient from "dockerode";
-import {ContainerFilter} from "@nsm/engine";
-import {toDockerFilters} from "@nsm/engine/docker/util/labels";
+import { ContainerFilter } from "@nsm/engine";
+import { toDockerFilters } from "@nsm/engine/docker/util/labels";
export default function listRunningFunc(client: DockerClient) {
return async (filter: ContainerFilter) => {
const list = await client.listContainers({
all: true,
- filters: toDockerFilters(filter)
+ filters: toDockerFilters(filter),
});
- return list
- .filter(c => c.State === 'running')
- .map(c => c.Id);
- }
-}
\ No newline at end of file
+ return list.filter((c) => c.State === "running").map((c) => c.Id);
+ };
+}
diff --git a/src/engine/docker/action/listc.ts b/src/engine/docker/action/listc.ts
index 00b7831..3c1fa2c 100644
--- a/src/engine/docker/action/listc.ts
+++ b/src/engine/docker/action/listc.ts
@@ -1,19 +1,22 @@
import DockerClient from "dockerode";
-import {ServiceEngine} from "@nsm/engine";
-import {toDockerFilters} from "@nsm/engine/docker/util/labels";
+import { ServiceEngine } from "@nsm/engine";
+import { toDockerFilters } from "@nsm/engine/docker/util/labels";
-export default function (self: ServiceEngine, client: DockerClient): ServiceEngine['listContainers'] {
- return async (filter) => {
- try {
- const containers = await client.listContainers({
- all: true,
- filters: toDockerFilters(filter)
- });
+export default function (
+ self: ServiceEngine,
+ client: DockerClient,
+): ServiceEngine["listContainers"] {
+ return async (filter) => {
+ try {
+ const containers = await client.listContainers({
+ all: true,
+ filters: toDockerFilters(filter),
+ });
- return containers.map(c => c.Id);
- } catch (e) {
- console.log(e);
- return [];
- }
+ return containers.map((c) => c.Id);
+ } catch (e) {
+ console.log(e);
+ return [];
}
-}
\ No newline at end of file
+ };
+}
diff --git a/src/engine/docker/action/listp.ts b/src/engine/docker/action/listp.ts
index 05c876f..652137e 100644
--- a/src/engine/docker/action/listp.ts
+++ b/src/engine/docker/action/listp.ts
@@ -1,15 +1,18 @@
import DockerClient from "dockerode";
-import {ServiceEngine} from "../../engine";
+import { ServiceEngine } from "../../engine";
-export default function (self: ServiceEngine, client: DockerClient): ServiceEngine['listAttachedPorts'] {
- return async () => {
- try {
- return (await client.listContainers())
- .map(c => c.Ports.map(p => p.PublicPort))
- .flat();
- } catch (e) {
- console.log(e);
- return [];
- }
+export default function (
+ self: ServiceEngine,
+ client: DockerClient,
+): ServiceEngine["listAttachedPorts"] {
+ return async () => {
+ try {
+ return (await client.listContainers())
+ .map((c) => c.Ports.map((p) => p.PublicPort))
+ .flat();
+ } catch (e) {
+ console.log(e);
+ return [];
}
-}
\ No newline at end of file
+ };
+}
diff --git a/src/engine/docker/action/reattach.ts b/src/engine/docker/action/reattach.ts
index cdc9ac3..ed4b075 100644
--- a/src/engine/docker/action/reattach.ts
+++ b/src/engine/docker/action/reattach.ts
@@ -1,11 +1,22 @@
import DockerClient from "dockerode";
-import {DockerServiceEngine, ServiceEngine, ServiceLogRecord} from "@nsm/engine";
-import {getActionType} from "@nsm/engine/asyncp";
-import {currentContext} from "@nsm/app";
-import {deleteNetwork as doDeleteNetwork, isInNetwork} from "@nsm/networking/manager";
+import {
+ DockerServiceEngine,
+ ServiceEngine,
+ ServiceLogRecord,
+} from "@nsm/engine";
+import { getActionType } from "@nsm/engine/asyncp";
+import { currentContext } from "@nsm/app";
+import {
+ deleteNetwork as doDeleteNetwork,
+ isInNetwork,
+} from "@nsm/networking/manager";
import winston from "winston";
-async function deleteContainer(id: string, client: DockerClient, options: { deleteNetwork?: boolean }) {
+async function deleteContainer(
+ id: string,
+ client: DockerClient,
+ options: { deleteNetwork?: boolean },
+) {
try {
const c = client.getContainer(id);
try {
@@ -18,7 +29,9 @@ async function deleteContainer(id: string, client: DockerClient, options: { dele
const networkId = await isInNetwork(client, id);
if (networkId) {
// Disconnect this container from the attached network.
- await client.getNetwork(networkId).disconnect({ Container: id, Force: true });
+ await client
+ .getNetwork(networkId)
+ .disconnect({ Container: id, Force: true });
if (options.deleteNetwork == true) {
// Delete network if requested.
await doDeleteNetwork(client, id);
@@ -26,8 +39,11 @@ async function deleteContainer(id: string, client: DockerClient, options: { dele
}
return true;
} catch (e) {
- if (e.message.includes('No such container:') || e.message.includes('removal of container')) {
- currentContext?.logger.warn('Ignoring error: ' + e.message);
+ if (
+ e.message.includes("No such container:") ||
+ e.message.includes("removal of container")
+ ) {
+ currentContext?.logger.warn("Ignoring error: " + e.message);
return true;
}
@@ -36,7 +52,10 @@ async function deleteContainer(id: string, client: DockerClient, options: { dele
}
}
-export default function reattach(self: ServiceEngine, client: DockerClient): ServiceEngine["reattach"] {
+export default function reattach(
+ self: ServiceEngine,
+ client: DockerClient,
+): ServiceEngine["reattach"] {
return async (id, listener) => {
const container = client.getContainer(id);
const logger = currentContext.logger;
@@ -45,23 +64,31 @@ export default function reattach(self: ServiceEngine, client: DockerClient): Ser
await deleteContainer(container.id, client, { deleteNetwork: true });
await listener.onClose?.();
- }
+ };
const info = await container.inspect();
if (!info.State.Running) {
// If the container is not running, we can delete it right after
await handleClosed();
- throw new Error("Container is not running. Maybe it stopped before it could be attached?");
+ throw new Error(
+ "Container is not running. Maybe it stopped before it could be attached?",
+ );
}
- const attachOptions = { stream: true, stdin: true, stdout: true, stderr: true, hijack: true };
+ const attachOptions = {
+ stream: true,
+ stdin: true,
+ stdout: true,
+ stderr: true,
+ hijack: true,
+ };
const rws = await container.attach(attachOptions);
- rws.on('data', (data) => {
+ rws.on("data", (data) => {
try {
- data = Buffer.from(data).toString('ascii');
+ data = Buffer.from(data).toString("ascii");
const record: ServiceLogRecord = {
- level: 'info',
- message: data
+ level: "info",
+ message: data,
};
listener.onMessage?.(record);
@@ -69,8 +96,8 @@ export default function reattach(self: ServiceEngine, client: DockerClient): Ser
logger.error("Error producing container output: " + e);
}
}); // no-op, keepalive
- rws.on('end', async () => {
- if (getActionType(container.id) != 'stop') {
+ rws.on("end", async () => {
+ if (getActionType(container.id) != "stop") {
// Stopped from the inside
await handleClosed();
@@ -82,6 +109,10 @@ export default function reattach(self: ServiceEngine, client: DockerClient): Ser
});
(self as DockerServiceEngine).rws[container.id] = rws;
- await listener.onStateChange?.({ id: 'watching_changes', description: 'Watching changes', ready: true });
- }
-}
\ No newline at end of file
+ await listener.onStateChange?.({
+ id: "watching_changes",
+ description: "Watching changes",
+ ready: true,
+ });
+ };
+}
diff --git a/src/engine/docker/action/run.ts b/src/engine/docker/action/run.ts
index cc22b4d..96572d9 100644
--- a/src/engine/docker/action/run.ts
+++ b/src/engine/docker/action/run.ts
@@ -1,21 +1,26 @@
import DockerClient from "dockerode";
-import {RunOptions, MetaStorage, ServiceEngine, ServiceState} from "@nsm/engine";
-import {accessNetwork, createNetwork} from "@nsm/networking/manager";
-import {constructObjectLabels} from "@nsm/util/services";
-import {currentContext as ctx} from "@nsm/app";
-import {propagateOptionsToEnv} from "@nsm/engine/docker/util/env";
-import {infoRecord as info} from "@nsm/engine/docker/util/logging";
+import {
+ RunOptions,
+ MetaStorage,
+ ServiceEngine,
+ ServiceState,
+} from "@nsm/engine";
+import { accessNetwork, createNetwork } from "@nsm/networking/manager";
+import { constructObjectLabels } from "@nsm/util/services";
+import { currentContext as ctx } from "@nsm/app";
+import { propagateOptionsToEnv } from "@nsm/engine/docker/util/env";
+import { infoRecord as info } from "@nsm/engine/docker/util/logging";
async function prepareVolume(client: DockerClient, volumeId: string) {
try {
await client.getVolume(volumeId).inspect();
} catch (e) {
- if (e.message.includes('No such')) {
+ if (e.message.includes("No such")) {
await client.createVolume({
Name: volumeId,
Labels: {
...constructObjectLabels({ id: volumeId }),
- 'nsm.volumeId': volumeId,
+ "nsm.volumeId": volumeId,
},
});
@@ -28,18 +33,18 @@ async function prepareVolume(client: DockerClient, volumeId: string) {
async function prepareNetwork(
client: DockerClient,
- network: RunOptions['network'],
+ network: RunOptions["network"],
meta: MetaStorage,
- creatingContainer: boolean
+ creatingContainer: boolean,
) {
- let net: DockerClient.Network|undefined = undefined;
+ let net: DockerClient.Network | undefined = undefined;
if (network && !network.portsOnly) {
const metaKey = "net-id";
let netId = await meta.get(metaKey);
if (creatingContainer || !netId) {
net = await createNetwork(client, network.address);
netId = net.id;
- if (!await meta.set(metaKey, netId)) {
+ if (!(await meta.set(metaKey, netId))) {
throw new Error("Could not save network data.");
}
} else {
@@ -54,13 +59,14 @@ async function prepareContainer(
imageTag: string,
volumeId: string,
options: RunOptions,
- net: DockerClient.Network|undefined
+ net: DockerClient.Network | undefined,
) {
- const {ram, cpu, disk, port, network} = options;
- const env = {...options.env};
+ const { ram, cpu, disk, port, network } = options;
+ const env = { ...options.env };
propagateOptionsToEnv(options, env);
- const fullPortDef = (port: number) => (network?.portsOnly ? network.address + ":" : "") + port + "";
+ const fullPortDef = (port: number) =>
+ (network?.portsOnly ? network.address + ":" : "") + port + "";
// Create container
const container = await client.createContainer({
Image: imageTag,
@@ -68,15 +74,15 @@ async function prepareContainer(
HostConfig: {
Memory: ram,
CpuShares: cpu,
- PortBindings: { [port + '/tcp']: [{HostPort: fullPortDef(port)}] },
+ PortBindings: { [port + "/tcp"]: [{ HostPort: fullPortDef(port) }] },
DiskQuota: disk,
Mounts: [
{
- Type: 'volume',
+ Type: "volume",
Source: client.getVolume(volumeId).name,
- Target: '/data',
+ Target: "/data",
ReadOnly: false,
- }
+ },
],
},
Env: Object.entries(env).map(([k, v]) => `${k}=${v}`),
@@ -90,41 +96,54 @@ async function prepareContainer(
return container;
}
-const createState = (id: string, description: string, ready?: boolean): ServiceState => {
+const createState = (
+ id: string,
+ description: string,
+ ready?: boolean,
+): ServiceState => {
return {
id,
description,
- ready: ready ?? false
- }
+ ready: ready ?? false,
+ };
};
const createErrorState = (description: string): ServiceState => {
return {
- id: 'error',
+ id: "error",
description,
- ready: false
- }
-}
+ ready: false,
+ };
+};
-export default function run(self: ServiceEngine, client: DockerClient): ServiceEngine["run"] {
+export default function run(
+ self: ServiceEngine,
+ client: DockerClient,
+): ServiceEngine["run"] {
return async (imageId, volumeId, options, meta, listener) => {
let container: DockerClient.Container;
// Prepare volume
let creating = await prepareVolume(client, volumeId);
- await listener.onStateChange?.(createState('preparing_network', 'Preparing network'));
+ await listener.onStateChange?.(
+ createState("preparing_network", "Preparing network"),
+ );
const net = await prepareNetwork(client, options.network, meta, creating);
// Port decorator that takes port and according to network changes it to : or keeps the same.
- await listener.onStateChange?.(createState('preparing_container', 'Preparing container'));
+ await listener.onStateChange?.(
+ createState("preparing_container", "Preparing container"),
+ );
container = await prepareContainer(client, imageId, volumeId, options, net);
- await listener.onStateChange?.(createState('starting_container', 'Starting container'));
+ await listener.onStateChange?.(
+ createState("starting_container", "Starting container"),
+ );
await container.start();
const inspectInfo = await container.inspect();
if (!inspectInfo.State.Running) {
// Wait a bit for logs to be available
- await new Promise(r => setTimeout(r, 300));
+ await new Promise((r) => setTimeout(r, 300));
// Container failed to start, try to get logs and error message
// The necessary error will be thrown by reattach call
@@ -137,15 +156,20 @@ export default function run(self: ServiceEngine, client: DockerClient): ServiceE
});
const msg = logs.toString("utf8");
- await listener.onStateChange?.(createErrorState('Container failed to start'));
+ await listener.onStateChange?.(
+ createErrorState("Container failed to start"),
+ );
await listener.onMessage(info(msg));
} catch (e) {
- ctx.logger.error("Error while fetching logs for failed container " + container.id, e);
+ ctx.logger.error(
+ "Error while fetching logs for failed container " + container.id,
+ e,
+ );
}
}
await self.reattach(container.id, listener);
return container.id;
- }
-}
\ No newline at end of file
+ };
+}
diff --git a/src/engine/docker/action/stat.ts b/src/engine/docker/action/stat.ts
index 0cf729a..4672592 100644
--- a/src/engine/docker/action/stat.ts
+++ b/src/engine/docker/action/stat.ts
@@ -1,10 +1,13 @@
-import {ServiceEngine} from "@nsm/engine";
+import { ServiceEngine } from "@nsm/engine";
import DockerClient from "dockerode";
-import {adaptContainerStatsFromDocker} from "@nsm/util/docker";
+import { adaptContainerStatsFromDocker } from "@nsm/util/docker";
-export default function (self: ServiceEngine, client: DockerClient): ServiceEngine['stat'] {
- return async (id) => {
- const stats = await client.getContainer(id).stats({ stream: false });
- return adaptContainerStatsFromDocker(id, stats);
- }
-}
\ No newline at end of file
+export default function (
+ self: ServiceEngine,
+ client: DockerClient,
+): ServiceEngine["stat"] {
+ return async (id) => {
+ const stats = await client.getContainer(id).stats({ stream: false });
+ return adaptContainerStatsFromDocker(id, stats);
+ };
+}
diff --git a/src/engine/docker/action/statall.ts b/src/engine/docker/action/statall.ts
index c8320a2..3316694 100644
--- a/src/engine/docker/action/statall.ts
+++ b/src/engine/docker/action/statall.ts
@@ -1,9 +1,9 @@
-import {ServiceEngine} from "@nsm/engine";
+import { ServiceEngine } from "@nsm/engine";
-export default function (self: ServiceEngine): ServiceEngine['statAll'] {
- return async (filter) => {
- const containers = await self.listContainers(filter);
+export default function (self: ServiceEngine): ServiceEngine["statAll"] {
+ return async (filter) => {
+ const containers = await self.listContainers(filter);
- return Promise.all(containers.map(c => self.stat(c)));
- }
-}
\ No newline at end of file
+ return Promise.all(containers.map((c) => self.stat(c)));
+ };
+}
diff --git a/src/engine/docker/action/stop.ts b/src/engine/docker/action/stop.ts
index 7366526..2f0142c 100644
--- a/src/engine/docker/action/stop.ts
+++ b/src/engine/docker/action/stop.ts
@@ -1,20 +1,20 @@
import DockerClient from "dockerode";
-import {ServiceEngine} from "@nsm/engine";
+import { ServiceEngine } from "@nsm/engine";
-export default function (client: DockerClient): ServiceEngine['stop'] {
- return async (id) => {
- try {
- const list = await client.listContainers();
- if (list.map(c => c.Id).includes(id)) {
- await client.getContainer(id).stop({ signal: 'SIGINT' });
- }
+export default function (client: DockerClient): ServiceEngine["stop"] {
+ return async (id) => {
+ try {
+ const list = await client.listContainers();
+ if (list.map((c) => c.Id).includes(id)) {
+ await client.getContainer(id).stop({ signal: "SIGINT" });
+ }
- return true;
- } catch (e) {
- if (!e.message.includes('container already stopped')) {
- console.log(e);
- }
- return false;
- }
+ return true;
+ } catch (e) {
+ if (!e.message.includes("container already stopped")) {
+ console.log(e);
+ }
+ return false;
}
-}
\ No newline at end of file
+ };
+}
diff --git a/src/engine/docker/client.ts b/src/engine/docker/client.ts
index 6833e0f..96e2ce0 100644
--- a/src/engine/docker/client.ts
+++ b/src/engine/docker/client.ts
@@ -1,34 +1,41 @@
import DockerClient from "dockerode";
-import {AppConfig} from "@nsm/config";
+import { AppConfig } from "@nsm/config";
+import {currentGlobalLogger} from "@nsm/logger";
export function initDockerClient(appConfig: AppConfig) {
- let host = appConfig.getDockerHost();
+ let host = appConfig.getDockerHost();
- let client: DockerClient;
- if (host && (
- host.endsWith('.sock') ||
- host.startsWith('\\\\.\\pipe')
- )) {
- client = new DockerClient({ socketPath: host });
- } else if (host) {
- // http(s)://host:port
- host = host.substring(0, host.lastIndexOf(':') + 1);
+ let client: DockerClient;
+ if (host && (host.endsWith(".sock") || host.startsWith("\\\\.\\pipe"))) {
+ client = new DockerClient({ socketPath: host });
+ } else if (host) {
+ // http(s)://host:port
+ host = host.substring(0, host.lastIndexOf(":") + 1);
- let port = parseInt(appConfig.getDockerHost().replace(host, ''));
+ let port = parseInt(appConfig.getDockerHost().replace(host, ""));
- host = host.substring(0, host.length - 1);
+ host = host.substring(0, host.length - 1);
- let protocol = host.substring(0, host.indexOf('://')) as "http" | "https" | "ssh";
+ let protocol = host.substring(0, host.indexOf("://")) as
+ | "http"
+ | "https"
+ | "ssh";
- host = host.substring(host.indexOf('://') + 3);
+ host = host.substring(host.indexOf("://") + 3);
- if (isNaN(port)) {
- throw new Error('Docker host must be in this format: protocol://host:port');
- }
-
- client = new DockerClient({protocol, host, port});
- } else {
- throw new Error('Docker engine configuration variable not found! Please set docker_host in resources/config.yml or override using env.');
+ if (isNaN(port)) {
+ throw new Error(
+ "Docker host must be in this format: protocol://host:port",
+ );
}
- return client;
-}
\ No newline at end of file
+
+ currentGlobalLogger.info(`Initializing Docker client on ${protocol}://${host}:${port}`);
+
+ client = new DockerClient({ protocol, host, port });
+ } else {
+ throw new Error(
+ "Docker engine configuration variable not found! Please set docker_host in resources/config.yml or override using env.",
+ );
+ }
+ return client;
+}
diff --git a/src/engine/docker/index.ts b/src/engine/docker/index.ts
index 4814c81..dae08ec 100644
--- a/src/engine/docker/index.ts
+++ b/src/engine/docker/index.ts
@@ -1,46 +1,45 @@
-import {DockerServiceEngine} from "@nsm/engine";
-import {initDockerClient} from "@nsm/engine/docker/client";
+import { DockerServiceEngine } from "@nsm/engine";
+import { initDockerClient } from "@nsm/engine/docker/client";
-import build from './action/build';
+import build from "./action/build";
import run from "./action/run";
-import stop from './action/stop';
-import kill from './action/kill';
+import stop from "./action/stop";
+import kill from "./action/kill";
import reattach from "./action/reattach";
-import delVolume from './action/deletev';
-import delImage from './action/deletei';
-import cmd from './action/cmd';
-import getLabels from './action/getLabels';
-import listContainers from './action/listc';
-import listAttachedPorts from './action/listp';
+import delVolume from "./action/deletev";
+import delImage from "./action/deletei";
+import cmd from "./action/cmd";
+import getLabels from "./action/getLabels";
+import listContainers from "./action/listc";
+import listAttachedPorts from "./action/listp";
import stat from "./action/stat";
import statAll from "./action/statall";
import calcHostUsage from "./action/calcHostUsage";
import listRunning from "./action/listRunning";
-import {currentPaths} from "@nsm/filestructure";
-import {AppConfig} from "@nsm/config";
+import { AppConfig } from "@nsm/config";
export default function buildDockerEngine(appConfig: AppConfig) {
- // Default engine implementation
- const client = initDockerClient(appConfig);
- const engine = {} as DockerServiceEngine;
- engine.name = "Docker";
- engine.dockerClient = client;
- engine.rws = {};
- // engine.cast - Being replaced in manager.
- engine.build = build(client, currentPaths);
- engine.run = run(engine, client);
- engine.stop = stop(client);
- engine.kill = kill(client);
- engine.reattach = reattach(engine, client);
- engine.deleteVolume = delVolume(engine, client);
- engine.deleteImage = delImage(client);
- engine.cmd = cmd(engine, client);
- engine.getLabels = getLabels(client);
- engine.listContainers = listContainers(engine, client);
- engine.listAttachedPorts = listAttachedPorts(engine, client);
- engine.stat = stat(engine, client);
- engine.statAll = statAll(engine);
- engine.calcHostUsage = calcHostUsage(client);
- engine.listRunning = listRunning(client);
- return engine;
-}
\ No newline at end of file
+ // Default engine implementation
+ const client = initDockerClient(appConfig);
+ const engine = {} as DockerServiceEngine;
+ engine.name = "Docker";
+ engine.dockerClient = client;
+ engine.rws = {};
+ // engine.cast - Being replaced in manager.
+ engine.build = build(client);
+ engine.run = run(engine, client);
+ engine.stop = stop(client);
+ engine.kill = kill(client);
+ engine.reattach = reattach(engine, client);
+ engine.deleteVolume = delVolume(engine, client);
+ engine.deleteImage = delImage(client);
+ engine.cmd = cmd(engine, client);
+ engine.getLabels = getLabels(client);
+ engine.listContainers = listContainers(engine, client);
+ engine.listAttachedPorts = listAttachedPorts(engine, client);
+ engine.stat = stat(engine, client);
+ engine.statAll = statAll(engine);
+ engine.calcHostUsage = calcHostUsage(client);
+ engine.listRunning = listRunning(client);
+ return engine;
+}
diff --git a/src/engine/docker/util/env.ts b/src/engine/docker/util/env.ts
index 8f455a2..780cd88 100644
--- a/src/engine/docker/util/env.ts
+++ b/src/engine/docker/util/env.ts
@@ -1,9 +1,9 @@
-import {RunOptions} from "@nsm/engine";
+import { RunOptions } from "@nsm/engine";
export const propagateOptionsToEnv = (options: RunOptions, env: any) => {
env.SERVICE_PORT = options.port.toString();
- env.SERVICE_PORTS = options.ports.join(' ');
+ env.SERVICE_PORTS = options.ports.join(" ");
env.SERVICE_RAM = options.ram.toString();
env.SERVICE_CPU = options.cpu.toString();
env.SERVICE_DISK = options.disk.toString();
-}
\ No newline at end of file
+};
diff --git a/src/engine/docker/util/labels.ts b/src/engine/docker/util/labels.ts
index c9e168c..82fa424 100644
--- a/src/engine/docker/util/labels.ts
+++ b/src/engine/docker/util/labels.ts
@@ -1,4 +1,4 @@
-import {ContainerFilter} from "@nsm/engine";
+import { ContainerFilter } from "@nsm/engine";
/**
* Convert a ContainerFilter to Docker filters format.
@@ -9,8 +9,10 @@ import {ContainerFilter} from "@nsm/engine";
export const toDockerFilters = (filter: ContainerFilter) => {
const dockerFilters: any = {};
if (filter.labels) {
- dockerFilters.label = Object.entries(filter.labels).map(([key, value]) => `${key}=${value}`);
+ dockerFilters.label = Object.entries(filter.labels).map(
+ ([key, value]) => `${key}=${value}`,
+ );
}
return JSON.stringify(dockerFilters);
-}
\ No newline at end of file
+};
diff --git a/src/engine/docker/util/logging.ts b/src/engine/docker/util/logging.ts
index 48ae6b1..a49cc09 100644
--- a/src/engine/docker/util/logging.ts
+++ b/src/engine/docker/util/logging.ts
@@ -1,15 +1,15 @@
-import {ServiceLogRecord} from "@nsm/engine";
+import { ServiceLogRecord } from "@nsm/engine";
export const infoRecord = (message: string): ServiceLogRecord => {
return {
- level: 'info',
- message
- }
-}
+ level: "info",
+ message,
+ };
+};
export const errorRecord = (message: string): ServiceLogRecord => {
return {
- level: 'error',
- message
- }
-}
\ No newline at end of file
+ level: "error",
+ message,
+ };
+};
diff --git a/src/engine/engine.ts b/src/engine/engine.ts
index dd77c09..610c22d 100644
--- a/src/engine/engine.ts
+++ b/src/engine/engine.ts
@@ -1,111 +1,112 @@
import DockerClient from "dockerode";
import buildDockerEngine from "./docker";
-import {getSingleton} from "../depend";
-import {MetaStorage} from "./manager";
-import {AppConfig} from "@nsm/config";
+import { getSingleton } from "../depend";
+import { MetaStorage } from "./manager";
+import { AppConfig } from "@nsm/config";
/**
* The options for running a service.
*/
export type RunOptions = {
- port: number;
- ports: number[];
- ram: number; // in MB
- cpu: number; // in cores
- disk: number;
- env: { [key: string]: string };
- network?: {
- address: string,
- // If only ports should be exposed to this
- // IP address.
- portsOnly: boolean,
- };
- labels?: {
- [key: string]: string;
- }
-}
+ port: number;
+ ports: number[];
+ ram: number; // in MB
+ cpu: number; // in cores
+ disk: number;
+ env: { [key: string]: string };
+ network?: {
+ address: string;
+ // If only ports should be exposed to this
+ // IP address.
+ portsOnly: boolean;
+ };
+ labels?: {
+ [key: string]: string;
+ };
+};
/**
* The stats of a container, used for monitoring.
*/
export type ContainerStat = {
- id: string,
- memory: {
- used: number,
- total: number,
- percent: number
- },
- cpu: {
- used: number,
- total: number,
- percent: number
- },
-}
+ id: string;
+ memory: {
+ used: number;
+ total: number;
+ percent: number;
+ };
+ cpu: {
+ used: number;
+ total: number;
+ percent: number;
+ };
+};
export type ContainerFilter = {
- /**
- * Filter containers that have all those labels.
- */
- labels?: { [key: string]: string };
-}
+ /**
+ * Filter containers that have all those labels.
+ */
+ labels?: { [key: string]: string };
+};
export type ServiceLogRecord = {
- level: 'error' | 'info';
- message: string;
-}
+ level: "error" | "info";
+ message: string;
+};
export type ServiceState = {
- /**
- * Internal ID of the state.
- */
- id: string;
- /**
- * A brief description of the state, for display purposes.
- */
- description: string;
- /**
- * Whether the service is ready to accept commands and connections
- * in this state, thus is running.
- */
- ready: boolean;
-}
+ /**
+ * Internal ID of the state.
+ */
+ id: string;
+ /**
+ * A brief description of the state, for display purposes.
+ */
+ description: string;
+ /**
+ * Whether the service is ready to accept commands and connections
+ * in this state, thus is running.
+ */
+ ready: boolean;
+};
export type MessageListener = {
- /**
- * Called when there is a message from the container, with the message.
- *
- * @param message The message from the container
- */
- onMessage?: (message: ServiceLogRecord) => Promise|void;
-}
+ /**
+ * Called when there is a message from the container, with the message.
+ *
+ * @param message The message from the container
+ */
+ onMessage?: (message: ServiceLogRecord) => Promise | void;
+};
export type RunListener = MessageListener & {
- /**
- * Called when the container progress changes state.
- *
- * @param state The new state.
- */
- onStateChange?: (state: ServiceState) => Promise|void;
+ /**
+ * Called when the container progress changes state.
+ *
+ * @param state The new state.
+ */
+ onStateChange?: (state: ServiceState) => Promise | void;
- /**
- * Called when the container is closed, either by stop or kill, or by itself.
- */
- onClose?: () => Promise|void;
-}
+ /**
+ * Called when the container is closed, either by stop or kill, or by itself.
+ */
+ onClose?: () => Promise | void;
+};
export type DockerServiceEngine = ServiceEngineI & {
- dockerClient: DockerClient;
- /**
- * Map of container IDs and attached watchers.
- * IMPORTANT! Don't close or modify the streams, by any means! It
- * would have unexpected fatal consequences.
- */
- rws: { [id: string]: NodeJS.ReadWriteStream };
-}
+ dockerClient: DockerClient;
+ /**
+ * Map of container IDs and attached watchers.
+ * IMPORTANT! Don't close or modify the streams, by any means! It
+ * would have unexpected fatal consequences.
+ */
+ rws: { [id: string]: NodeJS.ReadWriteStream };
+};
-export type ServiceEngineI = ServiceEngine & { // Internal
- cast(): T;
-}
+export type ServiceEngineI = ServiceEngine & {
+ // Internal
+ cast(): T;
+};
/**
* The lowest layer which manipulates containers (services) directly.
@@ -113,167 +114,169 @@ export type ServiceEngineI = ServiceEngine & { // Internal
* containers themselves.
*/
export type ServiceEngine = {
- // Just for display purposes
- name: string;
+ // Just for display purposes
+ name: string;
- /**
- * Builds an image from build dir.
- *
- * @param imageId The image ID to build. If this is undefined, the engine should generate a random image ID and return it.
- * @param buildDir The build dir path
- * @param buildOptions The build options
- * @param listener The listener to use for calling back up messages from the process
- */
- build(
- imageId: string|undefined,
- buildDir: string,
- buildOptions: { [key: string]: string },
- listener?: MessageListener): Promise;
+ /**
+ * Builds an image from build dir.
+ *
+ * @param imageId The image ID to build. If this is undefined, the engine should generate a random image ID and return it.
+ * @param buildDir The build dir path
+ * @param buildOptions The build options
+ * @param listener The listener to use for calling back up messages from the process
+ */
+ build(
+ imageId: string | undefined,
+ buildDir: string,
+ buildOptions: { [key: string]: string },
+ listener?: MessageListener,
+ ): Promise;
- /**
- * Runs a container from an image, with the given options.
- *
- * @param imageId The ID of the image to use
- * @param volumeId The ID of the volume to use
- * @param options The options
- * @param meta The meta storage
- * @param listener An optional listener for back propagation
- */
- run(
- imageId: string,
- volumeId: string,
- options: RunOptions,
- meta: MetaStorage,
- listener?: RunListener): Promise;
+ /**
+ * Runs a container from an image, with the given options.
+ *
+ * @param imageId The ID of the image to use
+ * @param volumeId The ID of the volume to use
+ * @param options The options
+ * @param meta The meta storage
+ * @param listener An optional listener for back propagation
+ */
+ run(
+ imageId: string,
+ volumeId: string,
+ options: RunOptions,
+ meta: MetaStorage,
+ listener?: RunListener,
+ ): Promise;
- /**
- * Stops a container.
- *
- * @param id Container ID
- * @return Success state
- */
- stop(id: string): Promise;
+ /**
+ * Stops a container.
+ *
+ * @param id Container ID
+ * @return Success state
+ */
+ stop(id: string): Promise;
- /**
- * Kills a container.
- *
- * @param id Container ID
- * @param meta Meta storage for this unique context
- * @return Success state
- */
- kill(id: string, meta: MetaStorage): Promise;
+ /**
+ * Kills a container.
+ *
+ * @param id Container ID
+ * @param meta Meta storage for this unique context
+ * @return Success state
+ */
+ kill(id: string, meta: MetaStorage): Promise;
- /**
- * Reattaches to a container.
- *
- * When this completes, the service is up and running.
- *
- * @param id Container ID
- * @param listener Listener for container messages and state changes
- */
- reattach(id: string, listener: RunListener): Promise;
+ /**
+ * Reattaches to a container.
+ *
+ * When this completes, the service is up and running.
+ *
+ * @param id Container ID
+ * @param listener Listener for container messages and state changes
+ */
+ reattach(id: string, listener: RunListener): Promise;
- /**
- * Deletes a volume by ID.
- * This is NEVER called if ServiceEngine#useVolumes is false.
- *
- * @param id The volume ID.
- */
- deleteVolume(id: string): Promise;
+ /**
+ * Deletes a volume by ID.
+ * This is NEVER called if ServiceEngine#useVolumes is false.
+ *
+ * @param id The volume ID.
+ */
+ deleteVolume(id: string): Promise;
- /**
- * Deletes an image by ID.
- *
- * @param id The image ID.
- * @throw Error if the image cannot be deleted
- */
- deleteImage(id: string): Promise;
+ /**
+ * Deletes an image by ID.
+ *
+ * @param id The image ID.
+ * @throw Error if the image cannot be deleted
+ */
+ deleteImage(id: string): Promise;
- /**
- * Send a command to the container.
- *
- * @param id Container ID
- * @param cmd The command, without new line
- */
- cmd(id: string, cmd: string): Promise;
+ /**
+ * Send a command to the container.
+ *
+ * @param id Container ID
+ * @param cmd The command, without new line
+ */
+ cmd(id: string, cmd: string): Promise;
- /**
- * Gets the labels of a container.
- *
- * @param id Container ID
- */
- getLabels(id: string): Promise<{ [key: string]: string }>;
+ /**
+ * Gets the labels of a container.
+ *
+ * @param id Container ID
+ */
+ getLabels(id: string): Promise<{ [key: string]: string }>;
- /**
- * Lists container ids of containers by templates.
- *
- * @param filter The filter to apply
- * @return List of container IDs
- */
- listContainers(filter: ContainerFilter): Promise;
+ /**
+ * Lists container ids of containers by templates.
+ *
+ * @param filter The filter to apply
+ * @return List of container IDs
+ */
+ listContainers(filter: ContainerFilter): Promise;
- /**
- * List running containers owned by this engine on this machine.
- *
- * @param filter The filter to apply
- * @return List of container IDs
- */
- listRunning(filter: ContainerFilter): Promise;
+ /**
+ * List running containers owned by this engine on this machine.
+ *
+ * @param filter The filter to apply
+ * @return List of container IDs
+ */
+ listRunning(filter: ContainerFilter): Promise;
- listAttachedPorts(): Promise;
+ listAttachedPorts(): Promise;
- stat(id: string): Promise;
+ stat(id: string): Promise;
- statAll(filter: ContainerFilter): Promise;
+ statAll(filter: ContainerFilter): Promise;
- // Disk usage of all services here
- // [0]: free, [1]: size
- calcHostUsage(): Promise;
-}
+ // Disk usage of all services here
+ // [0]: free, [1]: size
+ calcHostUsage(): Promise;
+};
/**
* Standard labels that NSM uses to identify and manage containers.
* Used by the manager to keep consistency across the codebase.
*/
export enum StandardLabel {
- // The default label identifying a NSM-managed container.
- Nsm = 'nsm',
- // The service ID that owns the container.
- ServiceId = 'nsm.id',
- // The volume ID that the container is using.
- VolumeId = 'nsm.volumeId',
- // The template ID that the container is created from.
- TemplateId = 'nsm.templateId',
- // The node ID of the managing worker.
- NodeId = 'nsm.nodeId',
+ // The default label identifying a NSM-managed container.
+ Nsm = "nsm",
+ // The service ID that owns the container.
+ ServiceId = "nsm.id",
+ // The volume ID that the container is using.
+ VolumeId = "nsm.volumeId",
+ // The template ID that the container is created from.
+ TemplateId = "nsm.templateId",
+ // The node ID of the managing worker.
+ NodeId = "nsm.nodeId",
}
export const Filters = {
- /**
- * The standard filter for NSM-managed containers, which
- * filters containers that have the label "nsm" with value "true".
- */
- nsm() {
- return {
- labels: {
- [StandardLabel.Nsm]: 'true'
- }
- }
- },
- /**
- * The filter for containers belonging to a node with the given node ID.
- *
- * @param nodeId The node ID
- */
- node(nodeId: string) {
- return {
- labels: {
- ...this.nsm().labels,
- [StandardLabel.NodeId]: nodeId,
- }
- }
- }
-}
+ /**
+ * The standard filter for NSM-managed containers, which
+ * filters containers that have the label "nsm" with value "true".
+ */
+ nsm() {
+ return {
+ labels: {
+ [StandardLabel.Nsm]: "true",
+ },
+ };
+ },
+ /**
+ * The filter for containers belonging to a node with the given node ID.
+ *
+ * @param nodeId The node ID
+ */
+ node(nodeId: string) {
+ return {
+ labels: {
+ ...this.nsm().labels,
+ [StandardLabel.NodeId]: nodeId,
+ },
+ };
+ },
+};
/**
* Combines multiple run listeners into one, by calling them in sequence.
@@ -281,39 +284,39 @@ export const Filters = {
* @param listeners The listeners to combine.
*/
export const combineRunListeners = (listeners: RunListener[]): RunListener => {
- return {
- onStateChange: async (state) => {
- for (let listener of listeners) {
- await listener.onStateChange?.(state);
- }
- },
- onMessage: async (record) => {
- for (let listener of listeners) {
- await listener.onMessage?.(record);
- }
- },
- onClose: () => {
- for (let listener of listeners) {
- listener.onClose?.();
- }
- }
- }
-}
+ return {
+ onStateChange: async (state) => {
+ for (let listener of listeners) {
+ await listener.onStateChange?.(state);
+ }
+ },
+ onMessage: async (record) => {
+ for (let listener of listeners) {
+ await listener.onMessage?.(record);
+ }
+ },
+ onClose: () => {
+ for (let listener of listeners) {
+ listener.onClose?.();
+ }
+ },
+ };
+};
export default function (appConfig: AppConfig): ServiceEngineI {
- let engine = getSingleton('engine');
- if (!engine) {
- const engineId = process.env.NSM_ENGINE ?? 'docker';
- switch (engineId) {
- case 'docker':
- engine = buildDockerEngine(appConfig);
- break;
- default:
- throw new Error('Invalid engine ID: ' + engineId);
- }
+ let engine = getSingleton("engine");
+ if (!engine) {
+ const engineId = process.env.NSM_ENGINE ?? "docker";
+ switch (engineId) {
+ case "docker":
+ engine = buildDockerEngine(appConfig);
+ break;
+ default:
+ throw new Error("Invalid engine ID: " + engineId);
}
- return {
- cast: undefined, // Being set in manager
- ...engine,
- };
-}
\ No newline at end of file
+ }
+ return {
+ cast: undefined, // Being set in manager
+ ...engine,
+ };
+}
diff --git a/src/engine/error.ts b/src/engine/error.ts
new file mode 100644
index 0000000..36634c7
--- /dev/null
+++ b/src/engine/error.ts
@@ -0,0 +1,67 @@
+export class InternalError extends Error {
+ constructor(message: string) {
+ super(message);
+ }
+}
+
+export class KnownError extends Error {
+ constructor(
+ public readonly code: number,
+ message: string
+ ) {
+ super(message);
+ }
+}
+
+export class InvalidMetaError extends KnownError {
+ constructor(message: string) {
+ super(400, message);
+ }
+}
+
+export class ServiceNotFoundError extends KnownError {
+ constructor(
+ public readonly serviceId: string
+ ) {
+ super(404, `Service not found.`);
+ }
+}
+
+export class ServiceNotRunningError extends KnownError {
+ constructor(
+ public readonly serviceId: string
+ ) {
+ super(409, `Service is not running.`);
+ }
+}
+
+export class ServiceAlreadyRunningError extends KnownError {
+ constructor(
+ public readonly serviceId: string
+ ) {
+ super(409, `Service is already running.`);
+ }
+}
+
+export class ServiceWasNeverActiveError extends KnownError {
+ constructor() {
+ super(400, "Service was never active.");
+ }
+}
+
+export class ServicePendingActionError extends KnownError {
+ constructor(
+ public readonly serviceId: string,
+ public readonly pendingAction: string
+ ) {
+ super(409, `Service has a pending action '${pendingAction}'.`);
+ }
+}
+
+export class TemplateNotFoundError extends KnownError {
+ constructor(
+ public readonly templateId: string
+ ) {
+ super(404, `Template with ID ${templateId} not found.`);
+ }
+}
\ No newline at end of file
diff --git a/src/engine/ignore.ts b/src/engine/ignore.ts
index 3ba09ea..59c9daf 100644
--- a/src/engine/ignore.ts
+++ b/src/engine/ignore.ts
@@ -3,59 +3,62 @@ import ignore from "ignore";
import path from "path";
export const getRootFilesFiltered = (dir: string) => {
- let filtered = fs.readdirSync(dir);
- if (fs.existsSync(path.join(dir, '.nsmignore'))) {
- const ig = buildIgnore(dir);
+ let filtered = fs.readdirSync(dir);
+ if (fs.existsSync(path.join(dir, ".nsmignore"))) {
+ const ig = buildIgnore(dir);
- filtered = ig.filter(filtered);
- }
+ filtered = ig.filter(filtered);
+ }
- return filtered;
-}
+ return filtered;
+};
export const getFilteredPaths = (dir: string) => {
- const ig = buildIgnore(dir);
+ const ig = buildIgnore(dir);
+
+ let filtered = {
+ files: [] as string[],
+ dirs: [] as string[],
+ };
+ const walk = (currentDir: string) => {
+ const files = fs.readdirSync(currentDir);
+ for (const file of files) {
+ const relativePath =
+ currentDir === dir
+ ? file
+ : currentDir.substring(dir.length + 1) + path.sep + file;
+ const fullPath = currentDir + path.sep + file;
- let filtered = {
- files: [] as string[],
- dirs: [] as string[]
- };
- const walk = (currentDir: string) => {
- const files = fs.readdirSync(currentDir);
- for (const file of files) {
- const relativePath = currentDir === dir ? file : currentDir.substring(dir.length + 1) + path.sep + file;
- const fullPath = currentDir + path.sep + file;
-
- if (ig.ignores(relativePath)) {
- const isDir = fs.statSync(fullPath).isDirectory();
- if (isDir) {
- filtered.dirs.push(relativePath);
- } else {
- filtered.files.push(relativePath);
- }
-
- if (isDir) {
- // If it's a directory, we need to ignore all its contents as well, so we skip walking into it
- continue;
- }
- }
-
- if (fs.statSync(fullPath).isDirectory()) {
- walk(fullPath);
- }
+ if (ig.ignores(relativePath)) {
+ const isDir = fs.statSync(fullPath).isDirectory();
+ if (isDir) {
+ filtered.dirs.push(relativePath);
+ } else {
+ filtered.files.push(relativePath);
}
+
+ if (isDir) {
+ // If it's a directory, we need to ignore all its contents as well, so we skip walking into it
+ continue;
+ }
+ }
+
+ if (fs.statSync(fullPath).isDirectory()) {
+ walk(fullPath);
+ }
}
- walk(dir);
+ };
+ walk(dir);
- return filtered;
-}
+ return filtered;
+};
const buildIgnore = (dir: string) => {
- const ig = ignore();
- const ignorePath = path.join(dir, '.nsmignore');
- if (fs.existsSync(ignorePath)) {
- ig.add(fs.readFileSync(ignorePath, 'utf8'));
- }
+ const ig = ignore();
+ const ignorePath = path.join(dir, ".nsmignore");
+ if (fs.existsSync(ignorePath)) {
+ ig.add(fs.readFileSync(ignorePath, "utf8"));
+ }
- return ig;
-}
\ No newline at end of file
+ return ig;
+};
diff --git a/src/engine/image.ts b/src/engine/image.ts
index 3c23818..4d1a712 100644
--- a/src/engine/image.ts
+++ b/src/engine/image.ts
@@ -1,33 +1,36 @@
-import {Database, ImageModel} from "@nsm/database";
+import { Database, ImageModel } from "@nsm/database";
import winston from "winston";
-import {MessageListener, ServiceEngineI} from "@nsm/engine/engine";
-import {templateBuildDir} from "@nsm/engine/monitoring/util";
-import {TemplateManager} from "@nsm/engine/template";
-import {TemplateDirWatcher} from "@nsm/engine/monitoring/templateDirWatcher";
+import {MessageListener, ServiceEngine} from "@nsm/engine/engine";
+import { TemplateManager } from "@nsm/engine/template";
+import { TemplateDirWatcher } from "@nsm/engine/monitoring/templateDirWatcher";
+import {AppConfig} from "@nsm/config";
type BuildOptionsMap = {
- [key: string]: string
+ [key: string]: string;
};
-let engine: ServiceEngineI;
+let engine: ServiceEngine;
let templateManager: TemplateManager;
let templateDirWatcher: TemplateDirWatcher;
+let appConfig: AppConfig;
let db: Database;
let logger: winston.Logger;
export const init = (
- engine_: ServiceEngineI,
+ engine_: ServiceEngine,
templateManager_: TemplateManager,
templateDirWatcher_: TemplateDirWatcher,
db_: Database,
- logger_: winston.Logger
+ appConfig_: AppConfig,
+ logger_: winston.Logger,
) => {
engine = engine_;
templateManager = templateManager_;
templateDirWatcher = templateDirWatcher_;
db = db_;
+ appConfig = appConfig_;
logger = logger_;
-}
+};
/**
* Ensures that the image associated with the given ID is up to date and
@@ -43,7 +46,9 @@ export const init = (
*/
export const processImage = async (
id: string | undefined | null,
- templateId: string, buildOptions: BuildOptionsMap, messageListener?: MessageListener
+ templateId: string,
+ buildOptions: BuildOptionsMap,
+ messageListener?: MessageListener,
) => {
const template = templateManager.getTemplate(templateId);
// Checks if the provided options are still compatible with the template
@@ -56,21 +61,29 @@ export const processImage = async (
const imageModel = await getImage(id);
if (imageModel.templateId != templateId) {
- throw new Error(`Image ${id} is based on template ${imageModel.templateId}, but template ${templateId} was expected`);
+ throw new Error(
+ `Image ${id} is based on template ${imageModel.templateId}, but template ${templateId} was expected`,
+ );
}
- const imageOutdated = imageModel.hash != templateDirWatcher.getTemplateHash(imageModel.templateId);
+ const imageOutdated =
+ imageModel.hash !=
+ templateDirWatcher.getTemplateHash(imageModel.templateId);
const optionsChanged = optionsDiffer(buildOptions, imageModel.buildOptions);
if (imageOutdated || optionsChanged) {
if (optionsChanged) {
- logger.info(`The target options differ, finding or building a new compatible image...`);
+ logger.info(
+ `The target options differ, finding or building a new compatible image...`,
+ );
id = await pickImageOrBuild(templateId, buildOptions);
// If the image becomes unused after the switch, delete it
await deleteImageIfUnused(imageModel);
} else {
- logger.info(`Image ${id} is outdated due to template changes. Rebuilding...`);
+ logger.info(
+ `Image ${id} is outdated due to template changes. Rebuilding...`,
+ );
// Template changed, we need to rebuild the image
await rebuildImage(imageModel, messageListener);
@@ -78,7 +91,7 @@ export const processImage = async (
}
return id;
-}
+};
/**
* Tries to find an existing image that is compatible with the given template ID and build options.
@@ -88,7 +101,10 @@ export const processImage = async (
* @param buildOptions Build options to use when finding/building the image
* @returns The ID of the found or built image
*/
-const pickImageOrBuild = async (templateId: string, buildOptions: BuildOptionsMap) => {
+const pickImageOrBuild = async (
+ templateId: string,
+ buildOptions: BuildOptionsMap,
+) => {
let id = await pickImage(templateId, buildOptions);
if (id == null) {
logger.info(`No compatible image found for request. Building new image...`);
@@ -98,9 +114,12 @@ const pickImageOrBuild = async (templateId: string, buildOptions: BuildOptionsMa
}
return id;
-}
+};
-export const optionsDiffer = (options1: BuildOptionsMap, options2: BuildOptionsMap): boolean => {
+export const optionsDiffer = (
+ options1: BuildOptionsMap,
+ options2: BuildOptionsMap,
+): boolean => {
const keys1 = Object.keys(options1);
const keys2 = Object.keys(options2);
@@ -119,7 +138,7 @@ export const optionsDiffer = (options1: BuildOptionsMap, options2: BuildOptionsM
}
return false;
-}
+};
/**
* Retrieves the image information from the database for the given image ID.
@@ -135,7 +154,7 @@ const getImage = async (id: string) => {
}
return image;
-}
+};
/**
* Builds a new image based on the given template ID and build options, and saves it to the database.
@@ -151,10 +170,15 @@ const buildImage = async (
templateId: string,
options: BuildOptionsMap,
imageId?: string,
- messageListener?: MessageListener
+ messageListener?: MessageListener,
): Promise => {
const hash = templateDirWatcher.getTemplateHash(templateId);
- imageId = await engine.build(imageId, templateBuildDir(templateId), options, messageListener);
+ imageId = await engine.build(
+ imageId,
+ appConfig.getTemplateBuildDir(templateId),
+ options,
+ messageListener,
+ );
await db.imageRepository.saveImage({
id: imageId,
@@ -163,10 +187,16 @@ const buildImage = async (
buildOptions: options,
});
return imageId;
-}
+};
-const pickImage = async (templateId: string, options: BuildOptionsMap): Promise => {
- const images = await db.imageRepository.listImagesByOptions(templateId, options);
+const pickImage = async (
+ templateId: string,
+ options: BuildOptionsMap,
+): Promise => {
+ const images = await db.imageRepository.listImagesByOptions(
+ templateId,
+ options,
+ );
if (images.length == 0) {
return null;
}
@@ -174,20 +204,32 @@ const pickImage = async (templateId: string, options: BuildOptionsMap): Promise<
const image = images[Math.floor(Math.random() * images.length)]; // TODO: implement better image picking strategy (e.g. based on usage)
return image.id;
-}
+};
-const rebuildImage = async (image: ImageModel, messageListener?: MessageListener) => {
- return buildImage(image.templateId, image.buildOptions, image.id, messageListener);
-}
+const rebuildImage = async (
+ image: ImageModel,
+ messageListener?: MessageListener,
+) => {
+ return buildImage(
+ image.templateId,
+ image.buildOptions,
+ image.id,
+ messageListener,
+ );
+};
export const deleteImageIfUnused = async (image: ImageModel) => {
- const servicesUsingImage = await db.permaRepository.listPermaUsingImage(image.id);
+ const servicesUsingImage = await db.permaRepository.listPermaUsingImage(
+ image.id,
+ );
if (servicesUsingImage.length > 0) {
// Image is still in use, do not delete
return;
}
- logger.debug(`Image ${image.id} is no longer used by any service. Deleting...`);
+ logger.debug(
+ `Image ${image.id} is no longer used by any service. Deleting...`,
+ );
try {
await engine.deleteImage(image.id);
@@ -195,4 +237,4 @@ export const deleteImageIfUnused = async (image: ImageModel) => {
logger.error(`Failed to delete image ${image.id}`, e);
}
await db.imageRepository.deleteImage(image.id);
-}
\ No newline at end of file
+};
diff --git a/src/engine/index.ts b/src/engine/index.ts
index abdc154..eff7ffb 100644
--- a/src/engine/index.ts
+++ b/src/engine/index.ts
@@ -1,2 +1,2 @@
export * from "./manager";
-export * from "./engine";
\ No newline at end of file
+export * from "./engine";
diff --git a/src/engine/manager.ts b/src/engine/manager.ts
index 8e7b99b..461074f 100644
--- a/src/engine/manager.ts
+++ b/src/engine/manager.ts
@@ -1,90 +1,111 @@
-import {currentContext} from "../app";
+import { currentContext } from "../app";
import createEngine, {
- RunOptions,
- RunListener,
- ServiceEngineI,
- StandardLabel,
- Filters, combineRunListeners
+ RunOptions,
+ RunListener,
+ ServiceEngineI,
+ StandardLabel,
+ Filters,
+ combineRunListeners,
} from "./engine";
-import {Template, getTemplate as loadTemplate, getAllTemplates} from "./template";
+import {
+ Template,
+ getTemplate as loadTemplate,
+ getAllTemplates,
+} from "./template";
import * as templateManager from "./template";
+import * as sessionManager from "./session";
import * as templateDirWatcher from "./monitoring/templateDirWatcher";
import crypto from "crypto";
-import {randomPort as retrieveRandomPort} from "@nsm/util/port";
-import {Database, PermaModel} from "../database";
+import { randomPort as retrieveRandomPort } from "@nsm/util/port";
+import { Database, PermaModel } from "../database";
import {
- isServicePending,
- lckStatusTp,
- lockBusyAction,
- reqNotPending,
- ulckStatusTp,
- UnlockObserver,
- whenUnlocked, whenUnlockedAll
+ getActionType,
+ isServicePending,
+ lockBusyAction,
+ reqNotPending, unlockBusyAction,
+ UnlockObserver,
+ whenUnlocked,
+ whenUnlockedAll,
} from "./asyncp";
import winston from "winston";
-import {isDebug} from "../helpers";
-import {resolveSequentially} from "@nsm/util/promises";
-import {watchTemplateDirChanges} from "@nsm/engine/monitoring/templateDirWatcher";
-import {processImage, init as initImageEngine, deleteImageIfUnused} from "@nsm/engine/image";
-import {propagateOptionsToEnv} from "@nsm/engine/docker/util/env";
-import {ActiveServiceSession, beginServiceSession, ServiceSession, init as initSessionEngine} from "@nsm/engine/session";
-import {AppConfig} from "@nsm/config";
+import { isDebug } from "../helpers";
+import {AsyncTask, resolveSequentially} from "@nsm/util/promises";
+import { watchTemplateDirChanges } from "@nsm/engine/monitoring/templateDirWatcher";
+import {
+ processImage,
+ init as initImageEngine,
+ deleteImageIfUnused,
+} from "@nsm/engine/image";
+import { propagateOptionsToEnv } from "@nsm/engine/docker/util/env";
+import {
+ ActiveServiceSession,
+ beginServiceSession,
+ ServiceSession,
+ init as initSessionEngine,
+} from "@nsm/engine/session";
+import {
+ InternalError,
+ InvalidMetaError,
+ ServiceAlreadyRunningError,
+ ServiceNotFoundError,
+ ServiceNotRunningError, ServicePendingActionError, ServiceWasNeverActiveError, TemplateNotFoundError
+} from "@nsm/engine/error";
export type Options = {
+ /**
+ * The amount of RAM that the service can allocate in MB.
+ * (optional)
+ */
+ ram?: number;
+ /**
+ * The amount of CPU cores that the service can use.
+ * (optional)
+ */
+ cpu?: number;
+ /**
+ * The amount of disk space that the service can use in MB.
+ * (optional)
+ */
+ disk?: number;
+ /**
+ * The additional ports to expose. (optional)
+ * Main port will be chosen automatically.
+ * (optional)
+ */
+ ports?: number[]; // Optional ports to expose
+ meta?: { [key: string]: any };
+ /**
+ * The optional environment variables (template options) to set.
+ * These are custom variables that the specific template uses to correctly
+ * build its environment.
+ *
+ * Firstly, you need to specify those env variables and their defaults
+ * in the settings.yml file of the template, and then they can be used
+ * in the Dockerfile of template. Those variables can be listed by the
+ * lookup and will be stored for later use when resuming the service.
+ * (optional)
+ */
+ env?: { [key: string]: string }; // Optional ENV, see example_settings.yml
+ /**
+ * The (optional) network settings for the service.
+ * This specifies fi the service will be bind to custom network interface
+ * in the future and how.
+ */
+ network?: {
/**
- * The amount of RAM that the service can allocate in MB.
- * (optional)
- */
- ram?: number,
- /**
- * The amount of CPU cores that the service can use.
- * (optional)
- */
- cpu?: number,
- /**
- * The amount of disk space that the service can use in MB.
- * (optional)
+ * Bind address.
*/
- disk?: number,
+ address: string;
/**
- * The additional ports to expose. (optional)
- * Main port will be chosen automatically.
- * (optional)
- */
- ports?: number[], // Optional ports to expose
- meta?: {[key: string]: any},
- /**
- * The optional environment variables (template options) to set.
- * These are custom variables that the specific template uses to correctly
- * build its environment.
+ * If whole service interface (all ports) should be exposed to the
+ * interface (false), or only defined ports (true).
*
- * Firstly, you need to specify those env variables and their defaults
- * in the settings.yml file of the template, and then they can be used
- * in the Dockerfile of template. Those variables can be listed by the
- * lookup and will be stored for later use when resuming the service.
- * (optional)
- */
- env?: {[key: string]: string}, // Optional ENV, see example_settings.yml
- /**
- * The (optional) network settings for the service.
- * This specifies fi the service will be bind to custom network interface
- * in the future and how.
+ * Defined ports are those specified in ports?: number[], and main
+ * service port.
*/
- network?: {
- /**
- * Bind address.
- */
- address: string,
- /**
- * If whole service interface (all ports) should be exposed to the
- * interface (false), or only defined ports (true).
- *
- * Defined ports are those specified in ports?: number[], and main
- * service port.
- */
- portsOnly: boolean,
- }
-}
+ portsOnly: boolean;
+ };
+};
/**
* Per-service storage.
@@ -92,237 +113,242 @@ export type Options = {
* as long term data. Every key set here is per-service.
*/
export type MetaStorage = {
- set: (key: string, value: any) => Promise;
- get: (key: string, def?: T) => Promise;
-}
+ set: (key: string, value: any) => Promise;
+ get: (key: string, def?: T) => Promise;
+};
export type EngineExpansion = {
- [k in keyof ServiceEngineI | string]: any;
+ [k in keyof ServiceEngineI | string]: any;
};
type ServiceEvent = {
- id: string;
- error?: Error;
+ id: string;
+ error?: Error;
+};
+
+type ServiceStateChangeEvent = ServiceEvent & {
+ state: State;
}
-type ServiceManagerEvents = {
- resume: ServiceEvent;
- stop: ServiceEvent;
+type ServiceEngineErrorEvent = ServiceEvent & {
+ error: Error;
}
-type EventHandler = (event: ServiceManagerEvents[T]) => boolean|void;
+type ServiceManagerEvents = {
+ resume: ServiceEvent;
+ stop: ServiceEvent;
+ statechange: ServiceStateChangeEvent;
+ engine_err: ServiceEngineErrorEvent;
+};
-type ServiceManagerEventBus = {
- on(evt: T, h: EventHandler): void;
-}
+/**
+ * The event handler for service manager events.
+ * If the handler returns true or nothing, it will be unsubscribed after this call.
+ */
+type EventHandler = (
+ event: ServiceManagerEvents[T],
+) => boolean | void;
+type ServiceManagerEventBus = {
+ on(evt: T, h: EventHandler): void;
+};
export type ListServicesOptions = {
+ /**
+ * The page number (index).
+ */
+ page: number;
+ /**
+ * The page size.
+ */
+ pageSize: number;
+
+ /**
+ * Filter options.
+ */
+ filter?: {
/**
- * The page number (index).
- */
- page: number;
- /**
- * The page size.
+ * Filter services by their meta attributes.
*/
- pageSize: number;
-
- /**
- * Filter options.
- */
- filter?: {
- /**
- * Filter services by their meta attributes.
- */
- meta?: {[key: string]: any};
- }
-}
+ meta?: { [key: string]: any };
+ };
+};
export type ServiceManager = ServiceManagerEventBus & {
- /**
- * This NSM instance ID
- */
- nodeId: string;
- /**
- * Internal engine implementation
- */
- engine: ServiceEngineI;
-
- /**
- * Initialize the service manager.
- *
- * @param db The database
- * @param appConfig The app config
- * @param logger The global logger
- */
- init(db: Database, appConfig: any, logger: winston.Logger): Promise;
-
- /**
- * Create a new service.
- *
- * @param template The template ID (folder name) to use
- * @param options The options to use. Options will be stored for later use.
- * @returns The service ID
- */
- createService(template: string, options: Options): Promise; // Service ID
-
- /**
- * Resume a service.
- *
- * @param id The service ID
- * @returns Whether the service was resumed
- */
- resumeService(id: string): Promise;
-
- /**
- * Stop a service.
- *
- * @param id The service ID
- */
- stopService(id: string): Promise;
-
- /**
- * Stop a service forcibly (kill).
- *
- * @param id The service ID
- */
- stopServiceForcibly(id: string): Promise;
-
- /**
- * Send pre-configured stop signal to the service.
- *
- * @param id The service ID
- * @returns Whether the signal has been sent
- */
- sendStopSignal(id: string): Promise;
-
- /**
- * Delete a service.
- *
- * @param id The service ID
- */
- deleteService(id: string): Promise;
-
- /**
- * Update the options of a service.
- *
- * @param id The service ID
- * @param options The new options
- */
- updateOptions(id: string, options: Options): Promise;
-
- /**
- * Get the template by ID.
- *
- * @param id The template ID
- * @returns The template wrapper
- */
- getTemplate(id: string): Template|undefined;
-
- /**
- * Get the service by ID.
- *
- * @param from The service ID, or model
- * @param options The get options
- * includeSession: Whether to include the session to result
- * otherNodes: If true, we will include services on other NSM nodes to search
- */
- getService(from: string|PermaModel, options?: { includeSession?: boolean, otherNodes?: boolean }): Promise;
-
- /**
- * Get the last power error of a service.
- *
- * @param id The service ID
- */
- getLastPowerError(id: string): Error|undefined;
-
- /**
- * Get list of running services on this node.
- */
- getRunningServices(): RunningService[];
-
- /**
- * Get the running service by ID.
- *
- * @param id The service ID
- */
- getRunningService(id: string): RunningService|undefined;
-
- /**
- * List all available services.
- *
- * @param options The list options
- * @returns The list of service IDs
- */
- listServices(options: ListServicesOptions): Promise;
-
- /**
- * List all available templates.
- *
- * @returns The list of template IDs
- */
- listTemplates(): Promise;
-
- /**
- * Stop all running services on this instance.
- */
- stopRunning(): Promise;
-
- isRunning(id: string): boolean;
-
- waitForBusyAction(id: string): Promise;
-
- // DON'T call those until you really know what you are doing.
- expandEngine(exp?: T): Promise;
-
- initEngineForcibly(): Promise;
- //
-} & {
- whenUnlocked: typeof whenUnlocked
+ /**
+ * This NSM instance ID
+ */
+ nodeId: string;
+ /**
+ * Internal engine implementation
+ */
+ engine: ServiceEngineI;
+
+ /**
+ * Initialize the service manager.
+ *
+ * @param db The database
+ * @param appConfig The app config
+ * @param logger The global logger
+ */
+ init(db: Database, appConfig: any, logger: winston.Logger): Promise;
+
+ /**
+ * Create a new service.
+ *
+ * @param template The template ID (folder name) to use
+ * @param options The options to use. Options will be stored for later use.
+ * @returns The service ID
+ * @throws InvalidMetaError if the template meta is invalid
+ */
+ createService(template: string, options: Options): Promise; // Service ID
+
+ /**
+ * Resume a service.
+ *
+ * @param id The service ID
+ */
+ resumeService(id: string): Promise>;
+
+ /**
+ * Stop a service.
+ * This hereby sends a stop signal and does not wait for it to be stopped. For waiting, use {@link waitForStopped}.
+ *
+ * @param id The service ID
+ * @param force Whether to force stop (kill) the service.
+ */
+ stopService(id: string, force?: boolean): Promise>;
+
+ /**
+ * Delete a service.
+ *
+ * @param id The service ID
+ */
+ deleteService(id: string): Promise;
+
+ /**
+ * Update the options of a service.
+ *
+ * @param id The service ID
+ * @param options The new options
+ */
+ updateOptions(id: string, options: Options): Promise;
+
+ /**
+ * Get the template by ID.
+ *
+ * @param id The template ID
+ * @returns The template wrapper
+ */
+ getTemplate(id: string): Template | undefined;
+
+ /**
+ * Get the service by ID.
+ *
+ * @param from The service ID, or model
+ * @param options The get options
+ * includeSession: Whether to include the session to result
+ * otherNodes: If true, we will include services on other NSM nodes to search
+ */
+ getService(
+ from: string | PermaModel,
+ options?: { includeSession?: boolean; otherNodes?: boolean },
+ ): Promise;
+
+ /**
+ * Get the last power error of a service.
+ *
+ * @param id The service ID
+ */
+ getLastPowerError(id: string): Error | undefined;
+
+ /**
+ * Get the last session ID of a service.
+ *
+ * @param id The service ID
+ * @throws ServiceWasNeverActiveError if the service was never active and thus does not have a last session
+ */
+ getLastSession(id: string): Promise;
+
+ /**
+ * Get list of running services on this node.
+ */
+ getRunningServices(): RunningService[];
+
+ /**
+ * Get the running service by ID.
+ *
+ * @param id The service ID
+ */
+ getRunningService(id: string): RunningService | undefined;
+
+ /**
+ * List all available services.
+ *
+ * @param options The list options
+ * @returns The list of service IDs
+ */
+ listServices(options: ListServicesOptions): Promise;
+
+ /**
+ * List all available templates.
+ *
+ * @returns The list of template IDs
+ */
+ listTemplates(): Promise;
+
+ /**
+ * Stop all running services on this instance.
+ */
+ stopRunning(): Promise;
+
+ /**
+ * Kill all running services on this instance.
+ */
+ killRunning(): Promise;
+
+ isRunning(id: string): boolean;
+
+ waitForBusyAction(id: string): Promise;
+
+ waitForStopped(id: string): Promise;
+
+ // DON'T call those until you really know what you are doing.
+ expandEngine(exp?: T): Promise;
+
+ initEngineForcibly(): Promise;
+ //
};
type RunningService = {
- id: string;
- session: ServiceSession;
- internalSession: InternalSession;
-}
+ id: string;
+ session: ServiceSession;
+ internalSession: InternalSession;
+};
export type InternalSession = {
- containerId: string;
- // TODO: add more useful information?
-}
+ containerId: string;
+ // TODO: add more useful information?
+};
export type ServiceInfo = PermaModel & {
- optionsRam: number; // From options.ram
- optionsCpu: number; // From options.cpu
- optionsDisk: number; // From options.disk
- state: State;
- session?: ServiceSession;
- internalSession?: InternalSession;
-}
-
-export type State = 'RUNNING' | 'BUILDING' | 'STOPPED';
-
-// 1 = unknown, 2 = conflict, 3 = not found
-export type StatusCode = 1 | 2 | 3;
+ optionsRam: number; // From options.ram
+ optionsCpu: number; // From options.cpu
+ optionsDisk: number; // From options.disk
+ state: State;
+ session?: ServiceSession;
+ internalSession?: InternalSession;
+};
-class _InternalError extends Error {
- readonly code: StatusCode;
- readonly msg: string;
-
- constructor(msg: string, code?: StatusCode) {
- super(msg);
- this.code = code ?? 1;
- this.msg = msg;
- }
-}
+export type State = "BUILDING" | "RUNNING" | "STOPPING" | "STOPPED";
export let engine: ServiceEngineI = undefined;
export let nodeId: string;
let db: Database;
+let logger: winston.Logger;
-// Save errors somewhere else?
+// TODO: Save errors somewhere else?
// Could it be a memory leak if there are tons of them??
const errors = {};
// Service IDs that are currently running
@@ -330,43 +356,56 @@ const started: RunningService[] = [];
const startedStates: Map = new Map();
const evtHandlers: Map[]> = new Map();
-["push", "splice"].forEach(funcName => {
- started[funcName] = (...args: any[]) => {
- const result = Array.prototype[funcName].apply(started, args);
+["push", "splice"].forEach((funcName) => {
+ started[funcName] = (...args: any[]) => {
+ const result = Array.prototype[funcName].apply(started, args);
- // Emit services change within those methods
- if (isDebug()) {
- currentContext.logger.debug('Service registry changed');
- }
-
- return result;
- };
-});
-
-export async function init(db_: Database, appConfig_: AppConfig, logger: winston.Logger) {
- const nodeId_ = appConfig_.getNodeId();
-
- logger.info(`Initializing service manager for node ${nodeId_}...`);
-
- db = db_;
- if (!engine) {
- // Init only if it has not already been force-initialized
- await initEngineForcibly();
+ // Emit services change within those methods
+ if (isDebug()) {
+ logger.debug("Service registry changed");
}
- nodeId = nodeId_ as string;
- initImageEngine(engine, templateManager, templateDirWatcher, db_, currentContext.logger);
- initSessionEngine(db_);
- watchTemplateDirChanges(currentContext.logger);
-
- await deleteGarbage(logger);
- await reattachStaleContainers(logger);
+ return result;
+ };
+});
- logger.info(`Using engine: ${engine.name}`);
+export const init: ServiceManager["init"] = async (
+ db_,
+ appConfig_,
+ logger_,
+) => {
+ db = db_;
+ logger = logger_;
+
+ const nodeId_ = appConfig_.getNodeId();
+ logger.info(`Initializing service manager for node ${nodeId_}...`);
+ if (!engine) {
+ // Init only if it has not already been force-initialized
+ await initEngineForcibly();
+ }
+ nodeId = nodeId_ as string;
+
+ initImageEngine(
+ engine,
+ templateManager,
+ templateDirWatcher,
+ db_,
+ appConfig_,
+ logger,
+ );
+ initSessionEngine(db_);
+ watchTemplateDirChanges(logger);
+
+ gatherEngineErrors();
+ registerLoggingEventHandlers();
+ await deleteGarbage(logger);
+ await reattachStaleContainers(logger);
+
+ logger.info(`Using engine: ${engine.name}`);
}
-async function deleteGarbage(logger: winston.Logger) {
- // TODO: delete containers that are not running and remained from last session
+const deleteGarbage = async (logger: winston.Logger) => {
+ // TODO: delete containers that are not running and remained from last session
}
/**
@@ -375,486 +414,572 @@ async function deleteGarbage(logger: winston.Logger) {
*
* @param logger The logger to use
*/
-async function reattachStaleContainers(logger: winston.Logger) {
- const running = await engine.listRunning(Filters.node(nodeId))
- .then(containerIds => containerIds
+const reattachStaleContainers = async (logger: winston.Logger) => {
+ const running = await engine
+ .listRunning(Filters.node(nodeId))
+ .then((containerIds) =>
+ containerIds
// Filter out those that we have already started in this session, just in case
// this was started more than once a session
- .filter(id => !started.find(runningService => runningService.internalSession.containerId === id)));
+ .filter(
+ (id) =>
+ !started.find(
+ (runningService) =>
+ runningService.internalSession.containerId === id,
+ ),
+ ),
+ );
- for (let containerId of running) {
- const labels = await engine.getLabels(containerId);
- if (!labels[StandardLabel.ServiceId]) {
- // The container was in the running list, but does not have the required labels
- // Should not happen, but just in case
- logger.warn(`Found a running container with id ${containerId} that does not have a service id label, stopping.`);
+ for (let containerId of running) {
+ const labels = await engine.getLabels(containerId);
+ if (!labels[StandardLabel.ServiceId]) {
+ // The container was in the running list, but does not have the required labels
+ // Should not happen, but just in case
+ logger.warn(
+ `Found a running container with id ${containerId} that does not have a service id label, stopping.`,
+ );
- await engine.stop(containerId);
- }
+ await engine.stop(containerId);
+ }
- const serviceId = labels[StandardLabel.ServiceId];
+ const serviceId = labels[StandardLabel.ServiceId];
- // We must begin a new session since the previous was interrupted
- const session = await beginServiceSession(serviceId);
- // Reattach and watch the container
- await engine.reattach(containerId, buildRunListener(session));
+ // We must begin a new session since the previous was interrupted
+ const session = await beginServiceSession(serviceId);
+ // Reattach and watch the container
+ await engine.reattach(containerId, buildRunListener(session));
- // Save session in-memory
- const info: RunningService = {
- id: serviceId,
- session,
- internalSession: {
- containerId
- }
- };
- started.push(info);
- logger.info(`Reattached container ${containerId} for service ${serviceId}`);
- }
+ // Save session in-memory
+ const info: RunningService = {
+ id: serviceId,
+ session,
+ internalSession: {
+ containerId,
+ },
+ };
+ started.push(info);
+ logger.info(`Reattached container ${containerId} for service ${serviceId}`);
+ }
- await new Promise((resolve) => whenUnlockedAll(() => resolve(null)));
+ await new Promise((resolve) => whenUnlockedAll(() => resolve(null)));
}
-export async function expandEngine(exp?: T): Promise {
- if (exp) {
- if (!engine && (!currentContext || !currentContext.appConfig)) {
- throw new Error("Engine is not yet loaded and can't be loaded forcibly!");
- } else if (!engine) {
- // Engine is not initialized yet, but we want to expand it, so
- // we need to force load it.
- await initEngineForcibly();
- }
- // An expansion is provided, so there are changes to be applied.
- Object.keys(exp).forEach((expKey) => {
- if (!Number.isNaN(Number(expKey))) {
- throw new Error("Invalid expansion format, please replace functions within with lambda functions. " +
- "Invalid: { funcName(param) {}, funcName2(param) {} }" +
- "Valid: { funcName: (param) => {}, funcName2: (param) => {} }")
- }
- engine[expKey] = exp[expKey];
- });
- }
- return engine as any;
-}
-
-export async function createService(template: string, options: Options) {
- const {
- ram,
- cpu,
- disk,
- ports,
- env,
- network
- } = options;
- const serviceSettings = reqTemplate(template).settings;
-
- // Join meta supplied by user and template meta
- const meta = {
- ...(options.meta ?? {}),
- ...(serviceSettings.meta ?? {})
- };
- if (!meta || !meta.stopCmd) {
- throw new _InternalError('Invalid template meta for ' + template);
- }
-
- const serviceId = crypto.randomUUID(); // Create new unique service id
- // Pick random main port from the range specified in settings.yml
- const portRange = serviceSettings.port_range;
- const port = await retrieveRandomPort(
- engine,
- portRange.min as number,
- portRange.max as number
- );
+const gatherEngineErrors = () => {
+ on("engine_err", (event) => {
+ errors[event.id] = event.error;
+ });
+}
- const perma: PermaModel = {
- serviceId,
- template,
- nodeId,
- port,
- options: {ram, cpu, disk, ports},
- meta,
- env: env ?? {},
- network
- };
- let err: any;
- // Save permanent info
- if (!await db.permaRepository.savePerma(perma)) {
- err = new _InternalError('Failed to save perma info to database');
- }
+/**
+ * Registers event handlers for logging in debug mode.
+ */
+const registerLoggingEventHandlers = () => {
+ const notifyIfSuccess = (
+ messageProvider: (serviceId: string) => string
+ ): EventHandler => {
+ return ({ id, error }) => {
+ if (error) {
+ return;
+ }
- if (err) {
- // Save to be later retrieved
- errors[serviceId] = err;
- currentContext.logger.error(err.message);
+ logger.debug(messageProvider(id));
}
+ }
- if (err) {
- throw err;
- } else {
- return serviceId;
- }
+ on("resume", notifyIfSuccess((id) => `Service ${id} resumed`));
+ on("stop", notifyIfSuccess((id) => `Service ${id} stopped`));
}
-export async function resumeService(id: string) {
- reqNotRunning(id);
- let {
- template,
- options,
- env,
- network,
- port,
- } = await getPermaModel(id);
-
- const {defaults, env: settingsEnv} = reqTemplate(template).settings;
- // Filter env to only those that are defined in settings.yml, because those are the only ones that
- // we can guarantee to be used and will not make problems when handling images.
- env = {
- ...Object.entries(env)
- .filter(([key]) => settingsEnv && key in settingsEnv)
- .reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {}),
+export const expandEngine: ServiceManager["expandEngine"] = async (
+ exp?: T,
+): Promise => {
+ if (exp) {
+ if (!engine && (!currentContext || !currentContext.appConfig)) {
+ throw new Error("Engine is not yet loaded and can't be loaded forcibly!");
+ } else if (!engine) {
+ // Engine is not initialized yet, but we want to expand it, so
+ // we need to force load it.
+ await initEngineForcibly();
}
+ // An expansion is provided, so there are changes to be applied.
+ Object.keys(exp).forEach((expKey) => {
+ if (!Number.isNaN(Number(expKey))) {
+ throw new Error(
+ "Invalid expansion format, please replace functions within with lambda functions. " +
+ "Invalid: { funcName(param) {}, funcName2(param) {} }" +
+ "Valid: { funcName: (param) => {}, funcName2: (param) => {} }",
+ );
+ }
+ engine[expKey] = exp[expKey];
+ });
+ }
+ return engine as any;
+}
+export const createService: ServiceManager["createService"] = async (template, options) => {
+ const { ram, cpu, disk, ports, env, network } = options;
+ const serviceSettings = reqTemplate(template).settings;
+
+ // Join meta supplied by user and template meta
+ const meta = {
+ ...(options.meta ?? {}),
+ ...(serviceSettings.meta ?? {}),
+ };
+ if (!meta || !meta.stopCmd) {
+ throw new InvalidMetaError("Invalid template meta for " + template);
+ }
+
+ const serviceId = crypto.randomUUID(); // Create new unique service id
+ // Pick random main port from the range specified in settings.yml
+ const portRange = serviceSettings.port_range;
+ const port = await retrieveRandomPort(
+ engine,
+ portRange.min as number,
+ portRange.max as number,
+ );
+
+ const perma: PermaModel = {
+ serviceId,
+ template,
+ nodeId,
+ port,
+ options: { ram, cpu, disk, ports },
+ meta,
+ env: env ?? {},
+ network,
+ };
+ // Save permanent info
+ if (!(await db.permaRepository.savePerma(perma))) {
+ throw new InternalError("Failed to save perma info to database");
+ }
+
+ return serviceId;
+}
- const meta = metaStorageForService(id);
- const unlock = lockBusyAction(id, 'resume');
-
- const runOptions: RunOptions = {
- ram: options.ram ?? defaults.ram as number,
- cpu: options.cpu ?? defaults.cpu as number,
- disk: options.disk ?? defaults.disk as number,
- env: env ?? defaults.env as {[key: string]: string},
- port,
- ports: options.ports ?? [],
- network,
- labels: {
- [StandardLabel.Nsm]: 'true',
- [StandardLabel.ServiceId]: id,
- [StandardLabel.NodeId]: nodeId,
- [StandardLabel.VolumeId]: id,
- [StandardLabel.TemplateId]: template,
- }
- };
-
- const perma = await db.permaRepository.getPerma(id);
- let image = perma.imageId;
-
- // Propagate other options to env, so they can be used in image processing and building
- propagateOptionsToEnv(runOptions, runOptions.env);
- // Include service ID in env
- runOptions.env.SERVICE_ID = id;
-
- // Omit the always-changing args from build env, since they would always trigger an
- // image rebuild
- const { SERVICE_ID, SERVICE_PORT, SERVICE_PORTS, ...buildEnv } = runOptions.env;
- const processedImage = await processImage(image, template, buildEnv); // TODO: tato funkce má poslední parametr messageListener, vymyslet jak sem propagovat message listener z session
+export const resumeService: ServiceManager["resumeService"] = async (id) => {
+ reqNotRunning(id);
+ let { template, options, env, network, port } = await reqExists(id);
+
+ const { defaults, env: settingsEnv } = reqTemplate(template).settings;
+ // Filter env to only those that are defined in settings.yml, because those are the only ones that
+ // we can guarantee to be used and will not make problems when handling images.
+ env = {
+ ...Object.entries(env)
+ .filter(([key]) => settingsEnv && key in settingsEnv)
+ .reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {}),
+ };
+
+ const meta = metaStorageForService(id);
+ const unlock = lockBusyAction(id, "resume");
+
+ const runOptions: RunOptions = {
+ ram: options.ram ?? (defaults.ram as number),
+ cpu: options.cpu ?? (defaults.cpu as number),
+ disk: options.disk ?? (defaults.disk as number),
+ env: env ?? (defaults.env as { [key: string]: string }),
+ port,
+ ports: options.ports ?? [],
+ network,
+ labels: {
+ [StandardLabel.Nsm]: "true",
+ [StandardLabel.ServiceId]: id,
+ [StandardLabel.NodeId]: nodeId,
+ [StandardLabel.VolumeId]: id,
+ [StandardLabel.TemplateId]: template,
+ },
+ };
+
+ const perma = await db.permaRepository.getPerma(id);
+ //let image = perma.imageId;
+
+ // Propagate other options to env, so they can be used in image processing and building
+ propagateOptionsToEnv(runOptions, runOptions.env);
+ // Include service ID in env
+ runOptions.env.SERVICE_ID = id;
+
+ // Omit the always-changing args from build env, since they would always trigger an
+ // image rebuild
+ const { SERVICE_ID, SERVICE_PORT, SERVICE_PORTS, ...buildEnv } =
+ runOptions.env;
+
+ const updateImageIfChanged = async (image: string) => {
// If the image was changed by processing (e.g. it was built or rebuilt), update the image id in database
- if (processedImage != image) {
- image = processedImage;
+ if (image != perma.imageId) {
- // Update image in database if it was changed by processing
- perma.imageId = image;
- await db.permaRepository.savePerma(perma);
+ // Update image in database if it was changed by processing
+ perma.imageId = image;
+ await db.permaRepository.savePerma(perma);
}
- let session: ActiveServiceSession|undefined;
- let containerId: string|undefined;
- try {
- // Run the container with the built image and save the container id for later use.
- if (image) {
- session = await beginServiceSession(id);
- containerId = await engine.run(
- image,
- id,
- runOptions,
- meta,
- buildRunListener(session)
- );
- }
- } catch (e) {
- currentContext.logger.error('Failed to run container for service ' + id);
- currentContext.logger.error(e);
- }
+ return image;
+ }
- let success: boolean = false;
- if (containerId) {
- const runningService: RunningService = {
+ return new AsyncTask(
+ // TODO: logovat někam message z image processingu pomocí posledního parametru
+ processImage(perma.imageId, template, buildEnv)
+ .then(updateImageIfChanged)
+ .then(async (image) => {
+ const session = await beginServiceSession(id);
+ // Run the container with the built image and save the container id for later use.
+ try {
+ const containerId = await engine.run(
+ image,
+ id,
+ runOptions,
+ meta,
+ buildRunListener(session),
+ );
+ const runningService: RunningService = {
id,
session,
internalSession: {
- containerId
- }
- };
- started.push(runningService);
- success = true;
- }
+ containerId,
+ },
+ };
+ started.push(runningService);
+
+ callManagerEvent("resume", { id });
+ } catch (e) {
+ callManagerEvent("resume", { id, error: e });
+ callServiceEngineError(id, e);
+ }
+ })
+ .finally(() => unlock())
+ );
+}
- if (success == true) {
- currentContext.logger.debug('Service ' + id + ' resumed');
- callManagerEvent('resume', { id });
- } else {
- errors[id] = new Error('Failed to resume service');
- clearRunningServiceIfExists(id);
- callManagerEvent('resume', { id, error: errors[id] });
- }
+export const stopService: ServiceManager["stopService"] = async (id, force) => {
+ await reqExists(id);
- unlock();
+ const { internalSession } = reqRunning(id);
- return true;
-}
+ const callEngine = async (task: () => Promise) => {
+ try {
+ await task();
+ } catch (e) {
+ logger.error(e);
+ callManagerEvent("stop", { id, error: e });
+ }
+ }
+
+ let awaitingPromise: Promise;
+ if (force) {
+ const pendingAction = getActionType(id);
+ if (pendingAction && getActionType(id) !== "stop") {
+ // the service is locked and not stopping, the force stop can't be allowed
+ throw new ServicePendingActionError(id, pendingAction);
+ }
-export async function stopService(id: string, force?: boolean) {
- await reqExists(id);
+ await callEngine(async () => engine.kill(internalSession.containerId, metaStorageForService(id)));
+ // resolves immediately on kill
+ awaitingPromise = Promise.resolve();
+ } else {
+ // lock only on soft stop, to allow hard-killing if any issues happen during stopping
+ const unlock = lockBusyAction(id, "stop");
+ awaitingPromise = new Promise((resolve) => {
+ // wait for stop
+ // this is really not necessary because any busy action is unlocked on stop, but
+ // just in case and for the promise
+ on("stop", ({ id: stoppedId, error }) => {
+ if (stoppedId !== id) {
+ // This call is not for me
+ return false;
+ }
- const { internalSession } = reqRunning(id);
+ if (isServicePending(id)) {
+ unlock(error);
+ }
+ resolve();
+ return true;
+ });
+ });
+
+ // TODO: stop strategy
+ const service = await getService(id);
+ const stopCmd = service.meta?.stopCmd;
+ await callEngine(async () => {
+ if (stopCmd) {
+ // send stop cmd if set
+ await engine.cmd(internalSession.containerId, stopCmd);
+ } else {
+ // send stop signal
+ await engine.stop(internalSession.containerId);
+ }
+ });
+ }
+ awaitingPromise = awaitingPromise.then(() => waitForStopped(id));
- lckStatusTp(internalSession.containerId, 'stop');
- const unlock = lockBusyAction(id, 'stop');
+ return new AsyncTask(awaitingPromise);
+}
- try {
- on("stop", ({ id: stoppedId, error }) => {
- if (stoppedId !== id) {
- // This call is not for me
- return false;
- }
-
- if (isServicePending(id)) {
- unlock(error);
- }
- ulckStatusTp(internalSession.containerId);
- return true;
- })
-
- const meta = metaStorageForService(id);
- if (force) {
- await engine.kill(internalSession.containerId, meta);
- } else {
- await engine.stop(internalSession.containerId);
+export const deleteService: ServiceManager["deleteService"] = async (id) => {
+ try {
+ await stopService(id, true);
+ } catch (e) {
+ // Skip not running error
+ if (!(e instanceof ServiceNotRunningError)) {
+ throw e;
+ }
+ }
+
+ const unlockHandler: UnlockObserver = (_, __, ___) => {
+ const resolveDeleteImageFunc = async () => {
+ const image = await db.permaRepository
+ .getPerma(id)
+ .then((perma) =>
+ perma.imageId
+ ? db.imageRepository.getImage(perma.imageId)
+ : undefined,
+ );
+
+ return async () => {
+ if (image) {
+ // If the image becomes unused after service deletion, delete it
+ await deleteImageIfUnused(image);
}
- } catch (e) {
- currentContext.logger.error(e);
+ };
+ };
- callManagerEvent('stop', { id, error: e });
- }
+ resolveDeleteImageFunc()
+ .then((deleteImageFunc) =>
+ resolveSequentially(
+ async () => engine.deleteVolume(id),
+ async () => db.permaRepository.deletePerma(id),
+ deleteImageFunc,
+ ),
+ )
+ .then(() => {
+ logger.debug(`Service ${id} deleted`);
+ });
+ };
+
+ whenUnlocked(id, unlockHandler);
}
-export async function stopServiceForcibly(id: string) {
- return stopService(id, true);
+export const updateOptions: ServiceManager["updateOptions"] = async (id, options) => {
+ reqNotPending(id);
+ const perma = await db.permaRepository.getPerma(id);
+ const data: PermaModel = {
+ ...perma,
+ ...options,
+ meta: {
+ ...perma.meta,
+ ...options.meta,
+ },
+ env: {
+ ...perma.env,
+ ...options.env,
+ },
+ };
+ return db.permaRepository.savePerma(data);
}
-export async function sendStopSignal(id: string) {
- const perma = await reqExists(id);
- const { internalSession } = reqRunning(id);
-
- const stopCmd = perma.meta?.stopCmd;
- if (!stopCmd) {
- throw new _InternalError('Service does not have stop command set.');
- }
-
- await engine.cmd(internalSession.containerId, stopCmd);
- return true;
+export const getTemplate: ServiceManager["getTemplate"] = (id) => {
+ return loadTemplate(id);
}
-export async function deleteService(id: string) {
- try {
- await stopService(id, true);
- } catch (e) {
- // Skip not running error
- if (!(e.code && e.code == 2)) {
- throw e;
- }
+export const getService: ServiceManager["getService"] = async (
+ from,
+ options,
+): ReturnType => {
+ const data =
+ typeof from === "string" ? await db.permaRepository.getPerma(from) : from;
+ if (data && (data.nodeId == nodeId || options?.otherNodes === true)) {
+ let session = undefined;
+ let internalSession = undefined;
+ if (options?.includeSession === true) {
+ const runningService = getRunningService(data.serviceId);
+ if (runningService) {
+ session = runningService.session;
+ internalSession = runningService.internalSession;
+ }
}
- const unlockHandler: UnlockObserver = (_, __, ___) => {
- const resolveDeleteImageFunc = async () => {
- const image = await db.permaRepository.getPerma(id)
- .then((perma) => perma.imageId
- ? db.imageRepository.getImage(perma.imageId)
- : undefined);
-
- return async () => {
- if (image) {
- // If the image becomes unused after service deletion, delete it
- await deleteImageIfUnused(image);
- }
- }
- };
-
- resolveDeleteImageFunc()
- .then((deleteImageFunc) => (
- resolveSequentially(
- async () => engine.deleteVolume(id),
- async () => db.permaRepository.deletePerma(id),
- deleteImageFunc,
- )
- ))
- .then(() => {
- currentContext.logger.debug(`Service ${id} deleted`);
- });
- };
-
- whenUnlocked(id, unlockHandler);
-}
-
-export async function updateOptions(id: string, options: Options) {
- reqNotPending(id);
- const perma = await db.permaRepository.getPerma(id);
- const data: PermaModel = {
- ...perma,
- ...options,
- meta: {
- ...perma.meta,
- ...options.meta,
- },
- env: {
- ...perma.env,
- ...options.env,
- },
+ return {
+ ...data,
+ optionsRam: data.env.SERVICE_RAM ? Number(data.env.SERVICE_RAM) : 0,
+ optionsCpu: data.env.SERVICE_CPU ? Number(data.env.SERVICE_CPU) : 0,
+ optionsDisk: data.env.SERVICE_DISK ? Number(data.env.SERVICE_DISK) : 0,
+ state: getServiceState(data.serviceId),
+ session,
+ internalSession,
};
- return db.permaRepository.savePerma(data);
+ } else {
+ return undefined;
+ }
}
-export function getTemplate(id: string) {
- return loadTemplate(id);
+export const getLastPowerError: ServiceManager["getLastPowerError"] = (id) => {
+ return errors[id];
}
-export async function getService(from: string, options?: { includeSession?: boolean, otherNodes?: boolean }): ReturnType {
- const data = typeof from === 'string' ? await db.permaRepository.getPerma(from) : from;
- if (data && (data.nodeId == nodeId || options?.otherNodes === true)) {
- let session = undefined;
- let internalSession = undefined;
- if (options?.includeSession === true) {
- const runningService = getRunningService(data.serviceId);
- if (runningService) {
- session = runningService.session;
- internalSession = runningService.internalSession;
- }
- }
-
- return {
- ...data,
- optionsRam: data.env.SERVICE_RAM ? Number(data.env.SERVICE_RAM) : 0,
- optionsCpu: data.env.SERVICE_CPU ? Number(data.env.SERVICE_CPU) : 0,
- optionsDisk: data.env.SERVICE_DISK ? Number(data.env.SERVICE_DISK) : 0,
- state: getServiceState(data.serviceId),
- session,
- internalSession
- }
- } else {
- return undefined;
+export const getLastSession: ServiceManager["getLastSession"] = async (id) => {
+ await reqExists(id);
+
+ const runningService = getRunningService(id);
+ if (runningService) {
+ // Service currently running, we can use logs from the current session
+ return runningService.session;
+ } else {
+ // Service not running, so we need to retrieve last session ID
+ const lastSession = await sessionManager.listSessions({
+ filter: { serviceId: id },
+ sort: { by: "startedAt", direction: "desc" },
+ page: { index: 0, size: 1 },
+ });
+ if (lastSession && lastSession.length > 0) {
+ return lastSession[0];
}
-}
+ }
-export function getLastPowerError(id: string) {
- return errors[id];
+ throw new ServiceWasNeverActiveError();
}
-export async function listServices(options: ListServicesOptions) {
- const meta = options.filter?.meta;
- return db.permaRepository
- .listPerma(nodeId, options.page, options.pageSize, meta)
- .then(list => list.map(d => d.serviceId));
+export const listServices: ServiceManager["listServices"] = async (options) => {
+ const meta = options.filter?.meta;
+ return db.permaRepository
+ .listPerma(nodeId, options.page, options.pageSize, meta)
+ .then((list) => list.map((d) => d.serviceId));
}
-export async function listTemplates(): Promise {
- return getAllTemplates().map(template => template.id);
+export const listTemplates: ServiceManager["listTemplates"] = async () => {
+ return getAllTemplates().map((template) => template.id);
}
-export async function stopRunning() {
- const tasks = started.map(({id}) => (
+export const stopRunning: ServiceManager["stopRunning"] = async () => {
+ const tasks = started.map(
+ ({ id }) =>
new Promise((resolve) => {
- whenUnlocked(id, () => {
- stopService(id)
- .catch(e => currentContext.logger.error(e))
- .then(() => {
- whenUnlocked(id, () => resolve(null));
- });
- });
- })
- ));
+ whenUnlocked(id, () => {
+ stopService(id)
+ .catch((e) => logger.error(e))
+ .then(() => {
+ whenUnlocked(id, () => resolve(null));
+ });
+ });
+ }),
+ );
- await Promise.all(tasks);
+ await Promise.all(tasks);
}
-export async function waitForBusyAction(id: string) {
- return new Promise(
- (resolve, reject) => {
- whenUnlocked(id, (_, __, err) => err ? reject(err) : resolve(null));
- }
- );
+export const killRunning: ServiceManager["killRunning"] = async () => {
+ await Promise.all(
+ started.map(
+ async ({ id }) => stopService(id, true).catch((e) => logger.error(e))
+ )
+ )
}
-export function isRunning(id: string) {
- return getRunningService(id) != undefined;
+export const waitForBusyAction: ServiceManager["waitForBusyAction"] = async (id: string) => {
+ return new Promise((resolve, reject) => {
+ whenUnlocked(id, (_, __, err) => (err ? reject(err) : resolve(null)));
+ });
}
-export function getRunningService(id: string) {
- return started.find(service => service.id === id);
+export const waitForStopped: ServiceManager["waitForStopped"] = async (id: string) => {
+ if (!isRunning(id)) {
+ // service not running, so we continue immediately
+ return;
+ }
+
+ return new Promise((resolve, reject) => {
+ on("stop", ({ id, error }) => {
+ if (id !== id) {
+ // This call is not for me
+ return false;
+ }
+
+ if (error) {
+ reject(error);
+ } else {
+ resolve();
+ }
+ });
+ });
}
-function metaStorageForService(id: string): MetaStorage { // service id
- return {
- set: async (key, value) => {
- return db.serviceMetaRepository.setServiceMeta(id, key, value);
- },
- get: async (key, def) => {
- const meta = await db.serviceMetaRepository.getServiceMeta(id, key);
-
- return meta ?? def;
- },
- };
+export const isRunning: ServiceManager["isRunning"] = (id: string) => {
+ return getRunningService(id) != undefined;
}
-export function initialized() {
- return engine !== undefined;
+export const getRunningService: ServiceManager["getRunningService"] = (id: string) => {
+ return started.find((service) => service.id === id);
}
-export async function initEngineForcibly() {
- if (engine) {
- throw new Error("Engine is already loaded.");
- }
- if (!currentContext || !currentContext.appConfig) {
- throw new Error("Engine can't be loaded forcibly!");
- }
- engine = createEngine(currentContext.appConfig);
- // I set it here to keep the exact reference if the engine
- // is changed in the future.
- engine.cast = () => engine as any;
+const metaStorageForService = (id: string): MetaStorage => {
+ // service id
+ return {
+ set: async (key, value) => {
+ return db.serviceMetaRepository.setServiceMeta(id, key, value);
+ },
+ get: async (key, def) => {
+ const meta = await db.serviceMetaRepository.getServiceMeta(id, key);
+
+ return meta ?? def;
+ },
+ };
}
-export function getRunningServices() {
- return [...started];
+export const initEngineForcibly = async () => {
+ if (engine) {
+ throw new Error("Engine is already loaded.");
+ }
+ if (!currentContext || !currentContext.appConfig) {
+ throw new Error("Engine can't be loaded forcibly!");
+ }
+ engine = createEngine(currentContext.appConfig);
+ // I set it here to keep the exact reference if the engine
+ // is changed in the future.
+ engine.cast = () => engine as any;
}
-export function on(evt: T, h: EventHandler) {
- if (!evtHandlers.has(evt)) {
- evtHandlers.set(evt, []);
- }
- evtHandlers.get(evt).push(h);
+export const getRunningServices: ServiceManager["getRunningServices"] = () => {
+ return [...started];
}
-export {
- whenUnlocked
+export const on: ServiceManager["on"] = (
+ evt: T,
+ h: EventHandler,
+) => {
+ if (!evtHandlers.has(evt)) {
+ evtHandlers.set(evt, []);
+ }
+ evtHandlers.get(evt).push(h);
}
-function clearRunningServiceIfExists(id: string) {
- const service = getRunningService(id);
+const clearRunningServiceIfExists = (id: string) => {
+ const service = getRunningService(id);
- if (service) {
- started.splice(started.indexOf(service, 1));
- }
+ if (service) {
+ started.splice(started.indexOf(service, 1));
+ }
}
-function callManagerEvent(e: T, event: ServiceManagerEvents[T]) {
- if (!evtHandlers.has(e)) {
- return;
- }
- const newArray = evtHandlers.get(e)
- .filter(handler => {
- // Filter out those who returned true, which means they want to be unsubscribed after this call.
- const result = handler(event);
+const callManagerEvent = (
+ e: T,
+ event: ServiceManagerEvents[T],
+) => {
+ if (!evtHandlers.has(e)) {
+ return;
+ }
+ const newArray = evtHandlers.get(e).filter((handler) => {
+ // Filter out those who returned true, which means they want to be unsubscribed after this call.
+ const result = handler(event);
+
+ return typeof result != "boolean" || !result;
+ });
+ evtHandlers.set(e, newArray);
+}
- return typeof result != 'boolean' || !result;
- });
- evtHandlers.set(e, newArray);
+/**
+ * Notifies about an error that happened during internal engine calling.
+ *
+ * @param id The service ID for which the error happened
+ * @param error The error that happened
+ */
+const callServiceEngineError = (id: string, error: Error) => {
+ callManagerEvent("engine_err", { id, error });
}
/**
@@ -863,33 +988,44 @@ function callManagerEvent(e: T, event: Ser
*
* @param session The session for whom to create the session.
*/
-function buildRunListener(session: ActiveServiceSession): RunListener {
- const {
- serviceId
- } = session;
-
- // The internal run listener of this manager
- const internalRunListener: RunListener = {
- onStateChange: (state) => {
- startedStates.set(serviceId, state.ready ? 'RUNNING' : 'BUILDING');
- },
- onClose: async () => {
- clearRunningServiceIfExists(serviceId);
- startedStates.delete(serviceId);
-
- // Call stop event on the manager for the stopService() to potentially
- // unlock a busy action
- callManagerEvent("stop", { id: serviceId });
-
- currentContext.logger.debug("Service " + serviceId + " stopped");
+const buildRunListener = (session: ActiveServiceSession): RunListener => {
+ const { serviceId } = session;
+
+ // The internal run listener of this manager
+ const internalRunListener: RunListener = {
+ onStateChange: (state) => {
+ setServiceState(serviceId, state.ready ? "RUNNING" : "BUILDING");
+ },
+ onClose: async () => {
+ clearRunningServiceIfExists(serviceId);
+ startedStates.delete(serviceId);
+ // clear any busy action that may potentially still be locked
+ try {
+ unlockBusyAction(serviceId);
+ } catch (e) {
+ if (e.message && e.message.includes("No busy action")) {
+ // ignore, since it just means there is no busy action to unlock, so nothing to do
}
- };
- // Combine collected listeners
- return combineRunListeners([
- internalRunListener,
- // Add listener from the session
- session.runListener
- ])
+ }
+
+ callManagerEvent("stop", { id: serviceId });
+ },
+ };
+ // Combine collected listeners
+ return combineRunListeners([
+ internalRunListener,
+ // Add listener from the session
+ session.runListener,
+ ]);
+}
+
+const setServiceState = (id: string, state: State) => {
+ startedStates.set(id, state);
+
+ callManagerEvent("statechange", {
+ id,
+ state,
+ });
}
/**
@@ -898,51 +1034,47 @@ function buildRunListener(session: ActiveServiceSession): RunListener {
* @param id The id of the service.
* @returns The state of the service
*/
-function getServiceState(id: string) {
- return startedStates.get(id) ?? 'STOPPED';
+const getServiceState = (id: string) => {
+ if (getActionType(id) === "stop") {
+ // service has stop locked, so is stopping
+ return "STOPPING";
+ }
+
+ return startedStates.get(id) ?? "STOPPED";
}
// ---------------------------------------------------------------------------------------
-async function getPermaModel(id: string) {
- const perma_ = await db.permaRepository.getPerma(id);
- if (!perma_) {
- // Service does not exist
- throw new _InternalError('Not found.', 3);
- }
-
- return perma_;
-}
-
-async function reqExists(id: string) {
- const perma = await db.permaRepository.getPerma(id);
- if (!perma) {
- throw new _InternalError("Service not found.", 3);
- }
+const reqExists = async (id: string) => {
+ const perma_ = await db.permaRepository.getPerma(id);
+ if (!perma_) {
+ // service does not exist
+ throw new ServiceNotFoundError(id);
+ }
- return perma;
+ return perma_;
}
-function reqRunning(id: string) {
- const session = getRunningService(id);
- if (!session) {
- throw new _InternalError("This service is not running.", 2);
- }
+const reqRunning = (id: string) => {
+ const session = getRunningService(id);
+ if (!session) {
+ throw new ServiceNotRunningError(id);
+ }
- return session;
+ return session;
}
-function reqNotRunning(id: string) {
- if (isRunning(id)) {
- throw new _InternalError('Already running.', 2);
- }
+const reqNotRunning = (id: string) => {
+ if (isRunning(id)) {
+ throw new ServiceAlreadyRunningError(id);
+ }
}
-function reqTemplate(id: string) {
- const template = getTemplate(id);
- if (!template) {
- throw new _InternalError('Template not found.', 3);
- }
+const reqTemplate = (id: string) => {
+ const template = getTemplate(id);
+ if (!template) {
+ throw new TemplateNotFoundError(id);
+ }
- return template;
-}
\ No newline at end of file
+ return template;
+}
diff --git a/src/engine/middle.ts b/src/engine/middle.ts
index 2b736c6..0eb968f 100644
--- a/src/engine/middle.ts
+++ b/src/engine/middle.ts
@@ -1,7 +1,15 @@
import {ServiceManager} from "@nsm/engine/manager";
import {currentContext} from "@nsm/app";
+import {KnownError} from "@nsm/engine/error";
+import {AsyncTask} from "@nsm/util/promises";
-export type ServiceActionType = 'create' | 'resume' | 'stop' | 'forceStop' | 'sendStopSignal' | 'delete';
+export type ServiceActionType =
+ | "create"
+ | "resume"
+ | "stop"
+ | "forceStop"
+ | "sendStopSignal"
+ | "delete";
/**
* Represents an error that occurred during a service action.
@@ -13,7 +21,6 @@ export interface ServiceActionError {
}
export interface ErrorPublisher {
-
/**
* Publishes an error that occurred during a service action.
*
@@ -35,15 +42,15 @@ const publishers: ErrorPublisher[] = [
*/
export const registerErrorPublisher = (publisher: ErrorPublisher) => {
publishers.push(publisher);
-}
+};
const publishError = async (action: ServiceActionError) => {
try {
- await Promise.all(publishers.map(p => p.publishError(action)));
+ await Promise.all(publishers.map((p) => p.publishError(action)));
} catch (e) {
- currentContext.logger.error('Failed to publish service action error', e);
+ currentContext.logger.error("Failed to publish service action error", e);
}
-}
+};
/**
* Decorates an asynchronous function to allow for additional behavior, such as error handling or logging.
@@ -56,25 +63,87 @@ const publishError = async (action: ServiceActionError) => {
const decorateFunc = ) => Promise>(
fn: F,
actionType: ServiceActionType,
- serviceIdExtractor: (args: Parameters) => string = (args) => args[0] as string,
+ serviceIdExtractor?: (args: Parameters) => string,
) => {
return async (...args: Parameters) => {
try {
- return await fn(...args);
+ // @ts-ignore
+ const result = await fn(...args);
+ if (result instanceof AsyncTask) {
+ // if the result is a scheduled task, attach error handler to catch any errors during the execution of the task
+ result.promise.catch((e) => handleExecutionError(serviceIdExtractor, args, actionType, e));
+ }
+
+ return result;
} catch (e) {
- const action: ServiceActionError = {
- serviceId: serviceIdExtractor?.(args),
- type: actionType,
- message: e instanceof Error ? e.message : String(e),
- };
- await publishError(action);
+ await handleExecutionError(serviceIdExtractor, args, actionType, e);
+ }
+ };
+};
+
+/**
+ * Handles errors that occur during the execution of a service action.
+ *
+ * @see {@link decorateFunc}
+ */
+const handleExecutionError = async ) => Promise>(
+ serviceIdExtractor: (args: Parameters) => string,
+ args: Parameters,
+ actionType: ServiceActionType,
+ e: Error
+) => {
+ const action: ServiceActionError = {
+ serviceId: serviceIdExtractor?.(args),
+ type: actionType,
+ message: e instanceof Error ? e.message : String(e),
+ };
+ await publishError(action);
+
+ // don't log stack trace of known errors
+ const errorMeta: any[] = e instanceof KnownError ? [] : [e];
+ currentContext.logger.error(
+ `${action.serviceId ? `Service ${action.serviceId} f` : "F"}ailed action ${action.type}: ${action.message}`,
+ ...errorMeta,
+ );
+
+ throw e;
+}
+
+/**
+ * Creates a service ID extractor function that extracts the service ID from the
+ * specified argument index of the function arguments.
+ *
+ * @param argIndex The index of the argument from which to extract the service ID.
+ * @returns A function that takes the function arguments and returns the extracted service ID.
+ */
+const argServiceIdExtractor = (
+ argIndex: number,
+): () => Promise>(
+ args: Parameters,
+) => string) => {
+ return (args) => {
+ if (!Array.isArray(args)) {
+ throw new Error("Expected function call arguments to be an array");
+ }
- currentContext.logger.error(`${action.serviceId ? `Service ${action.serviceId} f` : "F"}ailed action ${action.type}: ${action.message}`, e);
+ const argsArray = args as unknown[];
+ // Check if the argument index is within bounds
+ if (argsArray.length <= argIndex) {
+ throw new Error(
+ `Expected at least ${argIndex + 1} arguments, but got ${argsArray.length}`,
+ );
+ }
- throw e;
+ const serviceId = argsArray[argIndex];
+ if (typeof serviceId !== "string") {
+ throw new Error(
+ `Expected service ID argument to be a string, but got ${typeof serviceId}`,
+ );
}
- }
-}
+
+ return serviceId;
+ };
+};
/**
* Wraps a {@link ServiceManager} instance with additional capabilities.
@@ -88,16 +157,30 @@ export const middleLayer = (manager: ServiceManager): ServiceManager => {
return {
...manager,
- createService: decorateFunc(manager.createService, "create", null),
-
- resumeService: decorateFunc(manager.resumeService, "resume"),
-
- stopService: decorateFunc(manager.stopService, "stop"),
-
- stopServiceForcibly: decorateFunc(manager.stopServiceForcibly, "forceStop"),
-
- sendStopSignal: decorateFunc(manager.sendStopSignal, "sendStopSignal"),
-
- deleteService: decorateFunc(manager.deleteService, "delete"),
- }
-}
\ No newline at end of file
+ createService: decorateFunc(manager.createService, "create"),
+
+ resumeService: decorateFunc(
+ manager.resumeService,
+ "resume",
+ argServiceIdExtractor(0),
+ ),
+
+ stopService: decorateFunc(
+ manager.stopService,
+ "stop",
+ argServiceIdExtractor(0),
+ ),
+
+ sendStopSignal: decorateFunc(
+ manager.sendStopSignal,
+ "sendStopSignal",
+ argServiceIdExtractor(0),
+ ),
+
+ deleteService: decorateFunc(
+ manager.deleteService,
+ "delete",
+ argServiceIdExtractor(0),
+ ),
+ };
+};
diff --git a/src/engine/monitoring/templateDirWatcher.ts b/src/engine/monitoring/templateDirWatcher.ts
index 4cda3b5..0069a5a 100644
--- a/src/engine/monitoring/templateDirWatcher.ts
+++ b/src/engine/monitoring/templateDirWatcher.ts
@@ -1,14 +1,13 @@
-import {templateBuildDir, debounce} from "@nsm/engine/monitoring/util";
-import {hashElement} from "folder-hash";
-import {getFilteredPaths} from "@nsm/engine/ignore";
-import {getAllTemplates} from "@nsm/engine/template";
+import { debounce } from "@nsm/engine/monitoring/util";
+import { hashElement } from "folder-hash";
+import { getFilteredPaths } from "@nsm/engine/ignore";
+import { getAllTemplates } from "@nsm/engine/template";
import winston from "winston";
-import chokidar, {FSWatcher} from "chokidar";
+import chokidar, { FSWatcher } from "chokidar";
import path from "path";
-import {getTemplatesPath} from "@nsm/filestructure";
+import {getTemplateBuildDir, getTemplatesPath} from "@nsm/filestructure";
export type TemplateDirWatcher = {
-
/**
* Starts watching the template directories for changes.
* When a change is detected, the template hash is updated and cached.
@@ -33,12 +32,12 @@ export const watchTemplateDirChanges = (logger: winston.Logger) => {
const templates = getAllTemplates();
// Populate on startup
- templates.forEach(template => watchTemplateDir(template.id));
+ templates.forEach((template) => watchTemplateDir(template.id));
// Watch the base directory for new templates
watchBaseDir(logger);
logger.info("Watching template directories for changes...");
-}
+};
/**
* Watches the base templates directory for new template directories being added or removed.
@@ -56,7 +55,9 @@ const watchBaseDir = (logger: winston.Logger) => {
watcher.on("addDir", async (path_) => {
const template = path.basename(path_);
if (template && !watchers.has(template)) {
- logger.debug(`New template directory detected: ${template}. Starting to watch for changes...`);
+ logger.debug(
+ `New template directory detected: ${template}. Starting to watch for changes...`,
+ );
await watchTemplateDir(template);
}
@@ -67,7 +68,8 @@ const watchBaseDir = (logger: winston.Logger) => {
const tWatcher = watchers.get(template);
if (tWatcher) {
logger.debug(
- `Template directory removed: ${template}. Stopping watch and removing hash from cache...`);
+ `Template directory removed: ${template}. Stopping watch and removing hash from cache...`,
+ );
await tWatcher.close();
}
@@ -75,8 +77,8 @@ const watchBaseDir = (logger: winston.Logger) => {
watchers.delete(template);
hashCache.delete(template);
}
- })
-}
+ });
+};
/**
* Watches a specific template directory for changes and updates the hash cache when a change is detected.
@@ -90,7 +92,7 @@ const watchTemplateDir = async (template: string) => {
await recalculateTemplateHash(template);
- const dir = templateBuildDir(template);
+ const dir = getTemplateBuildDir(template);
const excluded = getFilteredPaths(dir);
const recalc = debounce(() => recalculateTemplateHash(template), 2000);
@@ -103,13 +105,13 @@ const watchTemplateDir = async (template: string) => {
// and unnecessary rehashing.
awaitWriteFinish: {
stabilityThreshold: 500,
- pollInterval: 100
- }
+ pollInterval: 100,
+ },
});
watcher.on("all", recalc);
watchers.set(template, watcher);
-}
+};
/**
* Recalculates the hash of a template directory and updates the cache.
@@ -117,7 +119,7 @@ const watchTemplateDir = async (template: string) => {
* @param template The name of the template to recalculate the hash for.
*/
const recalculateTemplateHash = async (template: string) => {
- const dir = templateBuildDir(template);
+ const dir = getTemplateBuildDir(template);
const excluded = getFilteredPaths(dir);
if (hashingInProgress.has(template)) {
@@ -128,19 +130,19 @@ const recalculateTemplateHash = async (template: string) => {
try {
const hash = await hashElement(dir, {
- encoding: 'hex',
+ encoding: "hex",
folders: {
- exclude: excluded.dirs
+ exclude: excluded.dirs,
},
files: {
- exclude: excluded.files
- }
+ exclude: excluded.files,
+ },
});
hashCache.set(template, hash.hash);
} finally {
hashingInProgress.delete(template);
}
-}
+};
export const getTemplateHash = (template: string): string => {
const hash = hashCache.get(template);
@@ -149,4 +151,4 @@ export const getTemplateHash = (template: string): string => {
}
return hash;
-}
\ No newline at end of file
+};
diff --git a/src/engine/monitoring/util.ts b/src/engine/monitoring/util.ts
index 112e2c7..ab8094c 100644
--- a/src/engine/monitoring/util.ts
+++ b/src/engine/monitoring/util.ts
@@ -1,11 +1,3 @@
-import path from 'path';
-import {getTemplatesPath} from "@nsm/filestructure";
-
-// Returns the build directory for the template
-export function templateBuildDir(template: string) {
- return path.join(getTemplatesPath(), template);
-}
-
/**
* Returns a debounced version of the given function.
* The debounced function will only be called after it has not been called for the specified number of milliseconds.
@@ -27,4 +19,4 @@ export const debounce = (fn: () => void | Promise, ms: number) => {
fn();
}, ms);
};
-};
\ No newline at end of file
+};
diff --git a/src/engine/session.ts b/src/engine/session.ts
index 20eb06c..6fa5fbb 100644
--- a/src/engine/session.ts
+++ b/src/engine/session.ts
@@ -1,20 +1,49 @@
-import {RunListener} from "@nsm/engine/engine";
+import { RunListener } from "@nsm/engine/engine";
import {
CreateLogRecordArgs,
Database,
ListRecordsArgs,
ListSessionsArgs,
ServiceLogRecordModel,
- ServiceSessionModel
+ ServiceSessionModel,
} from "@nsm/database";
export interface SessionManager {
+ /**
+ * Initializes the session manager with the given database instance.
+ *
+ * @param db The database instance to use for storing session and log data.
+ * This method must be called before using any other methods of the session manager.
+ */
init(db: Database): void;
+ /**
+ * Begins a new service session for the given service ID.
+ *
+ * @param serviceId The ID of the service for which to begin a session.
+ * @return An object representing the active service session, including a run listener for handling session events.
+ */
beginServiceSession(serviceId: string): Promise;
- listSessions(args: ListSessionsArgs): Promise;
- listSessionLogs(args: ListRecordsArgs): Promise;
+ /**
+ * Lists service sessions.
+ *
+ * @param args The arguments for listing sessions
+ * @return A list of service sessions matching the given criteria, or undefined if no sessions were found.
+ */
+ listSessions(
+ args: ListSessionsArgs,
+ ): Promise;
+
+ /**
+ * Lists log records for a service session.
+ *
+ * @param args The arguments for listing log records
+ * @return A list of log records matching the given criteria, or undefined if no records were found.
+ */
+ listSessionLogs(
+ args: ListRecordsArgs,
+ ): Promise;
}
export interface ServiceSession {
@@ -35,7 +64,7 @@ let db: Database;
export const init = (db_: Database) => {
db = db_;
-}
+};
/**
* Begins a new service session for the given service ID.
@@ -43,47 +72,43 @@ export const init = (db_: Database) => {
* @param serviceId The ID of the service for which to begin a session.
* @return An object representing the active service session.
*/
-export const beginServiceSession: SessionManager["beginServiceSession"] = async (
- serviceId: string
-): Promise => {
- let session = await db.sessionRepository.createSession(serviceId);
-
- // Debounce the push in bulk to prevent database overhead
- const {
- flush: flushRecords,
- debounce: pushRecord
- } = debounceBulkPush();
-
- const runListener: RunListener = {
- onStateChange: async (state) => {
- pushRecord({
- sessionId: session.id,
- source: 'ENGINE',
- logLevel: 'INFO',
- message: state.description
- });
- },
- onMessage: async (record) => {
- pushRecord({
- sessionId: session.id,
- source: 'CONTAINER',
- logLevel: record.level.toUpperCase(),
- message: record.message
- });
- },
- onClose: async () => {
- // Push remaining logs now
- await flushRecords();
-
- // TODO: mark session as closed
- }
- }
-
- return {
- ...session,
- runListener
- }
-}
+export const beginServiceSession: SessionManager["beginServiceSession"] =
+ async (serviceId: string): Promise => {
+ let session = await db.sessionRepository.createSession(serviceId);
+
+ // Debounce the push in bulk to prevent database overhead
+ const { flush: flushRecords, debounce: pushRecord } = debounceBulkPush();
+
+ const runListener: RunListener = {
+ onStateChange: async (state) => {
+ pushRecord({
+ sessionId: session.id,
+ source: "ENGINE",
+ logLevel: "INFO",
+ message: state.description,
+ });
+ },
+ onMessage: async (record) => {
+ pushRecord({
+ sessionId: session.id,
+ source: "CONTAINER",
+ logLevel: record.level.toUpperCase(),
+ message: record.message,
+ });
+ },
+ onClose: async () => {
+ // Push remaining logs now
+ await flushRecords();
+
+ // TODO: mark session as closed
+ },
+ };
+
+ return {
+ ...session,
+ runListener,
+ };
+ };
/**
* Creates a debounced function for pushing log records in bulk to the database.
@@ -94,12 +119,12 @@ export const beginServiceSession: SessionManager["beginServiceSession"] = async
* @return A function that can be called to push a log record, which will be debounced and pushed in bulk.
*/
const debounceBulkPush = () => {
- const logRecordsBulk: Omit[] = [];
+ const logRecordsBulk: Omit[] = [];
const MAX_BATCH_SIZE = 50;
const DEBOUNCE_MS = 500;
- let timeout: NodeJS.Timeout|null = null;
+ let timeout: NodeJS.Timeout | null = null;
let isFlushing = false;
const flush = async () => {
@@ -163,16 +188,20 @@ const debounceBulkPush = () => {
// Renew timer
renew();
- }
- }
-}
+ },
+ };
+};
// TODO: get service session
-export const listSessions: SessionManager["listSessions"] = async (args: ListSessionsArgs) => {
+export const listSessions: SessionManager["listSessions"] = async (
+ args: ListSessionsArgs,
+) => {
return db.sessionRepository.listSessions(args);
-}
+};
-export const listSessionLogs: SessionManager["listSessionLogs"] = async (args: ListRecordsArgs) => {
+export const listSessionLogs: SessionManager["listSessionLogs"] = async (
+ args: ListRecordsArgs,
+) => {
return db.serviceLogRepository.listRecords(args);
-}
\ No newline at end of file
+};
diff --git a/src/engine/template.ts b/src/engine/template.ts
index f6b7828..02b20a1 100644
--- a/src/engine/template.ts
+++ b/src/engine/template.ts
@@ -1,110 +1,122 @@
-import {loadYamlFile} from "@nsm/util/yaml";
+import { loadYamlFile } from "@nsm/util/yaml";
import * as fs from "fs";
import path from "path";
-import {getTemplatesPath} from "@nsm/filestructure";
+import { getTemplatesPath } from "@nsm/filestructure";
export type Template = {
- /**
- * The unique ID of the template.
- */
- id: string,
- /**
- * The display name of the template, used for display purposes.
- */
- name: string;
- /**
- * A short description of the template, used for display purposes.
- */
- description: string;
- /**
- * The settings (definitions) object.
- */
- settings: any;
-}
+ /**
+ * The unique ID of the template.
+ */
+ id: string;
+ /**
+ * The display name of the template, used for display purposes.
+ */
+ name: string;
+ /**
+ * A short description of the template, used for display purposes.
+ */
+ description: string;
+ /**
+ * The settings (definitions) object.
+ */
+ settings: any;
+};
export type TemplateManager = {
+ /**
+ * Prepares the environment variables for a template by validating the provided env object against
+ * the template's settings and filling in default values where necessary. It checks for required options, validates
+ * types, and returns a new env object that can be used when creating a service from the template.
+ *
+ * @param template The template or template ID for which to prepare the environment variables
+ * @param env The environment variables provided by the user, which may be incomplete or have incorrect types
+ * @return A new env object that has been validated and filled with default values according to the template's settings
+ * @throws Error if a required option is missing or if an option has an invalid type
+ */
+ prepareEnvForTemplate(template: Template | string, env: any): any;
- /**
- * Prepares the environment variables for a template by validating the provided env object against
- * the template's settings and filling in default values where necessary. It checks for required options, validates
- * types, and returns a new env object that can be used when creating a service from the template.
- *
- * @param template The template or template ID for which to prepare the environment variables
- * @param env The environment variables provided by the user, which may be incomplete or have incorrect types
- * @return A new env object that has been validated and filled with default values according to the template's settings
- * @throws Error if a required option is missing or if an option has an invalid type
- */
- prepareEnvForTemplate(template: Template | string, env: any): any;
+ /**
+ * Returns a template by ID.
+ *
+ * @param id The ID of the template
+ * @return The template, or null if not exists
+ */
+ getTemplate(id: string): Template | null;
- /**
- * Returns a template by ID.
- *
- * @param id The ID of the template
- * @return The template, or null if not exists
- */
- getTemplate(id: string): Template|null;
-
- getAllTemplates(): Template[];
-}
+ getAllTemplates(): Template[];
+};
const templateCache = {};
-export const getTemplate = (id: string): Template|null => {
- if (templateCache[id]) {
- return templateCache[id];
- }
- const settingsPath = path.join(getTemplatesPath(), id, 'settings.yml');
- if (!fs.existsSync(settingsPath)) {
- return null;
- }
- const settings = loadYamlFile(settingsPath);
- const template = {
- id,
- name: settings.name,
- description: settings.description,
- settings
- };
- templateCache[id] = template;
- return template;
-}
+export const getTemplate = (id: string): Template | null => {
+ if (templateCache[id]) {
+ return templateCache[id];
+ }
+ const settingsPath = path.join(getTemplatesPath(), id, "settings.yml");
+ if (!fs.existsSync(settingsPath)) {
+ return null;
+ }
+ const settings = loadYamlFile(settingsPath);
+ const template = {
+ id,
+ name: settings.name,
+ description: settings.description,
+ settings,
+ };
+ templateCache[id] = template;
+ return template;
+};
export const getAllTemplates = () => {
- if (!fs.existsSync(getTemplatesPath())) {
- return [];
- }
+ if (!fs.existsSync(getTemplatesPath())) {
+ return [];
+ }
- return fs
- .readdirSync(getTemplatesPath())
- .filter(file => fs.statSync(path.join(getTemplatesPath(), file)).isDirectory())
- .map(id => getTemplate(id))
- .filter(template => template !== null);
-}
+ return fs
+ .readdirSync(getTemplatesPath())
+ .filter((file) =>
+ fs.statSync(path.join(getTemplatesPath(), file)).isDirectory(),
+ )
+ .map((id) => getTemplate(id))
+ .filter((template) => template !== null);
+};
-export const prepareEnvForTemplate = (template: Template | string, env: any) => {
- env = { ...env }; // Shallow copy to avoid mutating the original object
- if (typeof template === 'string') {
- template = getTemplate(template); // Load the template if ID provided
- }
+export const prepareEnvForTemplate = (
+ template: Template | string,
+ env: any,
+) => {
+ env = { ...env }; // Shallow copy to avoid mutating the original object
+ if (typeof template === "string") {
+ template = getTemplate(template); // Load the template if ID provided
+ }
- for (const key of Object.keys(template.settings['env'])) {
- if (env[key] && typeof env[key] == typeof template.settings['env'][key]) {
- // Keep the value
- } else if (env[key]) {
- throw new Error('Invalid option type for ' + key + '. Got ' + typeof env[key] + ' but expected ' + typeof template.settings['env'][key] + '.');
- } else if (isRequiredOption(template.settings['env'][key])) {
- throw new Error('Missing required option ' + key);
- } else {
- // Set default
- env[key] = template.settings['env'][key];
- }
+ for (const key of Object.keys(template.settings["env"])) {
+ if (env[key] && typeof env[key] == typeof template.settings["env"][key]) {
+ // Keep the value
+ } else if (env[key]) {
+ throw new Error(
+ "Invalid option type for " +
+ key +
+ ". Got " +
+ typeof env[key] +
+ " but expected " +
+ typeof template.settings["env"][key] +
+ ".",
+ );
+ } else if (isRequiredOption(template.settings["env"][key])) {
+ throw new Error("Missing required option " + key);
+ } else {
+ // Set default
+ env[key] = template.settings["env"][key];
}
- return env;
-}
+ }
+ return env;
+};
// Defines if the value represents required option.
const isRequiredOption = (value: any) => {
- return (
- (typeof value == "string" && value === "") ||
- (typeof value === "number" && value == -1)
- )
-}
\ No newline at end of file
+ return (
+ (typeof value == "string" && value === "") ||
+ (typeof value === "number" && value == -1)
+ );
+};
diff --git a/src/filestructure.ts b/src/filestructure.ts
index 6aca09d..d4f0275 100644
--- a/src/filestructure.ts
+++ b/src/filestructure.ts
@@ -1,34 +1,47 @@
import path from "path";
-import envPaths, {Paths} from "env-paths";
-import {AppConfig} from "@nsm/config";
+import { AppConfig } from "@nsm/config";
import fs from "fs";
-export const currentPaths: Paths = envPaths("nsm");
-
let appConfig: AppConfig;
export const init = (appConfig_: AppConfig) => {
appConfig = appConfig_;
-}
-
-// The local resources dir (not the source of truth)
-export const resourcesPath = path.join(process.cwd(), "resources");
+};
// The target (platform-agnostic) resources dir (the source of truth)
-export const getResourcesTargetPath = () => {
- return appConfig.getResourcesPath() ?? path.join(currentPaths.data);
-}
+export const getResourcesPath = () => {
+ return appConfig.getResourcesPath();
+};
export const getTemplatesPath = () => {
- return path.join(getResourcesTargetPath(), 'templates')
+ return appConfig.getTemplatesPath();
+};
+
+export const getTemplateBuildDir = (template: string) => {
+ return appConfig.getTemplateBuildDir(template);
}
export const getTempPath = () => {
- return currentPaths.temp;
-}
+ return appConfig.getTempPath();
+};
+
+export const mkdirTemp = (...p: string[]) => {
+ const dir = path.join(getTempPath(), ...p);
+ if (fs.existsSync(dir)) {
+ if (!fs.statSync(dir).isDirectory()) {
+ throw new Error(
+ "Temp path already exists and is not a directory: " + dir,
+ );
+ }
+ } else {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+
+ return dir;
+};
export const prepareFolders = () => {
- const resourcesTargetPath = getResourcesTargetPath();
+ const resourcesTargetPath = getResourcesPath();
if (!fs.existsSync(resourcesTargetPath)) {
fs.mkdirSync(resourcesTargetPath, { recursive: true });
}
@@ -42,4 +55,4 @@ export const prepareFolders = () => {
if (!fs.existsSync(tempPath)) {
fs.mkdirSync(tempPath, { recursive: true });
}
-}
\ No newline at end of file
+};
diff --git a/src/helpers.ts b/src/helpers.ts
index 8a6da96..688a40a 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -1,10 +1,10 @@
export function isDebug() {
- return process.env.DEBUG === 'true';
+ return process.env.DEBUG === "true";
}
export function consumeEnginePowerAction(action: () => Promise) {
- action().catch((e) => {
- // Manager service power action errors are ignored since
- // they are handled by the middle-layer defined in engine/middle.ts
- });
-}
\ No newline at end of file
+ action().catch((e) => {
+ // Manager service power action errors are ignored since
+ // they are handled by the middle-layer defined in engine/middle.ts
+ });
+}
diff --git a/src/lib/isDocker.ts b/src/lib/isDocker.ts
deleted file mode 100644
index 1dbbdb9..0000000
--- a/src/lib/isDocker.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
-MIT License
-
-Copyright (c) Sindre Sorhus (https://sindresorhus.com)
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-import fs from 'node:fs';
-
-let isDockerCached;
-
-function hasDockerEnv() {
- try {
- fs.statSync('/.dockerenv');
- return true;
- } catch {
- return false;
- }
-}
-
-function hasDockerCGroup() {
- try {
- return fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker');
- } catch {
- return false;
- }
-}
-
-export default function isDocker() {
- // TODO: Use `??=` when targeting Node.js 16.
- if (isDockerCached === undefined) {
- isDockerCached = hasDockerEnv() || hasDockerCGroup();
- }
-
- return isDockerCached;
-}
\ No newline at end of file
diff --git a/src/lib/isInsideContainer.ts b/src/lib/isInsideContainer.ts
deleted file mode 100644
index f55695c..0000000
--- a/src/lib/isInsideContainer.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
-MIT License
-
-Copyright (c) Sindre Sorhus (https://sindresorhus.com)
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-import fs from 'node:fs';
-import isDocker from "@nsm/lib/isDocker";
-
-let cachedResult;
-
-// Podman detection
-const hasContainerEnv = () => {
- try {
- fs.statSync('/run/.containerenv');
- return true;
- } catch {
- return false;
- }
-};
-
-export default function isInsideContainer() {
- // TODO: Use `??=` when targeting Node.js 16.
- if (cachedResult === undefined) {
- cachedResult = hasContainerEnv() || isDocker();
- }
-
- return cachedResult;
-}
\ No newline at end of file
diff --git a/src/logger.ts b/src/logger.ts
index 9e88488..c0a0c18 100644
--- a/src/logger.ts
+++ b/src/logger.ts
@@ -1,37 +1,54 @@
import winston from "winston";
import fs from "fs";
import path from "path";
-import {getResourcesTargetPath} from "@nsm/filestructure";
+import { getResourcesPath } from "@nsm/filestructure";
const { combine, timestamp, label, errors, printf } = winston.format;
+export let currentGlobalLogger: winston.Logger;
+
+export function setCurrentGlobalLogger(logger: winston.Logger) {
+ currentGlobalLogger = logger;
+}
+
export function createLatestLogFile() {
- if (fs.existsSync(path.join(getResourcesTargetPath(), 'logs', 'latest.log'))) {
- const date = new Date(Date.now()).toJSON().slice(2, 10) + '.'
- + new Date(Date.now()).getHours() + '.'
- + new Date(Date.now()).getMinutes();
+ if (
+ fs.existsSync(path.join(getResourcesPath(), "logs", "latest.log"))
+ ) {
+ const date =
+ new Date(Date.now()).toJSON().slice(2, 10) +
+ "." +
+ new Date(Date.now()).getHours() +
+ "." +
+ new Date(Date.now()).getMinutes();
- fs.renameSync(path.join(getResourcesTargetPath(), 'logs', 'latest.log'), path.join(getResourcesTargetPath(), 'logs', date + '.log'));
- }
+ fs.renameSync(
+ path.join(getResourcesPath(), "logs", "latest.log"),
+ path.join(getResourcesPath(), "logs", date + ".log"),
+ );
+ }
}
export function createLogger(options?: { label?: string }) {
- const debug = process.env.DEBUG === 'true';
- return winston.createLogger({
- level: debug ? 'debug' : 'info',
- format: combine(
- errors({ stack: true }),
- label({ label: options?.label ?? 'NSM' }),
- timestamp(),
- printf(({ level, message, label, timestamp, stack }) => {
- let row = `${timestamp} [${label}] ${level}: ${message}`;
+ const debug = process.env.DEBUG === "true";
+ return winston.createLogger({
+ level: debug ? "debug" : "info",
+ format: combine(
+ errors({ stack: true }),
+ label({ label: options?.label ?? "NSM" }),
+ timestamp(),
+ printf(({ level, message, label, timestamp, stack }) => {
+ let row = `${timestamp} [${label}] ${level}: ${message}`;
- return stack ? row + `\n${stack}` : row;
- })
- ),
- transports: [
- new winston.transports.Console(),
- new winston.transports.File({dirname: path.join(getResourcesTargetPath(), 'logs'), filename: 'latest.log'})
- ]
- });
-}
\ No newline at end of file
+ return stack ? row + `\n${stack}` : row;
+ }),
+ ),
+ transports: [
+ new winston.transports.Console(),
+ new winston.transports.File({
+ dirname: path.join(getResourcesPath(), "logs"),
+ filename: "latest.log",
+ }),
+ ],
+ });
+}
diff --git a/src/networking/manager.ts b/src/networking/manager.ts
index e900fbd..eb4ec4f 100644
--- a/src/networking/manager.ts
+++ b/src/networking/manager.ts
@@ -1,57 +1,64 @@
import DockerClient from "dockerode";
-export async function accessNetwork(client: DockerClient, ip: string, id: string) {
- let net = client.getNetwork(id);
- try {
- await net.inspect();
- } catch (e) {
- if (e.message.includes('not found')) {
- net = await createNetwork(client, ip);
- } else {
- // Something unexpected occurred here.
- throw e;
- }
+export async function accessNetwork(
+ client: DockerClient,
+ ip: string,
+ id: string,
+) {
+ let net = client.getNetwork(id);
+ try {
+ await net.inspect();
+ } catch (e) {
+ if (e.message.includes("not found")) {
+ net = await createNetwork(client, ip);
+ } else {
+ // Something unexpected occurred here.
+ throw e;
}
- return net;
+ }
+ return net;
}
export async function createNetwork(client: DockerClient, ip: string) {
- const uuid = crypto.randomUUID();
- return client.createNetwork({
- Name: uuid,
- Driver: 'bridge',
- Options: {
- 'com.docker.network.bridge.enable_icc': 'true', // Inter-container connectivity, may disable
- 'com.docker.network.bridge.enable_ip_masquerade': 'true',
- 'com.docker.network.bridge.host_binding_ipv4': ip,
- 'com.docker.network.bridge.name': uuid,
- 'com.docker.network.driver.mtu': '1500'
- },
- Labels: {
- 'nsm': 'true',
- }
- });
+ const uuid = crypto.randomUUID();
+ return client.createNetwork({
+ Name: uuid,
+ Driver: "bridge",
+ Options: {
+ "com.docker.network.bridge.enable_icc": "true", // Inter-container connectivity, may disable
+ "com.docker.network.bridge.enable_ip_masquerade": "true",
+ "com.docker.network.bridge.host_binding_ipv4": ip,
+ "com.docker.network.bridge.name": uuid,
+ "com.docker.network.driver.mtu": "1500",
+ },
+ Labels: {
+ nsm: "true",
+ },
+ });
}
export async function deleteNetwork(client: DockerClient, id: string) {
- try {
- await client.getNetwork(id).remove();
- } catch (e) {
- if (!e.message.toLowerCase().includes('no such network')) {
- console.log(e);
- }
+ try {
+ await client.getNetwork(id).remove();
+ } catch (e) {
+ if (!e.message.toLowerCase().includes("no such network")) {
+ console.log(e);
}
+ }
}
// Returns network id, or undef if not in net
-export async function isInNetwork(client: DockerClient, containerId: string): Promise {
- try {
- await client.getNetwork(containerId).inspect();
- return containerId;
- } catch (e) {
- if (!e.message.includes('not found')) {
- console.log(e);
- }
- return undefined;
+export async function isInNetwork(
+ client: DockerClient,
+ containerId: string,
+): Promise {
+ try {
+ await client.getNetwork(containerId).inspect();
+ return containerId;
+ } catch (e) {
+ if (!e.message.includes("not found")) {
+ console.log(e);
}
-}
\ No newline at end of file
+ return undefined;
+ }
+}
diff --git a/src/profiler/index.ts b/src/profiler/index.ts
index e2f5f4c..66bb0f6 100644
--- a/src/profiler/index.ts
+++ b/src/profiler/index.ts
@@ -1,7 +1,7 @@
export function measureEventLoop() {
- var time = process.hrtime();
- process.nextTick(function() {
- var diff = process.hrtime(time);
- console.log('event loop took %d nanoseconds', diff[0] * 1e9 + diff[1]);
- });
-}
\ No newline at end of file
+ var time = process.hrtime();
+ process.nextTick(function () {
+ var diff = process.hrtime(time);
+ console.log("event loop took %d nanoseconds", diff[0] * 1e9 + diff[1]);
+ });
+}
diff --git a/src/resources.ts b/src/resources.ts
index d735a3c..c186f55 100644
--- a/src/resources.ts
+++ b/src/resources.ts
@@ -1,6 +1,9 @@
import path from "path";
import fs from "fs";
-import {getResourcesTargetPath, resourcesPath} from "@nsm/filestructure";
+import { getResourcesPath} from "@nsm/filestructure";
+
+// The local resources dir (not the source of truth)
+const resourcesPath = path.join(process.cwd(), "resources");
/**
* Reads resource from target dir.
@@ -8,10 +11,10 @@ import {getResourcesTargetPath, resourcesPath} from "@nsm/filestructure";
* @param name The name of the resource in the target dir.
*/
export const readResource = (name: string) => {
- const p = path.join(getResourcesTargetPath(), name);
+ const p = path.join(getResourcesPath(), name);
- return fs.readFileSync(p, 'utf8');
-}
+ return fs.readFileSync(p, "utf8");
+};
/**
* Creates a directory in the target dir. Creates parent dirs if missing.
@@ -19,10 +22,10 @@ export const readResource = (name: string) => {
* @param name The name of the dir in the target dir.
*/
export const mkdirResource = (name: string) => {
- const p = path.join(getResourcesTargetPath(), name);
+ const p = path.join(getResourcesPath(), name);
fs.mkdirSync(p, { recursive: true });
-}
+};
/**
* Saves resource to target dir. Creates parent dirs if missing.
@@ -36,7 +39,7 @@ export const saveResource = (
name: string,
targetName: string,
skipIfExists: boolean = false,
- targetDirPath: string = getResourcesTargetPath()
+ targetDirPath: string = getResourcesPath(),
) => {
const targetPath = path.join(targetDirPath, targetName);
// Create parent dirs if missing
@@ -46,7 +49,7 @@ export const saveResource = (
return;
}
fs.writeFileSync(targetPath, readCwdResource(name));
-}
+};
/**
* Reads resource from resources dir.
@@ -56,5 +59,5 @@ export const saveResource = (
export const readCwdResource = (name: string) => {
const p = path.join(resourcesPath, name);
- return fs.readFileSync(p, 'utf8');
-}
\ No newline at end of file
+ return fs.readFileSync(p, "utf8");
+};
diff --git a/src/router/index.ts b/src/router/index.ts
index 30e9a95..45c045f 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -1,57 +1,58 @@
-import {AppContext} from "../app";
-import {json, RequestHandler, Router} from "express";
+import { AppContext } from "../app";
+import { json, RequestHandler, Router } from "express";
import v1Routes from "./v1";
-import {measureEventLoop} from "@nsm/profiler";
+import {eventLoopProfiler} from "@nsm/router/middlewares/eventLoopProfiler";
+import {debugRequestLogger} from "@nsm/router/middlewares/debugRequestLogger";
+import {catchKnownErrors} from "@nsm/router/middlewares/catchKnownErrors";
export type RouterHandler = {
- url: string;
- routes: {[method: string]: RequestHandler};
+ url: string;
+ routes: { [method: string]: RequestHandler|RequestHandler[] };
};
type RouterInit = (context: AppContext) => Promise;
// Load API by version
async function api(ver: string, context: AppContext, routes: RouterInit[]) {
- const router = Router();
- router.use(json());
- if (context.debug) {
- router.use((req, res, next) => {
- if (req.body) {
- context.logger.debug(`Body: ${JSON.stringify(req.body)}`);
- } else {
- context.logger.debug('No body');
- }
- next();
- });
- // Measure event loop process time if in debug mode
- router.use((_, __, next) => {
- measureEventLoop();
- next();
- });
- }
- for (let init of routes) {
- // Create handler with changed router to the sub-router that will be
- // used specifically for this API version
- const handler = await init({ ...context, router });
- let reg = false;
-
- for (const method of ['get', 'post', 'put', 'delete']) {
- if (handler.routes[method]) {
- // Register handler to express
- router[method](handler.url, (req, res, next) => {
- context.logger.debug(`${method.toUpperCase()} ${req.url}`);
- next();
- }, handler.routes[method]);
- reg = true;
- }
- }
- if (reg) {
- context.logger.debug(`Registered route ${handler.url}`);
+ const router = Router();
+ router.use(json());
+ if (context.debug) {
+ router.use(debugRequestLogger({context}));
+ // Measure event loop process time if in debug mode
+ router.use(eventLoopProfiler());
+ }
+
+ for (let init of routes) {
+ // Create handler with changed router to the sub-router that will be
+ // used specifically for this API version
+ const handler = await init({ ...context, router });
+
+ let reg = false;
+ for (const method of ["get", "post", "put", "delete"]) {
+ const userDefinedRoutes = handler.routes[method];
+ if (userDefinedRoutes) {
+ const handlers: RequestHandler[] = [];
+ if (Array.isArray(userDefinedRoutes)) {
+ handlers.push(...userDefinedRoutes);
+ } else {
+ handlers.push(userDefinedRoutes);
}
+
+ // Register handler to express
+ router[method](handler.url, ...handlers);
+ reg = true;
+ }
}
- context.router.use(`/${ver}`, router);
+
+ if (reg) {
+ context.logger.debug(`Registered route ${handler.url}`);
+ }
+ }
+ router.use(catchKnownErrors());
+
+ context.router.use(`/${ver}`, router);
}
export default async function (context: AppContext) {
- await api('v1', context, v1Routes); // v1
-}
\ No newline at end of file
+ await api("v1", context, v1Routes); // v1
+}
diff --git a/src/router/middlewares/catchKnownErrors.ts b/src/router/middlewares/catchKnownErrors.ts
new file mode 100644
index 0000000..bc0eb0f
--- /dev/null
+++ b/src/router/middlewares/catchKnownErrors.ts
@@ -0,0 +1,22 @@
+import express from "express";
+import {KnownError} from "@nsm/engine/error";
+
+/**
+ * Middleware to catch known errors and respond properly.
+ */
+export const catchKnownErrors = (): express.ErrorRequestHandler => {
+ return (err, _, res, next) => {
+ if (res.headersSent) {
+ return next(err);
+ }
+
+ let status = 500;
+ let message = "Internal Server Error";
+ if (err instanceof KnownError) {
+ status = err.code;
+ message = err.message;
+ }
+
+ res.status(status).json({ status, message }).end();
+ }
+}
\ No newline at end of file
diff --git a/src/router/middlewares/debugRequestLogger.ts b/src/router/middlewares/debugRequestLogger.ts
new file mode 100644
index 0000000..f3a12a0
--- /dev/null
+++ b/src/router/middlewares/debugRequestLogger.ts
@@ -0,0 +1,27 @@
+import express from "express";
+import {AppContext} from "@nsm/app";
+
+export interface Options {
+ context: AppContext;
+}
+
+/**
+ * Middleware for logging incoming requests in debug mode.
+ *
+ * @param options The options.
+ */
+export const debugRequestLogger = (
+ options: Options
+): express.RequestHandler => {
+ return (req, _, next) => {
+ const context = options.context;
+
+ context.logger.debug(`${req.method.toUpperCase()} ${req.url}`);
+ if (req.body) {
+ context.logger.debug(`Body: ${JSON.stringify(req.body)}`);
+ } else {
+ context.logger.debug("No body");
+ }
+ next();
+ }
+}
\ No newline at end of file
diff --git a/src/router/middlewares/eventLoopProfiler.ts b/src/router/middlewares/eventLoopProfiler.ts
new file mode 100644
index 0000000..8652623
--- /dev/null
+++ b/src/router/middlewares/eventLoopProfiler.ts
@@ -0,0 +1,12 @@
+import express from "express";
+import {measureEventLoop} from "@nsm/profiler";
+
+/**
+ * Middleware to measure the event loop delay for each request.
+ */
+export const eventLoopProfiler = (): express.RequestHandler => {
+ return (_, __, next) => {
+ measureEventLoop();
+ next();
+ }
+}
\ No newline at end of file
diff --git a/src/router/middlewares/parseModel.ts b/src/router/middlewares/parseModel.ts
new file mode 100644
index 0000000..b14f93b
--- /dev/null
+++ b/src/router/middlewares/parseModel.ts
@@ -0,0 +1,39 @@
+import express from "express";
+import z from "zod";
+
+export interface ParseModelOptions {
+ model: {
+ body: z.ZodObject;
+ query: z.ZodObject;
+ params: z.ZodObject;
+ }
+}
+
+/**
+ * Middleware to parse and validate request parts using Zod schemas.
+ *
+ * @param options The options.
+ */
+export const parseModel = (
+ options: ParseModelOptions
+): express.RequestHandler => {
+ return (req, res, next) => {
+ for (const key in options.model) {
+ const model = options.model[key as keyof ParseModelOptions["model"]];
+
+ const result = model.safeParse(req[key as keyof express.Request]);
+ if (result.success) {
+ continue;
+ }
+
+ res.status(400).json({
+ status: 400,
+ message: `Invalid ${key} format.`,
+ errors: result.error.errors,
+ });
+ return;
+ }
+
+ next();
+ }
+}
\ No newline at end of file
diff --git a/src/router/util/preconditions.ts b/src/router/util/preconditions.ts
index b9e7b3a..d471544 100644
--- a/src/router/util/preconditions.ts
+++ b/src/router/util/preconditions.ts
@@ -1,25 +1,35 @@
import express from "express";
-import {isServicePending} from "@nsm/engine/asyncp";
-import {handleErrorMessage} from "@nsm/util/routes";
-import {ServiceManager} from "@nsm/engine";
+import { isServicePending } from "@nsm/engine/asyncp";
+import { handleErrorMessage } from "@nsm/util/routes";
+import { ServiceManager } from "@nsm/engine";
export const checkServiceExists = async (
- serviceId: string, manager: ServiceManager, res: express.Response) => {
- if (!await manager.getService(serviceId)) {
- handleErrorMessage(404, 'Service not found.', res);
+ serviceId: string,
+ manager: ServiceManager,
+ res: express.Response,
+) => {
+ if (!(await manager.getService(serviceId))) {
+ handleErrorMessage(404, "Service not found.", res);
return false;
}
return true;
-}
+};
-export const checkServicePending = (serviceId: string, res: express.Response) => {
+export const checkServicePending = (
+ serviceId: string,
+ res: express.Response,
+) => {
if (isServicePending(serviceId)) {
- handleErrorMessage(409, 'Service is pending another action. Please wait a moment.', res);
+ handleErrorMessage(
+ 409,
+ "Service is pending another action. Please wait a moment.",
+ res,
+ );
return false;
}
return true;
-};
\ No newline at end of file
+};
diff --git a/src/router/v1/index.ts b/src/router/v1/index.ts
index 2534223..a5352fd 100644
--- a/src/router/v1/index.ts
+++ b/src/router/v1/index.ts
@@ -7,7 +7,6 @@ import stopRoute from "./service/stopRoute";
import createRoute from "./service/createRoute";
import rebootRoute from "./service/rebootRoute";
import powerStatusRoute from "./service/powerStatusRoute";
-import stopCmdRoute from "@nsm/router/v1/service/stopCmdRoute";
import optionsRoute from "@nsm/router/v1/service/optionsRoute";
import sessionsRoute from "@nsm/router/v1/service/sessionsRoute";
import sessionLogsRoute from "@nsm/router/v1/session/sessionLogsRoute";
@@ -21,12 +20,11 @@ export default [
deleteRoute,
resumeRoute,
rebootRoute,
- stopCmdRoute,
stopRoute,
powerStatusRoute,
optionsRoute,
listRoute,
sessionsRoute,
logsRoute,
- sessionLogsRoute
-]
\ No newline at end of file
+ sessionLogsRoute,
+];
diff --git a/src/router/v1/service/createRoute.ts b/src/router/v1/service/createRoute.ts
index 49e634b..5a8bbd0 100644
--- a/src/router/v1/service/createRoute.ts
+++ b/src/router/v1/service/createRoute.ts
@@ -1,54 +1,57 @@
-import {RouterHandler} from "../../index";
-import {AppContext} from "@nsm/app";
-import {Options} from "@nsm/engine";
-import {clock} from "@nsm/util/clock";
-import {prepareEnvForTemplate} from "@nsm/engine/template";
-import {consumeEnginePowerAction} from "@nsm/helpers";
+import { RouterHandler } from "../../index";
+import { AppContext } from "@nsm/app";
+import { Options } from "@nsm/engine";
+import { clock } from "@nsm/util/clock";
+import { prepareEnvForTemplate } from "@nsm/engine/template";
+import {TemplateNotFoundError} from "@nsm/engine/error";
-export default async function ({manager}: AppContext): Promise {
- return {
- url: '/service/create',
- routes: {
- post: async (req, res) => {
- const clk = clock();
- if (!req.body || !req.body.template) {
- res.status(400).json({status: 400, message: 'Missing body or template key.'}).end();
- return;
- }
- const template = manager.getTemplate(req.body.template);
- if (!template) {
- res.status(400).json({status: 400, message: 'Invalid template ID.'}).end();
- return;
- }
- let env = req.body.env ?? {};
- try {
- env = prepareEnvForTemplate(template, env);
- } catch (e) {
- res.status(400).json({status: 400, message: e.message}).end();
- return;
- }
+export default async function ({
+ manager,
+}: AppContext): Promise {
+ return {
+ url: "/service/create",
+ routes: {
+ post: async (req, res) => {
+ const clk = clock();
+ if (!req.body || !req.body.template) {
+ res
+ .status(400)
+ .json({ status: 400, message: "Missing body or template key." })
+ .end();
+ return;
+ }
+ const template = manager.getTemplate(req.body.template);
+ if (!template) {
+ throw new TemplateNotFoundError(req.body.template);
+ }
- // Build options
- const options: Options = req.body;
- options.env = env;
- // Create the service
- try {
- const serviceId = await manager.createService(template.id, options);
+ let env = req.body.env ?? {};
+ try {
+ env = prepareEnvForTemplate(template, env);
+ } catch (e) {
+ res.status(400).json({ status: 400, message: e.message }).end();
+ return;
+ }
- // Resume right afterward
- consumeEnginePowerAction(() => manager.resumeService(serviceId));
+ // Build options
+ const options: Options = req.body;
+ options.env = env;
- res.status(200).json({
- status: 200,
- message: 'Service create action successfully registered to be completed in a moment.',
- serviceId,
- statusPath: '/v1/service/' + serviceId + '/powerstatus',
- time: clk.durFromCreation()
- }).end();
- } catch (e) {
- res.status(500).json({status: 500, message: e.message}).end();
- }
- }
- },
- }
-}
\ No newline at end of file
+ const serviceId = await manager.createService(template.id, options);
+
+ await manager.resumeService(serviceId);
+
+ res
+ .status(200)
+ .json({
+ status: 200,
+ message: "Service created successfully.",
+ serviceId,
+ statusPath: "/v1/service/" + serviceId + "/powerstatus",
+ time: clk.durFromCreation(),
+ })
+ .end();
+ },
+ },
+ };
+}
diff --git a/src/router/v1/service/deleteRoute.ts b/src/router/v1/service/deleteRoute.ts
index 86bc7e8..bfb7f07 100644
--- a/src/router/v1/service/deleteRoute.ts
+++ b/src/router/v1/service/deleteRoute.ts
@@ -1,29 +1,28 @@
-import {AppContext} from "@nsm/app";
-import {RouterHandler} from "../../index";
-import {handleErr} from "@nsm/util/routes";
-import {checkServiceExists} from "@nsm/router/util/preconditions";
+import { AppContext } from "@nsm/app";
+import { RouterHandler } from "../../index";
-export default async function ({manager}: AppContext): Promise {
- return {
- url: '/service/:id/delete',
- routes: {
- post: async (req, res) => {
- const id = req.params.id;
- if (!id) {
- res.status(400).json({status: 400, message: 'Required \'id\' field not present in the body.'});
- return;
- }
- if (!await checkServiceExists(id, manager, res)) {
- return;
- }
- try {
- await manager.deleteService(id);
+export default async function ({
+ manager,
+}: AppContext): Promise {
+ return {
+ url: "/service/:id/delete",
+ routes: {
+ post: async (req, res) => {
+ const id = req.params.id;
+ if (!id) {
+ res
+ .status(400)
+ .json({
+ status: 400,
+ message: "Required 'id' field not present in the body.",
+ });
+ return;
+ }
- res.status(200).json({status: 200, message: 'Service deleted.'});
- } catch (e) {
- handleErr(e, res);
- }
- }
- },
- }
-}
\ No newline at end of file
+ await manager.deleteService(id);
+
+ res.status(200).json({ status: 200, message: "Service deleted." });
+ },
+ },
+ };
+}
diff --git a/src/router/v1/service/listRoute.ts b/src/router/v1/service/listRoute.ts
index 27cb1f2..4b06c19 100644
--- a/src/router/v1/service/listRoute.ts
+++ b/src/router/v1/service/listRoute.ts
@@ -1,62 +1,83 @@
-import {AppContext} from "../../../app";
-import {RouterHandler} from "../../index";
-import {ListServicesOptions} from "@nsm/engine";
+import { AppContext } from "../../../app";
+import { RouterHandler } from "../../index";
+import { ListServicesOptions } from "@nsm/engine";
import z from "zod";
-export default async function ({manager, database}: AppContext): Promise {
- return {
- url: '/servicelist',
- routes: {
- post: async (req, res) => {
- const page = req.body.page ?? 0;
- const pageSize = req.body.pageSize ?? 10;
- const meta = req.body.meta;
- if (typeof page !== 'number' || typeof pageSize !== 'number' || page < 0 || pageSize < 1) {
- res.status(400).json({status: 400, message: 'Invalid page or pageSize.'}).end();
- return;
- }
+export default async function ({
+ manager,
+ database,
+}: AppContext): Promise {
+ return {
+ url: "/servicelist",
+ routes: {
+ post: async (req, res) => {
+ const page = req.body.page ?? 0;
+ const pageSize = req.body.pageSize ?? 10;
+ const meta = req.body.meta;
+ if (
+ typeof page !== "number" ||
+ typeof pageSize !== "number" ||
+ page < 0 ||
+ pageSize < 1
+ ) {
+ res
+ .status(400)
+ .json({ status: 400, message: "Invalid page or pageSize." })
+ .end();
+ return;
+ }
- // Options for the query
- const listOptions: ListServicesOptions = {
- page,
- pageSize,
- };
+ // Options for the query
+ const listOptions: ListServicesOptions = {
+ page,
+ pageSize,
+ };
- // Meta is optional in req body
- if (meta) {
- // Validate and parse meta
- const metaParse = z
- .object({})
- // Pass unrecognized keys
- .passthrough()
- .refine((data) => {
- // Allow only primitives (no nested objects)
- return Object.keys(data).every((key) => (typeof data[key]) !== "object")
- }, {
- message: "Meta should contain only primitives."
- })
- .safeParse(meta);
- if (metaParse.success) {
- listOptions.filter = { meta: metaParse.data };
- } else {
- res.status(400)
- .json({status: 400, message: 'Invalid meta filter format.', error: metaParse.error})
- .end();
- return;
- }
- }
+ // Meta is optional in req body
+ if (meta) {
+ // Validate and parse meta
+ const metaParse = z
+ .object({})
+ // Pass unrecognized keys
+ .passthrough()
+ .refine(
+ (data) => {
+ // Allow only primitives (no nested objects)
+ return Object.keys(data).every(
+ (key) => typeof data[key] !== "object",
+ );
+ },
+ {
+ message: "Meta should contain only primitives.",
+ },
+ )
+ .safeParse(meta);
+ if (metaParse.success) {
+ listOptions.filter = { meta: metaParse.data };
+ } else {
+ res
+ .status(400)
+ .json({
+ status: 400,
+ message: "Invalid meta filter format.",
+ error: metaParse.error,
+ })
+ .end();
+ return;
+ }
+ }
- // Response body
- const data = {
- services: await manager.listServices(listOptions),
- meta: {
- ...listOptions,
- // Total num of services on this node
- total: await database.permaRepository.countPerma(manager.nodeId),
- }
- };
- res.status(200).json(data).end();
- }
- },
- }
-}
\ No newline at end of file
+ // Response body
+ const data = {
+ services: await manager.listServices(listOptions),
+ meta: {
+ ...listOptions,
+ // Total num of services on this node
+ total: await database.permaRepository.countPerma(manager.nodeId),
+ },
+ };
+ res.status(200).json(data).end();
+ },
+ },
+ };
+}
diff --git a/src/router/v1/service/logsRoute.ts b/src/router/v1/service/logsRoute.ts
index 61ea880..c957e85 100644
--- a/src/router/v1/service/logsRoute.ts
+++ b/src/router/v1/service/logsRoute.ts
@@ -1,72 +1,58 @@
-import {AppContext} from "@nsm/app";
-import {RouterHandler} from "@nsm/router";
-import {ListRecordsArgs} from "@nsm/database";
-import {checkServiceExists} from "@nsm/router/util/preconditions";
+import { AppContext } from "@nsm/app";
+import { RouterHandler } from "@nsm/router";
+import {ServiceWasNeverActiveError} from "@nsm/engine/error";
+import {ServiceLogRecordModel} from "@nsm/database";
-export default async function(ctx: AppContext): Promise {
+export default async function (ctx: AppContext): Promise {
return {
- url: '/service/:id/logs',
+ url: "/service/:id/logs",
routes: {
get: async (req, res) => {
const id = req.params.id;
if (!id) {
- res.status(400).json({status: 400, message: 'Required \'id\' field not present in the body.'});
- return;
- }
- if (!await checkServiceExists(id, ctx.manager, res)) {
+ res
+ .status(400)
+ .json({
+ status: 400,
+ message: "Required 'id' field not present in the body.",
+ });
return;
}
- let sessionId: string;
-
- const runningService = ctx.manager.getRunningService(id);
- if (runningService) {
- // Service currently running, we can use logs from the current session
- sessionId = runningService.session.id;
- } else {
- // Service not running, so we need to retrieve last session ID
- const lastSession = await ctx.sessionManager.listSessions({
- filter: { serviceId: id },
- sort: { by: "startedAt", direction: "desc" },
- page: { index: 0, size: 1 }
+ // Use pagination only if it was requested by params
+ const page =
+ req.query.pageIndex || req.query.pageSize
+ ? {
+ index: req.query.pageIndex ? Number(req.query.pageIndex) : 0,
+ size: req.query.pageSize ? Number(req.query.pageSize) : 10,
+ }
+ : undefined;
+
+ let logs: ServiceLogRecordModel[];
+ try {
+ const session = await ctx.manager.getLastSession(id);
+ logs = await ctx.sessionManager.listSessionLogs({
+ filter: {
+ sessionId: session.id,
+ },
+ sort: {
+ by: "timestamp",
+ direction: "asc",
+ },
+ page,
});
- if (lastSession && lastSession.length > 0) {
- sessionId = lastSession[0].id;
+ } catch (e) {
+ if (e instanceof ServiceWasNeverActiveError) {
+ logs = [];
+ } else {
+ throw e;
}
}
- if (!sessionId) {
- res.status(400).json({status: 400, message: 'Service was never active.'});
- return;
- }
-
- const pageIndex = req.query.pageIndex ? Number(req.query.pageIndex) : 0;
- const pageSize = req.query.pageSize ? Number(req.query.pageSize) : 10;
-
- // Use pagination only if it was requested by params
- const page = req.query.pageIndex || req.query.pageSize
- ? (
- {
- index: pageIndex,
- size: pageSize
- }
- )
- : undefined;
-
- const args: ListRecordsArgs = {
- filter: {
- sessionId
- },
- sort: {
- by: "timestamp",
- direction: "asc"
- },
- page
- };
- const logs = await ctx.sessionManager.listSessionLogs(args);
-
- res.status(200).json({ logs });
- }
- }
- }
-}
\ No newline at end of file
+ res.status(200).json({
+ logs
+ });
+ },
+ },
+ };
+}
diff --git a/src/router/v1/service/lookupRoute.ts b/src/router/v1/service/lookupRoute.ts
index f9d10ec..3c18a82 100644
--- a/src/router/v1/service/lookupRoute.ts
+++ b/src/router/v1/service/lookupRoute.ts
@@ -1,45 +1,50 @@
-import {AppContext} from "@nsm/app";
-import {RouterHandler} from "../../index";
+import { AppContext } from "@nsm/app";
+import { RouterHandler } from "../../index";
-export default async function ({manager}: AppContext): Promise {
- return {
- url: '/service/:id',
- routes: {
- get: async (req, res) => {
- const id = req.params.id;
+export default async function ({
+ manager,
+}: AppContext): Promise {
+ return {
+ url: "/service/:id",
+ routes: {
+ get: async (req, res) => {
+ const id = req.params.id;
- const service = await manager.getService(id, { includeSession: true });
- if (!service) {
- res.status(404).json({status: 404, message: 'Invalid service ID.'}).end();
- return;
- }
+ const service = await manager.getService(id, { includeSession: true });
+ if (!service) {
+ res
+ .status(404)
+ .json({ status: 404, message: "Invalid service ID." })
+ .end();
+ return;
+ }
- const session = service.internalSession;
- let stats: any;
- if (session && req.query.stats === 'true') {
- stats = await manager.engine.stat(session.containerId);
- } else {
- stats = null;
- }
+ const session = service.internalSession;
+ let stats: any;
+ if (session && req.query.stats === "true") {
+ stats = await manager.engine.stat(session.containerId);
+ } else {
+ stats = null;
+ }
- const data: any = {
- id: service.serviceId,
- templateId: service.template,
- state: service.state,
- port: service.port,
- options: service.options,
- env: service.env
- };
- if (session) {
- data.session = {
- id: service.session.id,
- startedAt: service.session.startedAt.getTime(),
- stats,
- };
- }
+ const data: any = {
+ id: service.serviceId,
+ templateId: service.template,
+ state: service.state,
+ port: service.port,
+ options: service.options,
+ env: service.env,
+ };
+ if (session) {
+ data.session = {
+ id: service.session.id,
+ startedAt: service.session.startedAt.getTime(),
+ stats,
+ };
+ }
- res.json(data).end();
- },
- },
- }
-}
\ No newline at end of file
+ res.json(data).end();
+ },
+ },
+ };
+}
diff --git a/src/router/v1/service/optionsRoute.ts b/src/router/v1/service/optionsRoute.ts
index dfa4401..105aeba 100644
--- a/src/router/v1/service/optionsRoute.ts
+++ b/src/router/v1/service/optionsRoute.ts
@@ -1,31 +1,50 @@
-import {AppContext} from "../../../app";
-import {RouterHandler} from "../../index";
+import { AppContext } from "../../../app";
+import { RouterHandler } from "../../index";
-export default async function ({manager}: AppContext): Promise {
- return {
- url: '/service/:id/options',
- routes: {
- post: async (req, res) => {
- const id = req.params.id;
- if (!id) {
- res.status(400).json({status: 400, message: 'Required \'id\' field not present in the body.'});
- return;
- }
- const options = req.body;
- if (!options) {
- res.status(400).json({status: 400, message: 'Body is required.'});
- return;
- }
- if (Object.keys(options).includes('port') || Object.keys(options).includes('ports')) {
- res.status(400).json({status: 400, message: 'Port(s) cannot be changed yet.'});
- return;
- }
- if (await manager.updateOptions(id, options)) {
- res.status(200).json({status: 200, message: 'Service options updated.'});
- } else {
- res.status(404).json({status: 404, message: 'Service not found or unknown error occured.'});
- }
- }
- },
- }
-}
\ No newline at end of file
+export default async function ({
+ manager,
+}: AppContext): Promise {
+ return {
+ url: "/service/:id/options",
+ routes: {
+ post: async (req, res) => {
+ const id = req.params.id;
+ if (!id) {
+ res
+ .status(400)
+ .json({
+ status: 400,
+ message: "Required 'id' field not present in the body.",
+ });
+ return;
+ }
+ const options = req.body;
+ if (!options) {
+ res.status(400).json({ status: 400, message: "Body is required." });
+ return;
+ }
+ if (
+ Object.keys(options).includes("port") ||
+ Object.keys(options).includes("ports")
+ ) {
+ res
+ .status(400)
+ .json({ status: 400, message: "Port(s) cannot be changed yet." });
+ return;
+ }
+ if (await manager.updateOptions(id, options)) {
+ res
+ .status(200)
+ .json({ status: 200, message: "Service options updated." });
+ } else {
+ res
+ .status(404)
+ .json({
+ status: 404,
+ message: "Service not found or unknown error occured.",
+ });
+ }
+ },
+ },
+ };
+}
diff --git a/src/router/v1/service/powerStatusRoute.ts b/src/router/v1/service/powerStatusRoute.ts
index c2cce21..e3db385 100644
--- a/src/router/v1/service/powerStatusRoute.ts
+++ b/src/router/v1/service/powerStatusRoute.ts
@@ -1,30 +1,37 @@
-import {AppContext} from "../../../app";
-import {RouterHandler} from "../../index";
-import {isServicePending} from "@nsm/engine/asyncp";
+import { AppContext } from "../../../app";
+import { RouterHandler } from "../../index";
+import { isServicePending } from "@nsm/engine/asyncp";
-export default async function ({manager}: AppContext): Promise {
- return {
- url: '/service/:id/powerstatus',
- routes: {
- get: async (req, res) => {
- const id = req.params.id;
- if (!id) {
- res.status(400).json({status: 400, message: 'Required \'id\' field not present in the body.'});
- return;
- }
- let status = 'IDLE';
- let error = undefined;
- if (isServicePending(id)) {
- status = 'PENDING';
- } else {
- const err = manager.getLastPowerError(id);
- if (err) {
- status = 'ERROR';
- error = err;
- }
- }
- res.status(200).json({ id, status, error }).end();
- }
- },
- }
-}
\ No newline at end of file
+export default async function ({
+ manager,
+}: AppContext): Promise {
+ return {
+ url: "/service/:id/powerstatus",
+ routes: {
+ get: async (req, res) => {
+ const id = req.params.id;
+ if (!id) {
+ res
+ .status(400)
+ .json({
+ status: 400,
+ message: "Required 'id' field not present in the body.",
+ });
+ return;
+ }
+ let status = "IDLE";
+ let error = undefined;
+ if (isServicePending(id)) {
+ status = "PENDING";
+ } else {
+ const err = manager.getLastPowerError(id);
+ if (err) {
+ status = "ERROR";
+ error = err;
+ }
+ }
+ res.status(200).json({ id, status, error }).end();
+ },
+ },
+ };
+}
diff --git a/src/router/v1/service/rebootRoute.ts b/src/router/v1/service/rebootRoute.ts
index 9820372..78628e9 100644
--- a/src/router/v1/service/rebootRoute.ts
+++ b/src/router/v1/service/rebootRoute.ts
@@ -1,45 +1,58 @@
-import {AppContext} from "@nsm/app";
-import {RouterHandler} from "../../index";
-import {checkServiceExists, checkServicePending} from "@nsm/router/util/preconditions";
-import {consumeEnginePowerAction} from "@nsm/helpers";
+import { AppContext } from "@nsm/app";
+import { RouterHandler } from "../../index";
+import {KnownError, ServiceNotRunningError} from "@nsm/engine/error";
-export default async function ({manager, logger}: AppContext): Promise {
- return {
- url: '/service/:id/reboot',
- routes: {
- post: async (req, res) => {
- const id = req.params.id;
- if (!id) {
- res.status(400).json({status: 400, message: 'Required \'id\' field not present in the body.'});
- return;
- }
- if (!await checkServiceExists(id, manager, res)) {
- return;
- }
- if (!checkServicePending(id, res)) {
- return;
- }
+export default async function ({
+ manager,
+}: AppContext): Promise {
+ return {
+ url: "/service/:id/reboot",
+ routes: {
+ post: async (req, res) => {
+ const id = req.params.id;
+ const isForce = req.query.force === "true";
+ if (!id) {
+ res
+ .status(400)
+ .json({
+ status: 400,
+ message: "Required 'id' field not present in the body.",
+ });
+ return;
+ }
- consumeEnginePowerAction(() => (
- manager.stopService(id)
- .then(() => {
- // Service stopped successfully, now wait for it to be unlocked before resuming.
+ let promise: Promise;
+ try {
+ const task = await manager.stopService(id, isForce);
+ promise = task.promise;
+ } catch (e) {
+ if (e instanceof ServiceNotRunningError) {
+ // not running, just start it
+ promise = Promise.resolve();
+ } else {
+ throw e;
+ }
+ }
+ promise.then(async () => {
+ try {
+ const task = await manager.resumeService(id);
- manager.whenUnlocked(id, (_, __, err) => {
- if (err) {
- logger.error(err);
- } else {
- manager.resumeService(id);
- }
- });
- })
- ));
-
- res.status(200).json({
- status: 200,
- message: 'Service reboot action successfully registered to be completed in a moment.'
- });
+ await task.promise;
+ } catch (e) {
+ // just log
+ if (e instanceof KnownError) {
+ console.error("Error while resuming service after reboot ", e.message);
+ } else {
+ console.error("Error while resuming service after reboot", e);
}
- },
- }
-}
\ No newline at end of file
+ }
+ });
+
+ res.status(200).json({
+ status: 200,
+ message: "Service reboot action scheduled.",
+ });
+ },
+ },
+ };
+}
diff --git a/src/router/v1/service/resumeRoute.ts b/src/router/v1/service/resumeRoute.ts
index 54e8e9f..7140bbc 100644
--- a/src/router/v1/service/resumeRoute.ts
+++ b/src/router/v1/service/resumeRoute.ts
@@ -1,37 +1,32 @@
-import {AppContext} from "@nsm/app";
-import {RouterHandler} from "../../index";
-import {checkServiceExists, checkServicePending} from "@nsm/router/util/preconditions";
-import {consumeEnginePowerAction} from "@nsm/helpers";
+import { AppContext } from "@nsm/app";
+import { RouterHandler } from "../../index";
-export default async function ({manager}: AppContext): Promise {
- return {
- url: '/service/:id/resume',
- routes: {
- post: async (req, res) => {
- const id = req.params.id;
- if (!id) {
- res.status(400).json({status: 400, message: 'Required \'id\' field not present in the body.'});
- return;
- }
- if (!await checkServiceExists(id, manager, res)) {
- return;
- }
- if (!checkServicePending(id, res)) {
- return;
- }
- if (manager.isRunning(id)) {
- res.status(409).json({status: 400, message: 'Service is already running.'});
- return;
- }
+export default async function ({
+ manager,
+}: AppContext): Promise {
+ return {
+ url: "/service/:id/resume",
+ routes: {
+ post: async (req, res) => {
+ const id = req.params.id;
+ if (!id) {
+ res
+ .status(400)
+ .json({
+ status: 400,
+ message: "Required 'id' field not present in the body.",
+ });
+ return;
+ }
- consumeEnginePowerAction(() => manager.resumeService(id));
+ await manager.resumeService(id);
- res.status(200).json({
- status: 200,
- message: 'Service resume action successfully registered to be completed in a moment.',
- statusPath: '/v1/service/' + id + '/powerstatus',
- });
- }
- },
- }
-}
\ No newline at end of file
+ res.status(200).json({
+ status: 200,
+ message: "Service resumed.",
+ statusPath: "/v1/service/" + id + "/powerstatus",
+ });
+ },
+ },
+ };
+}
diff --git a/src/router/v1/service/sessionsRoute.ts b/src/router/v1/service/sessionsRoute.ts
index 8426652..d19ea63 100644
--- a/src/router/v1/service/sessionsRoute.ts
+++ b/src/router/v1/service/sessionsRoute.ts
@@ -1,11 +1,10 @@
-import {AppContext} from "@nsm/app";
-import {RouterHandler} from "@nsm/router";
-import {checkServiceExists} from "@nsm/router/util/preconditions";
-import {ListSessionsArgs} from "@nsm/database";
+import { AppContext } from "@nsm/app";
+import { RouterHandler } from "@nsm/router";
+import { checkServiceExists } from "@nsm/router/util/preconditions";
-export default async function(ctx: AppContext): Promise {
+export default async function (ctx: AppContext): Promise {
return {
- url: '/service/:id/sessions',
+ url: "/service/:id/sessions",
routes: {
get: async (req, res) => {
const id = req.params.id;
@@ -13,29 +12,28 @@ export default async function(ctx: AppContext): Promise {
const pageIndex = req.query.pageIndex ? Number(req.query.pageIndex) : 0;
const pageSize = req.query.pageSize ? Number(req.query.pageSize) : 10;
- if (!await checkServiceExists(id, ctx.manager, res)) {
+ if (!(await checkServiceExists(id, ctx.manager, res))) {
return;
}
- const args: ListSessionsArgs = {
- filter: {
- serviceId: id
- },
- sort: {
- by: "startedAt",
- direction: "desc"
- },
- page: {
- index: pageIndex,
- size: pageSize
- }
- };
- const sessionIds = await ctx.sessionManager
- .listSessions(args)
- .then(sessions => sessions.map(session => session.id));
-
- res.status(200).json({ sessions: sessionIds });
- }
- }
- }
-}
\ No newline at end of file
+ res.status(200).json({
+ sessions: await ctx.sessionManager
+ .listSessions({
+ filter: {
+ serviceId: id,
+ },
+ sort: {
+ by: "startedAt",
+ direction: "desc",
+ },
+ page: {
+ index: pageIndex,
+ size: pageSize,
+ },
+ })
+ .then((sessions) => sessions.map((session) => session.id))
+ });
+ },
+ },
+ };
+}
diff --git a/src/router/v1/service/stopCmdRoute.ts b/src/router/v1/service/stopCmdRoute.ts
deleted file mode 100644
index f11b90e..0000000
--- a/src/router/v1/service/stopCmdRoute.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import {AppContext} from "@nsm/app";
-import {RouterHandler} from "@nsm/router";
-import {isServicePending} from "@nsm/engine/asyncp";
-import {handleErr} from "@nsm/util/routes";
-import {checkServicePending} from "@nsm/router/util/preconditions";
-
-export default async function ({manager}: AppContext): Promise {
- return {
- url: '/service/:id/stopcmd',
- routes: {
- post: async (req, res) => {
- const id = req.params.id;
- if (!id) {
- res.status(400).json({status: 400, message: 'Required \'id\' field not present in the body.'});
- return;
- }
- if (!checkServicePending(id, res)) {
- return;
- }
- if (!await manager.getService(id)) {
- res.status(404).json({status: 404, message: 'Service not found.'});
- return;
- }
- try {
- const result = await manager.sendStopSignal(id);
- if (result) {
- res.status(200).json({status: 200, message: 'Service stop signal sent.'});
- } else {
- res.status(404).json({status: 404, message: 'Service not found or unknown error occured.'});
- }
- } catch (e) {
- handleErr(e, res);
- }
- }
- },
- }
-}
\ No newline at end of file
diff --git a/src/router/v1/service/stopRoute.ts b/src/router/v1/service/stopRoute.ts
index dd782a8..9857f0c 100644
--- a/src/router/v1/service/stopRoute.ts
+++ b/src/router/v1/service/stopRoute.ts
@@ -1,43 +1,33 @@
-import {AppContext} from "@nsm/app";
-import {RouterHandler} from "../../index";
-import {checkServiceExists, checkServicePending} from "@nsm/router/util/preconditions";
-import {consumeEnginePowerAction} from "@nsm/helpers";
+import { AppContext } from "@nsm/app";
+import { RouterHandler } from "../../index";
-export default async function ({manager, logger}: AppContext): Promise {
- return {
- url: '/service/:id/stop',
- routes: {
- post: async (req, res) => {
- const id = req.params.id;
- if (!id) {
- res.status(400).json({status: 400, message: 'Required \'id\' field not present in the body.'});
- return;
- }
- if (!await checkServiceExists(id, manager, res)) {
- return;
- }
- if (!checkServicePending(id, res)) {
- return;
- }
- if (!manager.isRunning(id)) {
- res.status(409).json({status: 400, message: 'Service is not running.'});
- return;
- }
+export default async function ({
+ manager,
+}: AppContext): Promise {
+ return {
+ url: "/service/:id/stop",
+ routes: {
+ post: async (req, res) => {
+ const id = req.params.id;
+ const isForce = req.query.force === "true";
+ if (!id) {
+ res
+ .status(400)
+ .json({
+ status: 400,
+ message: "Required 'id' field not present in the body.",
+ });
+ return;
+ }
- consumeEnginePowerAction(async () => {
- if (req.query.force === 'true') {
- await manager.stopServiceForcibly(id);
- } else {
- await manager.stopService(id)
- }
- });
+ await manager.stopService(id, isForce);
- res.status(200).json({
- status: 200,
- message: 'Service stop action successfully registered to be completed in a moment.',
- statusPath: '/v1/service/' + id + '/powerstatus',
- });
- }
- },
- }
-}
\ No newline at end of file
+ res.status(200).json({
+ status: 200,
+ message: "Service stop called.",
+ statusPath: "/v1/service/" + id + "/powerstatus",
+ });
+ },
+ },
+ };
+}
diff --git a/src/router/v1/session/sessionLogsRoute.ts b/src/router/v1/session/sessionLogsRoute.ts
index dc111bc..624c248 100644
--- a/src/router/v1/session/sessionLogsRoute.ts
+++ b/src/router/v1/session/sessionLogsRoute.ts
@@ -1,10 +1,9 @@
-import {AppContext} from "@nsm/app";
-import {RouterHandler} from "@nsm/router";
-import {ListRecordsArgs} from "@nsm/database";
+import { AppContext } from "@nsm/app";
+import { RouterHandler } from "@nsm/router";
-export default async function(ctx: AppContext): Promise {
+export default async function (ctx: AppContext): Promise {
return {
- url: '/session/:id/logs',
+ url: "/session/:id/logs",
routes: {
get: async (req, res) => {
const id = req.params.id;
@@ -13,29 +12,29 @@ export default async function(ctx: AppContext): Promise {
const pageSize = req.query.pageSize ? Number(req.query.pageSize) : 10;
// Use pagination only if it was requested by params
- const page = req.query.pageIndex || req.query.pageSize
- ? (
- {
- index: pageIndex,
- size: pageSize
- }
- )
- : undefined;
+ const page =
+ req.query.pageIndex || req.query.pageSize
+ ? {
+ index: pageIndex,
+ size: pageSize,
+ }
+ : undefined;
- const args: ListRecordsArgs = {
- filter: {
- sessionId: id
- },
- sort: {
- by: "timestamp",
- direction: "asc"
- },
- page
- };
- const logs = await ctx.sessionManager.listSessionLogs(args);
-
- res.status(200).json({ logs });
- }
- }
- }
-}
\ No newline at end of file
+ res
+ .status(200)
+ .json({
+ logs: await ctx.sessionManager.listSessionLogs({
+ filter: {
+ sessionId: id,
+ },
+ sort: {
+ by: "timestamp",
+ direction: "asc",
+ },
+ page,
+ })
+ });
+ },
+ },
+ };
+}
diff --git a/src/router/v1/status/index.ts b/src/router/v1/status/index.ts
index aa8497d..3388a02 100644
--- a/src/router/v1/status/index.ts
+++ b/src/router/v1/status/index.ts
@@ -1,48 +1,52 @@
-import {AppContext} from "@nsm/app";
-import {RouterHandler} from "../../index";
+import { AppContext } from "@nsm/app";
+import { RouterHandler } from "../../index";
import * as os from "os";
-import {Filters, ServiceManager} from "@nsm/engine";
-import {Database} from "@nsm/database";
+import { Filters, ServiceManager } from "@nsm/engine";
+import { Database } from "@nsm/database";
async function checkNsmResources(engine: ServiceManager, db: Database) {
- const stats = await engine.engine.statAll(Filters.node(engine.nodeId));
- const servicesGlobal = await db.permaRepository.listPerma(engine.nodeId);
- const res = stats.reduce((acc, s) => {
- acc.memory.used += s.memory.used;
- acc.memory.total += s.memory.total;
- acc.cpu.used += s.cpu.used;
- acc.cpu.total += s.cpu.total;
- return acc;
- }, {
- memory: {
- used: 0,
- total: 0,
- percent: 0,
- },
- cpu: {
- used: 0,
- total: 0,
- percent: 0,
- },
- services: { // TODO: Ukazuje stále 0???
- memTotal: BigInt(0),
- cpuTotal: BigInt(0),
- diskTotal: BigInt(0),
- },
- });
- for (const s of servicesGlobal) {
- const service = await engine.getService(s);
- res.services.memTotal += BigInt(service.optionsRam);
- res.services.cpuTotal += BigInt(service.optionsCpu);
- res.services.diskTotal += BigInt(service.optionsDisk);
- }
- if (res.memory.total > 0) {
- res.memory.percent = res.memory.used / res.memory.total;
- }
- if (res.cpu.total > 0) {
- res.cpu.percent = res.cpu.used / res.cpu.total;
- }
- return res;
+ const stats = await engine.engine.statAll(Filters.node(engine.nodeId));
+ const servicesGlobal = await db.permaRepository.listPerma(engine.nodeId);
+ const res = stats.reduce(
+ (acc, s) => {
+ acc.memory.used += s.memory.used;
+ acc.memory.total += s.memory.total;
+ acc.cpu.used += s.cpu.used;
+ acc.cpu.total += s.cpu.total;
+ return acc;
+ },
+ {
+ memory: {
+ used: 0,
+ total: 0,
+ percent: 0,
+ },
+ cpu: {
+ used: 0,
+ total: 0,
+ percent: 0,
+ },
+ services: {
+ // TODO: Ukazuje stále 0???
+ memTotal: BigInt(0),
+ cpuTotal: BigInt(0),
+ diskTotal: BigInt(0),
+ },
+ },
+ );
+ for (const s of servicesGlobal) {
+ const service = await engine.getService(s);
+ res.services.memTotal += BigInt(service.optionsRam);
+ res.services.cpuTotal += BigInt(service.optionsCpu);
+ res.services.diskTotal += BigInt(service.optionsDisk);
+ }
+ if (res.memory.total > 0) {
+ res.memory.percent = res.memory.used / res.memory.total;
+ }
+ if (res.cpu.total > 0) {
+ res.cpu.percent = res.cpu.used / res.cpu.total;
+ }
+ return res;
}
/**
@@ -51,29 +55,36 @@ async function checkNsmResources(engine: ServiceManager, db: Database) {
*
* @param context The app context
*/
-export default async function ({manager, appConfig, database}: AppContext): Promise {
- return {
- url: '/status',
- routes: {
- get: async (req, res) => {
- const nodeId = appConfig.getNodeId();
- const all = await database.permaRepository.listPerma(nodeId);
- const [free, size] = await manager.engine.calcHostUsage();
- const system = {
- totalmem: os.totalmem(),
- freemem: os.freemem(),
- totaldisk: size,
- freedisk: free,
- }
- res.json({
- nodeId,
- running: manager.getRunningServices()
- .map(s => s.id),
- all: all.length,
- system,
- ...(req.query.stats === 'true' ? { stats: await checkNsmResources(manager, database) } : {})
- }).end();
- },
- },
- }
-}
\ No newline at end of file
+export default async function ({
+ manager,
+ appConfig,
+ database,
+}: AppContext): Promise {
+ return {
+ url: "/status",
+ routes: {
+ get: async (req, res) => {
+ const nodeId = appConfig.getNodeId();
+ const all = await database.permaRepository.listPerma(nodeId);
+ const [free, size] = await manager.engine.calcHostUsage();
+ const system = {
+ totalmem: os.totalmem(),
+ freemem: os.freemem(),
+ totaldisk: size,
+ freedisk: free,
+ };
+ res
+ .json({
+ nodeId,
+ running: manager.getRunningServices().map((s) => s.id),
+ all: all.length,
+ system,
+ ...(req.query.stats === "true"
+ ? { stats: await checkNsmResources(manager, database) }
+ : {}),
+ })
+ .end();
+ },
+ },
+ };
+}
diff --git a/src/security/index.ts b/src/security/index.ts
index ee20b05..d46fa04 100644
--- a/src/security/index.ts
+++ b/src/security/index.ts
@@ -1,11 +1,11 @@
-import {AppContext} from "../app";
-import token from './token';
+import { AppContext } from "../app";
+import token from "./token";
export default async function (ctx: AppContext) {
- // This code block is initialized before app routes.
- if (ctx.appConfig.getAuth() == 'auth_token') {
- // Basic credentials auth type
- await token(ctx);
- }
- ctx.logger.info('Using ' + ctx.appConfig.getAuth() + ' auth.');
-}
\ No newline at end of file
+ // This code block is initialized before app routes.
+ if (ctx.appConfig.getAuth() == "auth_token") {
+ // Basic credentials auth type
+ await token(ctx);
+ }
+ ctx.logger.info("Using " + ctx.appConfig.getAuth() + " auth.");
+}
diff --git a/src/security/token/index.ts b/src/security/token/index.ts
index 0cd367e..62d7c35 100644
--- a/src/security/token/index.ts
+++ b/src/security/token/index.ts
@@ -1,29 +1,36 @@
-import {AppContext} from "../../app";
+import { AppContext } from "../../app";
import crypto from "crypto";
-export default async function ({database, router, logger}: AppContext) {
- const token_new = crypto.randomBytes(30).toString('hex');
- const token = process.env.NSM_TOKEN ?? await database.metaRepository.getMetaVal('auth:basic_token', token_new);
- router.use((req, res, next) => {
- if (!req.header('Authorization') || req.header('Authorization') != token) {
- res.status(401).json({ status: 401, message: 'Unauthorized. Invalid \'Authorization\' header.' });
- return;
- }
- next();
- });
- if (token == token_new) {
- setTimeout(() => {
- logger.info('==============================================');
- logger.info('Your Authorization token has been generated');
- logger.info('since you enabled \'auth_token\' authorization');
- logger.info('for the first time. Please copy it and keep safe.');
- logger.info('You will need to use it while requesting NSM.');
- logger.info('');
- logger.info('Token: ' + token);
- logger.info('==============================================');
- }, 500);
+export default async function ({ database, router, logger }: AppContext) {
+ const token_new = crypto.randomBytes(30).toString("hex");
+ const token =
+ process.env.NSM_TOKEN ??
+ (await database.metaRepository.getMetaVal("auth:basic_token", token_new));
+ router.use((req, res, next) => {
+ if (!req.header("Authorization") || req.header("Authorization") != token) {
+ res
+ .status(401)
+ .json({
+ status: 401,
+ message: "Unauthorized. Invalid 'Authorization' header.",
+ });
+ return;
}
- if (process.env.NSM_TOKEN) {
- logger.info('Authorization token loaded from env');
- }
-}
\ No newline at end of file
+ next();
+ });
+ if (token == token_new) {
+ setTimeout(() => {
+ logger.info("==============================================");
+ logger.info("Your Authorization token has been generated");
+ logger.info("since you enabled 'auth_token' authorization");
+ logger.info("for the first time. Please copy it and keep safe.");
+ logger.info("You will need to use it while requesting NSM.");
+ logger.info("");
+ logger.info("Token: " + token);
+ logger.info("==============================================");
+ }, 500);
+ }
+ if (process.env.NSM_TOKEN) {
+ logger.info("Authorization token loaded from env");
+ }
+}
diff --git a/src/server.ts b/src/server.ts
index 714751a..49d7de1 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -6,8 +6,8 @@ import temp from "temp";
// Pre
// toJSON() for BigInt to avoid JSON.stringify() errors
(BigInt.prototype as any).toJSON = function () {
- return this.toString();
-}
+ return this.toString();
+};
const server = ws(express()).app;
@@ -21,7 +21,7 @@ server.use(cors());
temp.track();
export function setStatus(status_: string) {
- status = status_;
+ status = status_;
}
-export default server;
\ No newline at end of file
+export default server;
diff --git a/src/util/clock.ts b/src/util/clock.ts
index c25f247..877fc88 100644
--- a/src/util/clock.ts
+++ b/src/util/clock.ts
@@ -1,10 +1,10 @@
export function clock() {
- const creationDate = Date.now();
+ const creationDate = Date.now();
- function durFromCreation() {
- return Date.now() - creationDate;
- }
- return {
- durFromCreation
- };
-}
\ No newline at end of file
+ function durFromCreation() {
+ return Date.now() - creationDate;
+ }
+ return {
+ durFromCreation,
+ };
+}
diff --git a/src/util/docker.ts b/src/util/docker.ts
index 4256748..b9f0b32 100644
--- a/src/util/docker.ts
+++ b/src/util/docker.ts
@@ -1,32 +1,38 @@
-import DockerClient, {ContainerStats} from "dockerode";
+import DockerClient, { ContainerStats } from "dockerode";
-function calcCpuUsage(precpu: DockerClient.CPUStats, cpu: DockerClient.CPUStats) {
- const cpu_delta = cpu?.cpu_usage.total_usage - precpu?.cpu_usage.total_usage;
- const system_cpu_delta = cpu?.system_cpu_usage - precpu?.system_cpu_usage;
- const number_cpus = cpu?.online_cpus;
- const result = (cpu_delta / system_cpu_delta) * number_cpus * 100.0;
- if (result == null) {
- return 0.0;
- } else {
- return result;
- }
+function calcCpuUsage(
+ precpu: DockerClient.CPUStats,
+ cpu: DockerClient.CPUStats,
+) {
+ const cpu_delta = cpu?.cpu_usage.total_usage - precpu?.cpu_usage.total_usage;
+ const system_cpu_delta = cpu?.system_cpu_usage - precpu?.system_cpu_usage;
+ const number_cpus = cpu?.online_cpus;
+ const result = (cpu_delta / system_cpu_delta) * number_cpus * 100.0;
+ if (result == null) {
+ return 0.0;
+ } else {
+ return result;
+ }
}
-export function adaptContainerStatsFromDocker(id: string, stats: ContainerStats) {
- const { memory_stats, precpu_stats, cpu_stats } = stats;
+export function adaptContainerStatsFromDocker(
+ id: string,
+ stats: ContainerStats,
+) {
+ const { memory_stats, precpu_stats, cpu_stats } = stats;
- return {
- id,
- memory: {
- used: memory_stats?.usage,
- total: memory_stats?.limit,
- percent: memory_stats?.usage / memory_stats?.limit,
- },
- cpu: {
- used: cpu_stats?.cpu_usage.total_usage,
- total: cpu_stats?.system_cpu_usage,
- //percent: cpu_stats.cpu_usage.total_usage / cpu_stats.system_cpu_usage,
- percent: calcCpuUsage(precpu_stats, cpu_stats),
- },
- }
-}
\ No newline at end of file
+ return {
+ id,
+ memory: {
+ used: memory_stats?.usage,
+ total: memory_stats?.limit,
+ percent: memory_stats?.usage / memory_stats?.limit,
+ },
+ cpu: {
+ used: cpu_stats?.cpu_usage.total_usage,
+ total: cpu_stats?.system_cpu_usage,
+ //percent: cpu_stats.cpu_usage.total_usage / cpu_stats.system_cpu_usage,
+ percent: calcCpuUsage(precpu_stats, cpu_stats),
+ },
+ };
+}
diff --git a/src/util/env.ts b/src/util/env.ts
index 59e8075..7d5b049 100644
--- a/src/util/env.ts
+++ b/src/util/env.ts
@@ -1,12 +1,14 @@
export function env(env: string[], options?: { required?: boolean }) {
- const values = env.map(k => k in process.env ? process.env[k] : undefined);
- const missing = values
- .map((v, i) => [v, i])
- .filter(([v]) => !v)
- .map(([_, i]) => env[i]);
- const required = options?.required ?? true;
- if (missing.length > 0 && required == true) {
- throw new Error('Missing env variables: ' + missing.join(', '));
- }
- return values;
-}
\ No newline at end of file
+ const values = env.map((k) =>
+ k in process.env ? process.env[k] : undefined,
+ );
+ const missing = values
+ .map((v, i) => [v, i])
+ .filter(([v]) => !v)
+ .map(([_, i]) => env[i]);
+ const required = options?.required ?? true;
+ if (missing.length > 0 && required == true) {
+ throw new Error("Missing env variables: " + missing.join(", "));
+ }
+ return values;
+}
diff --git a/src/util/port.ts b/src/util/port.ts
index 94a0063..fbe04e1 100644
--- a/src/util/port.ts
+++ b/src/util/port.ts
@@ -1,40 +1,48 @@
-import net from 'net';
-import {ServiceEngine} from "@nsm/engine";
+import net from "net";
+import { ServiceEngine } from "@nsm/engine";
-export async function isPortAvailable(engine: ServiceEngine, port: number, a_ports: number[] = undefined) {
- if (a_ports === undefined) {
- a_ports = await engine.listAttachedPorts();
- }
- if (a_ports.includes(port)) {
- return false;
- }
- const server = net.createServer();
- return new Promise(resolve => {
- server.once('error', () => {
- resolve(false);
- });
- server.once('listening', () => {
- server.close();
- resolve(true);
- });
- server.listen(port);
+export async function isPortAvailable(
+ engine: ServiceEngine,
+ port: number,
+ a_ports: number[] = undefined,
+) {
+ if (a_ports === undefined) {
+ a_ports = await engine.listAttachedPorts();
+ }
+ if (a_ports.includes(port)) {
+ return false;
+ }
+ const server = net.createServer();
+ return new Promise((resolve) => {
+ server.once("error", () => {
+ resolve(false);
+ });
+ server.once("listening", () => {
+ server.close();
+ resolve(true);
});
+ server.listen(port);
+ });
}
-export async function randomPort(engine: ServiceEngine, from: number, to: number) {
- const checked = [];
- const all = await engine.listAttachedPorts();
- while (true) {
- const port = Math.floor(Math.random() * (to - from) + from);
- if (checked.includes(port)) {
- continue;
- }
- if (await isPortAvailable(engine, port, all)) {
- return port;
- }
- if (checked.length === to - from) {
- throw new Error('No available ports');
- }
- checked.push(port);
+export async function randomPort(
+ engine: ServiceEngine,
+ from: number,
+ to: number,
+) {
+ const checked = [];
+ const all = await engine.listAttachedPorts();
+ while (true) {
+ const port = Math.floor(Math.random() * (to - from) + from);
+ if (checked.includes(port)) {
+ continue;
}
-}
\ No newline at end of file
+ if (await isPortAvailable(engine, port, all)) {
+ return port;
+ }
+ if (checked.length === to - from) {
+ throw new Error("No available ports");
+ }
+ checked.push(port);
+ }
+}
diff --git a/src/util/promises.ts b/src/util/promises.ts
index f59b74a..e892b19 100644
--- a/src/util/promises.ts
+++ b/src/util/promises.ts
@@ -1,9 +1,15 @@
+export class AsyncTask {
+ constructor(
+ public readonly promise: Promise,
+ ) {}
+}
+
export async function resolveSequentially(...funcs: any[]) {
- for (const func of funcs) {
- if (typeof func == "function") {
- await (func());
- } else {
- await (func as Promise);
- }
+ for (const func of funcs) {
+ if (typeof func == "function") {
+ await func();
+ } else {
+ await (func as Promise);
}
-}
\ No newline at end of file
+ }
+}
diff --git a/src/util/routes.ts b/src/util/routes.ts
index 8d94f88..8e33693 100644
--- a/src/util/routes.ts
+++ b/src/util/routes.ts
@@ -1,17 +1,17 @@
export function handleErr(e: any, res: any) {
- if (e.code) {
- switch (e.code) {
- case 2:
- res.status(409).json({status: 409, message: e.message});
- return;
- case 3:
- res.status(404).json({status: 404, message: e.message});
- return;
- }
+ if (e.code) {
+ switch (e.code) {
+ case 2:
+ res.status(409).json({ status: 409, message: e.message });
+ return;
+ case 3:
+ res.status(404).json({ status: 404, message: e.message });
+ return;
}
- res.status(500).json({status: 500, message: e.message});
+ }
+ res.status(500).json({ status: 500, message: e.message });
}
export function handleErrorMessage(status: number, message: string, res: any) {
- res.status(status).json({status, message});
-}
\ No newline at end of file
+ res.status(status).json({ status, message });
+}
diff --git a/src/util/services.ts b/src/util/services.ts
index 127294e..c5a8425 100644
--- a/src/util/services.ts
+++ b/src/util/services.ts
@@ -1,11 +1,11 @@
export type NSMObjectLabels = {
- id: string,
-}
+ id: string;
+};
// Default labels to use in docker engine objects produced by NSM
export function constructObjectLabels({ id }: NSMObjectLabels) {
- return {
- 'nsm': 'true',
- 'nsm.id': id,
- }
-}
\ No newline at end of file
+ return {
+ nsm: "true",
+ "nsm.id": id,
+ };
+}
diff --git a/src/util/yaml.ts b/src/util/yaml.ts
index f735d38..3943001 100644
--- a/src/util/yaml.ts
+++ b/src/util/yaml.ts
@@ -2,5 +2,5 @@ import * as fs from "fs";
import YAML from "yaml";
export function loadYamlFile(path: string) {
- return YAML.parse(fs.readFileSync(path, 'utf8'));
-}
\ No newline at end of file
+ return YAML.parse(fs.readFileSync(path, "utf8"));
+}
diff --git a/tests/api/api.test.ts b/tests/api/api.test.ts
index 4e79651..32c7cba 100644
--- a/tests/api/api.test.ts
+++ b/tests/api/api.test.ts
@@ -1,223 +1,231 @@
import server from "@nsm/server";
-import {init as boot, AppBootContext, AppBootOptions} from "@nsm/app";
+import { init as boot, AppBootContext, AppBootOptions } from "@nsm/app";
import request from "supertest";
-import {afterAll, beforeAll, describe, expect, test} from "@jest/globals";
-import {isServicePending} from "@nsm/engine/asyncp";
-import {log} from "console";
+import { afterAll, beforeAll, describe, expect, test } from "@jest/globals";
+import { isServicePending } from "@nsm/engine/asyncp";
+import { log } from "console";
function expectProps(obj: any, model: any[]) {
- for (let i = 0; i < model.length; i += 2) {
- if (model[i + 1]) {
- expect(obj).toHaveProperty(model[i], model[i + 1]);
- } else {
- expect(obj).toHaveProperty(model[i]);
- }
+ for (let i = 0; i < model.length; i += 2) {
+ if (model[i + 1]) {
+ expect(obj).toHaveProperty(model[i], model[i + 1]);
+ } else {
+ expect(obj).toHaveProperty(model[i]);
}
+ }
}
async function miniService(ctx: AppBootContext) {
- const id = await ctx.manager.createService("test", {});
- await ctx.manager.resumeService(id);
-
- do {
- await new Promise((resolve) => {
- setTimeout(() => resolve(null), 300);
- });
- } while (isServicePending(id));
- // Status check
- if (ctx.manager.getLastPowerError(id)) {
- return undefined;
- } else {
- return id;
- }
+ const id = await ctx.manager.createService("test", {});
+ await ctx.manager.resumeService(id);
+
+ do {
+ await new Promise((resolve) => {
+ setTimeout(() => resolve(null), 300);
+ });
+ } while (isServicePending(id));
+ // Status check
+ if (ctx.manager.getLastPowerError(id)) {
+ return undefined;
+ } else {
+ return id;
+ }
}
-async function stopMini(ctx: AppBootContext, id: string) {
- await ctx.manager.stopService(id);
- await ctx.manager.waitForBusyAction(id); // Await stop
+async function killMini(ctx: AppBootContext, id: string) {
+ await ctx.manager.stopService(id, true);
+ await ctx.manager.waitForStopped(id);
}
describe("Test v1 API models", () => {
- let ctx: AppBootContext|undefined = undefined;
-
- beforeAll((done) => {
- const options: AppBootOptions = {
- test: true,
- disableWorkers: true,
- };
- boot(server, options).then((ctx_) => {
- ctx = ctx_;
- done();
- }).catch(err => {
- done(err);
- });
- }, 20000);
-
- test("Test /v1/status", async () => {
- const res = await request(server).get("/v1/status");
- expect(res.status).toBe(200);
- expectProps(res.body, [
- 'nodeId', undefined,
- 'running', undefined,
- 'all', undefined,
- 'system.totalmem', undefined,
- 'system.freemem', undefined,
- 'system.totaldisk', undefined,
- 'system.freedisk', undefined,
- ]);
- });
-
- test("Test /v1/status to have service in running", async () => {
- const id = await miniService(ctx);
- const res = await request(server).get("/v1/status");
- expect(res.status).toBe(200);
- expect(res.body.running).toContain(id);
- }, 60000);
-
- test("Test /v1/status?stats=true", async () => {
- const res = await request(server).get("/v1/status?stats=true");
- expect(res.status).toBe(200);
- expectProps(res.body, [
- 'stats.memory.used', undefined,
- 'stats.memory.total', undefined,
- 'stats.memory.percent', undefined,
- 'stats.cpu.used', undefined,
- 'stats.cpu.total', undefined,
- 'stats.cpu.percent', undefined,
- 'stats.services.memTotal', undefined,
- 'stats.services.cpuTotal', undefined,
- 'stats.services.diskTotal', undefined,
- ]);
- }, 60000);
-
- test("Test /v1/servicelist", async () => {
- const res = await request(server)
- .post("/v1/servicelist")
- .send({ page: 0, pageSize: 10 });
- expect(res.status).toBe(200);
- expectProps(res.body, [
- 'services', undefined,
- 'meta.page', 0,
- 'meta.pageSize', 10,
- 'meta.total', 0,
- ]);
- });
-
- test("Test /v1/servicelist right size", async () => {
- await miniService(ctx);
- await miniService(ctx);
- const res = await request(server)
- .post("/v1/servicelist")
- .send({ page: 0, pageSize: 1 });
- expect(res.status).toBe(200);
- expect(res.body.services).toHaveLength(1);
- });
-
- test("Test /v1/service/{serviceId}", async () => {
- const id = await miniService(ctx);
- log(id);
- const res = await request(server).get("/v1/service/" + id);
- expect(res.status).toBe(200);
- expectProps(res.body, [
- "id", id,
- "templateId", "test",
- "port", undefined,
- "options", undefined,
- "env", undefined,
- "session.id", undefined,
- "session.startedAt", undefined,
- ]);
- }, 20000);
-
- test("Test /v1/service/{serviceId}/resume", async () => {
- const id = await miniService(ctx);
- log(id);
- await stopMini(ctx, id);
- const res = await request(server)
- .post("/v1/service/" + id + "/resume");
- expect(res.status).toBe(200);
- expectProps(res.body, [
- "status", 200,
- "message", undefined,
- ]);
- }, 30000);
-
- // TODO: /v1/service//resume
-
- test("Test /v1/service/{serviceId}/stop", async () => {
- const id = await miniService(ctx);
- log(id);
- const res = await request(server)
- .post("/v1/service/" + id + "/stop");
- expect(res.status).toBe(200);
- expectProps(res.body, [
- "status", 200,
- "message", undefined,
- ]);
- }, 20000);
-
- test("Test /v1/service/{serviceId}/delete", async () => {
- const id = await miniService(ctx);
- log(id);
- const res = await request(server)
- .post("/v1/service/" + id + "/delete");
- expect(res.status).toBe(200);
- expectProps(res.body, [
- "status", 200,
- "message", undefined,
- ]);
- }, 20000);
-
- test("Test /v1/service/{serviceId}/reboot", async () => {
- const id = await miniService(ctx);
- log(id);
- const res = await request(server)
- .post("/v1/service/" + id + "/reboot");
- expect(res.status).toBe(200);
- expectProps(res.body, [
- "status", 200,
- "message", undefined,
- ]);
- // Wait for it to be started
- await new Promise((resolve, reject) => {
- ctx.manager.on('resume', (event) => {
- if (event.id == id) {
- if (event.error) {
- reject(event.error);
- } else {
- resolve(null);
- }
- return true;
- }
- });
- });
- }, 20000);
-
- test("Test /v1/service/{serviceId}/powerstatus", async () => {
- const id = await miniService(ctx);
- log(id);
- const res = await request(server)
- .get("/v1/service/" + id + "/powerstatus");
- expect(res.status).toBe(200);
- expectProps(res.body, [
- "id", id,
- "status", "IDLE",
- ]);
- }, 20000);
-
- afterAll(() => {
- if (!ctx) {
- return;
+ let ctx: AppBootContext | undefined = undefined;
+
+ beforeAll((done) => {
+ const options: AppBootOptions = {
+ test: true,
+ };
+ boot(server, options)
+ .then((ctx_) => {
+ ctx = ctx_;
+ done();
+ })
+ .catch((err) => {
+ done(err);
+ });
+ }, 20000);
+
+ test("Test /v1/status", async () => {
+ const res = await request(server).get("/v1/status");
+ expect(res.status).toBe(200);
+ expectProps(res.body, [
+ "nodeId",
+ undefined,
+ "running",
+ undefined,
+ "all",
+ undefined,
+ "system.totalmem",
+ undefined,
+ "system.freemem",
+ undefined,
+ "system.totaldisk",
+ undefined,
+ "system.freedisk",
+ undefined,
+ ]);
+ });
+
+ test("Test /v1/status to have service in running", async () => {
+ const id = await miniService(ctx);
+ const res = await request(server).get("/v1/status");
+ expect(res.status).toBe(200);
+ expect(res.body.running).toContain(id);
+ }, 60000);
+
+ test("Test /v1/status?stats=true", async () => {
+ const res = await request(server).get("/v1/status?stats=true");
+ expect(res.status).toBe(200);
+ expectProps(res.body, [
+ "stats.memory.used",
+ undefined,
+ "stats.memory.total",
+ undefined,
+ "stats.memory.percent",
+ undefined,
+ "stats.cpu.used",
+ undefined,
+ "stats.cpu.total",
+ undefined,
+ "stats.cpu.percent",
+ undefined,
+ "stats.services.memTotal",
+ undefined,
+ "stats.services.cpuTotal",
+ undefined,
+ "stats.services.diskTotal",
+ undefined,
+ ]);
+ }, 60000);
+
+ test("Test /v1/servicelist", async () => {
+ const res = await request(server)
+ .post("/v1/servicelist")
+ .send({ page: 0, pageSize: 10 });
+ expect(res.status).toBe(200);
+ expectProps(res.body, [
+ "services",
+ undefined,
+ "meta.page",
+ 0,
+ "meta.pageSize",
+ 10,
+ "meta.total",
+ 0,
+ ]);
+ });
+
+ test("Test /v1/servicelist right size", async () => {
+ await miniService(ctx);
+ await miniService(ctx);
+ const res = await request(server)
+ .post("/v1/servicelist")
+ .send({ page: 0, pageSize: 1 });
+ expect(res.status).toBe(200);
+ expect(res.body.services).toHaveLength(1);
+ });
+
+ test("Test /v1/service/{serviceId}", async () => {
+ const id = await miniService(ctx);
+ log(id);
+ const res = await request(server).get("/v1/service/" + id);
+ expect(res.status).toBe(200);
+ expectProps(res.body, [
+ "id",
+ id,
+ "templateId",
+ "test",
+ "port",
+ undefined,
+ "options",
+ undefined,
+ "env",
+ undefined,
+ "session.id",
+ undefined,
+ "session.startedAt",
+ undefined,
+ ]);
+ }, 20000);
+
+ test("Test /v1/service/{serviceId}/resume", async () => {
+ const id = await miniService(ctx);
+ log(id);
+ await killMini(ctx, id);
+ const res = await request(server).post("/v1/service/" + id + "/resume");
+ expect(res.status).toBe(200);
+ expectProps(res.body, ["status", 200, "message", undefined]);
+ }, 30000);
+
+ // TODO: /v1/service//resume
+
+ test("Test /v1/service/{serviceId}/stop", async () => {
+ const id = await miniService(ctx);
+ log(id);
+ const res = await request(server).post("/v1/service/" + id + "/stop");
+ expect(res.status).toBe(200);
+ expectProps(res.body, ["status", 200, "message", undefined]);
+ }, 20000);
+
+ test("Test /v1/service/{serviceId}/delete", async () => {
+ const id = await miniService(ctx);
+ log(id);
+ const res = await request(server).post("/v1/service/" + id + "/delete");
+ expect(res.status).toBe(200);
+ expectProps(res.body, ["status", 200, "message", undefined]);
+ }, 20000);
+
+ test("Test /v1/service/{serviceId}/reboot", async () => {
+ const id = await miniService(ctx);
+ log(id);
+ const res = await request(server).post("/v1/service/" + id + "/reboot?force=true");
+ expect(res.status).toBe(200);
+ expectProps(res.body, ["status", 200, "message", undefined]);
+ // Wait for it to be started
+ await new Promise((resolve, reject) => {
+ ctx.manager.on("resume", (event) => {
+ if (event.id == id) {
+ if (event.error) {
+ reject(event.error);
+ } else {
+ resolve(null);
+ }
+ return true;
}
+ });
+ });
+ }, 20000);
+
+ test("Test /v1/service/{serviceId}/powerstatus", async () => {
+ const id = await miniService(ctx);
+ log(id);
+ const res = await request(server).get("/v1/service/" + id + "/powerstatus");
+ expect(res.status).toBe(200);
+ expectProps(res.body, ["id", id, "status", "IDLE"]);
+ }, 20000);
+
+ afterAll(() => {
+ if (!ctx) {
+ return;
+ }
- return ctx.manager.stopRunning();
- }, 60000);
+ return ctx.manager.killRunning();
+ }, 60000);
- // TODO: /v1/service//options
- // TODO: /v1/service//stopcmd
- // TODO: /v1/service//stop?force=true
+ // TODO: /v1/service//options
+ // TODO: /v1/service/