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..4624d16
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,5 @@
+# Ignore artifacts:
+build
+coverage
+dev
+resources
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..8349399 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,10 +2,6 @@ FROM node:22
WORKDIR /data
-# Copy addons before install to install dependencies for addons as well
-COPY addons ./addons
-
-COPY installTempDeps.js ./
COPY package*.json ./
RUN npm install
@@ -23,4 +19,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
deleted file mode 100644
index 9c93283..0000000
--- a/addons/example_addon/index.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import {Addon} from "@nsm/addon";
-import winston from "winston";
-
-async function initAfterLogger(ctx: { logger: winston.Logger }) {
- 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
diff --git a/addons/example_addon/libraries.txt b/addons/example_addon/libraries.txt
deleted file mode 100644
index 828d07c..0000000
--- a/addons/example_addon/libraries.txt
+++ /dev/null
@@ -1 +0,0 @@
-express=4.18.2
\ 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/dev/config.yml b/dev/config.yml
new file mode 100644
index 0000000..db833ab
--- /dev/null
+++ b/dev/config.yml
@@ -0,0 +1,45 @@
+# All values here can be overwritten by environment variables
+# with CONFIG_ format.
+
+# ID of this node. Should be unique.
+node_id: "main"
+# Listen port.
+port: 3000
+# Security
+# Supported types: 'none', 'auth_token'
+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'
+repositories:
+ - id: local
+ type: filesystem
+ - id: dockerhub
+ type: docker-registry
+ puller:
+ registry: "https://index.docker.io/v1/"
+ # optional auth config
+ # auth:
+ # username: "your_username"
+ # password: "your_password"
+ templates:
+ - id: redis
+ image: "redis:latest"
+ name: "Redis"
+ description: "Redis template"
+ config:
+ port_range:
+ min: 22222
+ max: 33333
+ meta: { }
+ # Args, and their default values
+ args: { }
+ container:
+ # TODO: port mappings with variables resolving
+ env: { }
+ resources:
+ limits:
+ ram: 512000000
+ cpu: 2
+ disk: 2000000000
\ No newline at end of file
diff --git a/resources/template/example/example_nsmignore b/dev/templates/minecraft/.nsmignore
similarity index 100%
rename from resources/template/example/example_nsmignore
rename to dev/templates/minecraft/.nsmignore
diff --git a/dev/templates/minecraft/Dockerfile b/dev/templates/minecraft/Dockerfile
new file mode 100644
index 0000000..27f5167
--- /dev/null
+++ b/dev/templates/minecraft/Dockerfile
@@ -0,0 +1,6 @@
+FROM itzg/minecraft-server
+
+# Optional ones. From build-stage.yml
+
+# port1 port2 port3
+EXPOSE $SERVICE_PORTS
\ No newline at end of file
diff --git a/dev/templates/minecraft/build-stage.yml b/dev/templates/minecraft/build-stage.yml
new file mode 100644
index 0000000..0cf54ca
--- /dev/null
+++ b/dev/templates/minecraft/build-stage.yml
@@ -0,0 +1,2 @@
+# Build stage args to inject. Use ${} placeholders for args passed in settings.yml.
+buildargs: {}
\ No newline at end of file
diff --git a/dev/templates/minecraft/settings.yml b/dev/templates/minecraft/settings.yml
new file mode 100644
index 0000000..a933cc0
--- /dev/null
+++ b/dev/templates/minecraft/settings.yml
@@ -0,0 +1,21 @@
+name: "Minecraft"
+description: "Minecraft template"
+port_range:
+ min: 22222
+ max: 33333
+meta:
+ internal/stop-command: "stop"
+# Args, and their default values
+args:
+ eula: "TRUE"
+ version: "1.20.4"
+container:
+ env:
+ EULA: "${args.eula}"
+ SERVER_PORT: "${service.port}"
+ VERSION: "${args.version}"
+ resources:
+ limits:
+ ram: 4096000000
+ cpu: 2
+ disk: 2000000000
\ No newline at end of file
diff --git a/resources/template/test/test_nsmignore b/dev/templates/nginx/.nsmignore
similarity index 100%
rename from resources/template/test/test_nsmignore
rename to dev/templates/nginx/.nsmignore
diff --git a/dev/templates/nginx/Dockerfile b/dev/templates/nginx/Dockerfile
new file mode 100644
index 0000000..80b65a8
--- /dev/null
+++ b/dev/templates/nginx/Dockerfile
@@ -0,0 +1,6 @@
+FROM nginx
+
+# Optional ones. From build-stage.yml
+
+# port1 port2 port3
+EXPOSE $SERVICE_PORTS
\ No newline at end of file
diff --git a/dev/templates/nginx/build-stage.yml b/dev/templates/nginx/build-stage.yml
new file mode 100644
index 0000000..0cf54ca
--- /dev/null
+++ b/dev/templates/nginx/build-stage.yml
@@ -0,0 +1,2 @@
+# Build stage args to inject. Use ${} placeholders for args passed in settings.yml.
+buildargs: {}
\ No newline at end of file
diff --git a/dev/templates/nginx/settings.yml b/dev/templates/nginx/settings.yml
new file mode 100644
index 0000000..e81648a
--- /dev/null
+++ b/dev/templates/nginx/settings.yml
@@ -0,0 +1,15 @@
+name: "Nginx"
+description: "Nginx template"
+port_range:
+ min: 22222
+ max: 33333
+meta: {}
+# Args, and their default values
+args: {}
+container:
+ env: {}
+ resources:
+ limits:
+ ram: 512000000
+ cpu: 2
+ disk: 2000000000
\ No newline at end of file
diff --git a/dev/templates/test/.nsmignore b/dev/templates/test/.nsmignore
new file mode 100644
index 0000000..e04c9ca
--- /dev/null
+++ b/dev/templates/test/.nsmignore
@@ -0,0 +1,2 @@
+# Define list of ignored files & directories to NOT include in the container,
+# as you would do in .gitignore
\ No newline at end of file
diff --git a/dev/templates/test/Dockerfile b/dev/templates/test/Dockerfile
new file mode 100644
index 0000000..794058c
--- /dev/null
+++ b/dev/templates/test/Dockerfile
@@ -0,0 +1,9 @@
+FROM busybox
+
+# Don't change this!
+WORKDIR /data
+COPY . .
+
+# Optional ones. From build-stage.yml
+
+ENTRYPOINT ["tail", "-f", "/dev/null"]
\ No newline at end of file
diff --git a/dev/templates/test/build-stage.yml b/dev/templates/test/build-stage.yml
new file mode 100644
index 0000000..0cf54ca
--- /dev/null
+++ b/dev/templates/test/build-stage.yml
@@ -0,0 +1,2 @@
+# Build stage args to inject. Use ${} placeholders for args passed in settings.yml.
+buildargs: {}
\ No newline at end of file
diff --git a/dev/templates/test/settings.yml b/dev/templates/test/settings.yml
new file mode 100644
index 0000000..3676884
--- /dev/null
+++ b/dev/templates/test/settings.yml
@@ -0,0 +1,21 @@
+name: "Test"
+description: "A Test template"
+port_range:
+ min: 22222
+ max: 33333
+defaults:
+ ram: 512000000 # bytes
+ cpu: 2 # cores
+ disk: 2000000000 # bytes
+meta:
+ # Stop command to be sent in stop signal endpoint
+ internal/stop-command: "stop"
+# Args, and their default values
+args: {}
+container:
+ env: {}
+ resources:
+ limits:
+ ram: 512000000
+ cpu: 2
+ disk: 2000000000
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 19fe4b6..e3eec20 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,17 +2,24 @@ services:
nsm:
build: .
volumes:
- - '/var/run/docker.sock:/var/run/docker.sock'
+ - "./dev/templates:/data/resources/templates:ro"
+ - "./dev/config.yml:/data/resources/config.yml: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"
+ - "DEBUG=${DEBUG:-false}"
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 +35,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 +68,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
deleted file mode 100644
index a93900e..0000000
--- a/installTempDeps.js
+++ /dev/null
@@ -1,18 +0,0 @@
-const fs = require("fs");
-const npm = require("npm");
-
-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);
- });
- }
-});
\ No newline at end of file
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..aaff2e9 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"
@@ -154,9 +155,9 @@ components:
options:
type: "object"
description: "The options values used to create the service, or defaults from settings.yml apply"
- env:
+ args:
type: "object"
- description: "The custom variables mapped to values whose definitions are in settings.yml in template under 'env'"
+ description: "The custom variables mapped to values whose definitions are in settings.yml in template under 'args'"
session:
$ref: "#/components/schemas/SessionInfo"
ServiceCreateOptions:
@@ -181,10 +182,14 @@ components:
format: int32
required: false
description: "Disk limit, in bytes"
- env:
+ args:
type: object
required: false
- description: "A map of custom variables mapped to values whose definitions are in settings.yml in template under 'env'."
+ description: "A map of custom variables mapped to values whose definitions are in settings.yml in template under 'args'."
+ meta:
+ type: object
+ required: false
+ description: "A map of custom optional variables. string -> string"
paths:
/v1/status:
get:
@@ -250,6 +255,13 @@ paths:
/v1/service/create:
post:
description: "Create a new service"
+ parameters:
+ - name: resume
+ in: query
+ required: false
+ description: "Whether or not to immediately resume (start) the service after creation. Default: false"
+ schema:
+ type: "boolean"
requestBody:
required: true
content:
@@ -391,46 +403,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Result"
- /v1/service/{serviceId}/stopcmd:
- post:
- description: "Stop a service using cmd"
- parameters:
- - name: "serviceId"
- in: "path"
- required: true
- schema:
- type: "string"
- responses:
- "200":
- description: "Stop command successfully sent."
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Result"
- "400":
- description: "Invalid request"
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Result"
- "404":
- description: "Service not found"
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Result"
- "409":
- description: "Conflict, service is not running"
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Result"
- "500":
- description: "Internal server error"
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Result"
/v1/service/{serviceId}/delete:
post:
description: "Delete a service"
@@ -468,6 +440,11 @@ paths:
required: true
schema:
type: "string"
+ - name: "force"
+ in: "query"
+ required: false
+ schema:
+ type: boolean
responses:
"200":
description: "Successfully rebooted service."
diff --git a/package.json b/package.json
index 6ad3e3b..6fb19c0 100644
--- a/package.json
+++ b/package.json
@@ -4,9 +4,10 @@
"description": "A new service control engine, built on docker.",
"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"
+ "build": "tsc && tscp",
+ "migrate": "prisma migrate deploy",
+ "start": "npm run migrate && cross-env TS_NODE_BASEURL=./dist node -r tsconfig-paths/register --enable-source-maps 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,17 +57,18 @@
"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",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"yaml": "^2.4.0",
- "zod": "^3.24.1"
+ "zod": "^4.4.3"
}
}
diff --git a/prisma/migrations/20260615201051_service_rename_env_to_args/migration.sql b/prisma/migrations/20260615201051_service_rename_env_to_args/migration.sql
new file mode 100644
index 0000000..4bc4293
--- /dev/null
+++ b/prisma/migrations/20260615201051_service_rename_env_to_args/migration.sql
@@ -0,0 +1,9 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `env` on the `Service` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE `Service` DROP COLUMN `env`,
+ ADD COLUMN `args` JSON NOT NULL;
diff --git a/prisma/migrations/20260616172957_image_hash_nullable/migration.sql b/prisma/migrations/20260616172957_image_hash_nullable/migration.sql
new file mode 100644
index 0000000..40b8f69
--- /dev/null
+++ b/prisma/migrations/20260616172957_image_hash_nullable/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE `Image` MODIFY `hash` VARCHAR(191) NULL;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index a92fc77..4eaf90c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -26,7 +26,7 @@ model Service {
port Int
options Json
meta Json @default("{}")
- env Json
+ args Json @default("{}")
network Json?
image Image? @relation(fields: [imageId], references: [id])
sessions ServiceSession[]
@@ -68,7 +68,7 @@ model Meta {
model Image {
id String @id
templateId String
- hash String
+ hash String?
buildOptions ImageBuildOption[]
services Service[]
}
diff --git a/resources/config.yml b/resources/config.yml
index a610785..67c20a0 100644
--- a/resources/config.yml
+++ b/resources/config.yml
@@ -2,13 +2,16 @@
# 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'
+repositories:
+ - id: local
+ type: filesystem
\ No newline at end of file
diff --git a/resources/template/example/example_dockerfile b/resources/template/example/example_dockerfile
deleted file mode 100644
index c1e48f7..0000000
--- a/resources/template/example/example_dockerfile
+++ /dev/null
@@ -1,27 +0,0 @@
-# Optional arg JAVA_VERSION. This is here before FROM to dynamically change the base image.
-ARG JAVA_VERSION
-
-# Use args down there.
-FROM eclipse-temurin:$JAVA_VERSION
-
-# Don't change this!
-WORKDIR /data
-COPY . .
-
-# Built-in args. Don't change.
-ARG SERVICE_ID
-ARG SERVICE_PORT
-ARG SERVICE_PORTS
-ARG SERVICE_RAM
-ARG SERVICE_CPU
-ARG SERVICE_DISK
-
-# Optional ones. From settings.yml
-ARG STARTUP_FILE
-
-ADD https://api.papermc.io/v2/projects/paper/versions/1.12.2/builds/1620/downloads/paper-1.12.2-1620.jar $STARTUP_FILE
-
-# port1 port2 port3
-EXPOSE $SERVICE_PORTS
-
-CMD /bin/sh -c "java -Xmx$SERVICE_RAM -Dcom.mojang.eula.agree=true -jar $STARTUP_FILE --port=$SERVICE_PORT"
\ No newline at end of file
diff --git a/resources/template/example/example_settings.yml b/resources/template/example/example_settings.yml
deleted file mode 100644
index 3b8ee85..0000000
--- a/resources/template/example/example_settings.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-name: 'Example'
-description: 'An Example template'
-port_range:
- min: 25565
- max: 35565
-# Default parameters for build
-defaults:
- ram: 1024000000 # bytes
- cpu: 2 # cores
- disk: 2000000000 # bytes
-meta:
- # Stop command to be sent in stop signal endpoint
- stopCmd: 'stop'
-# Optional ENV vars, and their default values
-env:
- STARTUP_FILE: 'server.jar'
- JAVA_VERSION: '' # Required option
\ No newline at end of file
diff --git a/resources/template/test/test_dockerfile b/resources/template/test/test_dockerfile
deleted file mode 100644
index a04b29c..0000000
--- a/resources/template/test/test_dockerfile
+++ /dev/null
@@ -1,20 +0,0 @@
-FROM busybox
-
-# Don't change this!
-WORKDIR /data
-COPY . .
-
-# Built-in args. Don't change.
-ARG SERVICE_ID
-ARG SERVICE_PORT
-ARG SERVICE_PORTS
-ARG SERVICE_RAM
-ARG SERVICE_CPU
-ARG SERVICE_DISK
-
-# Optional ones. From settings.yml
-
-# port1 port2 port3
-EXPOSE $SERVICE_PORTS
-
-ENTRYPOINT ["tail", "-f", "/dev/null"]
\ No newline at end of file
diff --git a/resources/template/test/test_settings.yml b/resources/template/test/test_settings.yml
deleted file mode 100644
index d8e6f1d..0000000
--- a/resources/template/test/test_settings.yml
+++ /dev/null
@@ -1,14 +0,0 @@
-name: 'Test'
-description: 'A Test template'
-port_range:
- min: 22222
- max: 33333
-defaults:
- ram: 512000000 # bytes
- cpu: 2 # cores
- disk: 2000000000 # bytes
-meta:
- # Stop command to be sent in stop signal endpoint
- stopCmd: 'stop'
-# Optional ENV vars, and their default values
-env: {}
\ No newline at end of file
diff --git a/src/addon.ts b/src/addon.ts
deleted file mode 100644
index 9fbe55e..0000000
--- a/src/addon.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import winston from "winston";
-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";
-
-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;
-}
-
-export type Moment = keyof FunctionTypes;
-export type AddonSteps = {
- [key in Moment]: FunctionTypes[key];
-};
-export type Addon = {
- 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);
-}
-
-// 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);
- }
- });
- });
-}
-
-// Load addons
-export default async function (logger: winston.Logger) {
- // 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 addon = require(dir + '/index.js').default as Addon;
- if (!addon.disabled) {
- addons.push(addon);
-
- const { name, author, version } = addon;
-
- 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])
- });
- }
-}
\ No newline at end of file
diff --git a/src/app.ts b/src/app.ts
index a84cc8b..121a42e 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,6 +1,9 @@
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,
+ prepareFolders,
+} from "@nsm/filestructure";
// Load .env
dotenv.config();
@@ -9,71 +12,52 @@ dotenv.config();
const appConfig = loadAppConfig();
initFileStructure(appConfig);
-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 { Router } from "express";
+import { Database } from "@nsm/persistence";
+import {initEngine, ServiceManager} from "@nsm/engine";
+import loadAppRoutes from "@nsm/router";
+import createDbManager from "@nsm/persistence";
import loadSecurity from "@nsm/security";
-import * as manager from "@nsm/engine/manager";
+import * as facade from "@nsm/engine/facade";
+import * as manager from "@nsm/engine/service";
+import * as runner from "@nsm/engine/runner";
import * as sessionManager from "@nsm/engine/session";
+import * as templateManager from "@nsm/engine/template";
import * as logging from "./logger";
import winston from "winston";
-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 path from "path";
-import {AppConfig} from "@nsm/config";
-
-export type AppBootContext = AppContext & { steps: any };
+import { Application } from "express-ws";
+import {middleLayer, registerErrorPublishersFromConfig} from "@nsm/engine/middle";
+import { SessionManager } from "@nsm/engine/session";
+import { mkdirResource } from "@nsm/resources";
+import { AppConfig } from "@nsm/config";
+import { ServiceRunner } from "@nsm/engine/runner";
+import {Facade} from "@nsm/engine/facade";
+import {TemplateManager} from "@nsm/engine/template";
// 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;
+ facade: Facade,
+ manager: ServiceManager;
+ sessionManager: SessionManager;
+ templateManager: TemplateManager;
+ runner: ServiceRunner;
+ 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();
-
- return logging.createLogger();
-}
+ logging.createLatestLogFile();
-// 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);
+ return logging.createLogger();
}
/**
@@ -82,80 +66,57 @@ 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 };
-}
-
-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
+export const init = async (
+ router: Application,
+ options?: AppBootOptions,
+): Promise => {
+ // Prepare logging
+ const logger = initGlobalLogger();
+ logging.setCurrentGlobalLogger(logger);
+
+ prepareFolders();
+
+ // Prepare templates folder
+ mkdirResource("templates");
+
+ const database = createDbManager();
+
+ // Temporarily lock manager until it's initialized
+ const ctx: AppContext = (currentContext = {
+ router,
+ facade,
+ manager,
+ runner,
+ sessionManager,
+ templateManager,
+ database,
+ appConfig,
+ logger,
+ debug: process.env.DEBUG === "true",
+ });
+
+ await registerErrorPublishersFromConfig(appConfig);
+
+ const engine = await initEngine(ctx);
+ logger.info(`Using engine: ${engine.name}`);
+
+ templateManager.init(engine);
+ sessionManager.init(database);
+
+ await manager.init(appConfig, database, engine, templateManager, logger);
+
+ await runner.init(engine, appConfig, templateManager, manager, database, logger);
+ ctx.runner = currentContext.runner = middleLayer(runner);
+
+ await loadSecurity(ctx);
+ await loadAppRoutes(ctx);
+
+ if (options?.test == undefined || options.test == false) {
+ logger.info(`Starting server`);
+
+ router.listen(appConfig.getPort(), () => {
+ logger.info(`Server started on port ${appConfig.getPort()}`);
+ });
+ }
+ return ctx;
+};
\ No newline at end of file
diff --git a/src/cleanup.ts b/src/cleanup.ts
index fd65777..c50fca6 100644
--- a/src/cleanup.ts
+++ b/src/cleanup.ts
@@ -1,47 +1,40 @@
-import {AppBootContext} from "@nsm/app";
-import {setStatus} from "@nsm/server";
-import {resolveSequentially} from "@nsm/util/promises";
-import {setStopping} from "@nsm/engine/asyncp";
+import {AppContext} from "@nsm/app";
+import { setStatus } from "@nsm/server";
+import { setStopping } from "@nsm/engine/asyncp";
let active = false;
-const cleanup = (ctx: AppBootContext, exit?: boolean) => {
- const { manager, logger, steps } = ctx;
+const cleanup = (ctx: AppContext, exit?: boolean) => {
+ const { runner, logger } = ctx;
- if (active == true) {
- return;
- }
+ if (active == true) {
+ return;
+ }
+
+ active = true;
+ if (exit == true) {
+ logger.info("SIGINT" + ": Executing stop sequence, please wait");
+ setStatus("stopping");
+ setStopping();
+ }
- active = true;
+ runner.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
+ });
+};
+
+export const postInit = (ctx: AppContext) => {
+ // 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..5e9187f 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,8 +1,11 @@
-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";
+import {TemplateRepositoryConfig} from "@nsm/engine";
+
+export const currentPaths: Paths = envPaths("nsm");
export interface AppConfig {
getNodeId(): string;
@@ -13,7 +16,15 @@ export interface AppConfig {
getDockerHost(): string;
- getResourcesPath(): string|undefined;
+ getResourcesPath(): string;
+
+ getTemplatesPath(): string;
+
+ getTemplateBuildDir(template: string): string;
+
+ getTempPath(): string;
+
+ getTemplateRepositoryConfigs(): TemplateRepositoryConfig[];
}
/**
@@ -22,14 +33,22 @@ 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(),
+ repositories: z.array(
+ z.object({
+ id: z.string(),
+ type: z.string(),
+ }).passthrough()
+ )
+ })
+ .strict();
private readonly data: any;
@@ -55,26 +74,83 @@ 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;
+ }
+
+ getTemplateRepositoryConfigs(): TemplateRepositoryConfig[] {
+ const repositories: any[] = this.data["repositories"];
+
+ return repositories.map((repo) => {
+ return {
+ id: repo.id,
+ type: repo.type,
+ config: repo,
+ };
+ });
}
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"));
+
+ return YamlAppConfig.fillDataFromEnv(YamlAppConfig.schema.shape, config);
+ };
+
+
+ /**
+ * Recursively fills config data from environment variables.
+ *
+ * @param shape The shape of the config schema, used to determine which keys to look for in env variables.
+ * @param config The config object to fill with env variables.
+ * @param envPrefix The prefix to use for env variables, default is "CONFIG_". For nested objects, the prefix will be extended with the parent key in uppercase followed by an underscore.
+ * @returns The config object filled with env variables where applicable.
+ */
+ private static fillDataFromEnv = (
+ shape: z.ZodObject,
+ config: any,
+ envPrefix?: string,
+ ) => {
+ const prefix = envPrefix ?? "CONFIG_";
+
+ for (let key in shape) {
+ const envKey = prefix + key.toUpperCase();
+
+ // Recursively fill nested objects
+ if (shape[key] instanceof z.ZodObject) {
+ config[key] = YamlAppConfig.fillDataFromEnv(
+ shape[key],
+ config[key] || {},
+ envKey + "_",
+ );
+ continue;
+ }
- 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();
if (process.env[envKey]) {
config[key] = process.env[envKey];
} else if (config[key]) {
@@ -87,4 +163,4 @@ export class YamlAppConfig implements AppConfig {
export const loadAppConfig = (): AppConfig => {
return new YamlAppConfig();
-}
\ No newline at end of file
+};
diff --git a/src/database/index.ts b/src/database/index.ts
deleted file mode 100644
index e8bbdee..0000000
--- a/src/database/index.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import {Database} from "./models";
-import {PrismaClient} from "@prisma/client";
-
-import * as permaRepository from "./perma";
-import * as metaRepository from "./meta";
-import * as serviceMetaRepository from "./serviceMeta";
-import * as imageRepository from "./image";
-import * as sessionRepository from "./session";
-import * as serviceLogRepository from "./serviceLog";
-
-export * from './models';
-
-export default function (client?: PrismaClient): Database {
- 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));
-
- return {
- permaRepository,
- metaRepository,
- serviceMetaRepository,
- imageRepository,
- sessionRepository,
- serviceLogRepository
- }
-}
\ No newline at end of file
diff --git a/src/database/models.ts b/src/database/models.ts
deleted file mode 100644
index c44f9a3..0000000
--- a/src/database/models.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-export interface Database {
- 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;
-}
-
-export interface MetaRepository {
- getMetaVal(key: string, defaultVal?: string): Promise;
-}
-
-export interface ServiceMetaRepository {
- 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;
-}
-
-export interface SessionRepository {
- createSession(serviceId: string): Promise;
-
- listSessions(args: ListSessionsArgs): Promise;
-}
-
-export type ListSessionsArgs = {
- filter?: {
- serviceId?: string;
- }
- sort?: {
- by?: 'startedAt'
- direction?: 'asc' | 'desc'
- }
- page?: {
- index: number;
- size: number;
- }
-}
-
-export interface ServiceLogRepository {
- createRecords(records: CreateLogRecordArgs[]): Promise;
-
- listRecords(args: ListRecordsArgs): Promise;
-}
-
-export type CreateLogRecordArgs = Omit;
-
-export type ListRecordsArgs = {
- 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;
- }
-};
-
-export type ImageModel = {
- id: string;
- templateId: string;
- hash: string;
- buildOptions: {
- [key: string]: string;
- }
-}
-
-export type ServiceSessionModel = {
- 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
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..b1c0326 100644
--- a/src/engine/docker/action/deletei.ts
+++ b/src/engine/docker/action/deletei.ts
@@ -1,10 +1,17 @@
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
+ try {
+ const image = client.getImage(id);
+ await image.remove();
+ } catch (e) {
+ if (!e.message.includes("no such image")) {
+ throw e;
+ }
+ }
+ };
+}
diff --git a/src/engine/docker/action/deletev.ts b/src/engine/docker/action/deletev.ts
index 12f1604..389c83c 100644
--- a/src/engine/docker/action/deletev.ts
+++ b/src/engine/docker/action/deletev.ts
@@ -1,15 +1,20 @@
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) {
+ if (!e.message.includes("no such volume")) {
+ 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..b7cefc9 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({ all: true });
+ if (list.map((c) => c.Id).includes(id)) {
+ await client.getContainer(id).remove({ force: true });
+ }
- 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("is not running") && !e.message.includes("no such container")) {
+ 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..a3354e5 100644
--- a/src/engine/docker/action/reattach.ts
+++ b/src/engine/docker/action/reattach.ts
@@ -1,33 +1,49 @@
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 { PassThrough } from "stream";
+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/engine/docker/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);
+
+ // Find network ID before removing container
+ const networkId = await isInNetwork(client, id);
+
try {
await c.remove({ force: true });
} catch (e) {
- currentContext.logger.error("Unable to delete container " + id);
+ const msg = e.message.toLowerCase();
+ if (!msg.includes("no such container") && !msg.includes("removal of container") && !msg.includes("already in progress")) {
+ currentContext.logger.error("Unable to delete container " + id, e);
+ }
}
- // Delete network if it's associated with any.
- const networkId = await isInNetwork(client, id);
- if (networkId) {
- // Disconnect this container from the attached network.
- await client.getNetwork(networkId).disconnect({ Container: id, Force: true });
- if (options.deleteNetwork == true) {
- // Delete network if requested.
- await doDeleteNetwork(client, id);
- }
+ // Delete network if it's associated with any and requested.
+ if (options.deleteNetwork == true && networkId) {
+ await doDeleteNetwork(client, networkId);
}
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;
@@ -44,33 +63,57 @@ export default function reattach(self: ServiceEngine, client: DockerClient): Ser
const handleClosed = async () => {
await deleteContainer(container.id, client, { deleteNetwork: true });
+ await listener.onStateChange({
+ id: "closed",
+ description: "Container closed",
+ ready: false,
+ });
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) => {
+
+ const handleData = (data: Buffer, level: "info" | "error" = "info") => {
try {
- data = Buffer.from(data).toString('ascii');
+ const message = data.toString("utf8");
const record: ServiceLogRecord = {
- level: 'info',
- message: data
+ level,
+ message,
};
listener.onMessage?.(record);
} catch (e) {
logger.error("Error producing container output: " + e);
}
- }); // no-op, keepalive
- rws.on('end', async () => {
- if (getActionType(container.id) != 'stop') {
+ };
+
+ if (info.Config.Tty) {
+ rws.on("data", handleData);
+ } else {
+ const stdout = new PassThrough();
+ const stderr = new PassThrough();
+ container.modem.demuxStream(rws, stdout, stderr);
+ stdout.on("data", (data) => handleData(data, "info"));
+ stderr.on("data", (data) => handleData(data, "error"));
+ }
+ rws.on("end", async () => {
+ if (getActionType(container.id) != "stop") {
// Stopped from the inside
await handleClosed();
@@ -82,6 +125,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..4682e24 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/engine/docker/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, demuxBuffer } 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,21 +74,22 @@ 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}`),
ExposedPorts: { [fullPortDef(port)]: {} },
AttachStdin: true,
OpenStdin: true,
+ Tty: true,
});
if (net != null) {
await net.connect({ Container: container.id }); // Implement EndpointConfig?? TODO: Test
@@ -90,41 +97,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
@@ -135,17 +155,24 @@ export default function run(self: ServiceEngine, client: DockerClient): ServiceE
timestamps: false,
tail: 100,
});
- const msg = logs.toString("utf8");
+ const msg = inspectInfo.Config.Tty
+ ? logs.toString("utf8")
+ : demuxBuffer(logs);
- 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..34d1441 100644
--- a/src/engine/docker/index.ts
+++ b/src/engine/docker/index.ts
@@ -1,46 +1,47 @@
-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";
+import {DockerTemplateRepositoryRegistry} from "@nsm/engine/docker/template";
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.templateRepositoryRegistry = new DockerTemplateRepositoryRegistry(engine, client);
+ // 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/networking/manager.ts b/src/engine/docker/networking/manager.ts
new file mode 100644
index 0000000..59ef39e
--- /dev/null
+++ b/src/engine/docker/networking/manager.ts
@@ -0,0 +1,77 @@
+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;
+ }
+ }
+ 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",
+ },
+ });
+}
+
+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);
+ }
+ }
+}
+
+// Returns network id of the NSM-managed network the container is in, or undef if not in any NSM network
+export async function isInNetwork(
+ client: DockerClient,
+ containerId: string,
+): Promise {
+ try {
+ const container = client.getContainer(containerId);
+ const info = await container.inspect();
+ const networks = info.NetworkSettings.Networks;
+
+ for (const networkName in networks) {
+ const networkId = networks[networkName].NetworkID;
+ const network = client.getNetwork(networkId);
+ const networkInfo = await network.inspect();
+
+ if (networkInfo.Labels && networkInfo.Labels.nsm === "true") {
+ return networkId;
+ }
+ }
+
+ return undefined;
+ } catch (e) {
+ if (!e.message.toLowerCase().includes("no such container") && !e.message.toLowerCase().includes("not found")) {
+ console.log(e);
+ }
+ return undefined;
+ }
+}
diff --git a/src/engine/image.ts b/src/engine/docker/repository/filesystem/image.ts
similarity index 57%
rename from src/engine/image.ts
rename to src/engine/docker/repository/filesystem/image.ts
index 3c23818..094ba5f 100644
--- a/src/engine/image.ts
+++ b/src/engine/docker/repository/filesystem/image.ts
@@ -1,33 +1,34 @@
-import {Database, ImageModel} from "@nsm/database";
+import { Database, ImageModel } from "@nsm/persistence";
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, ServiceLogRecord} from "@nsm/engine/engine";
+import { Template } from "@nsm/engine/template";
+import { TemplateDirWatcher } from "@nsm/engine/docker/repository/filesystem/monitoring/templateDirWatcher";
+import { AppConfig } from "@nsm/config";
+import {InternalError} from "@nsm/engine/error";
type BuildOptionsMap = {
- [key: string]: string
+ [key: string]: string;
};
-let engine: ServiceEngineI;
-let templateManager: TemplateManager;
+let engine: ServiceEngine;
let templateDirWatcher: TemplateDirWatcher;
+let appConfig: AppConfig;
let db: Database;
let logger: winston.Logger;
export const init = (
- engine_: ServiceEngineI,
- templateManager_: TemplateManager,
+ engine_: ServiceEngine,
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
@@ -36,41 +37,54 @@ export const init = (
* or build a new one. It may also trigger a rebuild or remove unused images.
*
* @param id The ID of the current image
- * @param templateId The ID of the template
+ * @param template The template for the image
* @param buildOptions Build arguments used when building the image
* @param messageListener A message listener to use when building the image
* @returns The ID of the image that should be used
*/
export const processImage = async (
id: string | undefined | null,
- templateId: string, buildOptions: BuildOptionsMap, messageListener?: MessageListener
+ template: Template,
+ buildOptions: BuildOptionsMap,
+ messageListener?: MessageListener,
) => {
- const template = templateManager.getTemplate(templateId);
- // Checks if the provided options are still compatible with the template
- buildOptions = templateManager.prepareEnvForTemplate(template, buildOptions);
-
if (!id) {
// No image specified, need to build or pick a new one
- id = await pickImageOrBuild(templateId, buildOptions);
+ id = await pickImageOrBuild(template.id, buildOptions, messageListener);
}
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`);
+ if (imageModel.templateId != template.id) {
+ throw new Error(
+ `Image ${id} is based on template ${imageModel.templateId}, but template ${template.id} was expected`,
+ );
+ }
+
+ if (!imageModel.hash) {
+ logger.warn(
+ `Image ${id} does not have a template hash. This may indicate that the image
+ was not built from filesystem! Rebuilding image to ensure it's up to date...`,
+ )
}
- const imageOutdated = imageModel.hash != templateDirWatcher.getTemplateHash(imageModel.templateId);
+ const imageOutdated =
+ imageModel.hash !=
+ await 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...`);
- id = await pickImageOrBuild(templateId, buildOptions);
+ logger.info(
+ `The target options differ, finding or building a new compatible image...`,
+ );
+ id = await pickImageOrBuild(template.id, buildOptions, messageListener);
// 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 +92,7 @@ export const processImage = async (
}
return id;
-}
+};
/**
* Tries to find an existing image that is compatible with the given template ID and build options.
@@ -86,21 +100,29 @@ export const processImage = async (
*
* @param templateId The ID of the template to find/build the image for
* @param buildOptions Build options to use when finding/building the image
+ * @param messageListener A message listener to use for logs propagation when building a new image
* @returns The ID of the found or built image
*/
-const pickImageOrBuild = async (templateId: string, buildOptions: BuildOptionsMap) => {
+const pickImageOrBuild = async (
+ templateId: string,
+ buildOptions: BuildOptionsMap,
+ messageListener?: MessageListener,
+) => {
let id = await pickImage(templateId, buildOptions);
if (id == null) {
logger.info(`No compatible image found for request. Building new image...`);
// No compatible image, need to build a new one
- id = await buildImage(templateId, buildOptions);
+ id = await buildImage(templateId, buildOptions, undefined, messageListener);
}
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 +141,7 @@ export const optionsDiffer = (options1: BuildOptionsMap, options2: BuildOptionsM
}
return false;
-}
+};
/**
* Retrieves the image information from the database for the given image ID.
@@ -135,7 +157,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 +173,27 @@ 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);
+ await messageListener?.onEngineMessage?.({
+ message: `Building image...`,
+ level: "info"
+ });
+
+ let duration = Date.now();
+ const hash = await templateDirWatcher.getTemplateHash(templateId);
+ imageId = await engine.build(
+ imageId,
+ appConfig.getTemplateBuildDir(templateId),
+ options,
+ messageListener,
+ );
+ duration = Date.now() - duration;
+
+ await messageListener?.onEngineMessage?.({
+ message: `Image built in ${Math.round(duration / 1000)}s`,
+ level: "info"
+ });
await db.imageRepository.saveImage({
id: imageId,
@@ -163,10 +202,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 +219,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 +252,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/monitoring/templateDirWatcher.ts b/src/engine/docker/repository/filesystem/monitoring/templateDirWatcher.ts
similarity index 75%
rename from src/engine/monitoring/templateDirWatcher.ts
rename to src/engine/docker/repository/filesystem/monitoring/templateDirWatcher.ts
index 4cda3b5..b7c737a 100644
--- a/src/engine/monitoring/templateDirWatcher.ts
+++ b/src/engine/docker/repository/filesystem/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/docker/repository/filesystem/monitoring/util";
+import { hashElement } from "folder-hash";
+import { getFilteredPaths } from "@nsm/engine/ignore";
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";
+import {getAllTemplates} from "@nsm/engine/docker/repository/filesystem/template";
export type TemplateDirWatcher = {
-
/**
* Starts watching the template directories for changes.
* When a change is detected, the template hash is updated and cached.
@@ -22,7 +21,7 @@ export type TemplateDirWatcher = {
* @returns The cached hash of the template directory.
* @throws If the template does not exist or if there is an error reading the directory.
*/
- getTemplateHash(template: string): string;
+ getTemplateHash(template: string): Promise;
};
const hashCache: Map = new Map();
@@ -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,25 +130,30 @@ 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 => {
+export const getTemplateHash = async (template: string, fail?: boolean): Promise => {
const hash = hashCache.get(template);
if (!hash) {
- throw new Error(`No hash calculated for template ${template}.`);
+ if (fail) {
+ throw new Error(`No hash calculated for template ${template}.`);
+ }
+
+ await recalculateTemplateHash(template);
+ return getTemplateHash(template, true);
}
return hash;
-}
\ No newline at end of file
+};
diff --git a/src/engine/monitoring/util.ts b/src/engine/docker/repository/filesystem/monitoring/util.ts
similarity index 73%
rename from src/engine/monitoring/util.ts
rename to src/engine/docker/repository/filesystem/monitoring/util.ts
index 112e2c7..ab8094c 100644
--- a/src/engine/monitoring/util.ts
+++ b/src/engine/docker/repository/filesystem/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/docker/repository/filesystem/repository.ts b/src/engine/docker/repository/filesystem/repository.ts
new file mode 100644
index 0000000..5428d83
--- /dev/null
+++ b/src/engine/docker/repository/filesystem/repository.ts
@@ -0,0 +1,179 @@
+import {Template, templateSettingsModel} from "@nsm/engine/template";
+import {MessageListener, ServiceEngine, TemplateRepository} from "@nsm/engine";
+import winston from "winston";
+import {AppContext} from "@nsm/app";
+import {init as initImageEngine, processImage} from "@nsm/engine/docker/repository/filesystem/image";
+import * as templateDirWatcher from "@nsm/engine/docker/repository/filesystem/monitoring/templateDirWatcher";
+import {InternalError, TemplateNotFoundError} from "@nsm/engine/error";
+import {ParamsResolver} from "@nsm/util/args";
+import path from "path";
+import fs from "fs";
+import {loadYamlFile} from "@nsm/util/yaml";
+import z from "zod";
+import { getAllTemplates } from "./template";
+
+type BuildStageSettings = {
+ buildargs?: { [key: string]: string };
+}
+
+const settingsYamlModel = templateSettingsModel.extend({
+ name: z.string(),
+ description: z.string(),
+});
+
+const buildStageSettingsYamlModel = z.object({
+ buildargs: z.record(z.string(), z.string()).optional(),
+});
+
+/**
+ * A template repository that loads templates from the filesystem.
+ * The template dir is determined from the app config.
+ *
+ * @author ZorTik
+ */
+export class FilesystemTemplateRepository implements TemplateRepository {
+ private readonly templateCache: Map;
+ private readonly templateHashCache: Map;
+
+ private templatesPath: string;
+ private logger: winston.Logger;
+
+ constructor(
+ private readonly engine: ServiceEngine,
+ ) {
+ this.templateCache = new Map();
+ this.templateHashCache = new Map();
+ }
+
+ async init(ctx: AppContext) {
+ this.templatesPath = ctx.appConfig.getTemplatesPath();
+ this.logger = ctx.logger;
+
+ initImageEngine(this.engine, templateDirWatcher, ctx.database, ctx.appConfig, ctx.logger);
+ templateDirWatcher.watchTemplateDirChanges(ctx.logger);
+ }
+
+ async prepareImage(
+ templateId: string,
+ args: { [key: string]: string },
+ imageId?: string,
+ messageListener?: MessageListener
+ ) {
+ const template = await this.getTemplate(templateId);
+ if (template) {
+ const buildStageSettings = this.loadBuildStageFile(templateId);
+ if (!buildStageSettings) {
+ throw new InternalError(`Failed to load build-stage.yml for template ${templateId}`);
+ }
+ const buildArgs = buildStageSettings.buildargs
+ ? (
+ new ParamsResolver(buildStageSettings.buildargs)
+ .setArgs(args)
+ .getParams()
+ )
+ : {};
+
+ return processImage(imageId, template, buildArgs, messageListener);
+ } else {
+ throw new TemplateNotFoundError(templateId);
+ }
+ }
+
+ async getTemplate(id: string) {
+ if (this.templateCache.has(id)
+ && this.templateHashCache.has(id)
+ // template didn't change, so we can be sure that settings.yml didn't as well
+ && this.templateHashCache.get(id) === await templateDirWatcher.getTemplateHash(id)) {
+ return this.templateCache.get(id);
+ }
+
+ const settings = this.loadSettingsFile(id);
+ if (!settings) {
+ return undefined;
+ }
+
+ try {
+ if (!this.loadBuildStageFile(id)) {
+ // invalid build-stage file
+ return undefined;
+ }
+ } catch (e) {
+ this.logger.error(`Failed to load build-stage.yml for template ${id}: ${e.message}`);
+ this.logger.error(e);
+
+ return undefined;
+ }
+
+ const template: Template = {
+ id,
+ name: settings.name,
+ description: settings.description,
+ config: settings,
+ };
+ this.templateCache.set(id, template);
+
+ await this.updateCachedHash(id);
+ return template;
+ }
+
+ private loadBuildStageFile(templateId: string): BuildStageSettings {
+ const buildStagePath = path.join(this.templatesPath, templateId, "build-stage.yml");
+ if (!fs.existsSync(buildStagePath)) {
+ return {
+ buildargs: {}
+ };
+ }
+
+ try {
+ return buildStageSettingsYamlModel.parse(loadYamlFile(buildStagePath));
+ } catch (e) {
+ if (e instanceof z.ZodError) {
+ this.logger.warn(`Invalid build-stage.yml for template ${templateId}: ${e.message}`);
+
+ return undefined;
+ }
+
+ throw e;
+ }
+ }
+
+ private loadSettingsFile(templateId: string) {
+ const settingsPath = path.join(this.templatesPath, templateId, "settings.yml");
+ if (!fs.existsSync(settingsPath)) {
+ return undefined;
+ }
+
+ try {
+ return settingsYamlModel.parse(loadYamlFile(settingsPath));
+ } catch (e) {
+ if (e instanceof z.ZodError) {
+ this.logger.warn(`Invalid settings.yml for template ${templateId}: ${e.message}`);
+
+ return undefined;
+ }
+
+ throw e;
+ }
+ }
+
+ private async updateCachedHash(templateId: string) {
+ let hash: string;
+ try {
+ hash = await templateDirWatcher.getTemplateHash(templateId);
+ } catch (e) {
+ this.logger.warn(`Failed to get hash for template ${templateId}: ${e.message}`);
+ this.templateHashCache.delete(templateId);
+ }
+ if (hash) {
+ this.templateHashCache.set(templateId, hash);
+ }
+ }
+
+ async getAllTemplates() {
+ return (
+ await Promise.all(
+ getAllTemplates().map(async (t) => this.getTemplate(t.id))
+ )
+ ).filter((t): t is Template => t != undefined);
+ }
+}
\ No newline at end of file
diff --git a/src/engine/docker/repository/filesystem/template.ts b/src/engine/docker/repository/filesystem/template.ts
new file mode 100644
index 0000000..90fb247
--- /dev/null
+++ b/src/engine/docker/repository/filesystem/template.ts
@@ -0,0 +1,45 @@
+import path from "path";
+import {getTemplatesPath} from "@nsm/filestructure";
+import fs from "fs";
+import {loadYamlFile} from "@nsm/util/yaml";
+import {Template} from "@nsm/engine/template";
+
+export type FileSystemTemplateManager = {
+ /**
+ * 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[];
+};
+
+export const getTemplate = (id: string): Template | null => {
+ const settingsPath = path.join(getTemplatesPath(), id, "settings.yml");
+ if (!fs.existsSync(settingsPath)) {
+ return null;
+ }
+ const settings = loadYamlFile(settingsPath);
+ return {
+ id,
+ name: settings.name,
+ description: settings.description,
+ config: settings,
+ };
+};
+
+export const getAllTemplates = () => {
+ 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);
+};
\ No newline at end of file
diff --git a/src/engine/docker/repository/registry/repository.ts b/src/engine/docker/repository/registry/repository.ts
new file mode 100644
index 0000000..6f3df68
--- /dev/null
+++ b/src/engine/docker/repository/registry/repository.ts
@@ -0,0 +1,183 @@
+import {MessageListener, TemplateRepository} from "@nsm/engine";
+import DockerClient from "dockerode";
+import {Template} from "@nsm/engine/template";
+import {AppContext} from "@nsm/app";
+import {TemplateNotFoundError} from "@nsm/engine/error";
+
+interface ImagePuller {
+ /**
+ * Pulls the specified image from the registry and returns its ID.
+ *
+ * @param image The image to pull.
+ * @param imageId An optional image ID to pull.
+ * @param messageListener An optional message listener to receive progress updates during the pull operation.
+ * @returns The ID of the pulled image.
+ * @throws If there is an error pulling the image.
+ */
+ pullImage(image: string, imageId?: string, messageListener?: MessageListener): Promise;
+}
+
+interface DockerRegistryImagePullerOptions {
+ registry?: string;
+ auth?: {
+ username?: string;
+ password?: string;
+ };
+}
+
+export class DockerRegistryImagePuller implements ImagePuller {
+ private readonly DEFAULT_REGISTRY = 'https://index.docker.io/v1/';
+
+ constructor(
+ private readonly docker: DockerClient,
+ private readonly options: DockerRegistryImagePullerOptions,
+ ) {}
+
+ /**
+ * Helper to safely format and send messages to the listener with specific log levels
+ */
+ private emitLog(listener: MessageListener | undefined, text: string, level: "error" | "info" = "info"): void {
+ if (listener?.onEngineMessage) {
+ listener.onEngineMessage({
+ level,
+ message: text,
+ });
+ }
+ }
+
+ /**
+ * Pulls the specified image from the registry using dockerode.
+ */
+ async pullImage(
+ image: string,
+ imageId?: string,
+ messageListener?: MessageListener
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ const registry = this.options.registry || this.DEFAULT_REGISTRY;
+ const auth = this.options.auth?.username && this.options.auth?.password
+ ? {
+ username: this.options.auth.username,
+ password: this.options.auth.password,
+ serveraddress: registry
+ }
+ : undefined;
+
+ this.emitLog(messageListener, `Pulling "${image}" via ${registry}`, "info");
+
+ this.docker.pull(image, { authconfig: auth }, (err: Error | null, stream: NodeJS.ReadableStream) => {
+ if (err) {
+ this.emitLog(messageListener, `Initial pull request failed: ${err.message}`, "error");
+
+ return reject(err);
+ }
+
+ this.follow(stream, messageListener, reject, resolve, image, imageId);
+ });
+ });
+ }
+
+ private follow(
+ stream: NodeJS.ReadableStream,
+ messageListener: MessageListener,
+ reject: (reason?: any) => void,
+ resolve: (value: (PromiseLike | unknown)) => void,
+ image: string,
+ imageId: string,
+ ) {
+ this.docker.modem.followProgress(
+ stream,
+ async (finishErr: Error | null, _: any[]) => {
+ return await this.onFinish(finishErr, messageListener, reject, resolve, image, imageId);
+ },
+ (progressEvent: any) => {
+ this.onProgress(progressEvent, messageListener);
+ }
+ );
+ }
+
+ private async onFinish(
+ finishErr: Error,
+ messageListener: MessageListener,
+ reject: (reason?: any) => void,
+ resolve: (value: (PromiseLike | unknown)) => void,
+ image: string,
+ imageId: string,
+ ) {
+ if (finishErr) {
+ this.emitLog(messageListener, `Pull stream failed: ${finishErr.message}`, "error");
+ return reject(finishErr);
+ }
+
+ this.emitLog(messageListener, `Successfully finished pulling image: ${image}`, "info");
+
+ try {
+ // if an explicit imageId was provided, return it
+ if (imageId) {
+ return resolve(imageId);
+ }
+
+ const dockerImage = this.docker.getImage(image);
+ const inspectData = await dockerImage.inspect();
+
+ return resolve(inspectData.Id);
+ } catch (inspectError) {
+ this.emitLog(messageListener, `Failed to inspect image, using fallback reference`, "info");
+
+ return resolve(`unknown-sha-for-${image}`);
+ }
+ }
+
+ private onProgress(progressEvent: any, messageListener: MessageListener) {
+ if (messageListener?.onMessage) {
+ const status = progressEvent.status || '';
+ const id = progressEvent.id ? `[${progressEvent.id}] ` : '';
+ const progress = progressEvent.progress ? ` ${progressEvent.progress}` : '';
+
+ this.emitLog(messageListener, `${id}${status}${progress}`, "info");
+ }
+ }
+}
+
+interface DockerRegistryTemplateDefinition extends Template {
+ image: string;
+}
+
+interface DockerRegistryRepositoryOptions {
+ puller: ImagePuller;
+ templates: DockerRegistryTemplateDefinition[]
+}
+
+export class DockerRegistryTemplateRepository implements TemplateRepository {
+ constructor(
+ private readonly options: DockerRegistryRepositoryOptions,
+ ) {
+ }
+
+ async init(ctx: AppContext): Promise {
+ }
+
+ async prepareImage(templateId: string, _: {
+ [p: string]: string
+ }, imageId?: string, messageListener?: MessageListener): Promise {
+ const template = this.options.templates.find((t) => t.id === templateId);
+ if (!template) {
+ throw new TemplateNotFoundError(templateId);
+ }
+
+ if (imageId) {
+ // TODO: check if the image has changed, otherwise rebuild
+ }
+
+ imageId = await this.options.puller.pullImage(template.image, imageId, messageListener);
+ return imageId;
+ }
+
+ async getTemplate(id: string): Promise {
+ return this.options.templates.find((t) => t.id === id);
+ }
+
+ async getAllTemplates(): Promise {
+ return this.options.templates;
+ }
+}
\ No newline at end of file
diff --git a/src/engine/docker/template.ts b/src/engine/docker/template.ts
new file mode 100644
index 0000000..09e90de
--- /dev/null
+++ b/src/engine/docker/template.ts
@@ -0,0 +1,93 @@
+import {
+ RepositoryRegistration, ServiceEngine,
+ TemplateRepository,
+ TemplateRepositoryConfig,
+ TemplateRepositoryRegistry
+} from "@nsm/engine";
+import {TemplateRepositoryConfigurationError} from "@nsm/engine/error";
+import DockerClient from "dockerode";
+import {FilesystemTemplateRepository} from "@nsm/engine/docker/repository/filesystem/repository";
+import {
+ DockerRegistryImagePuller,
+ DockerRegistryTemplateRepository
+} from "@nsm/engine/docker/repository/registry/repository";
+import z from "zod";
+import {templateModel} from "@nsm/engine/template";
+
+const dockerRegistryConfigModel = z.object({
+ puller: z.object({
+ registry: z.string().optional(),
+ auth: z.object({
+ username: z.string().optional(),
+ password: z.string().optional()
+ }).optional()
+ }).optional(),
+ templates: z.array(
+ templateModel.extend({
+ image: z.string(),
+ })
+ )
+});
+
+/**
+ * A default template repository registry for the docker engine.
+ *
+ * @author ZorTik
+ */
+export class DockerTemplateRepositoryRegistry implements TemplateRepositoryRegistry {
+ private readonly repositories: RepositoryRegistration[];
+
+ constructor(
+ private readonly engine: ServiceEngine,
+ private readonly client: DockerClient,
+ ) {
+ this.repositories = [];
+ }
+
+ async saveRepository(config: TemplateRepositoryConfig) {
+ let repository: TemplateRepository;
+ if (config.type === "filesystem") {
+ repository = new FilesystemTemplateRepository(this.engine);
+ } else if (config.type === "docker-registry") {
+ repository = this.buildDockerRegistryRepository(config);
+ } else {
+ throw new TemplateRepositoryConfigurationError(config.id, `Unsupported repository type: ${config.type}`);
+ }
+
+ this.repositories.push({
+ id: config.id,
+ repository: repository,
+ });
+ }
+
+ private buildDockerRegistryRepository(config: TemplateRepositoryConfig): DockerRegistryTemplateRepository {
+ // validate config
+ try {
+ dockerRegistryConfigModel.parse(config.config);
+ } catch (e) {
+ if (e instanceof z.ZodError) {
+ throw new TemplateRepositoryConfigurationError(
+ config.id,
+ e
+ );
+ }
+
+ throw e;
+ }
+
+ const puller = new DockerRegistryImagePuller(this.client, config.config.puller ?? {});
+
+ return new DockerRegistryTemplateRepository({
+ puller,
+ templates: config.config.templates
+ });
+ }
+
+ getRepository(id: string) {
+ return this.repositories.find((r) => r.id === id);
+ }
+
+ getAllRepositories() {
+ return this.repositories;
+ }
+}
\ No newline at end of file
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..f8dc110 100644
--- a/src/engine/docker/util/logging.ts
+++ b/src/engine/docker/util/logging.ts
@@ -1,15 +1,50 @@
-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
+ level: "error",
+ message,
+ };
+};
+
+/**
+ * Demultiplexes a Docker log buffer (stdout/stderr) into a single string.
+ * Docker headers are 8 bytes: [type (1), 0, 0, 0, size (4)]
+ */
+export const demuxBuffer = (buffer: Buffer): string => {
+ let result = "";
+ let offset = 0;
+
+ while (offset < buffer.length) {
+ if (offset + 8 > buffer.length) break;
+
+ const type = buffer.readUInt8(offset);
+ // 0: stdin, 1: stdout, 2: stderr
+ if (type > 2) {
+ // Not a docker header, or at least not one we recognize as multiplexed
+ return buffer.toString("utf8");
+ }
+
+ const length = buffer.readUInt32BE(offset + 4);
+ offset += 8;
+
+ if (offset + length > buffer.length) {
+ // Partial payload
+ result += buffer.toString("utf8", offset);
+ break;
+ }
+
+ result += buffer.toString("utf8", offset, offset + length);
+ offset += length;
}
-}
\ No newline at end of file
+
+ return result || buffer.toString("utf8");
+};
+
diff --git a/src/engine/engine.ts b/src/engine/engine.ts
index dd77c09..59bede7 100644
--- a/src/engine/engine.ts
+++ b/src/engine/engine.ts
@@ -1,279 +1,377 @@
import DockerClient from "dockerode";
import buildDockerEngine from "./docker";
import {getSingleton} from "../depend";
-import {MetaStorage} from "./manager";
-import {AppConfig} from "@nsm/config";
+import {Template} from "@nsm/engine/template";
+import {AppContext} from "@nsm/app";
+import {TemplateRepositoryConfigurationError} from "@nsm/engine/error";
/**
* 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;
+
+ /**
+ * Called when there is a message from the engine itself, with the message.
+ *
+ * @param message The message from the engine
+ */
+ onEngineMessage?: (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 is closed, either by stop or kill, or by itself.
- */
- onClose?: () => 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;
+};
+
+/**
+ * Per-service storage.
+ * Data set here are being persisted to the relational database and being kept
+ * 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;
+};
+
+export interface TemplateRepository {
+ init(ctx: AppContext): Promise;
+
+ /**
+ * Prepares an image from the template with the given arguments, and returns the image ID.
+ *
+ * @param templateId The template ID used (from this repository)
+ * @param args The template args provided
+ * @param imageId The image ID to use. If this is undefined, the engine should generate a random image ID and return it.
+ * @param messageListener A message listener for logs propagation during image preparation
+ * @return The prepared image ID
+ * @throws TemplateNotFoundError if the template with the given ID is not found in this repository
+ * @throws Error if the image cannot be prepared for any reason
+ */
+ prepareImage(
+ templateId: string,
+ args: { [key: string]: string },
+ imageId?: string,
+ messageListener?: MessageListener
+ ): Promise;
+
+ /**
+ * Gets the template by ID.
+ *
+ * @param id The template ID
+ * @return The template, or undefined if not exists
+ */
+ getTemplate(id: string): Promise;
+
+ getAllTemplates(): Promise;
}
-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 };
+export interface RepositoryRegistration {
+ id: string;
+ repository: TemplateRepository;
}
-export type ServiceEngineI = ServiceEngine & { // Internal
- cast(): T;
+export interface TemplateRepositoryRegistry {
+
+ /**
+ * Set up and save the template repository based on the configuration.
+ *
+ * @param config The template repository configuration
+ * @throws TemplateRepositoryConfigurationError if the configuration is invalid or the repository cannot be set up
+ */
+ saveRepository(config: TemplateRepositoryConfig): Promise;
+
+ /**
+ * Get the template repository by ID.
+ *
+ * @param id The template repository ID
+ * @returns The template repository, or undefined if not exists
+ */
+ getRepository(id: string): RepositoryRegistration | undefined;
+
+ /**
+ * Get all template repositories.
+ *
+ * @return An array of all template repositories.
+ */
+ getAllRepositories(): RepositoryRegistration[];
+}
+
+export interface TemplateRepositoryConfig {
+ id: string;
+ type: string;
+ config: { [key: string]: any };
}
+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 };
+};
+
+export type ServiceEngineI = ServiceEngine & {
+ // Internal
+ cast(): T;
+};
+
/**
* The lowest layer which manipulates containers (services) directly.
* This is called by NSM whenever NSM needs to do something with the
* containers themselves.
*/
export type ServiceEngine = {
- // 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;
-
- /**
- * 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;
-
- /**
- * 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;
-
- /**
- * 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;
-
- /**
- * 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 }>;
-
- /**
- * 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;
-
- listAttachedPorts(): Promise;
-
- stat(id: string): Promise;
-
- statAll(filter: ContainerFilter): Promise;
-
- // Disk usage of all services here
- // [0]: free, [1]: size
- calcHostUsage(): Promise;
-}
+ // Just for display purposes
+ name: string;
+ templateRepositoryRegistry: TemplateRepositoryRegistry;
+
+ /**
+ * 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;
+
+ /**
+ * Stops a container.
+ *
+ * @param id Container ID
+ * @return Success state
+ */
+ stop(id: string): Promise;
+
+ /**
+ * Kills a container.
+ *
+ * @param id Container ID
+ * @return Success state
+ */
+ kill(id: string): 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.
+ *
+ * @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;
+
+ /**
+ * 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 }>;
+
+ /**
+ * 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;
+
+ listAttachedPorts(): Promise;
+
+ stat(id: string): Promise;
+
+ statAll(filter: ContainerFilter): 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,
+ },
+ };
+ },
+
+ service(serviceId: string) {
+ return {
+ labels: {
+ ...this.nsm().labels,
+ [StandardLabel.ServiceId]: serviceId,
+ }
}
-}
+ }
+};
/**
* Combines multiple run listeners into one, by calling them in sequence.
@@ -281,39 +379,66 @@ 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);
+ }
+ },
+ onEngineMessage: async (record) => {
+ for (let listener of listeners) {
+ await listener.onEngineMessage?.(record);
+ }
+ },
+ onClose: () => {
+ for (let listener of listeners) {
+ listener.onClose?.();
+ }
+ },
+ };
+};
+
+/**
+ * Initializes the service engine based on the configuration.
+ *
+ * @param ctx The application context.
+ * @returns The initialized service engine instance.
+ * @throws Error if the engine ID specified in the configuration is invalid.
+ */
+export const initEngine = async (ctx: AppContext): Promise => {
+ let engine = getSingleton("engine");
+ if (!engine) {
+ const engineId = process.env.NSM_ENGINE ?? "docker";
+ switch (engineId) {
+ case "docker":
+ engine = buildDockerEngine(ctx.appConfig);
+ break;
+ default:
+ throw new Error("Invalid engine ID: " + engineId);
}
-}
+ }
+
+ const repositoryRegistry = engine.templateRepositoryRegistry;
+ for (let config of ctx.appConfig.getTemplateRepositoryConfigs()) {
+ await repositoryRegistry.saveRepository(config);
-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);
- }
+ const repositoryRegistration = repositoryRegistry.getRepository(config.id);
+ if (!repositoryRegistration) {
+ // it just didn't register
+ throw new TemplateRepositoryConfigurationError(config.id);
}
- return {
- cast: undefined, // Being set in manager
- ...engine,
- };
+
+ // init repository
+ await repositoryRegistration.repository.init(ctx);
+ }
+
+ return {
+ cast: undefined, // Being set in manager
+ ...engine,
+ };
}
\ No newline at end of file
diff --git a/src/engine/error.ts b/src/engine/error.ts
new file mode 100644
index 0000000..c492606
--- /dev/null
+++ b/src/engine/error.ts
@@ -0,0 +1,86 @@
+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 ServiceEngineError extends InternalError {
+ constructor(
+ public readonly cause: Error
+ ) {
+ super("An error occurred in the service engine. Cause: " + cause.message);
+ }
+}
+
+export class TemplateNotFoundError extends KnownError {
+ constructor(
+ public readonly templateId: string
+ ) {
+ super(404, `Template with ID ${templateId} not found.`);
+ }
+}
+
+export class TemplateRepositoryConfigurationError extends InternalError {
+ constructor(
+ public readonly repository: string,
+ public readonly cause?: Error | string,
+ ) {
+ super(`Failed to configure template repository '${repository}'.` + (cause ? ` Cause: ${
+ cause instanceof Error ? cause.message : String(cause)
+ }` : ""));
+ }
+}
\ No newline at end of file
diff --git a/src/engine/facade.ts b/src/engine/facade.ts
new file mode 100644
index 0000000..b64d29b
--- /dev/null
+++ b/src/engine/facade.ts
@@ -0,0 +1,88 @@
+import {Service} from "@nsm/engine/service";
+import {ServiceSession} from "@nsm/engine/session";
+import {InternalSession} from "@nsm/engine/runner";
+import {PermaModel} from "@nsm/persistence";
+
+import * as manager from "@nsm/engine/service";
+import * as runner from "@nsm/engine/runner";
+
+export type ServiceInfo = Service & {
+ state: State;
+ session?: ServiceSession;
+ internalSession?: InternalSession;
+}
+
+export type State = "BUILDING" | "RUNNING" | "STOPPING" | "STOPPED";
+
+export interface Facade {
+ /**
+ * Deletes a service by its ID. If the service is currently running, it will be stopped before deletion.
+ *
+ * @param id The ID of the service to delete.
+ */
+ deleteService(id: string): Promise;
+
+ /**
+ * Retrieves information about a service, including its current state and session information if requested.
+ *
+ * @param from The identifier for the service, which can be either a string ID or a PermaModel instance.
+ * @param options Optional parameters for retrieving service information.
+ * @returns A promise that resolves to the service information, or undefined if the service is not found.
+ */
+ getServiceInfo(
+ from: string | PermaModel,
+ options?: { includeSession?: boolean },
+ ): Promise;
+
+ /**
+ * Retrieves the current state of a service by its ID.
+ *
+ * @param id The ID of the service to check the state of.
+ * @returns A promise that resolves to the current state of the service.
+ */
+ getServiceState(id: string): Promise;
+}
+
+export const deleteService: Facade["deleteService"] = async (id) => {
+ if (runner.isStarting(id) || runner.isRunning(id)) {
+ // if running, stop the service first before deleting
+ const task = await runner.stopService(id, true);
+ await task.promise;
+ }
+
+ await runner.clearService(id);
+ await manager.deleteService(id);
+}
+
+export const getServiceInfo: Facade["getServiceInfo"] = async (from, options) => {
+ const service = await manager.getService(from);
+ if (!service) {
+ return undefined;
+ }
+
+ const runningService = options?.includeSession
+ ? runner.getRunningService(service.serviceId)
+ : null;
+
+ return {
+ ...service,
+ state: await getServiceState(service.serviceId),
+ session: runningService ? runningService.session : undefined,
+ internalSession: runningService ? runningService.internalSession : undefined,
+ }
+}
+
+export const getServiceState: Facade["getServiceState"] = async (id) => {
+ if (runner.isStopping(id)) {
+ return "STOPPING";
+ }
+
+ const stage = runner.getServiceStage(id);
+ if (stage) {
+ return stage.state.ready ? "RUNNING" : "BUILDING";
+ } else if (runner.isStarting(id)) {
+ return "BUILDING";
+ } else {
+ return "STOPPED";
+ }
+}
\ 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/index.ts b/src/engine/index.ts
index abdc154..e4aeca6 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 "./service";
+export * from "./engine";
diff --git a/src/engine/manager.ts b/src/engine/manager.ts
deleted file mode 100644
index 8e7b99b..0000000
--- a/src/engine/manager.ts
+++ /dev/null
@@ -1,948 +0,0 @@
-import {currentContext} from "../app";
-import createEngine, {
- RunOptions,
- RunListener,
- ServiceEngineI,
- StandardLabel,
- Filters, combineRunListeners
-} from "./engine";
-import {Template, getTemplate as loadTemplate, getAllTemplates} from "./template";
-import * as templateManager from "./template";
-import * as templateDirWatcher from "./monitoring/templateDirWatcher";
-import crypto from "crypto";
-import {randomPort as retrieveRandomPort} from "@nsm/util/port";
-import {Database, PermaModel} from "../database";
-import {
- isServicePending,
- lckStatusTp,
- lockBusyAction,
- reqNotPending,
- ulckStatusTp,
- 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";
-
-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?: {
- /**
- * 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,
- }
-}
-
-/**
- * Per-service storage.
- * Data set here are being persisted to the relational database and being kept
- * 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;
-}
-
-export type EngineExpansion = {
- [k in keyof ServiceEngineI | string]: any;
-};
-
-type ServiceEvent = {
- id: string;
- error?: Error;
-}
-
-type ServiceManagerEvents = {
- resume: ServiceEvent;
- stop: ServiceEvent;
-}
-
-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?: {
- /**
- * Filter services by their meta attributes.
- */
- 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
-};
-
-type RunningService = {
- id: string;
- session: ServiceSession;
- internalSession: InternalSession;
-}
-
-export type InternalSession = {
- 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;
-
-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 let engine: ServiceEngineI = undefined;
-export let nodeId: string;
-
-let db: Database;
-
-// Save errors somewhere else?
-// Could it be a memory leak if there are tons of them??
-const errors = {};
-// Service IDs that are currently running
-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);
-
- // 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();
- }
- nodeId = nodeId_ as string;
-
- initImageEngine(engine, templateManager, templateDirWatcher, db_, currentContext.logger);
- initSessionEngine(db_);
- watchTemplateDirChanges(currentContext.logger);
-
- 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
-}
-
-/**
- * Reattach to containers that are still running from the previous session.
- * This may happen if NSM was force-stopped and not properly cleared up resources.
- *
- * @param logger The logger to use
- */
-async function reattachStaleContainers(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)));
-
- 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);
- }
-
- 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));
-
- // 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)));
-}
-
-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 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');
- }
-
- if (err) {
- // Save to be later retrieved
- errors[serviceId] = err;
- currentContext.logger.error(err.message);
- }
-
- if (err) {
- throw err;
- } else {
- return serviceId;
- }
-}
-
-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 }), {}),
- }
-
-
- 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
- // 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;
-
- // 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);
- }
-
- let success: boolean = false;
- if (containerId) {
- const runningService: RunningService = {
- id,
- session,
- internalSession: {
- containerId
- }
- };
- started.push(runningService);
- success = true;
- }
-
- 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] });
- }
-
- unlock();
-
- return true;
-}
-
-export async function stopService(id: string, force?: boolean) {
- await reqExists(id);
-
- const { internalSession } = reqRunning(id);
-
- lckStatusTp(internalSession.containerId, 'stop');
- const unlock = lockBusyAction(id, 'stop');
-
- 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);
- }
- } catch (e) {
- currentContext.logger.error(e);
-
- callManagerEvent('stop', { id, error: e });
- }
-}
-
-export async function stopServiceForcibly(id: string) {
- return stopService(id, true);
-}
-
-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 async function deleteService(id: string) {
- try {
- await stopService(id, true);
- } catch (e) {
- // Skip not running error
- if (!(e.code && e.code == 2)) {
- 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);
- }
- }
- };
-
- 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 db.permaRepository.savePerma(data);
-}
-
-export function getTemplate(id: string) {
- return loadTemplate(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 function getLastPowerError(id: string) {
- return errors[id];
-}
-
-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 async function listTemplates(): Promise {
- return getAllTemplates().map(template => template.id);
-}
-
-export async function stopRunning() {
- const tasks = started.map(({id}) => (
- new Promise((resolve) => {
- whenUnlocked(id, () => {
- stopService(id)
- .catch(e => currentContext.logger.error(e))
- .then(() => {
- whenUnlocked(id, () => resolve(null));
- });
- });
- })
- ));
-
- await Promise.all(tasks);
-}
-
-export async function waitForBusyAction(id: string) {
- return new Promise(
- (resolve, reject) => {
- whenUnlocked(id, (_, __, err) => err ? reject(err) : resolve(null));
- }
- );
-}
-
-export function isRunning(id: string) {
- return getRunningService(id) != undefined;
-}
-
-export function getRunningService(id: string) {
- return started.find(service => service.id === id);
-}
-
-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 function initialized() {
- return engine !== undefined;
-}
-
-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;
-}
-
-export function getRunningServices() {
- return [...started];
-}
-
-export function on(evt: T, h: EventHandler) {
- if (!evtHandlers.has(evt)) {
- evtHandlers.set(evt, []);
- }
- evtHandlers.get(evt).push(h);
-}
-
-export {
- whenUnlocked
-}
-
-function clearRunningServiceIfExists(id: string) {
- const service = getRunningService(id);
-
- 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);
-
- return typeof result != 'boolean' || !result;
- });
- evtHandlers.set(e, newArray);
-}
-
-/**
- * Collects all relevant run listeners and builds a composite one
- * to be used directly when running/attaching service container.
- *
- * @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");
- }
- };
- // Combine collected listeners
- return combineRunListeners([
- internalRunListener,
- // Add listener from the session
- session.runListener
- ])
-}
-
-/**
- * Returns the local service state managed by this manager.
- *
- * @param id The id of the service.
- * @returns The state of the service
- */
-function getServiceState(id: string) {
- 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);
- }
-
- return perma;
-}
-
-function reqRunning(id: string) {
- const session = getRunningService(id);
- if (!session) {
- throw new _InternalError("This service is not running.", 2);
- }
-
- return session;
-}
-
-function reqNotRunning(id: string) {
- if (isRunning(id)) {
- throw new _InternalError('Already running.', 2);
- }
-}
-
-function reqTemplate(id: string) {
- const template = getTemplate(id);
- if (!template) {
- throw new _InternalError('Template not found.', 3);
- }
-
- return template;
-}
\ No newline at end of file
diff --git a/src/engine/middle.ts b/src/engine/middle.ts
index 2b736c6..b761655 100644
--- a/src/engine/middle.ts
+++ b/src/engine/middle.ts
@@ -1,7 +1,14 @@
-import {ServiceManager} from "@nsm/engine/manager";
import {currentContext} from "@nsm/app";
+import {KnownError} from "@nsm/engine/error";
+import {AsyncTask} from "@nsm/util/promises";
+import {ServiceRunner} from "@nsm/engine/runner";
+import {AppConfig} from "@nsm/config";
+import {isDebug} from "@nsm/helpers";
-export type ServiceActionType = 'create' | 'resume' | 'stop' | 'forceStop' | 'sendStopSignal' | 'delete';
+export type ServiceActionType =
+ | "resume"
+ | "stop"
+ | "clear";
/**
* Represents an error that occurred during a service action.
@@ -10,10 +17,10 @@ export interface ServiceActionError {
serviceId?: string;
type: ServiceActionType;
message: string;
+ internal: boolean;
}
export interface ErrorPublisher {
-
/**
* Publishes an error that occurred during a service action.
*
@@ -35,15 +42,24 @@ const publishers: ErrorPublisher[] = [
*/
export const registerErrorPublisher = (publisher: ErrorPublisher) => {
publishers.push(publisher);
+};
+
+/**
+ * Registers error publishers based on the app configuration.
+ *
+ * @param config The application configuration used to determine which error publishers to register.
+ */
+export const registerErrorPublishersFromConfig = async (config: AppConfig) => {
+ // TODO: publishers
}
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,48 +72,125 @@ 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, false));
+ }
+
+ 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);
+ }
+ };
+};
- currentContext.logger.error(`${action.serviceId ? `Service ${action.serviceId} f` : "F"}ailed action ${action.type}: ${action.message}`, 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,
+ rethrow: boolean = true
+) => {
+ const action: ServiceActionError = {
+ serviceId: serviceIdExtractor?.(args),
+ type: actionType,
+ message: e instanceof Error ? e.message : String(e),
+ internal: !(e instanceof KnownError),
+ };
+ await publishError(action);
+
+ // don't log stack trace of known errors
+ const errorMeta: any[] = e instanceof KnownError ? [] : [e];
+ const loggerInput: [string, ...string[]] = [
+ `${action.serviceId ? `Service ${action.serviceId} f` : "F"}ailed action ${action.type}: ${action.message}`,
+ ...errorMeta,
+ ]
+ if (action.internal) {
+ currentContext.logger.error(...loggerInput);
+ } else if (isDebug()) {
+ currentContext.logger.debug(...loggerInput);
+ }
- throw e;
- }
+ if (rethrow) {
+ throw e;
}
}
/**
- * Wraps a {@link ServiceManager} instance with additional capabilities.
- * Asynchronous service lifecycle methods are decorated to allow
- * additional behavior.
+ * Creates a service ID extractor function that extracts the service ID from the
+ * specified argument index of the function arguments.
*
- * @param manager The original ServiceManager instance to wrap.
- * @returns A new ServiceManager instance with decorated methods.
+ * @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.
*/
-export const middleLayer = (manager: ServiceManager): ServiceManager => {
- return {
- ...manager,
-
- createService: decorateFunc(manager.createService, "create", null),
-
- resumeService: decorateFunc(manager.resumeService, "resume"),
+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");
+ }
- stopService: decorateFunc(manager.stopService, "stop"),
+ 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}`,
+ );
+ }
- stopServiceForcibly: decorateFunc(manager.stopServiceForcibly, "forceStop"),
+ const serviceId = argsArray[argIndex];
+ if (typeof serviceId !== "string") {
+ throw new Error(
+ `Expected service ID argument to be a string, but got ${typeof serviceId}`,
+ );
+ }
- sendStopSignal: decorateFunc(manager.sendStopSignal, "sendStopSignal"),
+ return serviceId;
+ };
+};
- deleteService: decorateFunc(manager.deleteService, "delete"),
- }
-}
\ No newline at end of file
+/**
+ * Wraps a {@link ServiceRunner} instance with additional capabilities.
+ * Asynchronous service lifecycle methods are decorated to allow
+ * additional behavior.
+ *
+ * @param runner The original ServiceRunner instance to wrap.
+ * @returns A new ServiceRunner instance with decorated methods.
+ */
+export const middleLayer = (runner: ServiceRunner): ServiceRunner => {
+ return {
+ ...runner,
+
+ resumeService: decorateFunc(
+ runner.resumeService,
+ "resume",
+ argServiceIdExtractor(0),
+ ),
+
+ stopService: decorateFunc(
+ runner.stopService,
+ "stop",
+ argServiceIdExtractor(0),
+ ),
+
+ clearService: decorateFunc(
+ runner.clearService,
+ "clear",
+ argServiceIdExtractor(0),
+ )
+ };
+};
diff --git a/src/engine/runner.ts b/src/engine/runner.ts
new file mode 100644
index 0000000..b83ad1e
--- /dev/null
+++ b/src/engine/runner.ts
@@ -0,0 +1,842 @@
+import {AsyncTask} from "@nsm/util/promises";
+import {ActiveServiceSession, beginServiceSession, ServiceSession} from "@nsm/engine/session";
+import {
+ getActionType,
+ isServicePending,
+ lockBusyAction,
+ unlockBusyAction,
+ whenUnlocked,
+ whenUnlockedAll
+} from "@nsm/engine/asyncp";
+import {
+ combineRunListeners,
+ Filters,
+ MetaStorage,
+ RunListener,
+ RunOptions, ServiceEngine,
+ ServiceState,
+ StandardLabel
+} from "@nsm/engine/engine";
+import {
+ InternalError,
+ ServiceAlreadyRunningError, ServiceEngineError,
+ ServiceNotFoundError, ServiceNotRunningError, ServicePendingActionError,
+ TemplateNotFoundError
+} from "@nsm/engine/error";
+import {Service, ServiceManager} from "@nsm/engine/service";
+import {Template, TemplateManager} from "@nsm/engine/template";
+import {Database} from "@nsm/persistence";
+import {isDebug} from "@nsm/helpers";
+import winston from "winston";
+import {AppConfig} from "@nsm/config";
+import {ParamsResolver, ServiceArgs} from "@nsm/util/args";
+
+type ServiceEvent = {
+ id: string;
+ error?: Error;
+};
+
+type ServiceStateChangeEvent = ServiceEvent & {
+ state: ServiceState;
+}
+
+type ServiceEngineErrorEvent = ServiceEvent & {
+ error: Error;
+}
+
+type ServiceRunnerEvents = {
+ resume: ServiceEvent;
+ stop: ServiceEvent;
+ statechange: ServiceStateChangeEvent;
+ engine_err: ServiceEngineErrorEvent;
+};
+
+/**
+ * The event handler for service runner events.
+ * If the handler returns true or nothing, it will be unsubscribed after this call.
+ */
+type EventHandler = (
+ event: ServiceRunnerEvents[T],
+) => boolean | void;
+
+interface StopStrategyProvider {
+ /**
+ * Get the stop strategy for a service.
+ *
+ * @param service The service for which to get the stop strategy
+ * @returns The stop strategy for the service
+ */
+ getStopStrategy(service: Service): Promise;
+}
+
+class MetaStopStrategyProvider implements StopStrategyProvider {
+
+ async getStopStrategy(service: Service) {
+ const metaKey = "internal/stop-command";
+
+ if (service.meta[metaKey]) {
+ return new StopCommandStopStrategy(service.meta[metaKey]);
+ } else {
+ return new DefaultStopStrategy();
+ }
+ }
+}
+
+interface StopStrategy {
+ /**
+ * Stop a service.
+ *
+ * @param service The service to stop
+ */
+ stop(service: Service): Promise;
+}
+
+class StopCommandStopStrategy implements StopStrategy {
+ constructor(
+ private readonly command: string,
+ ) {}
+
+ async stop(service: Service) {
+ const runningService = getRunningService(service.serviceId);
+ if (!runningService) {
+ throw new ServiceNotRunningError(service.serviceId);
+ }
+
+ const callEngine = createEngineCaller(
+ "stop",
+ (e) => ({ id: service.serviceId, error: e })
+ );
+ await callEngine(() => engine.cmd(runningService.internalSession.containerId, this.command));
+ }
+}
+
+class DefaultStopStrategy implements StopStrategy {
+
+ async stop(service: Service) {
+ const runningService = getRunningService(service.serviceId);
+ if (!runningService) {
+ throw new ServiceNotRunningError(service.serviceId);
+ }
+
+ const callEngine = createEngineCaller(
+ "stop",
+ (e) => ({ id: service.serviceId, error: e })
+ );
+ await callEngine(() => engine.stop(runningService.internalSession.containerId));
+ }
+}
+
+interface ServiceRunnerEventBus {
+ on(evt: T, h: EventHandler): void;
+}
+
+export interface ServiceRunner extends ServiceRunnerEventBus {
+ engine: ServiceEngine;
+
+ /**
+ * 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>;
+
+ /**
+ * Clear a service, that is, delete all its resources.
+ *
+ * @param id The service ID
+ */
+ clearService(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;
+
+ /**
+ * Get the current stage of a service, that is, currently being handled by the runner.
+ *
+ * @param id The service ID
+ */
+ getServiceStage(id: string): HandledServiceStage | undefined;
+
+ /**
+ * Get the last power error of a service.
+ *
+ * @param id The service ID
+ */
+ getLastPowerError(id: string): Error | undefined;
+
+ /**
+ * Stop all running services on this instance.
+ */
+ stopRunning(): Promise;
+
+ /**
+ * Kill all running services on this instance.
+ */
+ killRunning(): Promise;
+
+ isRunning(id: string): boolean;
+
+ isStarting(id: string): boolean;
+
+ isStopping(id: string): boolean;
+
+ waitForBusyAction(id: string): Promise;
+
+ waitForStopped(id: string): Promise;
+}
+
+type HandledServiceStage = {
+ state: ServiceState;
+}
+
+type RunningService = {
+ id: string;
+ session: ServiceSession;
+ internalSession: InternalSession;
+ state?: ServiceState;
+};
+
+export type InternalSession = {
+ containerId: string;
+ // TODO: add more useful information?
+};
+
+export let engine: ServiceEngine;
+
+let nodeId: string;
+let templateManager: TemplateManager;
+let serviceManager: ServiceManager;
+let stopStrategyProvider: StopStrategyProvider;
+let db: Database;
+let logger: winston.Logger;
+
+// Service IDs that are currently running
+const started: RunningService[] = [];
+const startedStages: Map = new Map();
+// TODO: Save errors somewhere else?
+// Could it be a memory leak if there are tons of them??
+const errors = {};
+const evtHandlers: Map[]> = new Map();
+
+["push", "splice"].forEach((funcName) => {
+ started[funcName] = (...args: any[]) => {
+ const result = Array.prototype[funcName].apply(started, args);
+
+ // Emit services change within those methods
+ if (isDebug()) {
+ logger.debug("Service registry changed");
+ }
+
+ return result;
+ };
+});
+
+export const init = async (
+ engine_: ServiceEngine,
+ appConfig: AppConfig,
+ templateManager_: TemplateManager,
+ serviceManager_: ServiceManager,
+ db_: Database,
+ logger_: winston.Logger,
+) => {
+ engine = engine_;
+ nodeId = appConfig.getNodeId();
+ templateManager = templateManager_;
+ serviceManager = serviceManager_;
+ stopStrategyProvider = new MetaStopStrategyProvider();
+ db = db_;
+ logger = logger_;
+
+ registerLoggingEventHandlers();
+ gatherEngineErrors();
+ await deleteGarbage(logger);
+ await reattachStaleContainers(logger);
+}
+
+const deleteGarbage = async (logger: winston.Logger) => {
+ // TODO: delete containers that are not running and remained from last session
+}
+
+/**
+ * Reattach to containers that are still running from the previous session.
+ * This may happen if NSM was force-stopped and not properly cleared up resources.
+ *
+ * @param logger The logger to use
+ */
+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,
+ ),
+ ),
+ );
+
+ 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, killing.`,
+ );
+
+ await engine.kill(containerId);
+ continue;
+ }
+
+ const serviceId = labels[StandardLabel.ServiceId];
+
+ // We must begin a new session since the previous was interrupted
+ let session: ActiveServiceSession;
+ try {
+ session = await beginServiceSession(serviceId);
+ } catch (e) {
+ if (e instanceof ServiceNotFoundError) {
+ logger.warn(
+ `Found a running container ${containerId} for service ${serviceId}, but the service was not found
+ in database, killing the container and clearing resources.`,
+ );
+ await clearService(serviceId);
+ continue;
+ }
+ }
+ // 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}`);
+ }
+
+ await new Promise((resolve) => whenUnlockedAll(() => resolve(null)));
+}
+
+/**
+ * Registers event handlers for logging in debug mode.
+ */
+const registerLoggingEventHandlers = () => {
+ const notifyIfSuccess = (
+ messageProvider: (serviceId: string) => string
+ ): EventHandler => {
+ return ({ id, error }) => {
+ if (error) {
+ return;
+ }
+
+ logger.debug(messageProvider(id));
+ }
+ }
+
+ on("resume",
+ notifyIfSuccess((id) => `Service ${id} resumed`));
+ on("stop",
+ notifyIfSuccess((id) => `Service ${id} stopped`));
+}
+
+const gatherEngineErrors = () => {
+ on("engine_err", (event) => {
+ errors[event.id] = event.error;
+ });
+}
+
+export const resumeService: ServiceRunner["resumeService"] = async (id) => {
+ if (isRunning(id)) {
+ throw new ServiceAlreadyRunningError(id);
+ }
+
+ const service = await serviceManager.getService(id);
+ if (!service) {
+ throw new ServiceNotFoundError(id);
+ }
+
+ let {
+ options,
+ args,
+ network,
+ port,
+ ...rest
+ } = service;
+
+ const template = await templateManager.getTemplate(rest.template);
+ if (!template) {
+ throw new TemplateNotFoundError(rest.template);
+ }
+
+ let { container: { env: envTemplate, resources } } = template.config;
+
+ args = prepareArgsForRun(args, template);
+
+ const meta = buildMetaStorage(id);
+
+ const runOptions: RunOptions = {
+ ram: options.ram ?? resources.limits.ram,
+ cpu: options.cpu ?? resources.limits.cpu,
+ disk: options.disk ?? resources.limits.disk,
+ env: {},
+ port,
+ ports: options.ports ?? [],
+ network,
+ labels: {
+ [StandardLabel.Nsm]: "true",
+ [StandardLabel.ServiceId]: id,
+ [StandardLabel.NodeId]: nodeId,
+ [StandardLabel.VolumeId]: id,
+ [StandardLabel.TemplateId]: template.id,
+ },
+ };
+ runOptions.env = prepareEnvForRun(envTemplate, service, args, runOptions);
+
+ 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 (image != service.imageId) {
+ const storedImage = await db.imageRepository.getImage(image);
+ if (!storedImage) {
+ // image was not stored during processing
+ await db.imageRepository.saveImage({
+ id: image,
+ templateId: template.id,
+ buildOptions: args,
+ });
+ }
+
+ // Update image in database if it was changed by processing
+ const updated = await serviceManager.updateService(service.serviceId, { imageId: image });
+ if (!updated) {
+ throw new InternalError(`Failed to update image ID for service ${service.serviceId}`);
+ }
+ }
+
+ return image;
+ }
+
+ const templateRepository = engine.templateRepositoryRegistry.getRepository(
+ template.sourceRepositoryId
+ )?.repository;
+ if (!templateRepository) {
+ throw new InternalError(`Failed to get template repository for template ${template.id} with
+ source repository id ${template.sourceRepositoryId}`);
+ }
+
+ const unlock = lockBusyAction(id, "resume");
+
+ const session = await beginServiceSession(id);
+ const runListener = buildRunListener(session);
+ const task = templateRepository.prepareImage(template.id, args, service.imageId, runListener)
+ .then(updateImageIfChanged)
+ .then(async (image) => {
+ // 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,
+ runListener,
+ );
+ started.push({
+ id,
+ session,
+ internalSession: {
+ containerId,
+ },
+ });
+
+ callManagerEvent("resume", { id });
+ } catch (e) {
+ callManagerEvent("resume", { id, error: e });
+ callServiceEngineError(id, e);
+
+ clearRunningServiceIfExists(id);
+
+ throw new ServiceEngineError(e);
+ }
+ })
+ .catch(async (e) => {
+ // TODO: close session when it's implemented
+
+ throw e;
+ })
+ .finally(() => unlock());
+
+ return new AsyncTask(task);
+}
+
+const prepareArgsForRun = (args: { [key: string]: string }, template: Template) => {
+ const settingsArgs = template.config.args;
+
+ // 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.
+ args = {
+ ...Object.entries(args)
+ .filter(([key]) => settingsArgs && key in settingsArgs)
+ .reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {}),
+ };
+ return args;
+}
+
+const prepareEnvForRun = (
+ envTemplate: { [key: string]: string },
+ service: Service,
+ args: { [key: string]: string },
+ runOptions: RunOptions
+) => {
+ // preprocess placeholders in the configured env template
+ const serviceArgs: ServiceArgs = {
+ id: service.serviceId,
+ port: runOptions.port.toString(),
+ ports: runOptions.ports.join(" "),
+ ram: runOptions.ram.toString(),
+ cpu: runOptions.cpu.toString(),
+ disk: runOptions.disk.toString(),
+ };
+
+ const resolver = new ParamsResolver(envTemplate);
+ resolver.setArgs(args);
+ resolver.setServiceArgs(serviceArgs);
+ envTemplate = resolver.getParams();
+
+ return {
+ ...envTemplate,
+ SERVICE_ID: serviceArgs.id,
+ SERVICE_PORT: serviceArgs.port,
+ SERVICE_PORTS: serviceArgs.ports,
+ SERVICE_RAM: serviceArgs.ram,
+ SERVICE_CPU: serviceArgs.cpu,
+ SERVICE_DISK: serviceArgs.disk,
+ }
+}
+
+/**
+ * Creates a wrapper for calling engine methods, which handles errors and calls the appropriate events.
+ *
+ * @param action The action type for which to call the events in case of error
+ * @param onErrorEventFactory A factory function that creates the event to be called in case of error, based on the error that happened
+ * @returns A function that takes a task to be executed
+ */
+const createEngineCaller = (
+ action: T,
+ onErrorEventFactory: (e: Error) => ServiceRunnerEvents[T]
+) => {
+ return async (task: () => Promise): Promise => {
+ try {
+ return await task();
+ } catch (e) {
+ logger.error(e);
+ callManagerEvent(action, onErrorEventFactory(e));
+
+ throw new ServiceEngineError(e);
+ }
+ }
+}
+
+export const stopService: ServiceRunner["stopService"] = async (id, force) => {
+ const service = await serviceManager.getService(id);
+ if (!service) {
+ throw new ServiceNotFoundError(id);
+ }
+
+ const runningService = getRunningService(id);
+ if (!runningService) {
+ throw new ServiceNotRunningError(id);
+ }
+
+ const callEngine = createEngineCaller("stop", (e) => ({ id, error: e }));
+
+ let awaitingPromise: Promise;
+ if (force) {
+ const pendingAction = getActionType(id);
+ if (pendingAction && pendingAction !== "stop") {
+ // the service is locked and not stopping, the force stop can't be allowed
+ throw new ServicePendingActionError(id, pendingAction);
+ }
+
+ await callEngine(async () => engine.kill(runningService.internalSession.containerId));
+ // 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;
+ }
+
+ if (isServicePending(id)) {
+ unlock(error);
+ }
+ resolve();
+ return true;
+ });
+ });
+
+ const stopStrategy = await stopStrategyProvider.getStopStrategy(service);
+ await stopStrategy.stop(service);
+ }
+ awaitingPromise = awaitingPromise.then(() => waitForStopped(id));
+
+ return new AsyncTask(awaitingPromise);
+}
+
+export const clearService: ServiceRunner["clearService"] = async (id) => {
+ try {
+ const task = await stopService(id, true);
+ await task.promise;
+ } catch (e) {
+ if (e instanceof ServiceNotRunningError) {
+ // ignore
+ } else {
+ throw e;
+ }
+ }
+
+ const containerIds = await engine.listContainers(Filters.service(id));
+ for (let containerId of containerIds) {
+ try {
+ await engine.kill(containerId);
+ } catch (e) {
+ throw new ServiceEngineError(e);
+ }
+ }
+
+ try {
+ const deleted = await engine.deleteVolume(id);
+ if (!deleted) {
+ logger.warn(`Failed to delete volume for service ${id}`);
+ }
+ } catch (e) {
+ throw new ServiceEngineError(e);
+ }
+}
+
+export const getRunningService: ServiceRunner["getRunningService"] = (id) => {
+ return started.find((service) => service.id === id);
+}
+
+export const getServiceStage: ServiceRunner["getServiceStage"] = (id) => {
+ return startedStages.get(id);
+}
+
+export const isRunning: ServiceRunner["isRunning"] = (id: string) => {
+ return getRunningService(id) != undefined;
+}
+
+export const isStarting: ServiceRunner["isStarting"] = (id: string) => {
+ return getActionType(id) === "resume";
+}
+
+export const isStopping: ServiceRunner["isStopping"] = (id: string) => {
+ return getActionType(id) === "stop";
+}
+
+/**
+ * Builds the meta storage for a service, which is used for storing and retrieving internal metadata for the service.
+ *
+ * @param serviceId The ID of the service for which to build the meta storage.
+ */
+const buildMetaStorage = (serviceId: string): MetaStorage => {
+ // service id
+ return {
+ set: async (key, value) => {
+ return db.serviceMetaRepository.setServiceMeta(serviceId, key, value);
+ },
+ get: async (key, def) => {
+ const meta = await db.serviceMetaRepository.getServiceMeta(serviceId, key);
+
+ return meta ?? def;
+ },
+ };
+}
+
+/**
+ * Collects all relevant run listeners and builds a composite one
+ * to be used directly when running/attaching service container.
+ *
+ * @param session The session for whom to create the session.
+ */
+const buildRunListener = (session: ActiveServiceSession): RunListener => {
+ const { serviceId } = session;
+
+ // The internal run listener of this manager
+ const internalRunListener: RunListener = {
+ onStateChange: (state) => {
+ const stage = startedStages.get(serviceId);
+ if (stage) {
+ stage.state = state;
+ } else {
+ startedStages.set(serviceId, { state });
+ }
+
+ callManagerEvent("statechange", { id: serviceId, state });
+ },
+ onClose: async () => {
+ clearRunningServiceIfExists(serviceId);
+ startedStages.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
+ }
+ }
+
+ callManagerEvent("stop", { id: serviceId });
+ },
+ };
+ // Combine collected listeners
+ return combineRunListeners([
+ internalRunListener,
+ // Add listener from the session
+ session.runListener,
+ ]);
+}
+
+export const on: ServiceRunner["on"] = (
+ evt: T,
+ h: EventHandler,
+) => {
+ if (!evtHandlers.has(evt)) {
+ evtHandlers.set(evt, []);
+ }
+ evtHandlers.get(evt).push(h);
+}
+
+const callManagerEvent = (
+ e: T,
+ event: ServiceRunnerEvents[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);
+}
+
+/**
+ * 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 });
+}
+
+const clearRunningServiceIfExists = (id: string) => {
+ const service = getRunningService(id);
+
+ if (service) {
+ const index = started.indexOf(service);
+ if (index !== -1) {
+ started.splice(index, 1);
+ }
+ }
+
+ startedStages.delete(id);
+}
+
+export const getLastPowerError: ServiceRunner["getLastPowerError"] = (id) => {
+ return errors[id];
+}
+
+export const getRunningServices: ServiceRunner["getRunningServices"] = () => {
+ return [...started];
+}
+
+export const waitForStopped: ServiceRunner["waitForStopped"] = async (id: string) => {
+ if (!isRunning(id)) {
+ // service not running, so we continue immediately
+ return;
+ }
+
+ return new Promise((resolve, reject) => {
+ on("stop", ({ id: stoppedId, error }) => {
+ if (stoppedId !== id) {
+ // This call is not for me
+ return false;
+ }
+
+ if (error) {
+ reject(error);
+ } else {
+ resolve();
+ }
+
+ return true;
+ });
+ });
+}
+
+export const stopRunning: ServiceRunner["stopRunning"] = async () => {
+ const tasks = started.map(
+ ({ id }) =>
+ new Promise((resolve) => {
+ whenUnlocked(id, () => {
+ stopService(id)
+ .catch((e) => logger.error(e))
+ .then(() => {
+ whenUnlocked(id, () => resolve(null));
+ });
+ });
+ }),
+ );
+
+ await Promise.all(tasks);
+}
+
+export const killRunning: ServiceRunner["killRunning"] = async () => {
+ await Promise.all(
+ started.map(
+ async ({ id }) => stopService(id, true).catch((e) => logger.error(e))
+ )
+ )
+}
+
+export const waitForBusyAction: ServiceRunner["waitForBusyAction"] = async (id: string) => {
+ return new Promise((resolve, reject) => {
+ whenUnlocked(id, (_, __, err) => (err ? reject(err) : resolve(null)));
+ });
+}
\ No newline at end of file
diff --git a/src/engine/service.ts b/src/engine/service.ts
new file mode 100644
index 0000000..c01df68
--- /dev/null
+++ b/src/engine/service.ts
@@ -0,0 +1,314 @@
+import {
+ ServiceEngine,
+} from "./engine";
+import crypto from "crypto";
+import { randomPort as retrieveRandomPort } from "@nsm/util/port";
+import {Database, ImageModel, PermaModel} from "../persistence";
+import {
+ lockBusyAction,
+ reqNotPending,
+} from "./asyncp";
+import winston from "winston";
+import {
+ deleteImageIfUnused,
+} from "@nsm/engine/docker/repository/filesystem/image";
+import {
+ InternalError,
+ ServiceNotFoundError,
+ TemplateNotFoundError
+} from "@nsm/engine/error";
+import {AppConfig} from "@nsm/config";
+import {TemplateManager} from "./template";
+
+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
+ /**
+ * The optional meta attributes to set for the service.
+ */
+ meta?: { [key: string]: any };
+ /**
+ * The optional args (template options) to set.
+ * These are custom variables that the specific template uses to correctly
+ * build its environment.
+ *
+ * Firstly, you need to specify those arg 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)
+ */
+ args?: { [key: string]: string }; // Optional ARGS, 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?: {
+ /**
+ * 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;
+ };
+};
+
+export type UpdateServiceOptions = {
+ imageId?: string;
+ options?: Options;
+}
+
+export type ListServicesOptions = {
+ /**
+ * The page number (index).
+ */
+ page: number;
+ /**
+ * The page size.
+ */
+ pageSize: number;
+
+ /**
+ * Filter options.
+ */
+ filter?: {
+ /**
+ * Filter services by their meta attributes.
+ */
+ meta?: { [key: string]: any };
+ };
+};
+
+export interface ServiceManager {
+ /**
+ * 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
+
+ /**
+ * 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;
+
+ /**
+ * Update the service.
+ *
+ * @param id The service ID
+ * @param options The update options
+ */
+ updateService(id: string, options: UpdateServiceOptions): Promise;
+
+ /**
+ * Get the service by ID.
+ *
+ * @param from The service ID, or model
+ */
+ getService(from: string | PermaModel): Promise;
+
+ /**
+ * List all available services.
+ *
+ * @param options The list options
+ * @returns The list of service IDs
+ */
+ listServices(options: ListServicesOptions): Promise;
+}
+
+export type Service = PermaModel;
+
+let nodeId: string;
+let db: Database;
+let engine: ServiceEngine;
+let templateManager: TemplateManager;
+let logger: winston.Logger;
+
+/**
+ * Initialize the service manager.
+ *
+ * @param appConfig_ The app config
+ * @param db_ The database
+ * @param engine_ The service engine to use
+ * @param templateManager_ The template manager
+ * @param logger_ The global logger
+ */
+export const init = async (
+ appConfig_: AppConfig,
+ db_: Database,
+ engine_: ServiceEngine,
+ templateManager_: TemplateManager,
+ logger_: winston.Logger
+) => {
+ nodeId = appConfig_.getNodeId();
+ db = db_;
+ engine = engine_;
+ templateManager = templateManager_;
+ logger = logger_;
+}
+
+export const createService: ServiceManager["createService"] = async (template, options) => {
+ const { ram, cpu, disk, ports, args, network } = options;
+
+ const foundTemplate = await templateManager.getTemplate(template);
+ if (!foundTemplate) {
+ throw new TemplateNotFoundError(template);
+ }
+ const serviceSettings = foundTemplate.config;
+
+ // Join meta supplied by user and template meta
+ let meta = {};
+ if (options.meta) {
+ meta = { ...meta, ...options.meta };
+ }
+ if (serviceSettings.meta) {
+ meta = { ...meta, ...serviceSettings.meta };
+ }
+ // validate meta? and throw InvalidMetaError
+
+ 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( // TODO: port retrieving strategy
+ engine,
+ portRange.min as number,
+ portRange.max as number,
+ );
+
+ const perma: PermaModel = {
+ serviceId,
+ template,
+ nodeId,
+ port,
+ options: {
+ ram,
+ cpu,
+ disk,
+ ports
+ },
+ meta,
+ args: args ?? {},
+ network,
+ };
+ // Save permanent info
+ const saved = await db.permaRepository.savePerma(perma);
+ if (!saved) {
+ throw new InternalError("Failed to save perma info to database");
+ }
+
+ return serviceId;
+}
+
+export const deleteService: ServiceManager["deleteService"] = async (id) => {
+ let image: ImageModel | undefined;
+
+ const perma = await db.permaRepository.getPerma(id);
+ if (!perma) {
+ throw new ServiceNotFoundError(id);
+ }
+
+ const unlock = lockBusyAction(id, "delete");
+
+ try {
+ if (perma.imageId) {
+ image = await db.imageRepository.getImage(perma.imageId);
+ }
+
+ await engine.deleteVolume(id);
+ await db.permaRepository.deletePerma(id);
+ if (image) {
+ // If the image becomes unused after service deletion, delete it
+ await deleteImageIfUnused(image);
+ }
+
+ logger.debug(`Service ${id} deleted`);
+ } finally {
+ unlock();
+ }
+}
+
+export const updateService: ServiceManager["updateService"] = async (id, options) => {
+ let success = true;
+ if (options.imageId) {
+ const perma = await db.permaRepository.getPerma(id);
+ if (!perma) {
+ throw new ServiceNotFoundError(id);
+ }
+ perma.imageId = options.imageId;
+
+ success = await db.permaRepository.savePerma(perma);
+ }
+ if (options.options && !await updateOptions(id, options.options)) {
+ success = false;
+ }
+ return success;
+}
+
+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,
+ },
+ args: {
+ ...perma.args,
+ ...options.args,
+ },
+ };
+ return db.permaRepository.savePerma(data);
+}
+
+export const getService: ServiceManager["getService"] = async (from) => {
+ return typeof from === "string" ? await db.permaRepository.getPerma(from) : from;
+}
+
+export const listServices: ServiceManager["listServices"] = async (options) => {
+ const meta = options.filter?.meta;
+
+ const list = await db.permaRepository.listPerma(nodeId, options.page, options.pageSize, meta);
+
+ return list.map((d) => d.serviceId);
+}
\ No newline at end of file
diff --git a/src/engine/session.ts b/src/engine/session.ts
index 20eb06c..d709e59 100644
--- a/src/engine/session.ts
+++ b/src/engine/session.ts
@@ -1,20 +1,59 @@
-import {RunListener} from "@nsm/engine/engine";
+import { RunListener } from "@nsm/engine/engine";
import {
CreateLogRecordArgs,
Database,
ListRecordsArgs,
ListSessionsArgs,
ServiceLogRecordModel,
- ServiceSessionModel
-} from "@nsm/database";
+ ServiceSessionModel,
+} from "@nsm/persistence";
+import {ServiceNotFoundError, ServiceWasNeverActiveError} from "@nsm/engine/error";
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;
+ /**
+ * Retrieves the last session for a given service ID.
+ *
+ * @param serviceId The ID of the service for which to retrieve the last session.
+ * @return An object representing the last service session, or undefined if no sessions were found.
+ * @throws ServiceWasNeverActiveError if the service has never had an active session.
+ */
+ getLastSession(serviceId: string): 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,55 +74,65 @@ let db: Database;
export const init = (db_: Database) => {
db = db_;
-}
+};
/**
* 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.
+ * @throws ServiceNotFoundError if the service with the given ID does not exist.
*/
-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
+export const beginServiceSession: SessionManager["beginServiceSession"] =
+ async (serviceId: string): Promise => {
+ const perma = await db.permaRepository.getPerma(serviceId);
+ if (!perma) {
+ throw new ServiceNotFoundError(serviceId);
}
- }
- return {
- ...session,
- runListener
- }
-}
+ 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,
+ });
+ },
+ onEngineMessage: async (record) => {
+ pushRecord({
+ sessionId: session.id,
+ source: "ENGINE",
+ 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 +143,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 +212,34 @@ const debounceBulkPush = () => {
// Renew timer
renew();
- }
+ },
+ };
+};
+
+export const getLastSession: SessionManager["getLastSession"] = async (
+ serviceId
+) => {
+ // Service not running, so we need to retrieve last session ID
+ const lastSession = await listSessions({
+ filter: { serviceId },
+ sort: { by: "startedAt", direction: "desc" },
+ page: { index: 0, size: 1 },
+ });
+ if (lastSession && lastSession.length > 0) {
+ return lastSession[0];
}
-}
-// TODO: get service session
+ throw new ServiceWasNeverActiveError();
+}
-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..1ecced1 100644
--- a/src/engine/template.ts
+++ b/src/engine/template.ts
@@ -1,110 +1,178 @@
-import {loadYamlFile} from "@nsm/util/yaml";
-import * as fs from "fs";
-import path from "path";
-import {getTemplatesPath} from "@nsm/filestructure";
+import {ServiceEngine} from "@nsm/engine/engine";
+import z from "zod";
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 config (definitions) object.
+ */
+ config: TemplateConfig;
+};
-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;
-
- /**
- * 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[];
+export type TemplateFinding = Template & {
+ /**
+ * The ID of the repository where the template was found.
+ */
+ sourceRepositoryId: string;
}
-const templateCache = {};
+export type TemplateConfig = {
+ port_range: {
+ min: number;
+ max: number;
+ };
+ meta: {
+ [key: string]: string;
+ };
+ args: {
+ [key: string]: string;
+ };
+ container: TemplateContainerConfig;
+}
-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;
+export type TemplateContainerConfig = {
+ env: {
+ [key: string]: string;
+ };
+ resources: {
+ limits: {
+ ram: number;
+ cpu: number;
+ disk: number;
}
- 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 [];
- }
+export const templateSettingsModel = z.object({
+ port_range: z.object({
+ min: z.number(),
+ max: z.number()
+ }),
+ meta: z.record(z.string(), z.string()),
+ args: z.record(z.string(), z.string()),
+ container: z.object({
+ env: z.record(z.string(), z.string()),
+ resources: z.object({
+ limits: z.object({
+ ram: z.number(),
+ cpu: z.number(),
+ disk: z.number()
+ })
+ })
+ })
+});
+
+export const templateModel = z.object({
+ id: z.string(),
+ name: z.string(),
+ description: z.string(),
+ config: templateSettingsModel
+});
+
+export interface TemplateManager {
+ /**
+ * Returns a template by ID.
+ *
+ * @param id The ID of the template
+ * @return The template, or null if not exists
+ */
+ getTemplate(id: string): Promise;
+
+ getAllTemplates(): Promise;
+}
+
+let engine: ServiceEngine;
- return fs
- .readdirSync(getTemplatesPath())
- .filter(file => fs.statSync(path.join(getTemplatesPath(), file)).isDirectory())
- .map(id => getTemplate(id))
- .filter(template => template !== null);
+export const init = (
+ engine_: ServiceEngine,
+) => {
+ engine = engine_;
}
-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 getTemplate: TemplateManager["getTemplate"] = async (id) => {
+ for (let registration of engine.templateRepositoryRegistry.getAllRepositories()) {
+ const template = await registration.repository.getTemplate(id);
+
+ if (template) {
+ return { ...template, sourceRepositoryId: registration.id };
}
+ }
+
+ return null;
+}
+
+export const getAllTemplates: TemplateManager["getAllTemplates"] = async () => {
+ const result: TemplateFinding[] = [];
+
+ for (let registration of engine.templateRepositoryRegistry.getAllRepositories()) {
+ const templates = await registration.repository.getAllTemplates();
- 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 (let template of templates) {
+ if (result.find((t) => t.id === template.id)) {
+ // duplicate id, we count with the first only
+ continue;
+ }
+
+ result.push({ ...template, sourceRepositoryId: registration.id });
}
- return env;
+ }
+ return result;
}
+/**
+ * Prepares the args for a template by validating the provided args object against
+ * the template's settings and filling in default values where necessary. It checks for required options, validates
+ * types, and returns a new args 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 args The environment variables provided by the user, which may be incomplete or have incorrect types
+ * @return A new args 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
+ */
+export const prepareArgsForTemplate = (
+ template: Template,
+ args: any,
+) => {
+ args = { ...args }; // Shallow copy to avoid mutating the original object
+
+ for (const key of Object.keys(template.config["args"])) {
+ if (args[key] && typeof args[key] == typeof template.config["args"][key]) {
+ // Keep the value
+ } else if (args[key]) {
+ throw new Error(
+ "Invalid option type for " +
+ key +
+ ". Got " +
+ typeof args[key] +
+ " but expected " +
+ typeof template.config["args"][key] +
+ ".",
+ );
+ } else if (isRequiredOption(template.config["args"][key])) {
+ throw new Error("Missing required option " + key);
+ } else {
+ // Set default
+ args[key] = template.config["args"][key];
+ }
+ }
+ return args;
+};
+
// 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
deleted file mode 100644
index e900fbd..0000000
--- a/src/networking/manager.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-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;
- }
- }
- 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',
- }
- });
-}
-
-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);
- }
- }
-}
-
-// 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;
- }
-}
\ No newline at end of file
diff --git a/src/database/image.ts b/src/persistence/image.ts
similarity index 52%
rename from src/database/image.ts
rename to src/persistence/image.ts
index 189a778..cce04ff 100644
--- a/src/database/image.ts
+++ b/src/persistence/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/persistence/models";
+import { optionsDiffer } from "@nsm/engine/docker/repository/filesystem/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/persistence/index.ts b/src/persistence/index.ts
new file mode 100644
index 0000000..dd7dfaa
--- /dev/null
+++ b/src/persistence/index.ts
@@ -0,0 +1,38 @@
+import { Database } from "./models";
+import { PrismaClient } from "@prisma/client";
+
+import * as permaRepository from "./perma";
+import * as metaRepository from "./meta";
+import * as serviceMetaRepository from "./serviceMeta";
+import * as imageRepository from "./image";
+import * as sessionRepository from "./session";
+import * as serviceLogRepository from "./serviceLog";
+
+export * from "./models";
+
+export default function (client?: PrismaClient): Database {
+ 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));
+
+ return {
+ permaRepository,
+ metaRepository,
+ serviceMetaRepository,
+ imageRepository,
+ sessionRepository,
+ serviceLogRepository,
+ };
+}
diff --git a/src/database/meta.ts b/src/persistence/meta.ts
similarity index 64%
rename from src/database/meta.ts
rename to src/persistence/meta.ts
index 738e840..9e2b2dc 100644
--- a/src/database/meta.ts
+++ b/src/persistence/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/persistence/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/persistence/models.ts b/src/persistence/models.ts
new file mode 100644
index 0000000..958264c
--- /dev/null
+++ b/src/persistence/models.ts
@@ -0,0 +1,135 @@
+import {Options} from "@nsm/engine";
+
+export interface Database {
+ 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;
+}
+
+export interface MetaRepository {
+ getMetaVal(key: string, defaultVal?: string): Promise;
+}
+
+export interface ServiceMetaRepository {
+ 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;
+}
+
+export interface SessionRepository {
+ createSession(serviceId: string): Promise;
+
+ listSessions(
+ args: ListSessionsArgs,
+ ): Promise;
+}
+
+export type ListSessionsArgs = {
+ filter?: {
+ serviceId?: string;
+ };
+ sort?: {
+ by?: "startedAt";
+ direction?: "asc" | "desc";
+ };
+ page?: {
+ index: number;
+ size: number;
+ };
+};
+
+export interface ServiceLogRepository {
+ createRecords(records: CreateLogRecordArgs[]): Promise;
+
+ listRecords(
+ args: ListRecordsArgs,
+ ): Promise;
+}
+
+export type CreateLogRecordArgs = Omit<
+ ServiceLogRecordModel,
+ "id" | "timestamp"
+>;
+
+export type ListRecordsArgs = {
+ 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: Pick
+ meta: {
+ [key: string]: string;
+ };
+ args: {
+ [key: string]: string;
+ };
+ network?: {
+ address: string;
+ portsOnly: boolean;
+ };
+};
+
+export type ImageModel = {
+ id: string;
+ templateId: string;
+ hash?: string;
+ buildOptions: {
+ [key: string]: string;
+ };
+};
+
+export type ServiceSessionModel = {
+ id: string;
+ serviceId: string;
+ startedAt: Date;
+};
+
+export type ServiceLogRecordModel = {
+ id: bigint;
+ sessionId: string;
+ source: "ENGINE" | "CONTAINER";
+ timestamp: Date;
+ logLevel: string;
+ message: string;
+};
diff --git a/src/database/perma.ts b/src/persistence/perma.ts
similarity index 76%
rename from src/database/perma.ts
rename to src/persistence/perma.ts
index 642b5bd..8eb939a 100644
--- a/src/database/perma.ts
+++ b/src/persistence/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/persistence/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/persistence/serviceLog.ts
similarity index 74%
rename from src/database/serviceLog.ts
rename to src/persistence/serviceLog.ts
index f79c6d7..1fc5a36 100644
--- a/src/database/serviceLog.ts
+++ b/src/persistence/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/persistence/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/persistence/serviceMeta.ts
similarity index 68%
rename from src/database/serviceMeta.ts
rename to src/persistence/serviceMeta.ts
index 8799340..c06617d 100644
--- a/src/database/serviceMeta.ts
+++ b/src/persistence/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/persistence/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/persistence/session.ts
similarity index 74%
rename from src/database/session.ts
rename to src/persistence/session.ts
index 2d33d6e..bd9cf08 100644
--- a/src/database/session.ts
+++ b/src/persistence/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/persistence/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/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..ea1ecc0
--- /dev/null
+++ b/src/router/middlewares/catchKnownErrors.ts
@@ -0,0 +1,24 @@
+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;
+ } else {
+ console.error(err.stack);
+ }
+
+ 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..12628f8
--- /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.issues,
+ });
+ 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..697d162 100644
--- a/src/router/v1/index.ts
+++ b/src/router/v1/index.ts
@@ -7,11 +7,11 @@ 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";
import logsRoute from "@nsm/router/v1/service/logsRoute";
+import templateListRoute from "@nsm/router/v1/template/listRoute";
export default [
// v1 routes
@@ -21,12 +21,12 @@ export default [
deleteRoute,
resumeRoute,
rebootRoute,
- stopCmdRoute,
stopRoute,
powerStatusRoute,
optionsRoute,
listRoute,
sessionsRoute,
logsRoute,
- sessionLogsRoute
-]
\ No newline at end of file
+ sessionLogsRoute,
+ templateListRoute,
+];
diff --git a/src/router/v1/service/createRoute.ts b/src/router/v1/service/createRoute.ts
index 49e634b..33ae20d 100644
--- a/src/router/v1/service/createRoute.ts
+++ b/src/router/v1/service/createRoute.ts
@@ -1,54 +1,61 @@
-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 { prepareArgsForTemplate } 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,
+ templateManager,
+ runner,
+}: 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 = await templateManager.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 args = req.body.args ?? {};
+ try {
+ args = prepareArgsForTemplate(template, args);
+ } 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.args = args;
- 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);
+
+ if (req.query.resume === "true") {
+ await runner.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..441c43a 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 ({
+ facade,
+}: 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 facade.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..af4c854 100644
--- a/src/router/v1/service/listRoute.ts
+++ b/src/router/v1/service/listRoute.ts
@@ -1,62 +1,84 @@
-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,
+ appConfig
+}: 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(appConfig.getNodeId()),
+ },
+ };
+ res.status(200).json(data).end();
+ },
+ },
+ };
+}
diff --git a/src/router/v1/service/logsRoute.ts b/src/router/v1/service/logsRoute.ts
index 61ea880..6b8c37b 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/persistence";
-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.sessionManager.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..6b2b25b 100644
--- a/src/router/v1/service/lookupRoute.ts
+++ b/src/router/v1/service/lookupRoute.ts
@@ -1,45 +1,51 @@
-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 ({
+ runner,
+ facade
+}: 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 facade.getServiceInfo(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 && session.containerId && req.query.stats === "true") {
+ stats = await runner.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.args,
+ };
+ 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..fc1556c 100644
--- a/src/router/v1/service/powerStatusRoute.ts
+++ b/src/router/v1/service/powerStatusRoute.ts
@@ -1,30 +1,35 @@
-import {AppContext} from "../../../app";
-import {RouterHandler} from "../../index";
-import {isServicePending} from "@nsm/engine/asyncp";
+import { AppContext } from "@nsm/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 (ctx: 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 = ctx.runner.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..3ea5233 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 ({
+ runner,
+}: 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 runner.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 runner.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..03e7ef3 100644
--- a/src/router/v1/service/resumeRoute.ts
+++ b/src/router/v1/service/resumeRoute.ts
@@ -1,37 +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}: 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,
+ runner
+}: 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 runner.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..4cae218 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 ({
+ runner
+}: AppContext): Promise