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