From 0e532b4a9e41957acb5e29abd1ec1d75ff2f444a Mon Sep 17 00:00:00 2001 From: u_m1mkt Date: Wed, 1 Jul 2026 16:06:07 +0300 Subject: [PATCH 1/4] feat(deploy_kube_feature): skip_e2e deploy_kube_feature --- packages/arui-scripts-docker/.eslintignore | 2 + packages/arui-scripts-docker/.eslintrc.js | 8 + packages/arui-scripts-docker/.gitignore | 2 + packages/arui-scripts-docker/.npmignore | 3 + packages/arui-scripts-docker/.prettierignore | 2 + packages/arui-scripts-docker/CHANGELOG.md | 1 + packages/arui-scripts-docker/README.md | 130 +++++++++++ packages/arui-scripts-docker/jest.config.js | 6 + packages/arui-scripts-docker/package.json | 47 ++++ .../src/__tests__/config.test.ts | 48 ++++ .../src/__tests__/docker-build.test.ts | 80 +++++++ .../src/__tests__/render.test.ts | 78 +++++++ packages/arui-scripts-docker/src/bin/index.ts | 102 ++++++++ .../src/build-docker-image.ts | 69 ++++++ packages/arui-scripts-docker/src/config.ts | 93 ++++++++ packages/arui-scripts-docker/src/constants.ts | 27 +++ packages/arui-scripts-docker/src/index.ts | 37 +++ packages/arui-scripts-docker/src/render.ts | 73 ++++++ .../src/templates/base-nginx.conf.template.ts | 66 ++++++ .../templates/dockerfile-compiled.template.ts | 52 +++++ .../src/templates/dockerfile.template.ts | 49 ++++ .../src/templates/index.ts | 5 + .../src/templates/nginx.conf.template.ts | 61 +++++ .../src/templates/start.template.ts | 77 +++++++ packages/arui-scripts-docker/src/types.ts | 193 ++++++++++++++++ .../src/utils/docker-build.ts | 218 ++++++++++++++++++ .../arui-scripts-docker/src/utils/exec.ts | 19 ++ .../arui-scripts-docker/src/utils/yarn.ts | 80 +++++++ .../arui-scripts-docker/tsconfig.eslint.json | 5 + packages/arui-scripts-docker/tsconfig.json | 14 ++ yarn.lock | 20 ++ 31 files changed, 1667 insertions(+) create mode 100644 packages/arui-scripts-docker/.eslintignore create mode 100644 packages/arui-scripts-docker/.eslintrc.js create mode 100644 packages/arui-scripts-docker/.gitignore create mode 100644 packages/arui-scripts-docker/.npmignore create mode 100644 packages/arui-scripts-docker/.prettierignore create mode 100644 packages/arui-scripts-docker/CHANGELOG.md create mode 100644 packages/arui-scripts-docker/README.md create mode 100644 packages/arui-scripts-docker/jest.config.js create mode 100644 packages/arui-scripts-docker/package.json create mode 100644 packages/arui-scripts-docker/src/__tests__/config.test.ts create mode 100644 packages/arui-scripts-docker/src/__tests__/docker-build.test.ts create mode 100644 packages/arui-scripts-docker/src/__tests__/render.test.ts create mode 100644 packages/arui-scripts-docker/src/bin/index.ts create mode 100644 packages/arui-scripts-docker/src/build-docker-image.ts create mode 100644 packages/arui-scripts-docker/src/config.ts create mode 100644 packages/arui-scripts-docker/src/constants.ts create mode 100644 packages/arui-scripts-docker/src/index.ts create mode 100644 packages/arui-scripts-docker/src/render.ts create mode 100644 packages/arui-scripts-docker/src/templates/base-nginx.conf.template.ts create mode 100644 packages/arui-scripts-docker/src/templates/dockerfile-compiled.template.ts create mode 100644 packages/arui-scripts-docker/src/templates/dockerfile.template.ts create mode 100644 packages/arui-scripts-docker/src/templates/index.ts create mode 100644 packages/arui-scripts-docker/src/templates/nginx.conf.template.ts create mode 100644 packages/arui-scripts-docker/src/templates/start.template.ts create mode 100644 packages/arui-scripts-docker/src/types.ts create mode 100644 packages/arui-scripts-docker/src/utils/docker-build.ts create mode 100644 packages/arui-scripts-docker/src/utils/exec.ts create mode 100644 packages/arui-scripts-docker/src/utils/yarn.ts create mode 100644 packages/arui-scripts-docker/tsconfig.eslint.json create mode 100644 packages/arui-scripts-docker/tsconfig.json diff --git a/packages/arui-scripts-docker/.eslintignore b/packages/arui-scripts-docker/.eslintignore new file mode 100644 index 00000000..b97cc924 --- /dev/null +++ b/packages/arui-scripts-docker/.eslintignore @@ -0,0 +1,2 @@ +build +.turbo diff --git a/packages/arui-scripts-docker/.eslintrc.js b/packages/arui-scripts-docker/.eslintrc.js new file mode 100644 index 00000000..5c1f5886 --- /dev/null +++ b/packages/arui-scripts-docker/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + root: true, + extends: [require.resolve('arui-presets-lint/eslint')], + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig.eslint.json'], + }, +}; diff --git a/packages/arui-scripts-docker/.gitignore b/packages/arui-scripts-docker/.gitignore new file mode 100644 index 00000000..dd87e2d7 --- /dev/null +++ b/packages/arui-scripts-docker/.gitignore @@ -0,0 +1,2 @@ +node_modules +build diff --git a/packages/arui-scripts-docker/.npmignore b/packages/arui-scripts-docker/.npmignore new file mode 100644 index 00000000..825b96da --- /dev/null +++ b/packages/arui-scripts-docker/.npmignore @@ -0,0 +1,3 @@ +src +build/tsconfig.tsbuildinfo +__tests__ diff --git a/packages/arui-scripts-docker/.prettierignore b/packages/arui-scripts-docker/.prettierignore new file mode 100644 index 00000000..0d6a1e94 --- /dev/null +++ b/packages/arui-scripts-docker/.prettierignore @@ -0,0 +1,2 @@ +.turbo +build diff --git a/packages/arui-scripts-docker/CHANGELOG.md b/packages/arui-scripts-docker/CHANGELOG.md new file mode 100644 index 00000000..47164cd6 --- /dev/null +++ b/packages/arui-scripts-docker/CHANGELOG.md @@ -0,0 +1 @@ +# @alfalab/scripts-docker diff --git a/packages/arui-scripts-docker/README.md b/packages/arui-scripts-docker/README.md new file mode 100644 index 00000000..2430c7ed --- /dev/null +++ b/packages/arui-scripts-docker/README.md @@ -0,0 +1,130 @@ +@alfalab/scripts-docker +=== + +Максимально кастомизируемые скрипты и шаблоны для сборки docker-образов приложений, основанных на +`arui-scripts`. Логика, которая раньше жила внутри команд `arui-scripts docker-build` и была намертво +завязана на глобальный конфиг, вынесена в отдельный пакет: все шаблоны и утилиты работают с обычным +объектом опций, поэтому образ можно собрать из любого места — CLI, программно или из другого пакета +(например, `newclick-builder`). + +## Установка + +```bash +yarn add @alfalab/scripts-docker +``` + +## CLI + +```bash +# «сырой» образ (приложение собирается на хосте, результат кладется в образ) +arui-scripts-docker docker-build + +# compiled-образ (зависимости и сборка выполняются внутри образа) +arui-scripts-docker docker-build:compiled + +# переопределение имени/версии/registry +arui-scripts-docker docker-build name=my-app version=1.2.3 registry=registry.example.com +``` + +CLI автоматически: + +- берет `name`/`version` из `package.json`; +- подхватывает локальные `Dockerfile`, `start.sh`, `nginx.conf`, `base-nginx.conf` из корня проекта; +- загружает пользовательский конфиг `arui-scripts-docker.config.js`, если он есть. + +### Пользовательский конфиг + +```js +// arui-scripts-docker.config.js +module.exports = { + baseDockerImage: 'alfabankui/arui-scripts:24.10.0-slim', + clientOnly: true, + nginx: { workerProcesses: 4 }, + // точечно донасыщаем сгенерированный Dockerfile + overrides: { + dockerfile: (generated) => `${generated}\nLABEL team="web"`, + }, +}; +``` + +Конфиг может экспортировать объект или функцию (в т.ч. асинхронную), возвращающую опции. + +## Программное API + +### `buildDockerImage` + +Высокоуровневая сборка: донасыщает конфиг, рендерит шаблоны, готовит временную директорию, запускает +`docker build`, чистит за собой и (опционально) пушит образ. + +```ts +import { buildDockerImage } from '@alfalab/scripts-docker'; + +await buildDockerImage({ + variant: 'compiled', // 'runtime' | 'compiled' + name: 'my-app', + version: '1.0.0', + dockerRegistry: 'registry.example.com', + baseDockerImage: 'alfabankui/arui-scripts:24.10.0-slim', + clientOnly: false, + nginx: { workerProcesses: 4 }, + extraBuildArgs: { COMMIT_SHA: process.env.COMMIT_SHA }, + // хук перед docker build — например, собрать приложение + beforeBuild: async () => { + // await exec('npm run build'); + }, +}); +``` + +### Гранулярные утилиты + +Если нужен полный контроль над процессом, используйте отдельные функции — именно так их использует +`newclick-builder`: + +```ts +import { + resolveDockerConfig, + renderDockerTemplates, + prepareFilesForDocker, + getBuildParamsFromArgs, + getDockerBuildCommand, + exec, +} from '@alfalab/scripts-docker'; + +const config = resolveDockerConfig({ + baseDockerImage: 'my-registry/base:1.0.0', + serverOutput: 'server/index.js', + clientServerPort: 8080, + serverPort: 3000, + addNodeModulesToDockerIgnore: true, +}); + +const { imageFullName } = getBuildParamsFromArgs(config); +const templates = renderDockerTemplates({ + config, + variant: 'compiled', + // полная замена шаблона nginx-конфига + templates: { + nginxConf: (cfg) => myCustomNginxTemplate(cfg), + }, +}); + +await prepareFilesForDocker({ config, templates }); +await exec(getDockerBuildCommand(config)); +await exec(`docker push ${imageFullName}`); +``` + +## Кастомизация + +Каждый файл, попадающий в образ, можно настроить на трех уровнях (по возрастанию приоритета): + +1. **Опции** — `baseDockerImage`, `clientOnly`, `runFromNonRootUser`, `nginx`, `platform`, + `extraBuildArgs`, `buildPath`, `serverOutput` и т.д. (см. `DockerBuildOptions`). +2. **`templates`** — полная замена рендерера конкретного шаблона (`dockerfile`, `dockerfileCompiled`, + `nginxConf`, `baseNginxConf`, `startScript`). +3. **`overrides`** — точечная функция `(generated, config) => string` поверх сгенерированного шаблона. +4. **Локальные файлы** — `Dockerfile`, `start.sh`, `nginx.conf`, `base-nginx.conf` в корне проекта. + +## Экспортируемые шаблоны + +`renderDockerfile`, `renderDockerfileCompiled`, `renderNginxConf`, `renderBaseNginxConf`, +`renderStartScript` — чистые функции `(config) => string`, которые можно переиспользовать и оборачивать. diff --git a/packages/arui-scripts-docker/jest.config.js b/packages/arui-scripts-docker/jest.config.js new file mode 100644 index 00000000..a271aacc --- /dev/null +++ b/packages/arui-scripts-docker/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testPathIgnorePatterns: ['/node_modules/', '/build/'], +}; diff --git a/packages/arui-scripts-docker/package.json b/packages/arui-scripts-docker/package.json new file mode 100644 index 00000000..4955504a --- /dev/null +++ b/packages/arui-scripts-docker/package.json @@ -0,0 +1,47 @@ +{ + "name": "@alfalab/scripts-docker", + "version": "1.0.0", + "description": "Максимально кастомизируемые скрипты и шаблоны для сборки docker-образов приложений, основанных на arui-scripts", + "main": "./build/index.js", + "typings": "./build/index.d.ts", + "bin": { + "arui-scripts-docker": "./build/bin/index.js" + }, + "license": "MPL-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/core-ds/arui-scripts.git" + }, + "bugs": { + "url": "https://github.com/core-ds/arui-scripts/issues" + }, + "homepage": "https://github.com/core-ds/arui-scripts/tree/master/packages/arui-scripts-docker#readme", + "scripts": { + "build": "tsc --project tsconfig.json", + "test": "jest", + "lint:scripts": "arui-presets-lint scripts", + "format": "arui-presets-lint format", + "format:check": "arui-presets-lint format:check", + "lint": "yarn lint:scripts && yarn format:check", + "lint:fix": "yarn lint:scripts --fix && yarn format", + "audit": "yarn npm audit --severity high --environment production" + }, + "dependencies": { + "fs-extra": "6.0.1", + "semver": "^7.5.4", + "shelljs": "0.8.5" + }, + "devDependencies": { + "@types/fs-extra": "^9.0.13", + "@types/jest": "^29.5.14", + "@types/node": "^20.19.0", + "@types/semver": "^7.5.0", + "@types/shelljs": "^0.8.12", + "jest": "^29.7.0", + "ts-jest": "29.1.0", + "typescript": "6.0.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } +} diff --git a/packages/arui-scripts-docker/src/__tests__/config.test.ts b/packages/arui-scripts-docker/src/__tests__/config.test.ts new file mode 100644 index 00000000..0dcf436f --- /dev/null +++ b/packages/arui-scripts-docker/src/__tests__/config.test.ts @@ -0,0 +1,48 @@ +import { resolveDockerConfig } from '../config'; + +describe('resolveDockerConfig', () => { + it('should fill defaults matching arui-scripts historical behaviour', () => { + const config = resolveDockerConfig({ cwd: __dirname }); + + expect(config.baseDockerImage).toBe('alfabankui/arui-scripts:24.10.0-slim'); + expect(config.buildPath).toBe('.build'); + expect(config.serverOutput).toBe('server.js'); + expect(config.nginxRootPath).toBe('/src'); + expect(config.clientServerPort).toBe(8080); + expect(config.serverPort).toBe(3000); + expect(config.runFromNonRootUser).toBe(true); + expect(config.clientOnly).toBe(false); + expect(config.tempDirName).toBe('.docker-build'); + expect(config.platform).toBe('auto'); + expect(config.nginx).toBeNull(); + }); + + it('should not push by default in debug mode', () => { + expect(resolveDockerConfig({ cwd: __dirname, debug: true }).push).toBe(false); + expect(resolveDockerConfig({ cwd: __dirname, debug: false }).push).toBe(true); + }); + + it('should allow explicit push override even in debug mode', () => { + expect(resolveDockerConfig({ cwd: __dirname, debug: true, push: true }).push).toBe(true); + }); + + it('should normalize nginx: false to null', () => { + expect(resolveDockerConfig({ cwd: __dirname, nginx: false }).nginx).toBeNull(); + expect( + resolveDockerConfig({ cwd: __dirname, nginx: { workerProcesses: 4 } }).nginx, + ).toEqual({ workerProcesses: 4 }); + }); + + it('should keep falsy but valid values (empty registry, port 0)', () => { + const config = resolveDockerConfig({ cwd: __dirname, dockerRegistry: '', serverPort: 0 }); + + expect(config.dockerRegistry).toBe(''); + expect(config.serverPort).toBe(0); + }); + + it('should respect explicit yarnVersion and derived commands', () => { + const config = resolveDockerConfig({ cwd: __dirname, yarnVersion: '2+' }); + + expect(config.installProductionCommand).toBe('yarn workspaces focus --production --all'); + }); +}); diff --git a/packages/arui-scripts-docker/src/__tests__/docker-build.test.ts b/packages/arui-scripts-docker/src/__tests__/docker-build.test.ts new file mode 100644 index 00000000..67c02104 --- /dev/null +++ b/packages/arui-scripts-docker/src/__tests__/docker-build.test.ts @@ -0,0 +1,80 @@ +import { resolveDockerConfig } from '../config'; +import { + applyCommandLineArguments, + getBuildParams, + getDockerBuildCommand, + getPlatformFlag, +} from '../utils/docker-build'; + +const baseOptions = { cwd: '/tmp/project', name: 'app', version: '1.0.0' }; + +describe('getBuildParams', () => { + it('should build image name without registry', () => { + const config = resolveDockerConfig(baseOptions); + + expect(getBuildParams(config).imageFullName).toBe('app:1.0.0'); + }); + + it('should build image name with registry', () => { + const config = resolveDockerConfig({ + ...baseOptions, + dockerRegistry: 'registry.example.com', + }); + + expect(getBuildParams(config).imageFullName).toBe('registry.example.com/app:1.0.0'); + }); + + it('should place temp dir inside cwd', () => { + const config = resolveDockerConfig(baseOptions); + + expect(getBuildParams(config).pathToTempDir).toBe('/tmp/project/.docker-build'); + }); +}); + +describe('applyCommandLineArguments', () => { + it('should override name/version/registry from args', () => { + const config = resolveDockerConfig(baseOptions); + const next = applyCommandLineArguments(config, [ + 'name=other', + 'version=2.0.0', + 'registry=r.example.com', + ]); + + expect(getBuildParams(next).imageFullName).toBe('r.example.com/other:2.0.0'); + }); +}); + +describe('getPlatformFlag', () => { + it('should return empty string when platform is false', () => { + const config = resolveDockerConfig({ ...baseOptions, platform: false }); + + expect(getPlatformFlag(config)).toBe(''); + }); + + it('should use explicit platform', () => { + const config = resolveDockerConfig({ ...baseOptions, platform: 'linux/arm64' }); + + expect(getPlatformFlag(config)).toBe('--platform linux/arm64'); + }); +}); + +describe('getDockerBuildCommand', () => { + it('should include dockerfile, build-args and context', () => { + const config = resolveDockerConfig({ ...baseOptions, platform: false }); + const command = getDockerBuildCommand(config); + + expect(command).toContain('-f "./.docker-build/Dockerfile"'); + expect(command).toContain('--build-arg START_SH_LOCATION="./.docker-build/start.sh"'); + expect(command).toContain('-t app:1.0.0 .'); + }); + + it('should include extra build args', () => { + const config = resolveDockerConfig({ + ...baseOptions, + platform: false, + extraBuildArgs: { COMMIT_SHA: 'abc123' }, + }); + + expect(getDockerBuildCommand(config)).toContain('--build-arg COMMIT_SHA="abc123"'); + }); +}); diff --git a/packages/arui-scripts-docker/src/__tests__/render.test.ts b/packages/arui-scripts-docker/src/__tests__/render.test.ts new file mode 100644 index 00000000..c3a42b5d --- /dev/null +++ b/packages/arui-scripts-docker/src/__tests__/render.test.ts @@ -0,0 +1,78 @@ +import { resolveDockerConfig } from '../config'; +import { renderDockerTemplates } from '../render'; + +const baseOptions = { cwd: __dirname, name: 'app', version: '1.0.0' }; + +describe('renderDockerTemplates', () => { + it('should render runtime dockerfile with the base image and start.sh', () => { + const config = resolveDockerConfig({ ...baseOptions, baseDockerImage: 'my/base:1.0.0' }); + const templates = renderDockerTemplates({ config, variant: 'runtime' }); + + expect(templates.dockerfile).toContain('FROM my/base:1.0.0'); + expect(templates.dockerfile).toContain('ADD $START_SH_LOCATION /src/start.sh'); + expect(templates.startScript).toContain('#!/bin/sh'); + }); + + it('should render compiled dockerfile with install command', () => { + const config = resolveDockerConfig({ ...baseOptions, yarnVersion: 'unavailable' }); + const templates = renderDockerTemplates({ config, variant: 'compiled' }); + + expect(templates.dockerfile).toContain('npm install --production'); + expect(templates.dockerfile).toContain('npm cache clean --force'); + }); + + it('should not render base nginx conf when nginx is disabled', () => { + const config = resolveDockerConfig({ ...baseOptions, nginx: false }); + const templates = renderDockerTemplates({ config }); + + expect(templates.nginxBaseConf).toBe(''); + }); + + it('should render base nginx conf with custom worker processes', () => { + const config = resolveDockerConfig({ ...baseOptions, nginx: { workerProcesses: 9 } }); + const templates = renderDockerTemplates({ config }); + + expect(templates.nginxBaseConf).toContain('worker_processes 9;'); + }); + + it('should render client-only start script when clientOnly is set', () => { + const config = resolveDockerConfig({ ...baseOptions, clientOnly: true }); + const templates = renderDockerTemplates({ config }); + + expect(templates.startScript).toContain('env-config.json'); + expect(templates.startScript).not.toContain('max-old-space-size'); + }); + + it('should apply full template replacement via templates', () => { + const config = resolveDockerConfig(baseOptions); + const templates = renderDockerTemplates({ + config, + templates: { nginxConf: () => 'CUSTOM NGINX' }, + }); + + expect(templates.nginxConf).toBe('CUSTOM NGINX'); + }); + + it('should apply point overrides on top of generated template', () => { + const config = resolveDockerConfig(baseOptions); + const templates = renderDockerTemplates({ + config, + variant: 'runtime', + overrides: { dockerfile: (generated) => `${generated}\nLABEL team="web"` }, + }); + + expect(templates.dockerfile).toContain('FROM'); + expect(templates.dockerfile).toContain('LABEL team="web"'); + }); + + it('should route overrides to dockerfileCompiled key for compiled variant', () => { + const config = resolveDockerConfig(baseOptions); + const templates = renderDockerTemplates({ + config, + variant: 'compiled', + overrides: { dockerfileCompiled: () => 'COMPILED OVERRIDE' }, + }); + + expect(templates.dockerfile).toBe('COMPILED OVERRIDE'); + }); +}); diff --git a/packages/arui-scripts-docker/src/bin/index.ts b/packages/arui-scripts-docker/src/bin/index.ts new file mode 100644 index 00000000..dbf78204 --- /dev/null +++ b/packages/arui-scripts-docker/src/bin/index.ts @@ -0,0 +1,102 @@ +#! /usr/bin/env node +/* eslint-disable global-require, import/no-dynamic-require */ +import path from 'path'; + +import fs from 'fs-extra'; + +import { buildDockerImage, type BuildDockerImageOptions } from '../build-docker-image'; +import { BASE_NGINX_CONFIG_FILENAME, NGINX_CONFIG_FILENAME } from '../constants'; +import { type DockerBuildOptions } from '../types'; + +const CONFIG_FILE_NAMES = [ + 'arui-scripts-docker.config.js', + 'arui-scripts-docker.config.cjs', + '.arui-scripts-docker.js', +]; + +/** + * Пытается загрузить пользовательский конфиг из cwd. Конфиг может экспортировать объект опций или + * функцию, возвращающую опции (в т.ч. асинхронно). + */ +async function loadUserOptions(cwd: string): Promise { + const configPath = CONFIG_FILE_NAMES.map((fileName) => path.join(cwd, fileName)).find( + (filePath) => fs.existsSync(filePath), + ); + + if (!configPath) { + return {}; + } + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const required = require(configPath); + // eslint-disable-next-line no-underscore-dangle + const configModule = required?.__esModule ? required.default : required; + + return typeof configModule === 'function' ? configModule() : configModule; +} + +/** + * Определяет локальные файлы проекта, замещающие сгенерированные шаблоны, если они лежат в cwd. + */ +function detectLocalFiles(cwd: string): DockerBuildOptions['localFiles'] { + const resolveIfExists = (fileName: string) => { + const filePath = path.join(cwd, fileName); + + return fs.existsSync(filePath) ? filePath : null; + }; + + return { + dockerfile: resolveIfExists('Dockerfile'), + startScript: resolveIfExists('start.sh'), + nginxConf: resolveIfExists(NGINX_CONFIG_FILENAME), + nginxBaseConf: resolveIfExists(BASE_NGINX_CONFIG_FILENAME), + }; +} + +const commandDefaults: Record> = { + 'docker-build': { + variant: 'runtime', + allowLocalDockerfile: true, + allowLocalStartScript: true, + addNodeModulesToDockerIgnore: false, + }, + 'docker-build:compiled': { + variant: 'compiled', + allowLocalDockerfile: false, + allowLocalStartScript: false, + addNodeModulesToDockerIgnore: true, + }, +}; + +(async () => { + const command = process.argv[2]; + + if (!command || !commandDefaults[command]) { + console.error( + `Please specify one of available commands: ${Object.keys(commandDefaults) + .map((c) => `"${c}"`) + .join(' ')}`, + ); + process.exit(-1); + + return; + } + + const cwd = process.cwd(); + + try { + const userOptions = await loadUserOptions(cwd); + const defaults = commandDefaults[command]; + + await buildDockerImage({ + ...defaults, + localFiles: { ...detectLocalFiles(cwd), ...userOptions.localFiles }, + ...userOptions, + // аргументы командной строки (name=... version=... registry=...) имеют наивысший приоритет + argv: process.argv.slice(3), + }); + } catch (err) { + console.error(err); + process.exit(1); + } +})(); diff --git a/packages/arui-scripts-docker/src/build-docker-image.ts b/packages/arui-scripts-docker/src/build-docker-image.ts new file mode 100644 index 00000000..63701741 --- /dev/null +++ b/packages/arui-scripts-docker/src/build-docker-image.ts @@ -0,0 +1,69 @@ +import fs from 'fs-extra'; + +import { + applyCommandLineArguments, + getBuildParams, + getDockerBuildCommand, + prepareFilesForDocker, +} from './utils/docker-build'; +import { exec } from './utils/exec'; +import { resolveDockerConfig } from './config'; +import { type DockerfileVariant, renderDockerTemplates } from './render'; +import { type DockerBuildOptions, type ResolvedDockerConfig } from './types'; + +export type BuildDockerImageOptions = DockerBuildOptions & { + /** Вариант Dockerfile: `runtime` (по умолчанию) или `compiled`. */ + variant?: DockerfileVariant; + /** Аргументы командной строки (`name=... version=... registry=...`), накладываются поверх опций. */ + argv?: string[]; + /** Хук, вызываемый после подготовки файлов, но до `docker build` (например, для сборки приложения). */ + beforeBuild?: (config: ResolvedDockerConfig) => void | Promise; +}; + +/** + * Высокоуровневая сборка docker-образа: донасыщает конфиг, рендерит все шаблоны, готовит временную + * директорию, запускает `docker build`, чистит за собой и (опционально) пушит образ. + * + * Для более тонкого контроля используйте отдельные утилиты: {@link resolveDockerConfig}, + * {@link renderDockerTemplates}, {@link prepareFilesForDocker}, {@link getDockerBuildCommand}. + */ +export async function buildDockerImage(options: BuildDockerImageOptions = {}): Promise { + const { variant = 'runtime', argv, beforeBuild, templates, overrides, ...rest } = options; + + let config = resolveDockerConfig(rest); + + if (argv) { + config = applyCommandLineArguments(config, argv); + } + + const { imageFullName, pathToTempDir } = getBuildParams(config); + + try { + console.log(`Build docker image ${imageFullName}`); + console.time('Total time'); + + const renderedTemplates = renderDockerTemplates({ config, variant, templates, overrides }); + + await prepareFilesForDocker({ config, templates: renderedTemplates }); + + if (beforeBuild) { + await beforeBuild(config); + } + + await exec(getDockerBuildCommand(config)); + await fs.remove(pathToTempDir); + + if (config.push) { + await exec(`docker push ${imageFullName}`); + } + + console.timeEnd('Total time'); + } catch (err) { + await fs.remove(pathToTempDir); + console.error('Error during docker-build.'); + if (config.debug) { + console.error(err); + } + throw err; + } +} diff --git a/packages/arui-scripts-docker/src/config.ts b/packages/arui-scripts-docker/src/config.ts new file mode 100644 index 00000000..eb01edb8 --- /dev/null +++ b/packages/arui-scripts-docker/src/config.ts @@ -0,0 +1,93 @@ +import path from 'path'; + +import fs from 'fs-extra'; + +import { + detectUseYarn, + getInstallProductionCommand, + getPruningCommand, + getYarnVersion, +} from './utils/yarn'; +import { DEFAULT_BASE_DOCKER_IMAGE, DEFAULT_TEMP_DIR_NAME } from './constants'; +import { type DockerBuildOptions, type ResolvedDockerConfig } from './types'; + +function readPackageJson(cwd: string): { name?: string; version?: string } { + try { + return fs.readJsonSync(path.join(cwd, 'package.json')); + } catch { + return {}; + } +} + +/** + * Дефолт значения флага `undefined ? fallback : value`, но с учетом того, что `false`/`0`/`''` + * — валидные значения, которые не должны затираться дефолтом. + */ +function withDefault(value: T | undefined, fallback: T): T { + return value === undefined ? fallback : value; +} + +/** + * Донасыщает частичные опции сборки полным набором значений с дефолтами. + * + * Значения по умолчанию совпадают с историческим поведением arui-scripts, поэтому вызов без + * аргументов даст такой же образ, как команда `arui-scripts docker-build`. + */ +export function resolveDockerConfig(options: DockerBuildOptions = {}): ResolvedDockerConfig { + const cwd = options.cwd ?? process.cwd(); + const pkg = readPackageJson(cwd); + + const clientOnly = withDefault(options.clientOnly, false); + const debug = withDefault(options.debug, false); + + const useYarn = withDefault(options.useYarn, detectUseYarn(cwd)); + const yarnVersion = options.yarnVersion ?? getYarnVersion({ useYarn }); + + const nginx = options.nginx === false ? null : withDefault(options.nginx, null); + + const localFiles = { + dockerfile: options.localFiles?.dockerfile ?? null, + startScript: options.localFiles?.startScript ?? null, + nginxConf: options.localFiles?.nginxConf ?? null, + nginxBaseConf: options.localFiles?.nginxBaseConf ?? null, + }; + + return { + name: options.name ?? pkg.name ?? '', + version: options.version ?? pkg.version ?? '', + dockerRegistry: withDefault(options.dockerRegistry, ''), + + baseDockerImage: withDefault(options.baseDockerImage, DEFAULT_BASE_DOCKER_IMAGE), + clientOnly, + buildPath: withDefault(options.buildPath, '.build'), + serverOutput: withDefault(options.serverOutput, 'server.js'), + nginxRootPath: withDefault(options.nginxRootPath, '/src'), + publicPath: withDefault(options.publicPath, ''), + + clientServerPort: withDefault(options.clientServerPort, 8080), + serverPort: withDefault(options.serverPort, 3000), + + nginx, + enablePreviousVersionHeaders: withDefault(options.enablePreviousVersionHeaders, false), + + runFromNonRootUser: withDefault(options.runFromNonRootUser, true), + cwd, + context: withDefault(options.context, '.'), + tempDirName: withDefault(options.tempDirName, DEFAULT_TEMP_DIR_NAME), + debug, + push: withDefault(options.push, !debug), + platform: withDefault(options.platform, 'auto'), + extraBuildArgs: withDefault(options.extraBuildArgs, {}), + + useYarn, + yarnVersion, + installProductionCommand: + options.installProductionCommand ?? getInstallProductionCommand(yarnVersion), + pruneCommand: options.pruneCommand ?? getPruningCommand({ yarnVersion, clientOnly }), + + addNodeModulesToDockerIgnore: withDefault(options.addNodeModulesToDockerIgnore, false), + allowLocalDockerfile: withDefault(options.allowLocalDockerfile, true), + allowLocalStartScript: withDefault(options.allowLocalStartScript, true), + localFiles, + }; +} diff --git a/packages/arui-scripts-docker/src/constants.ts b/packages/arui-scripts-docker/src/constants.ts new file mode 100644 index 00000000..a853f63b --- /dev/null +++ b/packages/arui-scripts-docker/src/constants.ts @@ -0,0 +1,27 @@ +/** + * Имя временной директории, в которую складываются сгенерированные файлы (Dockerfile, nginx-конфиги, + * start.sh) перед запуском `docker build`. + */ +export const DEFAULT_TEMP_DIR_NAME = '.docker-build'; + +/** Имя файла с nginx-конфигом сервера (server-блок), который кладется в образ. */ +export const NGINX_CONFIG_FILENAME = 'nginx.conf'; + +/** Имя файла с базовым nginx-конфигом (http-блок), который кладется в образ. */ +export const BASE_NGINX_CONFIG_FILENAME = 'base-nginx.conf'; + +/** Имя файла с рантайм-конфигом клиентского приложения. */ +export const ENV_CONFIG_FILENAME = 'env-config.json'; + +/** Базовый docker-образ по умолчанию. */ +export const DEFAULT_BASE_DOCKER_IMAGE = 'alfabankui/arui-scripts:24.10.0-slim'; + +/** + * Минимальная версия docker, начиная с которой флаг `--platform` поддерживается без экспериментальных + * флагов. На многих серверах до сих пор живет docker 1.13.1, который упадет при наличии этого флага. + * @see https://docs.docker.com/engine/release-notes/20.10/ + */ +export const PLATFORM_FLAG_MIN_DOCKER_VERSION = '>=20.10.21'; + +/** Значение флага `--platform`, которое подставляется при `platform: 'auto'` на новых версиях docker. */ +export const DEFAULT_PLATFORM = 'linux/x86_64'; diff --git a/packages/arui-scripts-docker/src/index.ts b/packages/arui-scripts-docker/src/index.ts new file mode 100644 index 00000000..d965f906 --- /dev/null +++ b/packages/arui-scripts-docker/src/index.ts @@ -0,0 +1,37 @@ +export * from './types'; +export * from './constants'; + +export { resolveDockerConfig } from './config'; +export { buildDockerImage, type BuildDockerImageOptions } from './build-docker-image'; +export { + renderDockerTemplates, + type DockerfileVariant, + type RenderDockerTemplatesParams, +} from './render'; + +export { + renderDockerfile, + renderDockerfileCompiled, + renderNginxConf, + renderBaseNginxConf, + renderStartScript, +} from './templates'; + +export { + applyCommandLineArguments, + dockerVersionSatisfies, + getBuildParams, + getBuildParamsFromArgs, + getDockerBuildCommand, + getPlatformFlag, + prepareFilesForDocker, + type BuildParams, +} from './utils/docker-build'; + +export { exec } from './utils/exec'; +export { + detectUseYarn, + getInstallProductionCommand, + getPruningCommand, + getYarnVersion, +} from './utils/yarn'; diff --git a/packages/arui-scripts-docker/src/render.ts b/packages/arui-scripts-docker/src/render.ts new file mode 100644 index 00000000..e6108e0c --- /dev/null +++ b/packages/arui-scripts-docker/src/render.ts @@ -0,0 +1,73 @@ +import { + renderBaseNginxConf, + renderDockerfile, + renderDockerfileCompiled, + renderNginxConf, + renderStartScript, +} from './templates'; +import { + type DockerTemplateKey, + type DockerTemplateOverrides, + type DockerTemplates, + type RenderedDockerTemplates, + type ResolvedDockerConfig, + type TemplateRenderer, +} from './types'; + +/** Вариант Dockerfile: «сырой» (сборка на хосте) или compiled (сборка внутри образа). */ +export type DockerfileVariant = 'runtime' | 'compiled'; + +export type RenderDockerTemplatesParams = { + config: ResolvedDockerConfig; + /** Какой Dockerfile генерировать. По умолчанию — `runtime`. */ + variant?: DockerfileVariant; + /** Полная замена рендереров отдельных шаблонов. */ + templates?: DockerTemplates; + /** Точечные оверрайды поверх сгенерированных шаблонов. */ + overrides?: DockerTemplateOverrides; +}; + +const emptyRenderer: TemplateRenderer = () => ''; + +function renderTemplate( + key: DockerTemplateKey, + defaultRenderer: TemplateRenderer, + params: RenderDockerTemplatesParams, +): string { + const { config, templates, overrides } = params; + const renderer = templates?.[key] ?? defaultRenderer; + + let content = renderer(config); + + const override = overrides?.[key]; + + if (override) { + content = override(content, config); + } + + return content; +} + +/** + * Рендерит все файлы, которые кладутся в образ, применяя (в порядке приоритета) кастомные рендереры + * из `templates` и точечные оверрайды из `overrides`. Результат готов к передаче в + * `prepareFilesForDocker`. + */ +export function renderDockerTemplates( + params: RenderDockerTemplatesParams, +): RenderedDockerTemplates { + const { config, variant = 'runtime' } = params; + + const dockerfileKey: DockerTemplateKey = + variant === 'compiled' ? 'dockerfileCompiled' : 'dockerfile'; + const dockerfileRenderer = variant === 'compiled' ? renderDockerfileCompiled : renderDockerfile; + + return { + dockerfile: renderTemplate(dockerfileKey, dockerfileRenderer, params), + nginxConf: renderTemplate('nginxConf', renderNginxConf, params), + nginxBaseConf: config.nginx + ? renderTemplate('baseNginxConf', renderBaseNginxConf, params) + : renderTemplate('baseNginxConf', emptyRenderer, params), + startScript: renderTemplate('startScript', renderStartScript, params), + }; +} diff --git a/packages/arui-scripts-docker/src/templates/base-nginx.conf.template.ts b/packages/arui-scripts-docker/src/templates/base-nginx.conf.template.ts new file mode 100644 index 00000000..285729b6 --- /dev/null +++ b/packages/arui-scripts-docker/src/templates/base-nginx.conf.template.ts @@ -0,0 +1,66 @@ +import { type ResolvedDockerConfig } from '../types'; + +const baseNginxConfig = { + workerProcesses: 2, + workerRlimitNoFile: 20000, + workerConnections: 19000, + eventsUse: 'epoll', + daemon: 'off', +}; + +/** + * http-блок nginx-конфига (базовый nginx.conf). Значения по умолчанию можно переопределить через + * `nginx` в опциях сборки. + */ +export function renderBaseNginxConf(config: ResolvedDockerConfig): string { + const nginx = { + ...baseNginxConfig, + ...config.nginx, + }; + + return ` +worker_processes ${nginx.workerProcesses}; +worker_rlimit_nofile ${nginx.workerRlimitNoFile}; +daemon ${nginx.daemon}; + +error_log /var/log/nginx/error.log; +pid /var/run/nginx.pid; + +events { + worker_connections ${nginx.workerConnections}; + use ${nginx.eventsUse}; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + server_tokens off; + access_log off; + keepalive_timeout 65; + proxy_read_timeout 200; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + + gzip_static on; + brotli on; + brotli_static on; + brotli_types application/atom+xml application/javascript application/json application/rss+xml + application/vnd.ms-fontobject application/x-font-opentype application/x-font-truetype + application/x-font-ttf application/x-javascript application/xhtml+xml application/xml + font/eot font/opentype font/otf font/truetype image/svg+xml image/vnd.microsoft.icon + image/x-icon image/x-win-bitmap text/css text/javascript text/plain text/xml application/wasm; + + + # Only retry if there was a communication error, not a timeout + # on the Node server (to avoid propagating "queries of death" + # to all frontends) + proxy_next_upstream error; + + #cache + proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=all:32m max_size=1g; + + include /etc/nginx/conf.d/*.conf; +}`; +} diff --git a/packages/arui-scripts-docker/src/templates/dockerfile-compiled.template.ts b/packages/arui-scripts-docker/src/templates/dockerfile-compiled.template.ts new file mode 100644 index 00000000..16c2ce48 --- /dev/null +++ b/packages/arui-scripts-docker/src/templates/dockerfile-compiled.template.ts @@ -0,0 +1,52 @@ +import { type ResolvedDockerConfig } from '../types'; + +/** + * Dockerfile для compiled-образа: зависимости и сборка приложения происходят внутри образа, что + * позволяет кешировать установку зависимостей на уровне docker-слоев. + */ +export function renderDockerfileCompiled(config: ResolvedDockerConfig): string { + const { baseDockerImage, nginx, yarnVersion, installProductionCommand } = config; + + // В зависимости от используемого менеджера зависимостей для их установки нужно копировать разный + // набор файлов + const filesRequiredToInstallDependencies = [ + 'package.json', + 'yarn.lock', + yarnVersion === '2+' && '.yarnrc.yml', + yarnVersion === '2+' && '.yarn', + yarnVersion === 'unavailable' && 'package-lock.json', + ].filter(Boolean) as string[]; + + return ` +FROM ${baseDockerImage} +ARG START_SH_LOCATION +ARG NGINX_CONF_LOCATION +ARG NGINX_BASE_CONF_LOCATION + +WORKDIR /src + +# Полу-статичные файлы, могут легко кешироваться +ADD $START_SH_LOCATION /src/start.sh +ADD $NGINX_CONF_LOCATION /src/nginx.conf +${nginx ? 'ADD $NGINX_BASE_CONF_LOCATION /etc/nginx/nginx.conf' : ''} + +# Зависимости. При некоторой удаче могут кешироваться и соответственно кешировать установку зависимостей +${filesRequiredToInstallDependencies + .map((file) => `ADD --chown=nginx:nginx ${file} /src/${file}`) + .join('\n')} + +RUN ${installProductionCommand} && \\ + ${yarnVersion === 'unavailable' ? 'npm cache clean --force' : 'yarn cache clean --all'} + +ADD --chown=nginx:nginx . /src + +# Создаем директории для nginx и выставляем правильные права +RUN mkdir -p /var/lib/nginx && \ + chown -R nginx:nginx /var/lib/nginx && \ + chown -R nginx:nginx /var/log/nginx && \ + chown -R nginx:nginx /etc/nginx/conf.d +RUN touch /var/run/nginx.pid && \ + chown -R nginx:nginx /var/run/nginx.pid +USER nginx +`; +} diff --git a/packages/arui-scripts-docker/src/templates/dockerfile.template.ts b/packages/arui-scripts-docker/src/templates/dockerfile.template.ts new file mode 100644 index 00000000..df7cfec5 --- /dev/null +++ b/packages/arui-scripts-docker/src/templates/dockerfile.template.ts @@ -0,0 +1,49 @@ +import { type ResolvedDockerConfig } from '../types'; + +/** + * Dockerfile для «сырого» образа: приложение собирается на хосте, в образ кладется результат сборки. + */ +export function renderDockerfile(config: ResolvedDockerConfig): string { + const { clientOnly, buildPath, runFromNonRootUser, baseDockerImage, nginx } = config; + + const appPathToAdd = clientOnly ? buildPath : '.'; + const appTargetPath = clientOnly ? `/src/${buildPath}` : '/src'; + const nginxConfTargetLocation = clientOnly + ? '/etc/nginx/conf.d/default.conf' + : '/src/nginx.conf'; + + const nginxNonRootPart = runFromNonRootUser + ? `RUN chown -R nginx:nginx /src && \\ + mkdir -p /var/lib/nginx && \\ + chown -R nginx:nginx /var/lib/nginx && \\ + chown -R nginx:nginx /var/log/nginx && \\ + chown -R nginx:nginx /etc/nginx/conf.d + + RUN touch /var/run/nginx.pid && \\ + chown -R nginx:nginx /var/run/nginx.pid + + USER nginx` + : ''; + + return ` +FROM ${baseDockerImage} +ARG START_SH_LOCATION +ARG NGINX_CONF_LOCATION +ARG NGINX_BASE_CONF_LOCATION + +WORKDIR /src +ADD $START_SH_LOCATION /src/start.sh +ADD $NGINX_CONF_LOCATION ${nginxConfTargetLocation} +${nginx ? 'ADD $NGINX_BASE_CONF_LOCATION /etc/nginx/nginx.conf' : ''} + +${nginxNonRootPart} + +${ + runFromNonRootUser + ? `ADD --chown=nginx:nginx ${appPathToAdd} ${appTargetPath}` + : `ADD ${appPathToAdd} ${appTargetPath}` +} +${clientOnly ? 'COPY env-config.jso[n] /src/' : ''} +${clientOnly ? 'CMD ["nginx"]' : ''} +`; +} diff --git a/packages/arui-scripts-docker/src/templates/index.ts b/packages/arui-scripts-docker/src/templates/index.ts new file mode 100644 index 00000000..126a829e --- /dev/null +++ b/packages/arui-scripts-docker/src/templates/index.ts @@ -0,0 +1,5 @@ +export { renderDockerfile } from './dockerfile.template'; +export { renderDockerfileCompiled } from './dockerfile-compiled.template'; +export { renderNginxConf } from './nginx.conf.template'; +export { renderBaseNginxConf } from './base-nginx.conf.template'; +export { renderStartScript } from './start.template'; diff --git a/packages/arui-scripts-docker/src/templates/nginx.conf.template.ts b/packages/arui-scripts-docker/src/templates/nginx.conf.template.ts new file mode 100644 index 00000000..4ff656c8 --- /dev/null +++ b/packages/arui-scripts-docker/src/templates/nginx.conf.template.ts @@ -0,0 +1,61 @@ +import { type ResolvedDockerConfig } from '../types'; + +/** + * server-блок nginx-конфига, который раздает статику и (в не-clientOnly режиме) проксирует на nodejs. + */ +export function renderNginxConf(config: ResolvedDockerConfig): string { + const { + clientServerPort, + enablePreviousVersionHeaders, + clientOnly, + nginxRootPath, + buildPath, + serverPort, + publicPath, + } = config; + + return `client_max_body_size 20m; + +server { + listen ${clientServerPort}; + server_tokens off; + ${enablePreviousVersionHeaders ? 'brotli_auto_dictionary on;' : ''} + + ${ + clientOnly + ? `location / { + root ${nginxRootPath}/${buildPath}; + index index.html; + }` + : ` location / { + proxy_set_header Host $host; + proxy_pass http://127.0.0.1:${serverPort}; + }` + } + + location /${publicPath} { + expires max; + add_header Cache-Control public; + root ${nginxRootPath}/${buildPath}; + } + + location = /${publicPath}remoteEntry.js { + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header Pragma "no-cache"; + add_header Expires "0"; + root ${nginxRootPath}/${buildPath}; + types { + text/javascript js; + } + } + + location ~ /${publicPath}.*\\.js$ { + expires max; + add_header Cache-Control public; + root ${nginxRootPath}/${buildPath}; + types { + text/javascript js; + } + } +}`; +} diff --git a/packages/arui-scripts-docker/src/templates/start.template.ts b/packages/arui-scripts-docker/src/templates/start.template.ts new file mode 100644 index 00000000..8f3ae5f0 --- /dev/null +++ b/packages/arui-scripts-docker/src/templates/start.template.ts @@ -0,0 +1,77 @@ +import { ENV_CONFIG_FILENAME } from '../constants'; +import { type ResolvedDockerConfig } from '../types'; + +/** + * start.sh — entrypoint образа. Для serverful-режима поднимает nginx + nodejs, для clientOnly — + * подставляет env-config и запускает nginx. + */ +export function renderStartScript(config: ResolvedDockerConfig): string { + const { clientOnly, buildPath, serverOutput } = config; + + const serverStartTemplate = `#!/bin/sh + +# Подменяем env переменные в nginx конфиге перед стартом +# Сначала заменяем все слова, начинающиеся на $ но без \${} на ~~слово~~ +# затем запускаем envsubst, после этого обратно заменяем ~~слово~~ на $слово. +# Это нужно для того, чтоб специальные переменные nginx не подменялись envsubst'ом на +# пустые строки. envsubst будет заменять только \${слово}. +cat ./nginx.conf \\ + | sed 's/\\$\\([a-zA-Z0-9_-]\\{1,\\}\\)/~~\\1~~/g' \\ + | envsubst \\ + | sed 's/~~\\([a-zA-Z0-9_-]\\{1,\\}\\)~~/$\\1/g' \\ + > /etc/nginx/conf.d/default.conf + +# Достаем лимит памяти из cgroup, это то, как его докер задает. +if [[ -f /sys/fs/cgroup/cgroup.controllers ]]; then + max_total_memory=$(cat /sys/fs/cgroup/memory.max) +else + max_total_memory=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes) +fi +# Самой nodejs мы не можем отдать совсем всю память, операционная система+nginx все же требуют какого то количества. +# Поэтому мы вычитаем 100мб на нужды ос +node_memory_limit="$(($max_total_memory / 1024 / 1024 - 100))" + +# Start the nginx process in background +nginx & + +# Start nodejs process +exec node --max-old-space-size="$node_memory_limit" ./${buildPath}/${serverOutput} +`; + + const envConfigTargetPath = `/src/${buildPath}/${ENV_CONFIG_FILENAME}`; + const envConfigPath = `/src/${ENV_CONFIG_FILENAME}`; + const htmlPath = `/src/${buildPath}/index.html`; + + const clientOnlyStartTemplate = `#!/bin/sh + +# Мы подставляем значения из env в env-config.json если он есть, и кладем его в публичную папку. +# Дополнительно подставляем контент полученного файла в index.html +# Так как контент env-config может быть многострочным - дополнительно обрабатываем его через awk. +if [ -f ${envConfigPath} ]; then + cat ${envConfigPath} \\ + | envsubst \\ + > ${envConfigTargetPath} + + # Define the placeholder and the file paths + PLACEHOLDER='<%= envConfig %>' + SETTINGS_FILE='${envConfigTargetPath}' + TARGET_FILE='${htmlPath}' + + # Escape the placeholder for sed usage + ESCAPED_PLACEHOLDER=$(echo "$PLACEHOLDER" | sed 's/[\\/&]/\\\\&/g') + + # Read the content of the settings and prepare it for substitution + SETTINGS_CONTENT=$(awk '{printf "%s\\\\n", $0}' "$SETTINGS_FILE") + + # Replace the placeholder in the target file with the content of settings + cat ${htmlPath} \\ + | sed "s/$ESCAPED_PLACEHOLDER/$SETTINGS_CONTENT/" \\ + > /tmp/index.html + + mv /tmp/index.html ${htmlPath} +fi + +nginx`; + + return clientOnly ? clientOnlyStartTemplate : serverStartTemplate; +} diff --git a/packages/arui-scripts-docker/src/types.ts b/packages/arui-scripts-docker/src/types.ts new file mode 100644 index 00000000..c3a00a53 --- /dev/null +++ b/packages/arui-scripts-docker/src/types.ts @@ -0,0 +1,193 @@ +export type YarnVersion = '1' | '2+' | 'unavailable'; + +/** + * Настройки, которые прокидываются в базовый nginx-конфиг (http-блок). + */ +export type NginxConfig = { + workerProcesses?: number; + workerRlimitNoFile?: number; + workerConnections?: number; + eventsUse?: string; + daemon?: string; +}; + +/** + * Управление флагом `--platform` в команде `docker build`: + * - `'auto'` — исторное поведение: флаг подставляется только если версия docker его поддерживает; + * - `false` — никогда не добавлять флаг; + * - строка (например `'linux/amd64'`) — всегда использовать указанную платформу. + */ +export type DockerPlatform = 'auto' | false | string; + +/** + * Функция-оверрайд шаблона. Получает сгенерированную по умолчанию строку и итоговый конфиг, + * возвращает новую строку. Позволяет точечно донасыщать/подменять любой из шаблонов. + */ +export type TemplateOverride = (generatedContent: string, config: ResolvedDockerConfig) => string; + +/** + * Функция-рендерер шаблона. Полностью заменяет генерацию шаблона по умолчанию. + */ +export type TemplateRenderer = (config: ResolvedDockerConfig) => string; + +export type DockerTemplateKey = + | 'dockerfile' + | 'dockerfileCompiled' + | 'nginxConf' + | 'baseNginxConf' + | 'startScript'; + +/** + * Кастомные рендереры шаблонов. Любой из них можно переопределить целиком. + */ +export type DockerTemplates = Partial>; + +/** + * Точечные оверрайды поверх сгенерированных по умолчанию шаблонов. + */ +export type DockerTemplateOverrides = Partial>; + +/** + * Пути к локальным файлам, которые (если существуют) будут использованы вместо сгенерированных шаблонов. + */ +export type LocalFiles = { + dockerfile?: string | null; + startScript?: string | null; + nginxConf?: string | null; + nginxBaseConf?: string | null; +}; + +/** + * Полный набор опций сборки docker-образа. Все поля опциональны — недостающие донасыщаются дефолтами + * в {@link resolveDockerConfig}. Это единственная точка входа для кастомизации: любой потребитель + * (arui-scripts, newclick-builder, собственные скрипты) может собрать образ, передав сюда свои значения. + */ +export type DockerBuildOptions = { + /* --- Идентификация образа --- */ + /** Имя образа. По умолчанию берется из `package.json` в `cwd`. */ + name?: string; + /** Версия/тег образа. По умолчанию берется из `package.json` в `cwd`. */ + version?: string; + /** Docker registry, к которому будет добавлен образ (`registry/name:version`). */ + dockerRegistry?: string; + + /* --- Базовый образ и форма приложения --- */ + /** Базовый docker-образ (`FROM`). */ + baseDockerImage?: string; + /** Собирается ли только клиентская часть (nginx без nodejs-сервера). */ + clientOnly?: boolean; + /** Путь к директории со сборкой приложения относительно корня проекта. */ + buildPath?: string; + /** Путь к серверному бандлу относительно `buildPath`. */ + serverOutput?: string; + /** Корень, из которого nginx раздает статику. */ + nginxRootPath?: string; + /** Публичный префикс путей до статики. */ + publicPath?: string; + + /* --- Порты --- */ + clientServerPort?: number; + serverPort?: number; + + /* --- Nginx --- */ + /** Настройки базового nginx-конфига. `null`/`false` — не генерировать базовый конфиг. */ + nginx?: NginxConfig | null | false; + /** Добавлять ли заголовки для предыдущей версии словаря brotli. */ + enablePreviousVersionHeaders?: boolean; + + /* --- Поведение сборки --- */ + /** Запускать ли процессы в образе от пользователя nginx (не root). */ + runFromNonRootUser?: boolean; + /** Рабочая директория проекта. */ + cwd?: string; + /** Контекст сборки docker (последний аргумент `docker build`). */ + context?: string; + /** Имя временной директории для сгенерированных файлов. */ + tempDirName?: string; + /** Режим отладки: не пушить образ, печатать стек ошибок. */ + debug?: boolean; + /** Выполнять ли `docker push` после сборки. По умолчанию — да, кроме `debug`. */ + push?: boolean; + /** Управление флагом `--platform`. */ + platform?: DockerPlatform; + /** Дополнительные `--build-arg` для `docker build`. */ + extraBuildArgs?: Record; + + /* --- Менеджер зависимостей (для compiled-образа) --- */ + /** Использовать ли yarn (если доступен). */ + useYarn?: boolean; + /** Явно заданная версия yarn. По умолчанию определяется автоматически. */ + yarnVersion?: YarnVersion; + /** Команда установки production-зависимостей внутри образа. */ + installProductionCommand?: string; + /** Команда очистки dev-зависимостей перед сборкой образа. */ + pruneCommand?: string; + + /* --- Кастомизация файлов --- */ + /** Добавлять ли `node_modules` в `.dockerignore` (нужно для compiled-образа). */ + addNodeModulesToDockerIgnore?: boolean; + /** Разрешить подмену Dockerfile локальным файлом. */ + allowLocalDockerfile?: boolean; + /** Разрешить подмену start.sh локальным файлом. */ + allowLocalStartScript?: boolean; + /** Пути к локальным файлам, замещающим сгенерированные шаблоны. */ + localFiles?: LocalFiles; + + /* --- Шаблоны --- */ + /** Полная замена рендереров шаблонов. */ + templates?: DockerTemplates; + /** Точечные оверрайды поверх сгенерированных шаблонов. */ + overrides?: DockerTemplateOverrides; +}; + +/** + * Полностью донасыщенный конфиг сборки. С ним работают все шаблоны и утилиты — они не знают ничего + * о том, откуда пришли значения, что делает их независимыми и легко тестируемыми. + */ +export type ResolvedDockerConfig = { + name: string; + version: string; + dockerRegistry: string; + + baseDockerImage: string; + clientOnly: boolean; + buildPath: string; + serverOutput: string; + nginxRootPath: string; + publicPath: string; + + clientServerPort: number; + serverPort: number; + + nginx: NginxConfig | null; + enablePreviousVersionHeaders: boolean; + + runFromNonRootUser: boolean; + cwd: string; + context: string; + tempDirName: string; + debug: boolean; + push: boolean; + platform: DockerPlatform; + extraBuildArgs: Record; + + useYarn: boolean; + yarnVersion: YarnVersion; + installProductionCommand: string; + pruneCommand: string; + + addNodeModulesToDockerIgnore: boolean; + allowLocalDockerfile: boolean; + allowLocalStartScript: boolean; + localFiles: Required; +}; + +/** + * Готовые (отрендеренные) содержимого файлов, которые будут положены в образ. + */ +export type RenderedDockerTemplates = { + dockerfile: string; + nginxConf: string; + nginxBaseConf: string; + startScript: string; +}; diff --git a/packages/arui-scripts-docker/src/utils/docker-build.ts b/packages/arui-scripts-docker/src/utils/docker-build.ts new file mode 100644 index 00000000..6d179049 --- /dev/null +++ b/packages/arui-scripts-docker/src/utils/docker-build.ts @@ -0,0 +1,218 @@ +import path from 'path'; + +import fs from 'fs-extra'; +import satisfies from 'semver/functions/satisfies'; +import shell from 'shelljs'; + +import { + BASE_NGINX_CONFIG_FILENAME, + DEFAULT_PLATFORM, + NGINX_CONFIG_FILENAME, + PLATFORM_FLAG_MIN_DOCKER_VERSION, +} from '../constants'; +import { type RenderedDockerTemplates, type ResolvedDockerConfig } from '../types'; + +export type BuildParams = { + pathToTempDir: string; + imageFullName: string; + tempDirName: string; +}; + +/** + * Собирает параметры сборки (полное имя образа и пути) из конфига. + */ +export function getBuildParams(config: ResolvedDockerConfig): BuildParams { + const { dockerRegistry, name, version, cwd, tempDirName } = config; + const pathToTempDir = path.join(cwd, tempDirName); + const imageFullName = `${dockerRegistry ? `${dockerRegistry}/` : ''}${name}:${version}`; + + return { pathToTempDir, imageFullName, tempDirName }; +} + +/** + * Разбирает аргументы командной строки вида `name=... version=... registry=...` и накладывает их + * поверх конфига. Неизвестные аргументы игнорируются с предупреждением. + */ +export function applyCommandLineArguments( + config: ResolvedDockerConfig, + commandLineArguments: string[], +): ResolvedDockerConfig { + const next = { ...config }; + + commandLineArguments.forEach((arg) => { + let [argName, argValue] = arg.split('='); + + argName = argName.toLowerCase().trim(); + argValue = argValue ? argValue.trim() : ''; + switch (argName) { + case 'version': + next.version = argValue; + break; + case 'name': + next.name = argValue; + break; + case 'registry': + next.dockerRegistry = argValue; + break; + default: + console.warn(`Unknown argument ${argName}`); + } + }); + + return next; +} + +/** + * Как {@link getBuildParams}, но с учетом аргументов командной строки (по умолчанию — `process.argv`, + * начиная с третьего, как в CLI arui-scripts). + */ +export function getBuildParamsFromArgs( + config: ResolvedDockerConfig, + argv: string[] = process.argv.slice(3), +): BuildParams { + return getBuildParams(applyCommandLineArguments(config, argv)); +} + +type PrepareFilesForDockerParams = { + config: ResolvedDockerConfig; + templates: RenderedDockerTemplates; +}; + +/** + * Готовит временную директорию со всеми файлами, необходимыми для `docker build`: Dockerfile, + * nginx-конфиги и start.sh. Локальные файлы проекта (если разрешены и заданы) имеют приоритет над + * сгенерированными шаблонами. + */ +export async function prepareFilesForDocker({ config, templates }: PrepareFilesForDockerParams) { + const { + cwd, + nginx, + localFiles, + allowLocalDockerfile, + allowLocalStartScript, + addNodeModulesToDockerIgnore, + } = config; + const { pathToTempDir } = getBuildParams(config); + + await fs.emptyDir(pathToTempDir); + + let nginxBaseConf = ''; + + if (nginx) { + nginxBaseConf = localFiles.nginxBaseConf + ? await fs.readFile(localFiles.nginxBaseConf, 'utf8') + : templates.nginxBaseConf; + } + + const nginxConf = localFiles.nginxConf + ? await fs.readFile(localFiles.nginxConf, 'utf8') + : templates.nginxConf; + + const dockerfile = + localFiles.dockerfile && allowLocalDockerfile + ? await fs.readFile(localFiles.dockerfile, 'utf8') + : templates.dockerfile; + + const startScript = + localFiles.startScript && allowLocalStartScript + ? await fs.readFile(localFiles.startScript, 'utf8') + : templates.startScript; + + const dockerIgnoreFilePath = path.join(cwd, '.dockerignore'); + + const dockerIgnoreFileContent = + addNodeModulesToDockerIgnore && + (await getAndModifyDockerIgnoreContent(dockerIgnoreFilePath)); + + await Promise.all( + [ + fs.writeFile(path.join(pathToTempDir, 'Dockerfile'), dockerfile, 'utf8'), + fs.writeFile(path.join(pathToTempDir, NGINX_CONFIG_FILENAME), nginxConf, 'utf8'), + nginxBaseConf && + fs.writeFile( + path.join(pathToTempDir, BASE_NGINX_CONFIG_FILENAME), + nginxBaseConf, + 'utf8', + ), + fs.writeFile(path.join(pathToTempDir, 'start.sh'), startScript, { + encoding: 'utf8', + mode: 0o555, + }), + addNodeModulesToDockerIgnore && + dockerIgnoreFileContent && + fs.writeFile(dockerIgnoreFilePath, dockerIgnoreFileContent, 'utf-8'), + ].filter(Boolean), + ); +} + +/** + * Проверяет, что версия docker (клиент и сервер) удовлетворяет semver-диапазону. + */ +export function dockerVersionSatisfies(request: string) { + const dockerServerVersion = shell.exec("docker version --format '{{.Server.Version}}'", { + silent: true, + }); + const dockerClientVersion = shell.exec("docker version --format '{{.Client.Version}}'", { + silent: true, + }); + + return ( + satisfies(dockerServerVersion.toString(), request) && + satisfies(dockerClientVersion.toString(), request) + ); +} + +/** + * Вычисляет значение флага `--platform` согласно настройке `platform` в конфиге. + */ +export function getPlatformFlag(config: ResolvedDockerConfig): string { + const { platform } = config; + + if (platform === 'auto') { + // на маках с m1 без флага docker пытается вытянуть базовый образ под свою платформу и падает, + // но сам флаг поддерживается без экспериментальных флагов только начиная с docker 20.10.21. + return dockerVersionSatisfies(PLATFORM_FLAG_MIN_DOCKER_VERSION) + ? `--platform ${DEFAULT_PLATFORM}` + : ''; + } + + if (platform) { + return `--platform ${platform}`; + } + + return ''; +} + +/** + * Формирует команду `docker build` для сгенерированной ранее временной директории. + */ +export function getDockerBuildCommand(config: ResolvedDockerConfig): string { + const { tempDirName, context, extraBuildArgs } = config; + const { imageFullName } = getBuildParams(config); + + const platformFlag = getPlatformFlag(config); + + const extraArgs = Object.entries(extraBuildArgs) + .map(([key, value]) => `--build-arg ${key}="${value}"`) + .join(' '); + + return `docker build ${platformFlag} \ + -f "./${tempDirName}/Dockerfile" \ + --build-arg START_SH_LOCATION="./${tempDirName}/start.sh" \ + --build-arg NGINX_CONF_LOCATION="./${tempDirName}/${NGINX_CONFIG_FILENAME}" \ + --build-arg NGINX_BASE_CONF_LOCATION="./${tempDirName}/${BASE_NGINX_CONFIG_FILENAME}" \ + ${extraArgs} \ + -t ${imageFullName} ${context}`; +} + +async function getAndModifyDockerIgnoreContent(dockerIgnoreFilePath: string) { + if (fs.existsSync(dockerIgnoreFilePath)) { + return fs + .readFile(dockerIgnoreFilePath, 'utf-8') + .then((ignores) => `${ignores}\nnode_modules`); + } + + await fs.createFile(dockerIgnoreFilePath); + + return 'node_modules'; +} diff --git a/packages/arui-scripts-docker/src/utils/exec.ts b/packages/arui-scripts-docker/src/utils/exec.ts new file mode 100644 index 00000000..a9e100d1 --- /dev/null +++ b/packages/arui-scripts-docker/src/utils/exec.ts @@ -0,0 +1,19 @@ +import shell from 'shelljs'; + +/** + * Выполняет shell-команду, резолвится кодом возврата при успехе и реджектится им при ошибке. + */ +export function exec(command: string): Promise { + return new Promise((resolve, reject) => { + console.log(`Executing command: ${command}`); + shell.exec(command, (code: number) => { + if (code === 0) { + return resolve(code); + } + + return reject(code); + }); + }); +} + +export default exec; diff --git a/packages/arui-scripts-docker/src/utils/yarn.ts b/packages/arui-scripts-docker/src/utils/yarn.ts new file mode 100644 index 00000000..d9da16e8 --- /dev/null +++ b/packages/arui-scripts-docker/src/utils/yarn.ts @@ -0,0 +1,80 @@ +import path from 'path'; + +import fs from 'fs-extra'; +import shell from 'shelljs'; + +import { type YarnVersion } from '../types'; + +type GetYarnVersionParams = { + useYarn: boolean; +}; + +/** + * Определяет версию yarn, доступную в системе. Если yarn не используется/недоступен — `'unavailable'`. + */ +export function getYarnVersion({ useYarn }: GetYarnVersionParams): YarnVersion { + if (useYarn && shell.which('yarn')) { + const yarnVersion = shell.exec('yarn -v', { silent: true }); + const yarnMajorVersion = Number(yarnVersion.split('.')[0]); + + return yarnMajorVersion > 1 ? '2+' : '1'; + } + + return 'unavailable'; +} + +/** + * Проверяет наличие `yarn.lock` в директории проекта. Используется как дефолт для `useYarn`. + */ +export function detectUseYarn(cwd: string): boolean { + return fs.existsSync(path.join(cwd, 'yarn.lock')); +} + +type GetPruningCommandParams = { + yarnVersion: YarnVersion; + clientOnly: boolean; +}; + +/** + * Команда удаления dev-зависимостей перед копированием проекта в образ. + */ +export function getPruningCommand({ yarnVersion, clientOnly }: GetPruningCommandParams): string { + if (clientOnly) { + return 'echo "Skipping pruning in client only mode"'; + } + + switch (yarnVersion) { + case '1': { + return 'yarn install --production --ignore-optional --frozen-lockfile --ignore-scripts --prefer-offline'; + } + case '2+': { + return 'yarn workspaces focus --production --all'; + } + case 'unavailable': { + return 'npm prune --production'; + } + default: { + return ''; + } + } +} + +/** + * Команда установки production-зависимостей внутри образа (для compiled-варианта). + */ +export function getInstallProductionCommand(yarnVersion: YarnVersion): string { + switch (yarnVersion) { + case '1': { + return 'yarn install --production --ignore-optional --frozen-lockfile --ignore-scripts --prefer-offline'; + } + case '2+': { + return 'yarn workspaces focus --production --all'; + } + case 'unavailable': { + return 'npm install --production'; + } + default: { + return ''; + } + } +} diff --git a/packages/arui-scripts-docker/tsconfig.eslint.json b/packages/arui-scripts-docker/tsconfig.eslint.json new file mode 100644 index 00000000..8f000311 --- /dev/null +++ b/packages/arui-scripts-docker/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + // для корректной работы @typescript-eslint/parser + "include": ["src", "./*.js", "./*.ts", ".eslintrc.js"] +} diff --git a/packages/arui-scripts-docker/tsconfig.json b/packages/arui-scripts-docker/tsconfig.json new file mode 100644 index 00000000..58c6dc57 --- /dev/null +++ b/packages/arui-scripts-docker/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../arui-scripts/tsconfig.json", + "compilerOptions": { + "target": "ES2016", + "module": "commonjs", + "skipLibCheck": true, + "outDir": "./build", + "declaration": true, + "rootDir": "./src", + "types": ["jest", "node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["build"] +} diff --git a/yarn.lock b/yarn.lock index 048043be..ac0d500c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -147,6 +147,26 @@ __metadata: languageName: node linkType: hard +"@alfalab/scripts-docker@workspace:packages/arui-scripts-docker": + version: 0.0.0-use.local + resolution: "@alfalab/scripts-docker@workspace:packages/arui-scripts-docker" + dependencies: + "@types/fs-extra": "npm:^9.0.13" + "@types/jest": "npm:^29.5.14" + "@types/node": "npm:^20.19.0" + "@types/semver": "npm:^7.5.0" + "@types/shelljs": "npm:^0.8.12" + fs-extra: "npm:6.0.1" + jest: "npm:^29.7.0" + semver: "npm:^7.5.4" + shelljs: "npm:0.8.5" + ts-jest: "npm:29.1.0" + typescript: "npm:6.0.2" + bin: + arui-scripts-docker: ./build/bin/index.js + languageName: unknown + linkType: soft + "@alfalab/scripts-modules@workspace:*, @alfalab/scripts-modules@workspace:packages/arui-scripts-modules": version: 0.0.0-use.local resolution: "@alfalab/scripts-modules@workspace:packages/arui-scripts-modules" From 8eae13cb128be2ad0b8c873b7bd314b02d11cfb7 Mon Sep 17 00:00:00 2001 From: Stepan Ivankov Date: Thu, 6 Aug 2026 17:13:29 +0300 Subject: [PATCH 2/4] update --- .changeset/docker-package-extraction.md | 26 ++ .../.eslintignore | 0 .../.eslintrc.js | 0 .../.gitignore | 0 .../.npmignore | 0 .../.prettierignore | 0 packages/arui-scripts-artifacts/CHANGELOG.md | 1 + packages/arui-scripts-artifacts/README.md | 251 ++++++++++++++++++ .../jest.config.js | 2 + .../package.json | 13 +- .../src/__tests__/cli.test.ts | 99 +++++++ .../src/__tests__/config-file.test.ts | 128 +++++++++ .../src/__tests__/config.test.ts | 110 ++++++++ .../src/__tests__/docker-build.test.ts | 123 +++++++++ .../arui-scripts-artifacts.ts | 3 + .../src/__tests__/fixtures/cjs-config.cjs | 1 + .../src/__tests__/fixtures/fn-config.mjs | 1 + .../src/__tests__/fixtures/ts-config.ts | 12 + .../src/__tests__/load-config-file.test.ts | 54 ++++ .../src/__tests__/render.test.ts | 101 +++++++ .../arui-scripts-artifacts/src/bin/index.ts | 64 +++++ .../src/build-archive.ts | 112 ++++++++ .../src/build-artifact.ts | 17 ++ .../src/build-docker-image.ts | 87 ++++++ packages/arui-scripts-artifacts/src/cli.ts | 107 ++++++++ .../arui-scripts-artifacts/src/config-file.ts | 120 +++++++++ .../src/config.ts | 41 ++- .../src/constants.ts | 6 + .../src/host-pipeline.ts | 39 +++ packages/arui-scripts-artifacts/src/index.ts | 53 ++++ .../src/load-config-file.ts | 76 ++++++ .../src/render.ts | 28 +- .../src/templates/base-nginx.conf.template.ts | 4 +- .../templates/dockerfile-compiled.template.ts | 4 +- .../src/templates/dockerfile.template.ts | 4 +- .../src/templates/index.ts | 0 .../src/templates/nginx.conf.template.ts | 4 +- .../src/templates/start.template.ts | 4 +- .../src/types.ts | 85 +++++- .../src/utils/docker-build.ts | 81 ++++-- .../arui-scripts-artifacts/src/utils/exec.ts | 34 +++ .../src/utils/yarn.ts | 0 .../tsconfig.eslint.json | 7 + .../tsconfig.json | 2 +- packages/arui-scripts-docker/CHANGELOG.md | 1 - packages/arui-scripts-docker/README.md | 130 --------- .../src/__tests__/config.test.ts | 48 ---- .../src/__tests__/docker-build.test.ts | 80 ------ .../src/__tests__/render.test.ts | 78 ------ packages/arui-scripts-docker/src/bin/index.ts | 102 ------- .../src/build-docker-image.ts | 69 ----- packages/arui-scripts-docker/src/index.ts | 37 --- .../arui-scripts-docker/src/utils/exec.ts | 19 -- .../arui-scripts-docker/tsconfig.eslint.json | 5 - packages/arui-scripts/package.json | 1 + .../src/commands/archive-build/index.ts | 91 +------ .../src/commands/build/build-wrapper.ts | 9 +- .../arui-scripts/src/commands/build/client.ts | 8 +- .../arui-scripts/src/commands/build/index.ts | 2 +- .../arui-scripts/src/commands/build/server.ts | 5 +- .../commands/docker-build-compiled/index.ts | 47 +--- .../src/commands/docker-build/index.ts | 76 +----- .../src/commands/util/artifacts-options.ts | 91 +++++++ .../src/commands/util/docker-build.ts | 195 +++++--------- .../arui-scripts/src/commands/util/exec.ts | 15 +- .../arui-scripts/src/commands/util/yarn.ts | 64 ++--- packages/arui-scripts/src/index.ts | 28 ++ .../src/templates/base-nginx.conf.template.ts | 68 +---- .../templates/dockerfile-compiled.template.ts | 57 +--- .../src/templates/dockerfile.template.ts | 51 +--- .../src/templates/nginx.conf.template.ts | 59 +--- .../src/templates/start.template.ts | 72 +---- yarn.lock | 26 +- 73 files changed, 2144 insertions(+), 1294 deletions(-) create mode 100644 .changeset/docker-package-extraction.md rename packages/{arui-scripts-docker => arui-scripts-artifacts}/.eslintignore (100%) rename packages/{arui-scripts-docker => arui-scripts-artifacts}/.eslintrc.js (100%) rename packages/{arui-scripts-docker => arui-scripts-artifacts}/.gitignore (100%) rename packages/{arui-scripts-docker => arui-scripts-artifacts}/.npmignore (100%) rename packages/{arui-scripts-docker => arui-scripts-artifacts}/.prettierignore (100%) create mode 100644 packages/arui-scripts-artifacts/CHANGELOG.md create mode 100644 packages/arui-scripts-artifacts/README.md rename packages/{arui-scripts-docker => arui-scripts-artifacts}/jest.config.js (55%) rename packages/{arui-scripts-docker => arui-scripts-artifacts}/package.json (75%) create mode 100644 packages/arui-scripts-artifacts/src/__tests__/cli.test.ts create mode 100644 packages/arui-scripts-artifacts/src/__tests__/config-file.test.ts create mode 100644 packages/arui-scripts-artifacts/src/__tests__/config.test.ts create mode 100644 packages/arui-scripts-artifacts/src/__tests__/docker-build.test.ts create mode 100644 packages/arui-scripts-artifacts/src/__tests__/fixtures-autodetect/arui-scripts-artifacts.ts create mode 100644 packages/arui-scripts-artifacts/src/__tests__/fixtures/cjs-config.cjs create mode 100644 packages/arui-scripts-artifacts/src/__tests__/fixtures/fn-config.mjs create mode 100644 packages/arui-scripts-artifacts/src/__tests__/fixtures/ts-config.ts create mode 100644 packages/arui-scripts-artifacts/src/__tests__/load-config-file.test.ts create mode 100644 packages/arui-scripts-artifacts/src/__tests__/render.test.ts create mode 100644 packages/arui-scripts-artifacts/src/bin/index.ts create mode 100644 packages/arui-scripts-artifacts/src/build-archive.ts create mode 100644 packages/arui-scripts-artifacts/src/build-artifact.ts create mode 100644 packages/arui-scripts-artifacts/src/build-docker-image.ts create mode 100644 packages/arui-scripts-artifacts/src/cli.ts create mode 100644 packages/arui-scripts-artifacts/src/config-file.ts rename packages/{arui-scripts-docker => arui-scripts-artifacts}/src/config.ts (66%) rename packages/{arui-scripts-docker => arui-scripts-artifacts}/src/constants.ts (84%) create mode 100644 packages/arui-scripts-artifacts/src/host-pipeline.ts create mode 100644 packages/arui-scripts-artifacts/src/index.ts create mode 100644 packages/arui-scripts-artifacts/src/load-config-file.ts rename packages/{arui-scripts-docker => arui-scripts-artifacts}/src/render.ts (73%) rename packages/{arui-scripts-docker => arui-scripts-artifacts}/src/templates/base-nginx.conf.template.ts (94%) rename packages/{arui-scripts-docker => arui-scripts-artifacts}/src/templates/dockerfile-compiled.template.ts (93%) rename packages/{arui-scripts-docker => arui-scripts-artifacts}/src/templates/dockerfile.template.ts (91%) rename packages/{arui-scripts-docker => arui-scripts-artifacts}/src/templates/index.ts (100%) rename packages/{arui-scripts-docker => arui-scripts-artifacts}/src/templates/nginx.conf.template.ts (91%) rename packages/{arui-scripts-docker => arui-scripts-artifacts}/src/templates/start.template.ts (96%) rename packages/{arui-scripts-docker => arui-scripts-artifacts}/src/types.ts (65%) rename packages/{arui-scripts-docker => arui-scripts-artifacts}/src/utils/docker-build.ts (68%) create mode 100644 packages/arui-scripts-artifacts/src/utils/exec.ts rename packages/{arui-scripts-docker => arui-scripts-artifacts}/src/utils/yarn.ts (100%) create mode 100644 packages/arui-scripts-artifacts/tsconfig.eslint.json rename packages/{arui-scripts-docker => arui-scripts-artifacts}/tsconfig.json (88%) delete mode 100644 packages/arui-scripts-docker/CHANGELOG.md delete mode 100644 packages/arui-scripts-docker/README.md delete mode 100644 packages/arui-scripts-docker/src/__tests__/config.test.ts delete mode 100644 packages/arui-scripts-docker/src/__tests__/docker-build.test.ts delete mode 100644 packages/arui-scripts-docker/src/__tests__/render.test.ts delete mode 100644 packages/arui-scripts-docker/src/bin/index.ts delete mode 100644 packages/arui-scripts-docker/src/build-docker-image.ts delete mode 100644 packages/arui-scripts-docker/src/index.ts delete mode 100644 packages/arui-scripts-docker/src/utils/exec.ts delete mode 100644 packages/arui-scripts-docker/tsconfig.eslint.json create mode 100644 packages/arui-scripts/src/commands/util/artifacts-options.ts diff --git a/.changeset/docker-package-extraction.md b/.changeset/docker-package-extraction.md new file mode 100644 index 00000000..2658d9cf --- /dev/null +++ b/.changeset/docker-package-extraction.md @@ -0,0 +1,26 @@ +--- +'@alfalab/scripts-artifacts': minor +'arui-scripts': patch +--- + +Сборка артефактов поставки вынесена в отдельный пакет `@alfalab/scripts-artifacts`. + +Пакет устроен как набор чистых функций и настраивается одним файлом `arui-scripts-artifacts.ts` в +корне проекта (путь можно задать через `--c`/`--config`). Конфиг может быть на TypeScript, ESM или +CommonJS и описывает всё: базовый образ, параметры nginx, кастомные шаблоны и свои команды сборки — +так что кастомные сборочные скрипты в проектах больше не нужны. + +Поддерживаются оба типа артефактов: docker-образ (`docker-build`, `docker-build:compiled`) и +tar-архив (`archive-build`). Хост-пайплайн (очистка `buildPath` → сборка приложения → удаление +dev-зависимостей) у них общий. + +`arui-scripts` теперь использует этот пакет вместо собственной копии шаблонов и утилит. Поведение +команд и все ключи оверрайдов (`Dockerfile`, `DockerfileCompiled`, `nginx`, `nginxConf`, `start.sh`) +сохранены без изменений — отрендеренные Dockerfile, nginx-конфиги и start.sh совпадают побайтово. + +Попутно починен `archive-build`: он падал с `TypeError: Cannot read properties of undefined +(reading 'c')`, потому что в `tar@7` нет default-экспорта, а код использовал `import tar from 'tar'`. + +Единственное отличие в поведении: `archive-build` теперь подхватывает локальный `start.sh` из корня +проекта так же, как уже подхватывал `nginx.conf` (раньше игнорировал). Отключается опцией +`allowLocalStartScript: false`. diff --git a/packages/arui-scripts-docker/.eslintignore b/packages/arui-scripts-artifacts/.eslintignore similarity index 100% rename from packages/arui-scripts-docker/.eslintignore rename to packages/arui-scripts-artifacts/.eslintignore diff --git a/packages/arui-scripts-docker/.eslintrc.js b/packages/arui-scripts-artifacts/.eslintrc.js similarity index 100% rename from packages/arui-scripts-docker/.eslintrc.js rename to packages/arui-scripts-artifacts/.eslintrc.js diff --git a/packages/arui-scripts-docker/.gitignore b/packages/arui-scripts-artifacts/.gitignore similarity index 100% rename from packages/arui-scripts-docker/.gitignore rename to packages/arui-scripts-artifacts/.gitignore diff --git a/packages/arui-scripts-docker/.npmignore b/packages/arui-scripts-artifacts/.npmignore similarity index 100% rename from packages/arui-scripts-docker/.npmignore rename to packages/arui-scripts-artifacts/.npmignore diff --git a/packages/arui-scripts-docker/.prettierignore b/packages/arui-scripts-artifacts/.prettierignore similarity index 100% rename from packages/arui-scripts-docker/.prettierignore rename to packages/arui-scripts-artifacts/.prettierignore diff --git a/packages/arui-scripts-artifacts/CHANGELOG.md b/packages/arui-scripts-artifacts/CHANGELOG.md new file mode 100644 index 00000000..4ddd0753 --- /dev/null +++ b/packages/arui-scripts-artifacts/CHANGELOG.md @@ -0,0 +1 @@ +# @alfalab/scripts-artifacts diff --git a/packages/arui-scripts-artifacts/README.md b/packages/arui-scripts-artifacts/README.md new file mode 100644 index 00000000..1469fe0c --- /dev/null +++ b/packages/arui-scripts-artifacts/README.md @@ -0,0 +1,251 @@ +# @alfalab/scripts-artifacts + +Сборка артефактов поставки — docker-образа и tar-архива — для приложений, основанных на `arui-scripts`. + +Пакет устроен как набор чистых функций: конфиг → строка. Никакого глобального состояния, никакой +зависимости от `arui-scripts` — всё, что влияет на результат, приходит явным объектом опций. Из этого +следует главное свойство: **в проекте не нужны кастомные сборочные скрипты**. Всё, включая свои +команды, кастомный nginx и параметры базового nginx, описывается одним файлом `arui-scripts-artifacts.ts`. + +## Быстрый старт + +```bash +yarn add -D @alfalab/scripts-artifacts +``` + +`arui-scripts-artifacts.ts` в корне проекта: + +```ts +import { defineConfig } from '@alfalab/scripts-artifacts'; + +export default defineConfig({ + baseDockerImage: 'alfabankui/arui-scripts:24.10.0-slim', + dockerRegistry: 'registry.example.com', + nginx: { workerProcesses: 4 }, +}); +``` + +`package.json`: + +```json +{ + "scripts": { + "docker-build": "arui-scripts-artifacts docker-build", + "docker-build:compiled": "arui-scripts-artifacts docker-build:compiled" + } +} +``` + +## CLI + +```bash +arui-scripts-artifacts <команда> [--c <путь до конфига>] [name=... version=... registry=...] +``` + +Конфиг по умолчанию берется из корня проекта (`arui-scripts-artifacts.ts`, а также `.mts`/`.cts`/`.js`/ +`.mjs`/`.cjs`/`.config.ts`). Путь можно задать явно — `--c`, `--config` или `-c`, со знаком равенства +или отдельным аргументом: + +```bash +arui-scripts-artifacts docker-build --c './configs/docker.prod.ts' +arui-scripts-artifacts docker-build --config=./configs/docker.prod.ts version=1.2.3 +``` + +Конфиг может быть на TypeScript, ESM или CommonJS — загрузчик разбирается сам, регистрировать +`ts-node`/`tsx` в проекте не нужно. Экспортировать можно объект или (в т.ч. асинхронную) функцию, +возвращающую объект. + +### Встроенные команды + +| Команда | Что делает | +| ----------------------- | ------------------------------------------------------------------------------------- | +| `docker-build` | приложение собирается на хосте (`npm run build`), результат кладется в образ | +| `docker-build:compiled` | зависимости и сборка выполняются внутри образа, слои кешируются | +| `archive-build` | tar-архив с production-сборкой: `buildPath`, `node_modules`, `package.json`, `config` | + +CLI сам подхватывает лежащие в корне `Dockerfile`, `start.sh`, `nginx.conf` и `base-nginx.conf` — +если они есть, они заменяют сгенерированные шаблоны (для `docker-build:compiled` подмена Dockerfile +и start.sh запрещена, как и в `arui-scripts`). + +### Свои команды + +Вместо отдельного скрипта — секция `commands`. Команда наследует верхний уровень конфига, поэтому в +ней описывается только то, чем она отличается: + +```ts +import { defineConfig } from '@alfalab/scripts-artifacts'; + +export default defineConfig({ + baseDockerImage: 'registry.example.com/base:2.0.0', + nginx: { workerProcesses: 4 }, + + commands: { + // серверный образ: свой энтрипоинт и порт, всё остальное — из верхнего уровня + 'docker-build:server': { + variant: 'compiled', + serverOutput: 'server/index.js', + clientServerPort: 9090, + nginx: { workerConnections: 100 }, // сольется с верхнеуровневым workerProcesses + }, + + // образ только со статикой + 'docker-build:static': { + clientOnly: true, + buildCommand: 'npm run build:static', + }, + + // еще один архив — с отдельным именем и своей сборкой + 'archive-build:e2e': { + artifact: 'archive', + archiveName: 'e2e.tar', + buildCommand: 'npm run build:e2e', + }, + }, +}); +``` + +```bash +arui-scripts-artifacts docker-build:server +``` + +Запуск без команды печатает список доступных — встроенные плюс объявленные в конфиге. + +Вложенные объекты (`nginx`, `localFiles`, `templates`, `overrides`, `extraBuildArgs`) сливаются по +полям, остальные опции заменяются целиком. + +## Кастомизация файлов образа + +Каждый файл настраивается на четырех уровнях, по возрастанию приоритета: + +1. **Опции** — `baseDockerImage`, `clientOnly`, `runFromNonRootUser`, `nginx`, `platform`, + `extraBuildArgs`, `buildPath`, `serverOutput`, `publicPath` и остальные из `DockerBuildOptions`. +2. **`templates`** — полная замена рендерера: `(config) => string`. +3. **`overrides`** — точечная функция поверх сгенерированного: `(generated, config) => string`. +4. **Локальные файлы** — `Dockerfile`, `start.sh`, `nginx.conf`, `base-nginx.conf` в корне проекта. + +Ключи шаблонов: `dockerfile`, `dockerfileCompiled`, `nginxConf` (server-блок), `baseNginxConf` +(http-блок), `startScript`. + +```ts +import { defineConfig, renderNginxConf } from '@alfalab/scripts-artifacts'; + +export default defineConfig({ + // целиком свой server-блок nginx + templates: { + nginxConf: (config) => ` +client_max_body_size 20m; + +server { + listen ${config.clientServerPort}; + location = /health { return 200 ''; } + location / { proxy_pass http://127.0.0.1:${config.serverPort}; } +}`, + }, + + // или дописать к сгенерированному + overrides: { + dockerfile: (generated) => `${generated}\nLABEL team="web"`, + nginxConf: (generated, config) => + config.clientOnly ? generated : `${generated}\n# proxy tuning\n`, + }, +}); +``` + +Рендереры (`renderDockerfile`, `renderDockerfileCompiled`, `renderNginxConf`, `renderBaseNginxConf`, +`renderStartScript`) экспортируются наружу — их можно вызывать и оборачивать из своих шаблонов. + +## Программное API + +`buildArtifact` — тот же пайплайн, что и у CLI (диспетчеризует на `buildDockerImage`/`buildArchive` +по полю `artifact`): + +```ts +import { buildArtifact } from '@alfalab/scripts-artifacts'; + +await buildArtifact({ + variant: 'compiled', + name: 'my-app', + version: '1.0.0', + dockerRegistry: 'registry.example.com', + extraBuildArgs: { COMMIT_SHA: process.env.COMMIT_SHA ?? '' }, +}); +``` + +Если нужен контроль над каждым шагом — те же чистые функции по отдельности: + +```ts +import { + exec, + getBuildParams, + getDockerBuildCommand, + prepareFilesForDocker, + renderDockerTemplates, + resolveDockerConfig, +} from '@alfalab/scripts-artifacts'; + +const config = resolveDockerConfig({ variant: 'compiled', serverOutput: 'server/index.js' }); +const templates = renderDockerTemplates({ config }); + +const { restoreDockerIgnore } = await prepareFilesForDocker({ config, templates }); + +await exec(getDockerBuildCommand(config)); +await restoreDockerIgnore(); +await exec(`docker push ${getBuildParams(config).imageFullName}`); +``` + +Разбор конфига тоже доступен отдельно — так CLI можно встроить в свой: + +```ts +import { resolveCommandOptions, resolveConfigFile, parseArgv } from '@alfalab/scripts-artifacts'; + +const { configPath, rest } = parseArgv(process.argv.slice(3)); +const configFile = await resolveConfigFile(process.cwd(), configPath); +const options = resolveCommandOptions('docker-build:server', configFile); +``` + +## Пайплайн сборки + +Хост-пайплайн общий для образа и архива, поэтому оба собираются из одного состояния проекта. +Какие шаги выполнятся — зависит от типа артефакта, варианта и опций: + +| Шаг | Опция | Дефолт `runtime` | Дефолт `compiled` | +| ------------------------- | ---------------------------------------- | ----------------- | ----------------- | +| очистка `buildPath` | `cleanBuildPath` | `true` | `false` | +| хук перед сборкой | `beforeBuild` | — | — | +| сборка приложения | `buildCommand` | `'npm run build'` | `null` | +| удаление dev-зависимостей | `removeDevDependencies` / `pruneCommand` | `true` | `false` | +| `docker build` / `tar` | — | всегда | всегда | +| `docker push` | `push` | `!debug` | `!debug` | + +Для `artifact: 'archive'` хост-пайплайн включен всегда (внутри tar-а собирать нечего), поэтому +`variant` на него не влияет. + +## Миграция с `arui-scripts docker-build` + +Поведение и шаблоны совпадают, отличаются имена настроек: + +| arui-scripts | @alfalab/scripts-artifacts | +| ------------------------------------------------------------ | ------------------------------------ | +| `configs.removeDevDependenciesDuringDockerBuild` | `removeDevDependencies` | +| `configs.archiveName`, `configs.additionalBuildPath` | `archiveName`, `additionalBuildPath` | +| `configs.dictionaryCompression.enablePreviousVersionHeaders` | `enablePreviousVersionHeaders` | +| `configs.localDockerfile` и соседние | `localFiles.dockerfile` и соседние | +| оверрайд `Dockerfile` | `overrides.dockerfile` | +| оверрайд `DockerfileCompiled` | `overrides.dockerfileCompiled` | +| оверрайд `nginx` (server-блок) | `overrides.nginxConf` | +| оверрайд `nginxConf` (базовый http-блок) | `overrides.baseNginxConf` | +| оверрайд `start.sh` | `overrides.startScript` | + +⚠️ В `arui-scripts` имена `nginx` и `nginxConf` исторически перепутаны: `nginx` — это server-блок, а +`nginxConf` — базовый конфиг. Здесь они названы по смыслу, поэтому **`nginxConf` в двух пакетах +означает разные файлы**. Переносить оверрайды по таблице выше, а не по совпадению имен. + +`publicPath` по умолчанию — `` `${assetsPath}/` `` (то есть `assets/`), как это считает `arui-scripts`. +Пустой `publicPath` дал бы в nginx-конфиге второй `location /` и nginx не поднялся бы с +`duplicate location "/"`. + +### Отличие в `archive-build` + +Старый `arui-scripts archive-build` подхватывал локальный `nginx.conf`, но игнорировал локальный +`start.sh`. Здесь локальные файлы обрабатываются единообразно: `start.sh` из корня проекта тоже +используется. Отключается через `allowLocalStartScript: false`. diff --git a/packages/arui-scripts-docker/jest.config.js b/packages/arui-scripts-artifacts/jest.config.js similarity index 55% rename from packages/arui-scripts-docker/jest.config.js rename to packages/arui-scripts-artifacts/jest.config.js index a271aacc..cb7bb311 100644 --- a/packages/arui-scripts-docker/jest.config.js +++ b/packages/arui-scripts-artifacts/jest.config.js @@ -2,5 +2,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + // фикстуры лежат рядом с тестами и сами тестами не являются + testMatch: ['**/__tests__/**/*.test.ts'], testPathIgnorePatterns: ['/node_modules/', '/build/'], }; diff --git a/packages/arui-scripts-docker/package.json b/packages/arui-scripts-artifacts/package.json similarity index 75% rename from packages/arui-scripts-docker/package.json rename to packages/arui-scripts-artifacts/package.json index 4955504a..3e762581 100644 --- a/packages/arui-scripts-docker/package.json +++ b/packages/arui-scripts-artifacts/package.json @@ -1,11 +1,11 @@ { - "name": "@alfalab/scripts-docker", + "name": "@alfalab/scripts-artifacts", "version": "1.0.0", - "description": "Максимально кастомизируемые скрипты и шаблоны для сборки docker-образов приложений, основанных на arui-scripts", + "description": "Максимально кастомизируемые скрипты и шаблоны для сборки артефактов поставки (docker-образ, tar-архив) приложений, основанных на arui-scripts", "main": "./build/index.js", "typings": "./build/index.d.ts", "bin": { - "arui-scripts-docker": "./build/bin/index.js" + "arui-scripts-artifacts": "./build/bin/index.js" }, "license": "MPL-2.0", "repository": { @@ -15,7 +15,7 @@ "bugs": { "url": "https://github.com/core-ds/arui-scripts/issues" }, - "homepage": "https://github.com/core-ds/arui-scripts/tree/master/packages/arui-scripts-docker#readme", + "homepage": "https://github.com/core-ds/arui-scripts/tree/master/packages/arui-scripts-artifacts#readme", "scripts": { "build": "tsc --project tsconfig.json", "test": "jest", @@ -27,9 +27,12 @@ "audit": "yarn npm audit --severity high --environment production" }, "dependencies": { + "commander": "^12", "fs-extra": "6.0.1", + "jiti": "^2.4.2", "semver": "^7.5.4", - "shelljs": "0.8.5" + "shelljs": "0.8.5", + "tar": "7.5.11" }, "devDependencies": { "@types/fs-extra": "^9.0.13", diff --git a/packages/arui-scripts-artifacts/src/__tests__/cli.test.ts b/packages/arui-scripts-artifacts/src/__tests__/cli.test.ts new file mode 100644 index 00000000..9730bc22 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/__tests__/cli.test.ts @@ -0,0 +1,99 @@ +import { type Command } from 'commander'; + +import { createCli, extractConfigPath, type RunCommandParams } from '../cli'; +import { type ArtifactsConfigFile } from '../config-file'; + +describe('extractConfigPath', () => { + it('should return undefined when the flag is absent', () => { + expect(extractConfigPath(['docker-build', 'version=1.0.0'])).toBeUndefined(); + }); + + it.each(['--config', '--c', '-c'])('should support %s with a separate value', (flag) => { + expect(extractConfigPath(['docker-build', flag, './docker.ts'])).toBe('./docker.ts'); + }); + + it.each(['--config', '--c'])('should support %s with an equals sign', (flag) => { + expect(extractConfigPath(['docker-build', `${flag}=./docker.ts`])).toBe('./docker.ts'); + }); + + it('should not choke on commands and args it knows nothing about', () => { + expect(extractConfigPath(['some:custom:command', 'name=app', '-c', './docker.ts'])).toBe( + './docker.ts', + ); + }); +}); + +describe('createCli', () => { + function setup(configFile: ArtifactsConfigFile = {}) { + const runs: RunCommandParams[] = []; + const program = createCli({ + configFile, + version: '1.2.3', + run: (params) => { + runs.push(params); + }, + }); + + program.exitOverride(); + program.commands.forEach((command: Command) => command.exitOverride()); + program.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + + return { program, runs }; + } + + it('should run a built-in command', async () => { + const { program, runs } = setup(); + + await program.parseAsync(['docker-build'], { from: 'user' }); + + expect(runs).toEqual([{ command: 'docker-build', args: [] }]); + }); + + it('should pass name=/version=/registry= through as command args', async () => { + const { program, runs } = setup(); + + await program.parseAsync( + ['docker-build', 'name=app', 'version=2.0.0', 'registry=r.example.com'], + { from: 'user' }, + ); + + expect(runs[0].args).toEqual(['name=app', 'version=2.0.0', 'registry=r.example.com']); + }); + + it('should not treat the config flag as a command arg', async () => { + const { program, runs } = setup(); + + await program.parseAsync(['docker-build', '-c', './docker.ts', 'version=2.0.0'], { + from: 'user', + }); + + expect(runs[0].args).toEqual(['version=2.0.0']); + }); + + it('should register commands declared in the project config', async () => { + const { program, runs } = setup({ + commands: { 'docker-build:server': { variant: 'compiled' } }, + }); + + await program.parseAsync(['docker-build:server'], { from: 'user' }); + + expect(runs).toEqual([{ command: 'docker-build:server', args: [] }]); + }); + + it('should list custom commands in help', () => { + const { program } = setup({ commands: { 'docker-build:static': {} } }); + + const help = program.helpInformation(); + + expect(help).toContain('docker-build'); + expect(help).toContain('docker-build:compiled'); + expect(help).toContain('docker-build:static'); + }); + + it('should reject an unknown command instead of silently doing nothing', async () => { + const { program, runs } = setup(); + + await expect(program.parseAsync(['docker-build:nope'], { from: 'user' })).rejects.toThrow(); + expect(runs).toHaveLength(0); + }); +}); diff --git a/packages/arui-scripts-artifacts/src/__tests__/config-file.test.ts b/packages/arui-scripts-artifacts/src/__tests__/config-file.test.ts new file mode 100644 index 00000000..d3d6686d --- /dev/null +++ b/packages/arui-scripts-artifacts/src/__tests__/config-file.test.ts @@ -0,0 +1,128 @@ +import { getAvailableCommands, resolveCommandOptions } from '../config-file'; + +describe('resolveCommandOptions', () => { + it('should return null for an unknown command', () => { + expect(resolveCommandOptions('nope', {})).toBeNull(); + }); + + it('should provide built-in commands without any config', () => { + expect(resolveCommandOptions('docker-build', {})).toMatchObject({ + variant: 'runtime', + allowLocalDockerfile: true, + addNodeModulesToDockerIgnore: false, + }); + + expect(resolveCommandOptions('docker-build:compiled', {})).toMatchObject({ + variant: 'compiled', + allowLocalDockerfile: false, + addNodeModulesToDockerIgnore: true, + }); + }); + + it('should provide archive-build as a built-in command', () => { + expect(resolveCommandOptions('archive-build', {})).toMatchObject({ artifact: 'archive' }); + }); + + it('should let a project declare an extra archive command', () => { + const options = resolveCommandOptions('archive-build:e2e', { + archiveName: 'build.tar', + commands: { + 'archive-build:e2e': { artifact: 'archive' as const, archiveName: 'e2e.tar' }, + }, + }); + + expect(options).toMatchObject({ artifact: 'archive', archiveName: 'e2e.tar' }); + }); + + it('should apply shared config options to every command', () => { + const configFile = { baseDockerImage: 'my/base:1.0.0', serverPort: 4000 }; + + expect(resolveCommandOptions('docker-build', configFile)).toMatchObject({ + baseDockerImage: 'my/base:1.0.0', + serverPort: 4000, + variant: 'runtime', + }); + }); + + it('should let a command section win over shared options', () => { + const options = resolveCommandOptions('docker-build:compiled', { + serverPort: 4000, + commands: { 'docker-build:compiled': { serverPort: 5000 } }, + }); + + expect(options).toMatchObject({ serverPort: 5000, variant: 'compiled' }); + }); + + it('should let the config override built-in command defaults', () => { + const options = resolveCommandOptions('docker-build:compiled', { + commands: { 'docker-build:compiled': { allowLocalDockerfile: true } }, + }); + + expect(options).toMatchObject({ allowLocalDockerfile: true, variant: 'compiled' }); + }); + + it('should support fully custom commands declared in the config', () => { + const configFile = { + baseDockerImage: 'my/base:1.0.0', + commands: { + 'docker-build:server': { + variant: 'compiled' as const, + serverOutput: 'server/index.js', + clientServerPort: 9090, + }, + }, + }; + + expect(resolveCommandOptions('docker-build:server', configFile)).toMatchObject({ + baseDockerImage: 'my/base:1.0.0', + serverOutput: 'server/index.js', + clientServerPort: 9090, + variant: 'compiled', + }); + }); + + it('should shallow-merge nested option objects instead of replacing them', () => { + const options = resolveCommandOptions('docker-build', { + nginx: { workerProcesses: 4, workerConnections: 100 }, + extraBuildArgs: { A: '1' }, + commands: { + 'docker-build': { nginx: { workerProcesses: 8 }, extraBuildArgs: { B: '2' } }, + }, + }); + + expect(options?.nginx).toEqual({ workerProcesses: 8, workerConnections: 100 }); + expect(options?.extraBuildArgs).toEqual({ A: '1', B: '2' }); + }); + + it('should not merge nginx: false into an object', () => { + const options = resolveCommandOptions('docker-build', { + nginx: { workerProcesses: 4 }, + commands: { 'docker-build': { nginx: false } }, + }); + + expect(options?.nginx).toBe(false); + }); + + it('should not leak the commands key into build options', () => { + const options = resolveCommandOptions('docker-build', { + commands: { 'docker-build': {} }, + }); + + expect(options).not.toHaveProperty('commands'); + }); +}); + +describe('getAvailableCommands', () => { + it('should list built-in and declared commands without duplicates', () => { + const commands = getAvailableCommands({ + commands: { 'docker-build': {}, 'docker-build:server': {} }, + }); + + expect(commands).toEqual([ + 'docker-build', + 'docker-build:compiled', + 'archive-build', + 'docker-build:server', + ]); + }); +}); diff --git a/packages/arui-scripts-artifacts/src/__tests__/config.test.ts b/packages/arui-scripts-artifacts/src/__tests__/config.test.ts new file mode 100644 index 00000000..dcde2d06 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/__tests__/config.test.ts @@ -0,0 +1,110 @@ +import { resolveArtifactsConfig } from '../config'; + +describe('resolveArtifactsConfig', () => { + it('should fill defaults matching arui-scripts historical behaviour', () => { + const config = resolveArtifactsConfig({ cwd: __dirname }); + + expect(config.baseDockerImage).toBe('alfabankui/arui-scripts:24.10.0-slim'); + expect(config.buildPath).toBe('.build'); + expect(config.serverOutput).toBe('server.js'); + expect(config.nginxRootPath).toBe('/src'); + expect(config.clientServerPort).toBe(8080); + expect(config.serverPort).toBe(3000); + expect(config.runFromNonRootUser).toBe(true); + expect(config.clientOnly).toBe(false); + expect(config.tempDirName).toBe('.docker-build'); + expect(config.platform).toBe('auto'); + expect(config.nginx).toBeNull(); + // arui-scripts считает publicPath как `${assetsPath}/` + expect(config.assetsPath).toBe('assets'); + expect(config.publicPath).toBe('assets/'); + }); + + it('should derive publicPath from a custom assetsPath', () => { + expect(resolveArtifactsConfig({ cwd: __dirname, assetsPath: 'static' }).publicPath).toBe( + 'static/', + ); + expect( + resolveArtifactsConfig({ cwd: __dirname, assetsPath: 'static', publicPath: 'cdn/' }) + .publicPath, + ).toBe('cdn/'); + }); + + it('should default the host pipeline per variant', () => { + const runtime = resolveArtifactsConfig({ cwd: __dirname }); + + expect(runtime.variant).toBe('runtime'); + expect(runtime.cleanBuildPath).toBe(true); + expect(runtime.buildCommand).toBe('npm run build'); + expect(runtime.removeDevDependencies).toBe(true); + + const compiled = resolveArtifactsConfig({ cwd: __dirname, variant: 'compiled' }); + + expect(compiled.cleanBuildPath).toBe(false); + expect(compiled.buildCommand).toBeNull(); + expect(compiled.removeDevDependencies).toBe(false); + }); + + it('should default archive options and always build on the host for archives', () => { + const archive = resolveArtifactsConfig({ cwd: __dirname, artifact: 'archive' }); + + expect(archive.archiveName).toBe('build.tar'); + expect(archive.additionalBuildPath).toEqual(['config']); + expect(archive.tempDirName).toBe('.archive-build'); + // в tar нечего собирать «внутри», поэтому хост-пайплайн включен даже с variant: compiled + expect(archive.buildCommand).toBe('npm run build'); + expect(archive.removeDevDependencies).toBe(true); + expect(archive.cleanBuildPath).toBe(true); + }); + + it('should keep the docker temp dir for docker artifacts', () => { + expect(resolveArtifactsConfig({ cwd: __dirname }).tempDirName).toBe('.docker-build'); + expect( + resolveArtifactsConfig({ cwd: __dirname, artifact: 'archive', tempDirName: '.custom' }) + .tempDirName, + ).toBe('.custom'); + }); + + it('should allow disabling the host build explicitly', () => { + expect( + resolveArtifactsConfig({ cwd: __dirname, buildCommand: false }).buildCommand, + ).toBeNull(); + expect( + resolveArtifactsConfig({ cwd: __dirname, variant: 'compiled', buildCommand: 'make' }) + .buildCommand, + ).toBe('make'); + }); + + it('should not push by default in debug mode', () => { + expect(resolveArtifactsConfig({ cwd: __dirname, debug: true }).push).toBe(false); + expect(resolveArtifactsConfig({ cwd: __dirname, debug: false }).push).toBe(true); + }); + + it('should allow explicit push override even in debug mode', () => { + expect(resolveArtifactsConfig({ cwd: __dirname, debug: true, push: true }).push).toBe(true); + }); + + it('should normalize nginx: false to null', () => { + expect(resolveArtifactsConfig({ cwd: __dirname, nginx: false }).nginx).toBeNull(); + expect( + resolveArtifactsConfig({ cwd: __dirname, nginx: { workerProcesses: 4 } }).nginx, + ).toEqual({ workerProcesses: 4 }); + }); + + it('should keep falsy but valid values (empty registry, port 0)', () => { + const config = resolveArtifactsConfig({ + cwd: __dirname, + dockerRegistry: '', + serverPort: 0, + }); + + expect(config.dockerRegistry).toBe(''); + expect(config.serverPort).toBe(0); + }); + + it('should respect explicit yarnVersion and derived commands', () => { + const config = resolveArtifactsConfig({ cwd: __dirname, yarnVersion: '2+' }); + + expect(config.installProductionCommand).toBe('yarn workspaces focus --production --all'); + }); +}); diff --git a/packages/arui-scripts-artifacts/src/__tests__/docker-build.test.ts b/packages/arui-scripts-artifacts/src/__tests__/docker-build.test.ts new file mode 100644 index 00000000..3e564aa5 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/__tests__/docker-build.test.ts @@ -0,0 +1,123 @@ +import { resolveArtifactsConfig } from '../config'; +import { + applyCommandLineArguments, + getBuildParams, + getDockerBuildCommand, + getPlatformFlag, + shellQuote, +} from '../utils/docker-build'; + +const baseOptions = { cwd: '/tmp/project', name: 'app', version: '1.0.0' }; + +describe('getBuildParams', () => { + it('should build image name without registry', () => { + const config = resolveArtifactsConfig(baseOptions); + + expect(getBuildParams(config).imageFullName).toBe('app:1.0.0'); + }); + + it('should build image name with registry', () => { + const config = resolveArtifactsConfig({ + ...baseOptions, + dockerRegistry: 'registry.example.com', + }); + + expect(getBuildParams(config).imageFullName).toBe('registry.example.com/app:1.0.0'); + }); + + it('should place temp dir inside cwd', () => { + const config = resolveArtifactsConfig(baseOptions); + + expect(getBuildParams(config).pathToTempDir).toBe('/tmp/project/.docker-build'); + }); +}); + +describe('applyCommandLineArguments', () => { + it('should override name/version/registry from args', () => { + const config = resolveArtifactsConfig(baseOptions); + const next = applyCommandLineArguments(config, [ + 'name=other', + 'version=2.0.0', + 'registry=r.example.com', + ]); + + expect(getBuildParams(next).imageFullName).toBe('r.example.com/other:2.0.0'); + }); +}); + +describe('getPlatformFlag', () => { + it('should return empty string when platform is false', () => { + const config = resolveArtifactsConfig({ ...baseOptions, platform: false }); + + expect(getPlatformFlag(config)).toBe(''); + }); + + it('should use explicit platform', () => { + const config = resolveArtifactsConfig({ ...baseOptions, platform: 'linux/arm64' }); + + expect(getPlatformFlag(config)).toBe('--platform linux/arm64'); + }); +}); + +describe('getDockerBuildCommand', () => { + it('should include dockerfile, build-args and context', () => { + const config = resolveArtifactsConfig({ ...baseOptions, platform: false }); + const command = getDockerBuildCommand(config); + + expect(command).toContain('-f ./.docker-build/Dockerfile'); + expect(command).toContain('--build-arg START_SH_LOCATION=./.docker-build/start.sh'); + expect(command).toContain('-t app:1.0.0 .'); + }); + + it('should include extra build args', () => { + const config = resolveArtifactsConfig({ + ...baseOptions, + platform: false, + extraBuildArgs: { COMMIT_SHA: 'abc123' }, + }); + + expect(getDockerBuildCommand(config)).toContain('--build-arg COMMIT_SHA=abc123'); + }); + + it('should escape extra build args so they cannot break out of the command', () => { + const config = resolveArtifactsConfig({ + ...baseOptions, + platform: false, + extraBuildArgs: { EVIL: 'a"; touch /tmp/pwned; #' }, + }); + + const command = getDockerBuildCommand(config); + + expect(command).toContain("--build-arg EVIL='a\"; touch /tmp/pwned; #'"); + expect(command).not.toContain('--build-arg EVIL=a";'); + }); + + it('should escape image name and context', () => { + const config = resolveArtifactsConfig({ + ...baseOptions, + platform: false, + name: 'app; rm -rf /', + context: './my context', + }); + + const command = getDockerBuildCommand(config); + + expect(command).toContain("-t 'app; rm -rf /:1.0.0' './my context'"); + }); +}); + +describe('shellQuote', () => { + it('should leave safe values untouched', () => { + expect(shellQuote('registry.example.com/app:1.0.0')).toBe('registry.example.com/app:1.0.0'); + expect(shellQuote('./.docker-build/Dockerfile')).toBe('./.docker-build/Dockerfile'); + }); + + it('should quote values with shell metacharacters', () => { + expect(shellQuote('a b')).toBe("'a b'"); + expect(shellQuote('$(whoami)')).toBe("'$(whoami)'"); + }); + + it('should escape embedded single quotes', () => { + expect(shellQuote("it's")).toBe("'it'\\''s'"); + }); +}); diff --git a/packages/arui-scripts-artifacts/src/__tests__/fixtures-autodetect/arui-scripts-artifacts.ts b/packages/arui-scripts-artifacts/src/__tests__/fixtures-autodetect/arui-scripts-artifacts.ts new file mode 100644 index 00000000..376693c3 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/__tests__/fixtures-autodetect/arui-scripts-artifacts.ts @@ -0,0 +1,3 @@ +import { defineConfig } from '../../config-file'; + +export default defineConfig({ baseDockerImage: 'autodetect/base:1.0.0' }); diff --git a/packages/arui-scripts-artifacts/src/__tests__/fixtures/cjs-config.cjs b/packages/arui-scripts-artifacts/src/__tests__/fixtures/cjs-config.cjs new file mode 100644 index 00000000..2d5f831a --- /dev/null +++ b/packages/arui-scripts-artifacts/src/__tests__/fixtures/cjs-config.cjs @@ -0,0 +1 @@ +module.exports = { baseDockerImage: 'fixture/cjs:3.0.0' }; diff --git a/packages/arui-scripts-artifacts/src/__tests__/fixtures/fn-config.mjs b/packages/arui-scripts-artifacts/src/__tests__/fixtures/fn-config.mjs new file mode 100644 index 00000000..2c0f83ff --- /dev/null +++ b/packages/arui-scripts-artifacts/src/__tests__/fixtures/fn-config.mjs @@ -0,0 +1 @@ +export default async () => ({ baseDockerImage: 'fixture/fn:2.0.0' }); diff --git a/packages/arui-scripts-artifacts/src/__tests__/fixtures/ts-config.ts b/packages/arui-scripts-artifacts/src/__tests__/fixtures/ts-config.ts new file mode 100644 index 00000000..1dcf7421 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/__tests__/fixtures/ts-config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from '../../config-file'; + +export default defineConfig({ + baseDockerImage: 'fixture/base:1.0.0', + nginx: { workerProcesses: 7 }, + commands: { + 'docker-build:server': { + variant: 'compiled', + serverOutput: 'server/index.js', + }, + }, +}); diff --git a/packages/arui-scripts-artifacts/src/__tests__/load-config-file.test.ts b/packages/arui-scripts-artifacts/src/__tests__/load-config-file.test.ts new file mode 100644 index 00000000..50683469 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/__tests__/load-config-file.test.ts @@ -0,0 +1,54 @@ +import path from 'path'; + +import { resolveCommandOptions } from '../config-file'; +import { findConfigFile, loadConfigFile } from '../load-config-file'; + +const fixtures = path.join(__dirname, 'fixtures'); + +describe('loadConfigFile', () => { + it('should load a TypeScript config without any extra loader', async () => { + const config = await loadConfigFile(path.join(fixtures, 'ts-config.ts')); + + expect(config.baseDockerImage).toBe('fixture/base:1.0.0'); + expect(config.nginx).toEqual({ workerProcesses: 7 }); + expect(resolveCommandOptions('docker-build:server', config)).toMatchObject({ + baseDockerImage: 'fixture/base:1.0.0', + serverOutput: 'server/index.js', + variant: 'compiled', + }); + }); + + it('should load an ESM config exporting an async function', async () => { + const config = await loadConfigFile(path.join(fixtures, 'fn-config.mjs')); + + expect(config.baseDockerImage).toBe('fixture/fn:2.0.0'); + }); + + it('should load a CommonJS config', async () => { + const config = await loadConfigFile(path.join(fixtures, 'cjs-config.cjs')); + + expect(config.baseDockerImage).toBe('fixture/cjs:3.0.0'); + }); +}); + +describe('findConfigFile', () => { + it('should return null when the project has no config', () => { + expect(findConfigFile(fixtures)).toBeNull(); + }); + + it('should resolve an explicitly passed path relative to cwd', () => { + expect(findConfigFile(fixtures, './ts-config.ts')).toBe( + path.join(fixtures, 'ts-config.ts'), + ); + }); + + it('should throw on a missing explicit path instead of silently ignoring it', () => { + expect(() => findConfigFile(fixtures, './nope.ts')).toThrow('Config file not found'); + }); + + it('should auto-detect arui-scripts-artifacts.ts in the project root', () => { + const cwd = path.join(__dirname, 'fixtures-autodetect'); + + expect(findConfigFile(cwd)).toBe(path.join(cwd, 'arui-scripts-artifacts.ts')); + }); +}); diff --git a/packages/arui-scripts-artifacts/src/__tests__/render.test.ts b/packages/arui-scripts-artifacts/src/__tests__/render.test.ts new file mode 100644 index 00000000..959d657d --- /dev/null +++ b/packages/arui-scripts-artifacts/src/__tests__/render.test.ts @@ -0,0 +1,101 @@ +import { resolveArtifactsConfig } from '../config'; +import { renderTemplates } from '../render'; + +const baseOptions = { cwd: __dirname, name: 'app', version: '1.0.0' }; + +describe('renderTemplates', () => { + it('should render runtime dockerfile with the base image and start.sh', () => { + const config = resolveArtifactsConfig({ ...baseOptions, baseDockerImage: 'my/base:1.0.0' }); + const templates = renderTemplates({ config, variant: 'runtime' }); + + expect(templates.dockerfile).toContain('FROM my/base:1.0.0'); + expect(templates.dockerfile).toContain('ADD $START_SH_LOCATION /src/start.sh'); + expect(templates.startScript).toContain('#!/bin/sh'); + }); + + it('should render compiled dockerfile with install command', () => { + const config = resolveArtifactsConfig({ ...baseOptions, yarnVersion: 'unavailable' }); + const templates = renderTemplates({ config, variant: 'compiled' }); + + expect(templates.dockerfile).toContain('npm install --production'); + expect(templates.dockerfile).toContain('npm cache clean --force'); + }); + + it('should not render base nginx conf when nginx is disabled', () => { + const config = resolveArtifactsConfig({ ...baseOptions, nginx: false }); + const templates = renderTemplates({ config }); + + expect(templates.nginxBaseConf).toBe(''); + }); + + it('should render base nginx conf with custom worker processes', () => { + const config = resolveArtifactsConfig({ ...baseOptions, nginx: { workerProcesses: 9 } }); + const templates = renderTemplates({ config }); + + expect(templates.nginxBaseConf).toContain('worker_processes 9;'); + }); + + it('should render client-only start script when clientOnly is set', () => { + const config = resolveArtifactsConfig({ ...baseOptions, clientOnly: true }); + const templates = renderTemplates({ config }); + + expect(templates.startScript).toContain('env-config.json'); + expect(templates.startScript).not.toContain('max-old-space-size'); + }); + + it.each([ + ['serverful', {}], + ['clientOnly', { clientOnly: true }], + ])('should not produce a duplicate "location /" in %s mode', (_name, extraOptions) => { + const config = resolveArtifactsConfig({ ...baseOptions, ...extraOptions }); + const { nginxConf } = renderTemplates({ config }); + + const rootLocations = nginxConf + .split('\n') + .filter((line) => /^\s*location\s+\/\s*\{/.test(line)); + + // два `location /` в одном server-блоке — это `nginx: [emerg] duplicate location "/"` + expect(rootLocations).toHaveLength(1); + expect(nginxConf).toContain('location /assets/ {'); + }); + + it('should use compiled dockerfile when the variant comes from the config', () => { + const config = resolveArtifactsConfig({ ...baseOptions, variant: 'compiled' }); + const templates = renderTemplates({ config }); + + expect(templates.dockerfile).toContain('ADD --chown=nginx:nginx package.json'); + }); + + it('should apply full template replacement via templates', () => { + const config = resolveArtifactsConfig(baseOptions); + const templates = renderTemplates({ + config, + templates: { nginxConf: () => 'CUSTOM NGINX' }, + }); + + expect(templates.nginxConf).toBe('CUSTOM NGINX'); + }); + + it('should apply point overrides on top of generated template', () => { + const config = resolveArtifactsConfig(baseOptions); + const templates = renderTemplates({ + config, + variant: 'runtime', + overrides: { dockerfile: (generated) => `${generated}\nLABEL team="web"` }, + }); + + expect(templates.dockerfile).toContain('FROM'); + expect(templates.dockerfile).toContain('LABEL team="web"'); + }); + + it('should route overrides to dockerfileCompiled key for compiled variant', () => { + const config = resolveArtifactsConfig(baseOptions); + const templates = renderTemplates({ + config, + variant: 'compiled', + overrides: { dockerfileCompiled: () => 'COMPILED OVERRIDE' }, + }); + + expect(templates.dockerfile).toBe('COMPILED OVERRIDE'); + }); +}); diff --git a/packages/arui-scripts-artifacts/src/bin/index.ts b/packages/arui-scripts-artifacts/src/bin/index.ts new file mode 100644 index 00000000..1349ac39 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/bin/index.ts @@ -0,0 +1,64 @@ +#! /usr/bin/env node +import path from 'path'; + +import fs from 'fs-extra'; + +import { buildArtifact } from '../build-artifact'; +import { createCli, extractConfigPath } from '../cli'; +import { resolveCommandOptions } from '../config-file'; +import { BASE_NGINX_CONFIG_FILENAME, NGINX_CONFIG_FILENAME } from '../constants'; +import { resolveConfigFile } from '../load-config-file'; +import { type ArtifactsOptions } from '../types'; + +// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires +const { version } = require('../../package.json'); + +/** + * Локальные файлы проекта, замещающие сгенерированные шаблоны, если они лежат в корне проекта. + * Явно заданные в конфиге `localFiles` имеют приоритет над автодетектом. + */ +function detectLocalFiles(cwd: string): ArtifactsOptions['localFiles'] { + const resolveIfExists = (fileName: string) => { + const filePath = path.join(cwd, fileName); + + return fs.existsSync(filePath) ? filePath : null; + }; + + return { + dockerfile: resolveIfExists('Dockerfile'), + startScript: resolveIfExists('start.sh'), + nginxConf: resolveIfExists(NGINX_CONFIG_FILENAME), + nginxBaseConf: resolveIfExists(BASE_NGINX_CONFIG_FILENAME), + }; +} + +(async () => { + const argv = process.argv.slice(2); + const cwd = process.cwd(); + + const configFile = await resolveConfigFile(cwd, extractConfigPath(argv)); + + const program = createCli({ + configFile, + version, + run: async ({ command, args }) => { + const options = resolveCommandOptions(command, configFile); + + if (!options) { + throw new Error(`Unknown command: ${command}`); + } + + await buildArtifact({ + ...options, + localFiles: { ...detectLocalFiles(cwd), ...options.localFiles }, + // аргументы командной строки (name=... version=... registry=...) — высший приоритет + argv: args, + }); + }, + }); + + await program.parseAsync(argv, { from: 'user' }); +})().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/packages/arui-scripts-artifacts/src/build-archive.ts b/packages/arui-scripts-artifacts/src/build-archive.ts new file mode 100644 index 00000000..f36c5839 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/build-archive.ts @@ -0,0 +1,112 @@ +import path from 'path'; + +import fs from 'fs-extra'; +import { create as createTar } from 'tar'; + +import { resolveArtifactsConfig } from './config'; +import { NGINX_CONFIG_FILENAME } from './constants'; +import { type BeforeBuildHook, runHostPipeline } from './host-pipeline'; +import { renderTemplates } from './render'; +import { type ArtifactsOptions, type ResolvedArtifactsConfig } from './types'; + +export type BuildArchiveOptions = ArtifactsOptions & { + /** Хук, вызываемый после очистки `buildPath`, но до сборки приложения. */ + beforeBuild?: BeforeBuildHook; +}; + +const START_SCRIPT_FILENAME = 'start.sh'; +const NODE_MODULES_DIR_NAME = 'node_modules'; +const PACKAGE_JSON_FILENAME = 'package.json'; + +/** + * Собирает tar-архив с production-сборкой: nginx-конфиг, start.sh, `buildPath`, `node_modules`, + * `package.json` и дополнительные директории из `additionalBuildPath`. + * + * Хост-пайплайн (очистка, сборка, удаление dev-зависимостей) — тот же, что у docker-образа, поэтому + * архив и образ собираются из одного и того же состояния проекта. + */ +export async function buildArchive(options: BuildArchiveOptions = {}): Promise { + const { beforeBuild, templates, overrides, ...rest } = options; + + const config: ResolvedArtifactsConfig = resolveArtifactsConfig({ + ...rest, + artifact: 'archive', + }); + + const { + cwd, + tempDirName, + buildPath, + archiveName, + additionalBuildPath, + localFiles, + allowLocalStartScript, + } = config; + + const pathToTempDir = path.join(cwd, tempDirName); + + try { + console.log(`Build archive ${archiveName}`); + console.time('Total time'); + console.time('Setting up time'); + + const rendered = renderTemplates({ config, templates, overrides }); + + await fs.emptyDir(pathToTempDir); + + const nginxConf = localFiles.nginxConf + ? await fs.readFile(localFiles.nginxConf, 'utf8') + : rendered.nginxConf; + + const startScript = + localFiles.startScript && allowLocalStartScript + ? await fs.readFile(localFiles.startScript, 'utf8') + : rendered.startScript; + + await Promise.all([ + fs.writeFile(path.join(pathToTempDir, NGINX_CONFIG_FILENAME), nginxConf, 'utf8'), + fs.writeFile(path.join(pathToTempDir, START_SCRIPT_FILENAME), startScript, { + encoding: 'utf8', + mode: 0o555, + }), + ]); + + console.timeEnd('Setting up time'); + + await runHostPipeline(config, beforeBuild); + + console.time('Archive build time'); + + await Promise.all([ + fs.copy(path.resolve(cwd, buildPath), path.join(pathToTempDir, buildPath)), + fs.copy( + path.join(cwd, NODE_MODULES_DIR_NAME), + path.join(pathToTempDir, NODE_MODULES_DIR_NAME), + ), + fs.copy( + path.join(cwd, PACKAGE_JSON_FILENAME), + path.join(pathToTempDir, PACKAGE_JSON_FILENAME), + ), + ...additionalBuildPath.map((additionalPath) => + fs.copy(path.join(cwd, additionalPath), path.join(pathToTempDir, additionalPath)), + ), + ]); + + await createTar({ file: archiveName, cwd: pathToTempDir }, fs.readdirSync(pathToTempDir)); + + console.timeEnd('Archive build time'); + console.time('Cleanup time'); + + await fs.remove(pathToTempDir); + + console.timeEnd('Cleanup time'); + console.timeEnd('Total time'); + } catch (err) { + await fs.remove(pathToTempDir); + console.error('Error during archive-build.'); + if (config.debug) { + console.error(err); + } + throw err; + } +} diff --git a/packages/arui-scripts-artifacts/src/build-artifact.ts b/packages/arui-scripts-artifacts/src/build-artifact.ts new file mode 100644 index 00000000..1e67ddc8 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/build-artifact.ts @@ -0,0 +1,17 @@ +import { buildArchive } from './build-archive'; +import { buildDockerImage, type BuildDockerImageOptions } from './build-docker-image'; + +export type BuildArtifactOptions = BuildDockerImageOptions; + +/** + * Собирает артефакт поставки согласно `artifact` в опциях: docker-образ (по умолчанию) или tar-архив. + * Именно эту функцию вызывает CLI, поэтому любая команда из конфига проекта может собирать любой тип + * артефакта. + */ +export async function buildArtifact(options: BuildArtifactOptions = {}): Promise { + if (options.artifact === 'archive') { + return buildArchive(options); + } + + return buildDockerImage(options); +} diff --git a/packages/arui-scripts-artifacts/src/build-docker-image.ts b/packages/arui-scripts-artifacts/src/build-docker-image.ts new file mode 100644 index 00000000..a92cef73 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/build-docker-image.ts @@ -0,0 +1,87 @@ +import fs from 'fs-extra'; + +import { + applyCommandLineArguments, + getBuildParams, + getDockerBuildCommand, + prepareFilesForDocker, +} from './utils/docker-build'; +import { exec } from './utils/exec'; +import { resolveArtifactsConfig } from './config'; +import { type BeforeBuildHook, runHostPipeline } from './host-pipeline'; +import { renderTemplates } from './render'; +import { type ArtifactsOptions } from './types'; + +export type BuildDockerImageOptions = ArtifactsOptions & { + /** Аргументы командной строки (`name=... version=... registry=...`), накладываются поверх опций. */ + argv?: string[]; + /** + * Хук, вызываемый после подготовки файлов и очистки `buildPath`, но до сборки приложения и + * `docker build`. Если приложение собирается им, выставьте `buildCommand: null`. + */ + beforeBuild?: BeforeBuildHook; +}; + +/** + * Высокоуровневая сборка docker-образа. Повторяет пайплайн команд `arui-scripts docker-build` и + * `arui-scripts docker-build:compiled`: донасыщает конфиг, рендерит все шаблоны, готовит временную + * директорию, прогоняет хост-пайплайн, запускает `docker build`, чистит за собой и (опционально) + * пушит образ. + * + * Для более тонкого контроля используйте отдельные утилиты: {@link resolveArtifactsConfig}, + * {@link renderTemplates}, {@link prepareFilesForDocker}, {@link getDockerBuildCommand}. + */ +export async function buildDockerImage(options: BuildDockerImageOptions = {}): Promise { + const { argv, beforeBuild, templates, overrides, ...rest } = options; + + let config = resolveArtifactsConfig({ ...rest, artifact: 'docker' }); + + if (argv) { + config = applyCommandLineArguments(config, argv); + } + + const { imageFullName, pathToTempDir } = getBuildParams(config); + + let restoreDockerIgnore: (() => Promise) | null = null; + + try { + console.log(`Build docker image ${imageFullName}`); + console.time('Total time'); + console.time('Setting up time'); + + const renderedTemplates = renderTemplates({ config, templates, overrides }); + + ({ restoreDockerIgnore } = await prepareFilesForDocker({ + config, + templates: renderedTemplates, + })); + + console.timeEnd('Setting up time'); + + await runHostPipeline(config, beforeBuild); + + console.time('Build docker image time'); + await exec(getDockerBuildCommand(config)); + console.timeEnd('Build docker image time'); + + console.time('Cleanup time'); + await fs.remove(pathToTempDir); + await restoreDockerIgnore(); + restoreDockerIgnore = null; + + if (config.push) { + await exec(`docker push ${imageFullName}`); + } + + console.timeEnd('Cleanup time'); + console.timeEnd('Total time'); + } catch (err) { + await fs.remove(pathToTempDir); + await restoreDockerIgnore?.(); + console.error('Error during docker-build.'); + if (config.debug) { + console.error(err); + } + throw err; + } +} diff --git a/packages/arui-scripts-artifacts/src/cli.ts b/packages/arui-scripts-artifacts/src/cli.ts new file mode 100644 index 00000000..862c85e6 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/cli.ts @@ -0,0 +1,107 @@ +import { Command } from 'commander'; + +import { type ArtifactsConfigFile, BUILT_IN_COMMANDS, getAvailableCommands } from './config-file'; + +/** Описания встроенных команд для `--help`. */ +const BUILT_IN_DESCRIPTIONS: Record = { + 'docker-build': 'Сборка приложения на хосте, создание docker образа и пуш в registry', + 'docker-build:compiled': + 'Как docker-build, но зависимости и сборка выполняются внутри образа (для CI/CD)', + 'archive-build': + 'Собирает tar-архив с production-сборкой (buildPath, node_modules, package.json, config)', +}; + +const CONFIG_OPTION_HELP = + 'Путь до конфига (по умолчанию arui-scripts-artifacts.ts в корне проекта)'; + +/** + * Добавляет опцию конфига. Кроме канонических `-c`/`--config` принимаем и `--c` — commander не умеет + * несколько длинных имен у одной опции, поэтому вторая объявлена отдельно и скрыта из справки. + */ +function addConfigOption(command: Command): Command { + return command + .option('-c, --config ', CONFIG_OPTION_HELP) + .addOption(new Command().createOption('--c ', CONFIG_OPTION_HELP).hideHelp()); +} + +/** Значение опции конфига, из какого бы из ее написаний оно ни пришло. */ +function getConfigPath(options: { config?: string; c?: string }): string | undefined { + return options.config ?? options.c; +} + +/** + * Достает путь до конфига до того, как построен основной CLI: список команд зависит от конфига, + * а конфиг — от аргументов. Разбирает тем же commander, просто с выключенными проверками. + */ +export function extractConfigPath(argv: string[]): string | undefined { + const program = addConfigOption(new Command()) + .allowUnknownOption() + .allowExcessArguments() + .helpOption(false); + + program.parse(argv, { from: 'user' }); + + return getConfigPath(program.opts()); +} + +export type RunCommandParams = { + /** Имя команды из конфига или встроенной. */ + command: string; + /** Позиционные аргументы команды (`name=... version=... registry=...`). */ + args: string[]; +}; + +export type CreateCliParams = { + /** Загруженный конфиг проекта — из него берется список доступных команд. */ + configFile: ArtifactsConfigFile; + version: string; + run: (params: RunCommandParams) => Promise | void; +}; + +/** + * Собирает CLI: по одной подкоманде на каждую доступную сборку — встроенные плюс объявленные в + * `commands` конфига проекта. Благодаря этому `--help` показывает и кастомные команды, а опечатка в + * имени дает подсказку вместо простого списка. + */ +export function createCli({ configFile, version, run }: CreateCliParams): Command { + const program = new Command('arui-scripts-artifacts'); + + program + .description('Сборка docker-образов для приложений на arui-scripts') + .version(version, '-v, --version', 'Показать версию') + .showHelpAfterError('(используйте --help для списка команд)') + .showSuggestionAfterError(); + + addConfigOption(program); + + getAvailableCommands(configFile).forEach((name) => { + const isBuiltIn = Boolean(BUILT_IN_COMMANDS[name]); + const description = isBuiltIn + ? BUILT_IN_DESCRIPTIONS[name] + : 'Команда из arui-scripts-artifacts конфига проекта'; + + const command = program + .command(name) + .description(description) + // name=/version=/registry= — позиционные аргументы, а не опции commander + .allowUnknownOption() + .allowExcessArguments() + .action(async () => { + await run({ command: name, args: command.args }); + }); + + addConfigOption(command); + }); + + program.addHelpText( + 'after', + [ + '', + 'Имя образа формируется как {dockerRegistry}/{name}:{version}.', + 'name и version по умолчанию берутся из package.json, но их можно переопределить:', + ' arui-scripts-artifacts docker-build name=container-name version=0.1-beta', + ].join('\n'), + ); + + return program; +} diff --git a/packages/arui-scripts-artifacts/src/config-file.ts b/packages/arui-scripts-artifacts/src/config-file.ts new file mode 100644 index 00000000..b752f621 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/config-file.ts @@ -0,0 +1,120 @@ +import { type BuildArtifactOptions } from './build-artifact'; +import { type ArtifactsOptions } from './types'; + +/** + * Содержимое `arui-scripts-artifacts.ts` — единственный источник правды для всех команд проекта. + * + * Верхний уровень описывает общие для проекта настройки, `commands` — конкретные сборки. Команда + * наследует верхний уровень, поэтому «еще один образ с другим портом» — это несколько строк в + * конфиге, а не отдельный скрипт. + */ +export type ArtifactsConfigFile = ArtifactsOptions & { + /** + * Именованные команды. Имя становится аргументом CLI: `arui-scripts-artifacts <имя>`. + * Встроенные `docker-build` и `docker-build:compiled` можно донасыщать или переопределять. + */ + commands?: Record; +}; + +/** Конфиг может экспортировать объект или (в т.ч. асинхронную) функцию, возвращающую объект. */ +export type ArtifactsConfigFileExport = + | ArtifactsConfigFile + | (() => ArtifactsConfigFile | Promise); + +/** + * Хелпер для типизации `arui-scripts-artifacts.ts`. Ничего не делает в рантайме — существует только + * ради автодополнения и проверки типов в конфиге. + */ +export function defineConfig(config: ArtifactsConfigFileExport): ArtifactsConfigFileExport { + return config; +} + +/** + * Встроенные команды. Задают только то, что отличает одну команду от другой; все остальное берется + * из конфига проекта. + */ +export const BUILT_IN_COMMANDS: Record = { + 'docker-build': { + artifact: 'docker', + variant: 'runtime', + allowLocalDockerfile: true, + allowLocalStartScript: true, + addNodeModulesToDockerIgnore: false, + }, + 'docker-build:compiled': { + artifact: 'docker', + variant: 'compiled', + allowLocalDockerfile: false, + allowLocalStartScript: false, + addNodeModulesToDockerIgnore: true, + }, + 'archive-build': { + artifact: 'archive', + }, +}; + +/** Ключи, которые надо сливать по полям, а не заменять целиком. */ +const SHALLOW_MERGED_KEYS = [ + 'nginx', + 'localFiles', + 'templates', + 'overrides', + 'extraBuildArgs', +] as const; + +function mergeOptions(base: ArtifactsOptions, patch: ArtifactsOptions): ArtifactsOptions { + const result: ArtifactsOptions = { ...base, ...patch }; + + SHALLOW_MERGED_KEYS.forEach((key) => { + const baseValue = base[key]; + const patchValue = patch[key]; + + // `nginx: false` — осмысленное значение «не генерировать базовый конфиг», не сливаем + if ( + baseValue && + patchValue && + typeof baseValue === 'object' && + typeof patchValue === 'object' + ) { + Object.assign(result, { [key]: { ...baseValue, ...patchValue } }); + } + }); + + return result; +} + +/** Список команд, доступных с данным конфигом: встроенные плюс объявленные в проекте. */ +export function getAvailableCommands(configFile: ArtifactsConfigFile = {}): string[] { + return Array.from( + new Set([...Object.keys(BUILT_IN_COMMANDS), ...Object.keys(configFile.commands ?? {})]), + ); +} + +/** + * Собирает опции конкретной команды. Приоритет (по возрастанию): + * дефолты встроенной команды → верхний уровень конфига → секция `commands[command]`. + * + * Возвращает `null`, если такой команды нет ни среди встроенных, ни в конфиге. + */ +export function resolveCommandOptions( + command: string, + configFile: ArtifactsConfigFile = {}, +): BuildArtifactOptions | null { + const { commands, ...sharedOptions } = configFile; + const builtIn = BUILT_IN_COMMANDS[command]; + const declared = commands?.[command]; + + if (!builtIn && !declared) { + return null; + } + + let options = builtIn ?? {}; + + options = mergeOptions(options, sharedOptions); + + if (declared) { + options = mergeOptions(options, declared); + } + + return options; +} diff --git a/packages/arui-scripts-docker/src/config.ts b/packages/arui-scripts-artifacts/src/config.ts similarity index 66% rename from packages/arui-scripts-docker/src/config.ts rename to packages/arui-scripts-artifacts/src/config.ts index eb01edb8..ed7a0551 100644 --- a/packages/arui-scripts-docker/src/config.ts +++ b/packages/arui-scripts-artifacts/src/config.ts @@ -8,8 +8,13 @@ import { getPruningCommand, getYarnVersion, } from './utils/yarn'; -import { DEFAULT_BASE_DOCKER_IMAGE, DEFAULT_TEMP_DIR_NAME } from './constants'; -import { type DockerBuildOptions, type ResolvedDockerConfig } from './types'; +import { + DEFAULT_ARCHIVE_NAME, + DEFAULT_ARCHIVE_TEMP_DIR_NAME, + DEFAULT_BASE_DOCKER_IMAGE, + DEFAULT_TEMP_DIR_NAME, +} from './constants'; +import { type ArtifactsOptions, type ResolvedArtifactsConfig } from './types'; function readPackageJson(cwd: string): { name?: string; version?: string } { try { @@ -33,10 +38,15 @@ function withDefault(value: T | undefined, fallback: T): T { * Значения по умолчанию совпадают с историческим поведением arui-scripts, поэтому вызов без * аргументов даст такой же образ, как команда `arui-scripts docker-build`. */ -export function resolveDockerConfig(options: DockerBuildOptions = {}): ResolvedDockerConfig { +export function resolveArtifactsConfig(options: ArtifactsOptions = {}): ResolvedArtifactsConfig { const cwd = options.cwd ?? process.cwd(); const pkg = readPackageJson(cwd); + const artifact = withDefault(options.artifact, 'docker'); + const variant = withDefault(options.variant, 'runtime'); + // архив всегда собирается на хосте — внутри tar-а собирать нечего + const isRuntime = artifact === 'archive' || variant === 'runtime'; + const clientOnly = withDefault(options.clientOnly, false); const debug = withDefault(options.debug, false); @@ -45,6 +55,13 @@ export function resolveDockerConfig(options: DockerBuildOptions = {}): ResolvedD const nginx = options.nginx === false ? null : withDefault(options.nginx, null); + const assetsPath = withDefault(options.assetsPath, 'assets'); + // arui-scripts считает publicPath как `${assetsPath}/`. Пустой publicPath дал бы второй + // `location /` в nginx-конфиге и nginx не поднялся бы с `duplicate location "/"`. + const publicPath = withDefault(options.publicPath, `${assetsPath}/`); + + const buildCommand = withDefault(options.buildCommand, isRuntime ? 'npm run build' : null); + const localFiles = { dockerfile: options.localFiles?.dockerfile ?? null, startScript: options.localFiles?.startScript ?? null, @@ -53,6 +70,9 @@ export function resolveDockerConfig(options: DockerBuildOptions = {}): ResolvedD }; return { + artifact, + variant, + name: options.name ?? pkg.name ?? '', version: options.version ?? pkg.version ?? '', dockerRegistry: withDefault(options.dockerRegistry, ''), @@ -62,7 +82,8 @@ export function resolveDockerConfig(options: DockerBuildOptions = {}): ResolvedD buildPath: withDefault(options.buildPath, '.build'), serverOutput: withDefault(options.serverOutput, 'server.js'), nginxRootPath: withDefault(options.nginxRootPath, '/src'), - publicPath: withDefault(options.publicPath, ''), + assetsPath, + publicPath, clientServerPort: withDefault(options.clientServerPort, 8080), serverPort: withDefault(options.serverPort, 3000), @@ -73,12 +94,22 @@ export function resolveDockerConfig(options: DockerBuildOptions = {}): ResolvedD runFromNonRootUser: withDefault(options.runFromNonRootUser, true), cwd, context: withDefault(options.context, '.'), - tempDirName: withDefault(options.tempDirName, DEFAULT_TEMP_DIR_NAME), + tempDirName: withDefault( + options.tempDirName, + artifact === 'archive' ? DEFAULT_ARCHIVE_TEMP_DIR_NAME : DEFAULT_TEMP_DIR_NAME, + ), debug, push: withDefault(options.push, !debug), platform: withDefault(options.platform, 'auto'), extraBuildArgs: withDefault(options.extraBuildArgs, {}), + cleanBuildPath: withDefault(options.cleanBuildPath, isRuntime), + buildCommand: buildCommand || null, + removeDevDependencies: withDefault(options.removeDevDependencies, isRuntime), + + archiveName: withDefault(options.archiveName, DEFAULT_ARCHIVE_NAME), + additionalBuildPath: withDefault(options.additionalBuildPath, ['config']), + useYarn, yarnVersion, installProductionCommand: diff --git a/packages/arui-scripts-docker/src/constants.ts b/packages/arui-scripts-artifacts/src/constants.ts similarity index 84% rename from packages/arui-scripts-docker/src/constants.ts rename to packages/arui-scripts-artifacts/src/constants.ts index a853f63b..7baf6b25 100644 --- a/packages/arui-scripts-docker/src/constants.ts +++ b/packages/arui-scripts-artifacts/src/constants.ts @@ -4,6 +4,12 @@ */ export const DEFAULT_TEMP_DIR_NAME = '.docker-build'; +/** Имя временной директории, в которой собирается содержимое tar-архива. */ +export const DEFAULT_ARCHIVE_TEMP_DIR_NAME = '.archive-build'; + +/** Имя tar-архива по умолчанию. */ +export const DEFAULT_ARCHIVE_NAME = 'build.tar'; + /** Имя файла с nginx-конфигом сервера (server-блок), который кладется в образ. */ export const NGINX_CONFIG_FILENAME = 'nginx.conf'; diff --git a/packages/arui-scripts-artifacts/src/host-pipeline.ts b/packages/arui-scripts-artifacts/src/host-pipeline.ts new file mode 100644 index 00000000..80856790 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/host-pipeline.ts @@ -0,0 +1,39 @@ +import path from 'path'; + +import fs from 'fs-extra'; + +import { exec } from './utils/exec'; +import { type ResolvedArtifactsConfig } from './types'; + +/** Хук, вызываемый после очистки `buildPath`, но до сборки приложения. */ +export type BeforeBuildHook = (config: ResolvedArtifactsConfig) => void | Promise; + +/** + * Шаги, которые выполняются на хосте перед упаковкой артефакта: очистка прошлой сборки, сборка + * приложения и удаление dev-зависимостей. Общие для docker-образа и tar-архива — какие именно шаги + * выполнятся, определяют `cleanBuildPath`, `buildCommand` и `removeDevDependencies`. + */ +export async function runHostPipeline( + config: ResolvedArtifactsConfig, + beforeBuild?: BeforeBuildHook, +): Promise { + if (config.cleanBuildPath) { + await fs.remove(path.resolve(config.cwd, config.buildPath)); + } + + if (beforeBuild) { + await beforeBuild(config); + } + + if (config.buildCommand) { + console.time('Build application time'); + await exec(config.buildCommand); + console.timeEnd('Build application time'); + } + + if (config.removeDevDependencies && config.pruneCommand) { + console.time('Remove dev dependencies time'); + await exec(config.pruneCommand); + console.timeEnd('Remove dev dependencies time'); + } +} diff --git a/packages/arui-scripts-artifacts/src/index.ts b/packages/arui-scripts-artifacts/src/index.ts new file mode 100644 index 00000000..66cb66e7 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/index.ts @@ -0,0 +1,53 @@ +export * from './types'; +export * from './constants'; + +export { resolveArtifactsConfig } from './config'; +export { + BUILT_IN_COMMANDS, + defineConfig, + getAvailableCommands, + resolveCommandOptions, + type ArtifactsConfigFile, + type ArtifactsConfigFileExport, +} from './config-file'; +export { + CONFIG_FILE_NAMES, + findConfigFile, + loadConfigFile, + resolveConfigFile, +} from './load-config-file'; +export { createCli, extractConfigPath, type CreateCliParams, type RunCommandParams } from './cli'; +export { buildArtifact, type BuildArtifactOptions } from './build-artifact'; +export { buildDockerImage, type BuildDockerImageOptions } from './build-docker-image'; +export { buildArchive, type BuildArchiveOptions } from './build-archive'; +export { runHostPipeline, type BeforeBuildHook } from './host-pipeline'; +export { renderTemplates, type RenderTemplatesParams } from './render'; + +export { + renderDockerfile, + renderDockerfileCompiled, + renderNginxConf, + renderBaseNginxConf, + renderStartScript, +} from './templates'; + +export { + applyCommandLineArguments, + dockerVersionSatisfies, + getBuildParams, + getBuildParamsFromArgs, + getDockerBuildCommand, + getPlatformFlag, + prepareFilesForDocker, + shellQuote, + type BuildParams, + type PrepareFilesForDockerResult, +} from './utils/docker-build'; + +export { exec, ExecError } from './utils/exec'; +export { + detectUseYarn, + getInstallProductionCommand, + getPruningCommand, + getYarnVersion, +} from './utils/yarn'; diff --git a/packages/arui-scripts-artifacts/src/load-config-file.ts b/packages/arui-scripts-artifacts/src/load-config-file.ts new file mode 100644 index 00000000..a493d6d6 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/load-config-file.ts @@ -0,0 +1,76 @@ +import path from 'path'; + +import fs from 'fs-extra'; +import { createJiti } from 'jiti'; + +import { type ArtifactsConfigFile, type ArtifactsConfigFileExport } from './config-file'; + +/** Имена, по которым конфиг ищется автоматически, если не передан `--config`. */ +export const CONFIG_FILE_NAMES = [ + 'arui-scripts-artifacts.ts', + 'arui-scripts-artifacts.mts', + 'arui-scripts-artifacts.cts', + 'arui-scripts-artifacts.js', + 'arui-scripts-artifacts.mjs', + 'arui-scripts-artifacts.cjs', + 'arui-scripts-artifacts.config.ts', + 'arui-scripts-artifacts.config.js', +]; + +/** + * Ищет конфиг в `cwd`. Явно переданный путь имеет приоритет и обязан существовать — молча + * игнорировать опечатку в `--config` хуже, чем упасть. + */ +export function findConfigFile(cwd: string, explicitPath?: string): string | null { + if (explicitPath) { + const configPath = path.resolve(cwd, explicitPath); + + if (!fs.existsSync(configPath)) { + throw new Error(`Config file not found: ${configPath}`); + } + + return configPath; + } + + return ( + CONFIG_FILE_NAMES.map((fileName) => path.join(cwd, fileName)).find((filePath) => + fs.existsSync(filePath), + ) ?? null + ); +} + +/** + * Загружает конфиг. Через jiti, поэтому одинаково работают TypeScript, ESM и CommonJS без + * дополнительных загрузчиков в проекте. + */ +export async function loadConfigFile(configPath: string): Promise { + const jiti = createJiti(__filename, { interopDefault: true }); + + const configModule = await jiti.import(configPath, { + default: true, + }); + + const config = typeof configModule === 'function' ? await configModule() : configModule; + + if (!config || typeof config !== 'object') { + throw new Error( + `Config file ${configPath} must export an object or a function returning an object`, + ); + } + + return config; +} + +/** Находит и загружает конфиг проекта. Если конфига нет — пустой объект. */ +export async function resolveConfigFile( + cwd: string, + explicitPath?: string, +): Promise { + const configPath = findConfigFile(cwd, explicitPath); + + if (!configPath) { + return {}; + } + + return loadConfigFile(configPath); +} diff --git a/packages/arui-scripts-docker/src/render.ts b/packages/arui-scripts-artifacts/src/render.ts similarity index 73% rename from packages/arui-scripts-docker/src/render.ts rename to packages/arui-scripts-artifacts/src/render.ts index e6108e0c..d0476f0a 100644 --- a/packages/arui-scripts-docker/src/render.ts +++ b/packages/arui-scripts-artifacts/src/render.ts @@ -6,20 +6,20 @@ import { renderStartScript, } from './templates'; import { + type DockerfileVariant, type DockerTemplateKey, type DockerTemplateOverrides, type DockerTemplates, - type RenderedDockerTemplates, - type ResolvedDockerConfig, + type RenderedTemplates, + type ResolvedArtifactsConfig, type TemplateRenderer, } from './types'; -/** Вариант Dockerfile: «сырой» (сборка на хосте) или compiled (сборка внутри образа). */ -export type DockerfileVariant = 'runtime' | 'compiled'; +export { type DockerfileVariant }; -export type RenderDockerTemplatesParams = { - config: ResolvedDockerConfig; - /** Какой Dockerfile генерировать. По умолчанию — `runtime`. */ +export type RenderTemplatesParams = { + config: ResolvedArtifactsConfig; + /** Какой Dockerfile генерировать. По умолчанию — `config.variant`. */ variant?: DockerfileVariant; /** Полная замена рендереров отдельных шаблонов. */ templates?: DockerTemplates; @@ -27,12 +27,10 @@ export type RenderDockerTemplatesParams = { overrides?: DockerTemplateOverrides; }; -const emptyRenderer: TemplateRenderer = () => ''; - function renderTemplate( key: DockerTemplateKey, defaultRenderer: TemplateRenderer, - params: RenderDockerTemplatesParams, + params: RenderTemplatesParams, ): string { const { config, templates, overrides } = params; const renderer = templates?.[key] ?? defaultRenderer; @@ -53,10 +51,8 @@ function renderTemplate( * из `templates` и точечные оверрайды из `overrides`. Результат готов к передаче в * `prepareFilesForDocker`. */ -export function renderDockerTemplates( - params: RenderDockerTemplatesParams, -): RenderedDockerTemplates { - const { config, variant = 'runtime' } = params; +export function renderTemplates(params: RenderTemplatesParams): RenderedTemplates { + const { config, variant = config.variant } = params; const dockerfileKey: DockerTemplateKey = variant === 'compiled' ? 'dockerfileCompiled' : 'dockerfile'; @@ -65,9 +61,11 @@ export function renderDockerTemplates( return { dockerfile: renderTemplate(dockerfileKey, dockerfileRenderer, params), nginxConf: renderTemplate('nginxConf', renderNginxConf, params), + // при выключенном nginx базовый конфиг не попадает в образ, поэтому и рендерить (и звать + // оверрайд, результат которого все равно будет отброшен) нечего nginxBaseConf: config.nginx ? renderTemplate('baseNginxConf', renderBaseNginxConf, params) - : renderTemplate('baseNginxConf', emptyRenderer, params), + : '', startScript: renderTemplate('startScript', renderStartScript, params), }; } diff --git a/packages/arui-scripts-docker/src/templates/base-nginx.conf.template.ts b/packages/arui-scripts-artifacts/src/templates/base-nginx.conf.template.ts similarity index 94% rename from packages/arui-scripts-docker/src/templates/base-nginx.conf.template.ts rename to packages/arui-scripts-artifacts/src/templates/base-nginx.conf.template.ts index 285729b6..307ca5cd 100644 --- a/packages/arui-scripts-docker/src/templates/base-nginx.conf.template.ts +++ b/packages/arui-scripts-artifacts/src/templates/base-nginx.conf.template.ts @@ -1,4 +1,4 @@ -import { type ResolvedDockerConfig } from '../types'; +import { type ResolvedArtifactsConfig } from '../types'; const baseNginxConfig = { workerProcesses: 2, @@ -12,7 +12,7 @@ const baseNginxConfig = { * http-блок nginx-конфига (базовый nginx.conf). Значения по умолчанию можно переопределить через * `nginx` в опциях сборки. */ -export function renderBaseNginxConf(config: ResolvedDockerConfig): string { +export function renderBaseNginxConf(config: ResolvedArtifactsConfig): string { const nginx = { ...baseNginxConfig, ...config.nginx, diff --git a/packages/arui-scripts-docker/src/templates/dockerfile-compiled.template.ts b/packages/arui-scripts-artifacts/src/templates/dockerfile-compiled.template.ts similarity index 93% rename from packages/arui-scripts-docker/src/templates/dockerfile-compiled.template.ts rename to packages/arui-scripts-artifacts/src/templates/dockerfile-compiled.template.ts index 16c2ce48..1defcb84 100644 --- a/packages/arui-scripts-docker/src/templates/dockerfile-compiled.template.ts +++ b/packages/arui-scripts-artifacts/src/templates/dockerfile-compiled.template.ts @@ -1,10 +1,10 @@ -import { type ResolvedDockerConfig } from '../types'; +import { type ResolvedArtifactsConfig } from '../types'; /** * Dockerfile для compiled-образа: зависимости и сборка приложения происходят внутри образа, что * позволяет кешировать установку зависимостей на уровне docker-слоев. */ -export function renderDockerfileCompiled(config: ResolvedDockerConfig): string { +export function renderDockerfileCompiled(config: ResolvedArtifactsConfig): string { const { baseDockerImage, nginx, yarnVersion, installProductionCommand } = config; // В зависимости от используемого менеджера зависимостей для их установки нужно копировать разный diff --git a/packages/arui-scripts-docker/src/templates/dockerfile.template.ts b/packages/arui-scripts-artifacts/src/templates/dockerfile.template.ts similarity index 91% rename from packages/arui-scripts-docker/src/templates/dockerfile.template.ts rename to packages/arui-scripts-artifacts/src/templates/dockerfile.template.ts index df7cfec5..7d650ff8 100644 --- a/packages/arui-scripts-docker/src/templates/dockerfile.template.ts +++ b/packages/arui-scripts-artifacts/src/templates/dockerfile.template.ts @@ -1,9 +1,9 @@ -import { type ResolvedDockerConfig } from '../types'; +import { type ResolvedArtifactsConfig } from '../types'; /** * Dockerfile для «сырого» образа: приложение собирается на хосте, в образ кладется результат сборки. */ -export function renderDockerfile(config: ResolvedDockerConfig): string { +export function renderDockerfile(config: ResolvedArtifactsConfig): string { const { clientOnly, buildPath, runFromNonRootUser, baseDockerImage, nginx } = config; const appPathToAdd = clientOnly ? buildPath : '.'; diff --git a/packages/arui-scripts-docker/src/templates/index.ts b/packages/arui-scripts-artifacts/src/templates/index.ts similarity index 100% rename from packages/arui-scripts-docker/src/templates/index.ts rename to packages/arui-scripts-artifacts/src/templates/index.ts diff --git a/packages/arui-scripts-docker/src/templates/nginx.conf.template.ts b/packages/arui-scripts-artifacts/src/templates/nginx.conf.template.ts similarity index 91% rename from packages/arui-scripts-docker/src/templates/nginx.conf.template.ts rename to packages/arui-scripts-artifacts/src/templates/nginx.conf.template.ts index 4ff656c8..1282c8b1 100644 --- a/packages/arui-scripts-docker/src/templates/nginx.conf.template.ts +++ b/packages/arui-scripts-artifacts/src/templates/nginx.conf.template.ts @@ -1,9 +1,9 @@ -import { type ResolvedDockerConfig } from '../types'; +import { type ResolvedArtifactsConfig } from '../types'; /** * server-блок nginx-конфига, который раздает статику и (в не-clientOnly режиме) проксирует на nodejs. */ -export function renderNginxConf(config: ResolvedDockerConfig): string { +export function renderNginxConf(config: ResolvedArtifactsConfig): string { const { clientServerPort, enablePreviousVersionHeaders, diff --git a/packages/arui-scripts-docker/src/templates/start.template.ts b/packages/arui-scripts-artifacts/src/templates/start.template.ts similarity index 96% rename from packages/arui-scripts-docker/src/templates/start.template.ts rename to packages/arui-scripts-artifacts/src/templates/start.template.ts index 8f3ae5f0..7bd33d18 100644 --- a/packages/arui-scripts-docker/src/templates/start.template.ts +++ b/packages/arui-scripts-artifacts/src/templates/start.template.ts @@ -1,11 +1,11 @@ import { ENV_CONFIG_FILENAME } from '../constants'; -import { type ResolvedDockerConfig } from '../types'; +import { type ResolvedArtifactsConfig } from '../types'; /** * start.sh — entrypoint образа. Для serverful-режима поднимает nginx + nodejs, для clientOnly — * подставляет env-config и запускает nginx. */ -export function renderStartScript(config: ResolvedDockerConfig): string { +export function renderStartScript(config: ResolvedArtifactsConfig): string { const { clientOnly, buildPath, serverOutput } = config; const serverStartTemplate = `#!/bin/sh diff --git a/packages/arui-scripts-docker/src/types.ts b/packages/arui-scripts-artifacts/src/types.ts similarity index 65% rename from packages/arui-scripts-docker/src/types.ts rename to packages/arui-scripts-artifacts/src/types.ts index c3a00a53..8fbe015d 100644 --- a/packages/arui-scripts-docker/src/types.ts +++ b/packages/arui-scripts-artifacts/src/types.ts @@ -13,22 +13,31 @@ export type NginxConfig = { /** * Управление флагом `--platform` в команде `docker build`: - * - `'auto'` — исторное поведение: флаг подставляется только если версия docker его поддерживает; + * - `'auto'` — историческое поведение: флаг подставляется только если версия docker его поддерживает; * - `false` — никогда не добавлять флаг; * - строка (например `'linux/amd64'`) — всегда использовать указанную платформу. */ export type DockerPlatform = 'auto' | false | string; +/** Вариант Dockerfile: «сырой» (сборка на хосте) или compiled (сборка внутри образа). */ +export type DockerfileVariant = 'runtime' | 'compiled'; + +/** Тип собираемого артефакта поставки. */ +export type ArtifactKind = 'docker' | 'archive'; + /** * Функция-оверрайд шаблона. Получает сгенерированную по умолчанию строку и итоговый конфиг, * возвращает новую строку. Позволяет точечно донасыщать/подменять любой из шаблонов. */ -export type TemplateOverride = (generatedContent: string, config: ResolvedDockerConfig) => string; +export type TemplateOverride = ( + generatedContent: string, + config: ResolvedArtifactsConfig, +) => string; /** * Функция-рендерер шаблона. Полностью заменяет генерацию шаблона по умолчанию. */ -export type TemplateRenderer = (config: ResolvedDockerConfig) => string; +export type TemplateRenderer = (config: ResolvedArtifactsConfig) => string; export type DockerTemplateKey = | 'dockerfile' @@ -59,10 +68,23 @@ export type LocalFiles = { /** * Полный набор опций сборки docker-образа. Все поля опциональны — недостающие донасыщаются дефолтами - * в {@link resolveDockerConfig}. Это единственная точка входа для кастомизации: любой потребитель + * в {@link resolveArtifactsConfig}. Это единственная точка входа для кастомизации: любой потребитель * (arui-scripts, newclick-builder, собственные скрипты) может собрать образ, передав сюда свои значения. */ -export type DockerBuildOptions = { +export type ArtifactsOptions = { + /** + * Что собирать: docker-образ (по умолчанию) или tar-архив с production-сборкой. + * Docker-специфичные опции (`baseDockerImage`, `platform`, `extraBuildArgs`, …) при + * `artifact: 'archive'` не используются. + */ + artifact?: ArtifactKind; + /** + * Вариант сборки. `runtime` (по умолчанию) — приложение собирается на хосте и результат кладется + * в образ; `compiled` — зависимости и сборка выполняются внутри образа. От варианта зависят + * дефолты хост-пайплайна ({@link ArtifactsOptions.buildCommand} и соседние). + */ + variant?: DockerfileVariant; + /* --- Идентификация образа --- */ /** Имя образа. По умолчанию берется из `package.json` в `cwd`. */ name?: string; @@ -82,7 +104,13 @@ export type DockerBuildOptions = { serverOutput?: string; /** Корень, из которого nginx раздает статику. */ nginxRootPath?: string; - /** Публичный префикс путей до статики. */ + /** Директория со статикой внутри `buildPath`. Из нее выводится дефолт `publicPath`. */ + assetsPath?: string; + /** + * Публичный префикс путей до статики — используется в `location` блоках nginx-конфига. + * По умолчанию `` `${assetsPath}/` ``, как это считает arui-scripts. Пустая строка приведет к + * дублирующемуся `location /` в nginx-конфиге, поэтому переопределять стоит осознанно. + */ publicPath?: string; /* --- Порты --- */ @@ -113,14 +141,38 @@ export type DockerBuildOptions = { /** Дополнительные `--build-arg` для `docker build`. */ extraBuildArgs?: Record; - /* --- Менеджер зависимостей (для compiled-образа) --- */ + /* --- Хост-пайплайн (выполняется перед `docker build`) --- */ + /** + * Удалять ли `buildPath` перед сборкой приложения. + * По умолчанию — `true` для `runtime` и `false` для `compiled`. + */ + cleanBuildPath?: boolean; + /** + * Команда сборки приложения на хосте. `null`/`false` — не собирать. + * По умолчанию — `'npm run build'` для `runtime` и `null` для `compiled` (там сборка идет в образе). + */ + buildCommand?: string | null | false; + /** + * Удалять ли dev-зависимости ({@link ArtifactsOptions.pruneCommand}) перед `docker build`. + * Аналог `removeDevDependenciesDuringDockerBuild` в arui-scripts. + * По умолчанию — `true` для `runtime` и `false` для `compiled`. + */ + removeDevDependencies?: boolean; + + /* --- tar-архив (`artifact: 'archive'`) --- */ + /** Имя итогового tar-архива. */ + archiveName?: string; + /** Дополнительные директории проекта, которые кладутся в архив рядом со сборкой. */ + additionalBuildPath?: string[]; + + /* --- Менеджер зависимостей --- */ /** Использовать ли yarn (если доступен). */ useYarn?: boolean; /** Явно заданная версия yarn. По умолчанию определяется автоматически. */ yarnVersion?: YarnVersion; - /** Команда установки production-зависимостей внутри образа. */ + /** Команда установки production-зависимостей внутри образа (для `compiled`). */ installProductionCommand?: string; - /** Команда очистки dev-зависимостей перед сборкой образа. */ + /** Команда очистки dev-зависимостей на хосте перед сборкой образа. */ pruneCommand?: string; /* --- Кастомизация файлов --- */ @@ -144,7 +196,10 @@ export type DockerBuildOptions = { * Полностью донасыщенный конфиг сборки. С ним работают все шаблоны и утилиты — они не знают ничего * о том, откуда пришли значения, что делает их независимыми и легко тестируемыми. */ -export type ResolvedDockerConfig = { +export type ResolvedArtifactsConfig = { + artifact: ArtifactKind; + variant: DockerfileVariant; + name: string; version: string; dockerRegistry: string; @@ -154,6 +209,7 @@ export type ResolvedDockerConfig = { buildPath: string; serverOutput: string; nginxRootPath: string; + assetsPath: string; publicPath: string; clientServerPort: number; @@ -171,6 +227,13 @@ export type ResolvedDockerConfig = { platform: DockerPlatform; extraBuildArgs: Record; + cleanBuildPath: boolean; + buildCommand: string | null; + removeDevDependencies: boolean; + + archiveName: string; + additionalBuildPath: string[]; + useYarn: boolean; yarnVersion: YarnVersion; installProductionCommand: string; @@ -185,7 +248,7 @@ export type ResolvedDockerConfig = { /** * Готовые (отрендеренные) содержимого файлов, которые будут положены в образ. */ -export type RenderedDockerTemplates = { +export type RenderedTemplates = { dockerfile: string; nginxConf: string; nginxBaseConf: string; diff --git a/packages/arui-scripts-docker/src/utils/docker-build.ts b/packages/arui-scripts-artifacts/src/utils/docker-build.ts similarity index 68% rename from packages/arui-scripts-docker/src/utils/docker-build.ts rename to packages/arui-scripts-artifacts/src/utils/docker-build.ts index 6d179049..4585f4df 100644 --- a/packages/arui-scripts-docker/src/utils/docker-build.ts +++ b/packages/arui-scripts-artifacts/src/utils/docker-build.ts @@ -10,7 +10,7 @@ import { NGINX_CONFIG_FILENAME, PLATFORM_FLAG_MIN_DOCKER_VERSION, } from '../constants'; -import { type RenderedDockerTemplates, type ResolvedDockerConfig } from '../types'; +import { type RenderedTemplates, type ResolvedArtifactsConfig } from '../types'; export type BuildParams = { pathToTempDir: string; @@ -18,10 +18,26 @@ export type BuildParams = { tempDirName: string; }; +/** Символы, которые шелл не трактует специальным образом — такие значения можно не экранировать. */ +const SHELL_SAFE = /^[\w.:/=@+-]+$/; + +/** + * Экранирует значение для подстановки в shell-команду. Нужно потому, что итоговая строка + * выполняется через шелл, а значения (`extraBuildArgs`, имя образа, контекст) приходят из + * пользовательского конфига и переменных окружения CI. + */ +export function shellQuote(value: string): string { + if (SHELL_SAFE.test(value)) { + return value; + } + + return `'${value.replace(/'/g, "'\\''")}'`; +} + /** * Собирает параметры сборки (полное имя образа и пути) из конфига. */ -export function getBuildParams(config: ResolvedDockerConfig): BuildParams { +export function getBuildParams(config: ResolvedArtifactsConfig): BuildParams { const { dockerRegistry, name, version, cwd, tempDirName } = config; const pathToTempDir = path.join(cwd, tempDirName); const imageFullName = `${dockerRegistry ? `${dockerRegistry}/` : ''}${name}:${version}`; @@ -34,9 +50,9 @@ export function getBuildParams(config: ResolvedDockerConfig): BuildParams { * поверх конфига. Неизвестные аргументы игнорируются с предупреждением. */ export function applyCommandLineArguments( - config: ResolvedDockerConfig, + config: ResolvedArtifactsConfig, commandLineArguments: string[], -): ResolvedDockerConfig { +): ResolvedArtifactsConfig { const next = { ...config }; commandLineArguments.forEach((arg) => { @@ -67,15 +83,23 @@ export function applyCommandLineArguments( * начиная с третьего, как в CLI arui-scripts). */ export function getBuildParamsFromArgs( - config: ResolvedDockerConfig, + config: ResolvedArtifactsConfig, argv: string[] = process.argv.slice(3), ): BuildParams { return getBuildParams(applyCommandLineArguments(config, argv)); } type PrepareFilesForDockerParams = { - config: ResolvedDockerConfig; - templates: RenderedDockerTemplates; + config: ResolvedArtifactsConfig; + templates: RenderedTemplates; +}; + +export type PrepareFilesForDockerResult = { + /** + * Возвращает `.dockerignore` проекта в исходное состояние. Вызывать после `docker build` — + * иначе дописанный `node_modules` останется в рабочей копии пользователя. + */ + restoreDockerIgnore: () => Promise; }; /** @@ -83,7 +107,10 @@ type PrepareFilesForDockerParams = { * nginx-конфиги и start.sh. Локальные файлы проекта (если разрешены и заданы) имеют приоритет над * сгенерированными шаблонами. */ -export async function prepareFilesForDocker({ config, templates }: PrepareFilesForDockerParams) { +export async function prepareFilesForDocker({ + config, + templates, +}: PrepareFilesForDockerParams): Promise { const { cwd, nginx, @@ -119,6 +146,12 @@ export async function prepareFilesForDocker({ config, templates }: PrepareFilesF : templates.startScript; const dockerIgnoreFilePath = path.join(cwd, '.dockerignore'); + // запоминаем исходное состояние, чтобы вернуть файл как было: `node_modules` нужен только на + // время сборки образа и не должен оставаться в рабочей копии + const originalDockerIgnore = + addNodeModulesToDockerIgnore && fs.existsSync(dockerIgnoreFilePath) + ? await fs.readFile(dockerIgnoreFilePath, 'utf-8') + : null; const dockerIgnoreFileContent = addNodeModulesToDockerIgnore && @@ -143,6 +176,20 @@ export async function prepareFilesForDocker({ config, templates }: PrepareFilesF fs.writeFile(dockerIgnoreFilePath, dockerIgnoreFileContent, 'utf-8'), ].filter(Boolean), ); + + return { + restoreDockerIgnore: async () => { + if (!addNodeModulesToDockerIgnore) { + return; + } + + if (originalDockerIgnore === null) { + await fs.remove(dockerIgnoreFilePath); + } else { + await fs.writeFile(dockerIgnoreFilePath, originalDockerIgnore, 'utf-8'); + } + }, + }; } /** @@ -165,7 +212,7 @@ export function dockerVersionSatisfies(request: string) { /** * Вычисляет значение флага `--platform` согласно настройке `platform` в конфиге. */ -export function getPlatformFlag(config: ResolvedDockerConfig): string { +export function getPlatformFlag(config: ResolvedArtifactsConfig): string { const { platform } = config; if (platform === 'auto') { @@ -186,23 +233,25 @@ export function getPlatformFlag(config: ResolvedDockerConfig): string { /** * Формирует команду `docker build` для сгенерированной ранее временной директории. */ -export function getDockerBuildCommand(config: ResolvedDockerConfig): string { +export function getDockerBuildCommand(config: ResolvedArtifactsConfig): string { const { tempDirName, context, extraBuildArgs } = config; const { imageFullName } = getBuildParams(config); const platformFlag = getPlatformFlag(config); const extraArgs = Object.entries(extraBuildArgs) - .map(([key, value]) => `--build-arg ${key}="${value}"`) + .map(([key, value]) => `--build-arg ${key}=${shellQuote(value)}`) .join(' '); return `docker build ${platformFlag} \ - -f "./${tempDirName}/Dockerfile" \ - --build-arg START_SH_LOCATION="./${tempDirName}/start.sh" \ - --build-arg NGINX_CONF_LOCATION="./${tempDirName}/${NGINX_CONFIG_FILENAME}" \ - --build-arg NGINX_BASE_CONF_LOCATION="./${tempDirName}/${BASE_NGINX_CONFIG_FILENAME}" \ + -f ${shellQuote(`./${tempDirName}/Dockerfile`)} \ + --build-arg START_SH_LOCATION=${shellQuote(`./${tempDirName}/start.sh`)} \ + --build-arg NGINX_CONF_LOCATION=${shellQuote(`./${tempDirName}/${NGINX_CONFIG_FILENAME}`)} \ + --build-arg NGINX_BASE_CONF_LOCATION=${shellQuote( + `./${tempDirName}/${BASE_NGINX_CONFIG_FILENAME}`, + )} \ ${extraArgs} \ - -t ${imageFullName} ${context}`; + -t ${shellQuote(imageFullName)} ${shellQuote(context)}`; } async function getAndModifyDockerIgnoreContent(dockerIgnoreFilePath: string) { diff --git a/packages/arui-scripts-artifacts/src/utils/exec.ts b/packages/arui-scripts-artifacts/src/utils/exec.ts new file mode 100644 index 00000000..c4ba7f74 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/utils/exec.ts @@ -0,0 +1,34 @@ +import shell from 'shelljs'; + +/** Ошибка выполнения shell-команды. Код возврата доступен в `exitCode`. */ +export class ExecError extends Error { + public readonly exitCode: number; + + public readonly command: string; + + constructor(command: string, exitCode: number) { + super(`Command failed with exit code ${exitCode}: ${command}`); + this.name = 'ExecError'; + this.command = command; + this.exitCode = exitCode; + } +} + +/** + * Выполняет shell-команду, резолвится кодом возврата при успехе и реджектится {@link ExecError} + * при ошибке. + */ +export function exec(command: string): Promise { + return new Promise((resolve, reject) => { + console.log(`Executing command: ${command}`); + shell.exec(command, (code: number) => { + if (code === 0) { + return resolve(code); + } + + return reject(new ExecError(command, code)); + }); + }); +} + +export default exec; diff --git a/packages/arui-scripts-docker/src/utils/yarn.ts b/packages/arui-scripts-artifacts/src/utils/yarn.ts similarity index 100% rename from packages/arui-scripts-docker/src/utils/yarn.ts rename to packages/arui-scripts-artifacts/src/utils/yarn.ts diff --git a/packages/arui-scripts-artifacts/tsconfig.eslint.json b/packages/arui-scripts-artifacts/tsconfig.eslint.json new file mode 100644 index 00000000..f8b06f9a --- /dev/null +++ b/packages/arui-scripts-artifacts/tsconfig.eslint.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + // для корректной работы @typescript-eslint/parser + "include": ["src", "./*.js", "./*.ts", ".eslintrc.js"], + // tsconfig.json исключает тесты из сборки, но линтить их надо + "exclude": ["build"] +} diff --git a/packages/arui-scripts-docker/tsconfig.json b/packages/arui-scripts-artifacts/tsconfig.json similarity index 88% rename from packages/arui-scripts-docker/tsconfig.json rename to packages/arui-scripts-artifacts/tsconfig.json index 58c6dc57..d32e8d27 100644 --- a/packages/arui-scripts-docker/tsconfig.json +++ b/packages/arui-scripts-artifacts/tsconfig.json @@ -10,5 +10,5 @@ "types": ["jest", "node"] }, "include": ["src/**/*.ts"], - "exclude": ["build"] + "exclude": ["build", "src/__tests__"] } diff --git a/packages/arui-scripts-docker/CHANGELOG.md b/packages/arui-scripts-docker/CHANGELOG.md deleted file mode 100644 index 47164cd6..00000000 --- a/packages/arui-scripts-docker/CHANGELOG.md +++ /dev/null @@ -1 +0,0 @@ -# @alfalab/scripts-docker diff --git a/packages/arui-scripts-docker/README.md b/packages/arui-scripts-docker/README.md deleted file mode 100644 index 2430c7ed..00000000 --- a/packages/arui-scripts-docker/README.md +++ /dev/null @@ -1,130 +0,0 @@ -@alfalab/scripts-docker -=== - -Максимально кастомизируемые скрипты и шаблоны для сборки docker-образов приложений, основанных на -`arui-scripts`. Логика, которая раньше жила внутри команд `arui-scripts docker-build` и была намертво -завязана на глобальный конфиг, вынесена в отдельный пакет: все шаблоны и утилиты работают с обычным -объектом опций, поэтому образ можно собрать из любого места — CLI, программно или из другого пакета -(например, `newclick-builder`). - -## Установка - -```bash -yarn add @alfalab/scripts-docker -``` - -## CLI - -```bash -# «сырой» образ (приложение собирается на хосте, результат кладется в образ) -arui-scripts-docker docker-build - -# compiled-образ (зависимости и сборка выполняются внутри образа) -arui-scripts-docker docker-build:compiled - -# переопределение имени/версии/registry -arui-scripts-docker docker-build name=my-app version=1.2.3 registry=registry.example.com -``` - -CLI автоматически: - -- берет `name`/`version` из `package.json`; -- подхватывает локальные `Dockerfile`, `start.sh`, `nginx.conf`, `base-nginx.conf` из корня проекта; -- загружает пользовательский конфиг `arui-scripts-docker.config.js`, если он есть. - -### Пользовательский конфиг - -```js -// arui-scripts-docker.config.js -module.exports = { - baseDockerImage: 'alfabankui/arui-scripts:24.10.0-slim', - clientOnly: true, - nginx: { workerProcesses: 4 }, - // точечно донасыщаем сгенерированный Dockerfile - overrides: { - dockerfile: (generated) => `${generated}\nLABEL team="web"`, - }, -}; -``` - -Конфиг может экспортировать объект или функцию (в т.ч. асинхронную), возвращающую опции. - -## Программное API - -### `buildDockerImage` - -Высокоуровневая сборка: донасыщает конфиг, рендерит шаблоны, готовит временную директорию, запускает -`docker build`, чистит за собой и (опционально) пушит образ. - -```ts -import { buildDockerImage } from '@alfalab/scripts-docker'; - -await buildDockerImage({ - variant: 'compiled', // 'runtime' | 'compiled' - name: 'my-app', - version: '1.0.0', - dockerRegistry: 'registry.example.com', - baseDockerImage: 'alfabankui/arui-scripts:24.10.0-slim', - clientOnly: false, - nginx: { workerProcesses: 4 }, - extraBuildArgs: { COMMIT_SHA: process.env.COMMIT_SHA }, - // хук перед docker build — например, собрать приложение - beforeBuild: async () => { - // await exec('npm run build'); - }, -}); -``` - -### Гранулярные утилиты - -Если нужен полный контроль над процессом, используйте отдельные функции — именно так их использует -`newclick-builder`: - -```ts -import { - resolveDockerConfig, - renderDockerTemplates, - prepareFilesForDocker, - getBuildParamsFromArgs, - getDockerBuildCommand, - exec, -} from '@alfalab/scripts-docker'; - -const config = resolveDockerConfig({ - baseDockerImage: 'my-registry/base:1.0.0', - serverOutput: 'server/index.js', - clientServerPort: 8080, - serverPort: 3000, - addNodeModulesToDockerIgnore: true, -}); - -const { imageFullName } = getBuildParamsFromArgs(config); -const templates = renderDockerTemplates({ - config, - variant: 'compiled', - // полная замена шаблона nginx-конфига - templates: { - nginxConf: (cfg) => myCustomNginxTemplate(cfg), - }, -}); - -await prepareFilesForDocker({ config, templates }); -await exec(getDockerBuildCommand(config)); -await exec(`docker push ${imageFullName}`); -``` - -## Кастомизация - -Каждый файл, попадающий в образ, можно настроить на трех уровнях (по возрастанию приоритета): - -1. **Опции** — `baseDockerImage`, `clientOnly`, `runFromNonRootUser`, `nginx`, `platform`, - `extraBuildArgs`, `buildPath`, `serverOutput` и т.д. (см. `DockerBuildOptions`). -2. **`templates`** — полная замена рендерера конкретного шаблона (`dockerfile`, `dockerfileCompiled`, - `nginxConf`, `baseNginxConf`, `startScript`). -3. **`overrides`** — точечная функция `(generated, config) => string` поверх сгенерированного шаблона. -4. **Локальные файлы** — `Dockerfile`, `start.sh`, `nginx.conf`, `base-nginx.conf` в корне проекта. - -## Экспортируемые шаблоны - -`renderDockerfile`, `renderDockerfileCompiled`, `renderNginxConf`, `renderBaseNginxConf`, -`renderStartScript` — чистые функции `(config) => string`, которые можно переиспользовать и оборачивать. diff --git a/packages/arui-scripts-docker/src/__tests__/config.test.ts b/packages/arui-scripts-docker/src/__tests__/config.test.ts deleted file mode 100644 index 0dcf436f..00000000 --- a/packages/arui-scripts-docker/src/__tests__/config.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { resolveDockerConfig } from '../config'; - -describe('resolveDockerConfig', () => { - it('should fill defaults matching arui-scripts historical behaviour', () => { - const config = resolveDockerConfig({ cwd: __dirname }); - - expect(config.baseDockerImage).toBe('alfabankui/arui-scripts:24.10.0-slim'); - expect(config.buildPath).toBe('.build'); - expect(config.serverOutput).toBe('server.js'); - expect(config.nginxRootPath).toBe('/src'); - expect(config.clientServerPort).toBe(8080); - expect(config.serverPort).toBe(3000); - expect(config.runFromNonRootUser).toBe(true); - expect(config.clientOnly).toBe(false); - expect(config.tempDirName).toBe('.docker-build'); - expect(config.platform).toBe('auto'); - expect(config.nginx).toBeNull(); - }); - - it('should not push by default in debug mode', () => { - expect(resolveDockerConfig({ cwd: __dirname, debug: true }).push).toBe(false); - expect(resolveDockerConfig({ cwd: __dirname, debug: false }).push).toBe(true); - }); - - it('should allow explicit push override even in debug mode', () => { - expect(resolveDockerConfig({ cwd: __dirname, debug: true, push: true }).push).toBe(true); - }); - - it('should normalize nginx: false to null', () => { - expect(resolveDockerConfig({ cwd: __dirname, nginx: false }).nginx).toBeNull(); - expect( - resolveDockerConfig({ cwd: __dirname, nginx: { workerProcesses: 4 } }).nginx, - ).toEqual({ workerProcesses: 4 }); - }); - - it('should keep falsy but valid values (empty registry, port 0)', () => { - const config = resolveDockerConfig({ cwd: __dirname, dockerRegistry: '', serverPort: 0 }); - - expect(config.dockerRegistry).toBe(''); - expect(config.serverPort).toBe(0); - }); - - it('should respect explicit yarnVersion and derived commands', () => { - const config = resolveDockerConfig({ cwd: __dirname, yarnVersion: '2+' }); - - expect(config.installProductionCommand).toBe('yarn workspaces focus --production --all'); - }); -}); diff --git a/packages/arui-scripts-docker/src/__tests__/docker-build.test.ts b/packages/arui-scripts-docker/src/__tests__/docker-build.test.ts deleted file mode 100644 index 67c02104..00000000 --- a/packages/arui-scripts-docker/src/__tests__/docker-build.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { resolveDockerConfig } from '../config'; -import { - applyCommandLineArguments, - getBuildParams, - getDockerBuildCommand, - getPlatformFlag, -} from '../utils/docker-build'; - -const baseOptions = { cwd: '/tmp/project', name: 'app', version: '1.0.0' }; - -describe('getBuildParams', () => { - it('should build image name without registry', () => { - const config = resolveDockerConfig(baseOptions); - - expect(getBuildParams(config).imageFullName).toBe('app:1.0.0'); - }); - - it('should build image name with registry', () => { - const config = resolveDockerConfig({ - ...baseOptions, - dockerRegistry: 'registry.example.com', - }); - - expect(getBuildParams(config).imageFullName).toBe('registry.example.com/app:1.0.0'); - }); - - it('should place temp dir inside cwd', () => { - const config = resolveDockerConfig(baseOptions); - - expect(getBuildParams(config).pathToTempDir).toBe('/tmp/project/.docker-build'); - }); -}); - -describe('applyCommandLineArguments', () => { - it('should override name/version/registry from args', () => { - const config = resolveDockerConfig(baseOptions); - const next = applyCommandLineArguments(config, [ - 'name=other', - 'version=2.0.0', - 'registry=r.example.com', - ]); - - expect(getBuildParams(next).imageFullName).toBe('r.example.com/other:2.0.0'); - }); -}); - -describe('getPlatformFlag', () => { - it('should return empty string when platform is false', () => { - const config = resolveDockerConfig({ ...baseOptions, platform: false }); - - expect(getPlatformFlag(config)).toBe(''); - }); - - it('should use explicit platform', () => { - const config = resolveDockerConfig({ ...baseOptions, platform: 'linux/arm64' }); - - expect(getPlatformFlag(config)).toBe('--platform linux/arm64'); - }); -}); - -describe('getDockerBuildCommand', () => { - it('should include dockerfile, build-args and context', () => { - const config = resolveDockerConfig({ ...baseOptions, platform: false }); - const command = getDockerBuildCommand(config); - - expect(command).toContain('-f "./.docker-build/Dockerfile"'); - expect(command).toContain('--build-arg START_SH_LOCATION="./.docker-build/start.sh"'); - expect(command).toContain('-t app:1.0.0 .'); - }); - - it('should include extra build args', () => { - const config = resolveDockerConfig({ - ...baseOptions, - platform: false, - extraBuildArgs: { COMMIT_SHA: 'abc123' }, - }); - - expect(getDockerBuildCommand(config)).toContain('--build-arg COMMIT_SHA="abc123"'); - }); -}); diff --git a/packages/arui-scripts-docker/src/__tests__/render.test.ts b/packages/arui-scripts-docker/src/__tests__/render.test.ts deleted file mode 100644 index c3a42b5d..00000000 --- a/packages/arui-scripts-docker/src/__tests__/render.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { resolveDockerConfig } from '../config'; -import { renderDockerTemplates } from '../render'; - -const baseOptions = { cwd: __dirname, name: 'app', version: '1.0.0' }; - -describe('renderDockerTemplates', () => { - it('should render runtime dockerfile with the base image and start.sh', () => { - const config = resolveDockerConfig({ ...baseOptions, baseDockerImage: 'my/base:1.0.0' }); - const templates = renderDockerTemplates({ config, variant: 'runtime' }); - - expect(templates.dockerfile).toContain('FROM my/base:1.0.0'); - expect(templates.dockerfile).toContain('ADD $START_SH_LOCATION /src/start.sh'); - expect(templates.startScript).toContain('#!/bin/sh'); - }); - - it('should render compiled dockerfile with install command', () => { - const config = resolveDockerConfig({ ...baseOptions, yarnVersion: 'unavailable' }); - const templates = renderDockerTemplates({ config, variant: 'compiled' }); - - expect(templates.dockerfile).toContain('npm install --production'); - expect(templates.dockerfile).toContain('npm cache clean --force'); - }); - - it('should not render base nginx conf when nginx is disabled', () => { - const config = resolveDockerConfig({ ...baseOptions, nginx: false }); - const templates = renderDockerTemplates({ config }); - - expect(templates.nginxBaseConf).toBe(''); - }); - - it('should render base nginx conf with custom worker processes', () => { - const config = resolveDockerConfig({ ...baseOptions, nginx: { workerProcesses: 9 } }); - const templates = renderDockerTemplates({ config }); - - expect(templates.nginxBaseConf).toContain('worker_processes 9;'); - }); - - it('should render client-only start script when clientOnly is set', () => { - const config = resolveDockerConfig({ ...baseOptions, clientOnly: true }); - const templates = renderDockerTemplates({ config }); - - expect(templates.startScript).toContain('env-config.json'); - expect(templates.startScript).not.toContain('max-old-space-size'); - }); - - it('should apply full template replacement via templates', () => { - const config = resolveDockerConfig(baseOptions); - const templates = renderDockerTemplates({ - config, - templates: { nginxConf: () => 'CUSTOM NGINX' }, - }); - - expect(templates.nginxConf).toBe('CUSTOM NGINX'); - }); - - it('should apply point overrides on top of generated template', () => { - const config = resolveDockerConfig(baseOptions); - const templates = renderDockerTemplates({ - config, - variant: 'runtime', - overrides: { dockerfile: (generated) => `${generated}\nLABEL team="web"` }, - }); - - expect(templates.dockerfile).toContain('FROM'); - expect(templates.dockerfile).toContain('LABEL team="web"'); - }); - - it('should route overrides to dockerfileCompiled key for compiled variant', () => { - const config = resolveDockerConfig(baseOptions); - const templates = renderDockerTemplates({ - config, - variant: 'compiled', - overrides: { dockerfileCompiled: () => 'COMPILED OVERRIDE' }, - }); - - expect(templates.dockerfile).toBe('COMPILED OVERRIDE'); - }); -}); diff --git a/packages/arui-scripts-docker/src/bin/index.ts b/packages/arui-scripts-docker/src/bin/index.ts deleted file mode 100644 index dbf78204..00000000 --- a/packages/arui-scripts-docker/src/bin/index.ts +++ /dev/null @@ -1,102 +0,0 @@ -#! /usr/bin/env node -/* eslint-disable global-require, import/no-dynamic-require */ -import path from 'path'; - -import fs from 'fs-extra'; - -import { buildDockerImage, type BuildDockerImageOptions } from '../build-docker-image'; -import { BASE_NGINX_CONFIG_FILENAME, NGINX_CONFIG_FILENAME } from '../constants'; -import { type DockerBuildOptions } from '../types'; - -const CONFIG_FILE_NAMES = [ - 'arui-scripts-docker.config.js', - 'arui-scripts-docker.config.cjs', - '.arui-scripts-docker.js', -]; - -/** - * Пытается загрузить пользовательский конфиг из cwd. Конфиг может экспортировать объект опций или - * функцию, возвращающую опции (в т.ч. асинхронно). - */ -async function loadUserOptions(cwd: string): Promise { - const configPath = CONFIG_FILE_NAMES.map((fileName) => path.join(cwd, fileName)).find( - (filePath) => fs.existsSync(filePath), - ); - - if (!configPath) { - return {}; - } - - // eslint-disable-next-line @typescript-eslint/no-var-requires - const required = require(configPath); - // eslint-disable-next-line no-underscore-dangle - const configModule = required?.__esModule ? required.default : required; - - return typeof configModule === 'function' ? configModule() : configModule; -} - -/** - * Определяет локальные файлы проекта, замещающие сгенерированные шаблоны, если они лежат в cwd. - */ -function detectLocalFiles(cwd: string): DockerBuildOptions['localFiles'] { - const resolveIfExists = (fileName: string) => { - const filePath = path.join(cwd, fileName); - - return fs.existsSync(filePath) ? filePath : null; - }; - - return { - dockerfile: resolveIfExists('Dockerfile'), - startScript: resolveIfExists('start.sh'), - nginxConf: resolveIfExists(NGINX_CONFIG_FILENAME), - nginxBaseConf: resolveIfExists(BASE_NGINX_CONFIG_FILENAME), - }; -} - -const commandDefaults: Record> = { - 'docker-build': { - variant: 'runtime', - allowLocalDockerfile: true, - allowLocalStartScript: true, - addNodeModulesToDockerIgnore: false, - }, - 'docker-build:compiled': { - variant: 'compiled', - allowLocalDockerfile: false, - allowLocalStartScript: false, - addNodeModulesToDockerIgnore: true, - }, -}; - -(async () => { - const command = process.argv[2]; - - if (!command || !commandDefaults[command]) { - console.error( - `Please specify one of available commands: ${Object.keys(commandDefaults) - .map((c) => `"${c}"`) - .join(' ')}`, - ); - process.exit(-1); - - return; - } - - const cwd = process.cwd(); - - try { - const userOptions = await loadUserOptions(cwd); - const defaults = commandDefaults[command]; - - await buildDockerImage({ - ...defaults, - localFiles: { ...detectLocalFiles(cwd), ...userOptions.localFiles }, - ...userOptions, - // аргументы командной строки (name=... version=... registry=...) имеют наивысший приоритет - argv: process.argv.slice(3), - }); - } catch (err) { - console.error(err); - process.exit(1); - } -})(); diff --git a/packages/arui-scripts-docker/src/build-docker-image.ts b/packages/arui-scripts-docker/src/build-docker-image.ts deleted file mode 100644 index 63701741..00000000 --- a/packages/arui-scripts-docker/src/build-docker-image.ts +++ /dev/null @@ -1,69 +0,0 @@ -import fs from 'fs-extra'; - -import { - applyCommandLineArguments, - getBuildParams, - getDockerBuildCommand, - prepareFilesForDocker, -} from './utils/docker-build'; -import { exec } from './utils/exec'; -import { resolveDockerConfig } from './config'; -import { type DockerfileVariant, renderDockerTemplates } from './render'; -import { type DockerBuildOptions, type ResolvedDockerConfig } from './types'; - -export type BuildDockerImageOptions = DockerBuildOptions & { - /** Вариант Dockerfile: `runtime` (по умолчанию) или `compiled`. */ - variant?: DockerfileVariant; - /** Аргументы командной строки (`name=... version=... registry=...`), накладываются поверх опций. */ - argv?: string[]; - /** Хук, вызываемый после подготовки файлов, но до `docker build` (например, для сборки приложения). */ - beforeBuild?: (config: ResolvedDockerConfig) => void | Promise; -}; - -/** - * Высокоуровневая сборка docker-образа: донасыщает конфиг, рендерит все шаблоны, готовит временную - * директорию, запускает `docker build`, чистит за собой и (опционально) пушит образ. - * - * Для более тонкого контроля используйте отдельные утилиты: {@link resolveDockerConfig}, - * {@link renderDockerTemplates}, {@link prepareFilesForDocker}, {@link getDockerBuildCommand}. - */ -export async function buildDockerImage(options: BuildDockerImageOptions = {}): Promise { - const { variant = 'runtime', argv, beforeBuild, templates, overrides, ...rest } = options; - - let config = resolveDockerConfig(rest); - - if (argv) { - config = applyCommandLineArguments(config, argv); - } - - const { imageFullName, pathToTempDir } = getBuildParams(config); - - try { - console.log(`Build docker image ${imageFullName}`); - console.time('Total time'); - - const renderedTemplates = renderDockerTemplates({ config, variant, templates, overrides }); - - await prepareFilesForDocker({ config, templates: renderedTemplates }); - - if (beforeBuild) { - await beforeBuild(config); - } - - await exec(getDockerBuildCommand(config)); - await fs.remove(pathToTempDir); - - if (config.push) { - await exec(`docker push ${imageFullName}`); - } - - console.timeEnd('Total time'); - } catch (err) { - await fs.remove(pathToTempDir); - console.error('Error during docker-build.'); - if (config.debug) { - console.error(err); - } - throw err; - } -} diff --git a/packages/arui-scripts-docker/src/index.ts b/packages/arui-scripts-docker/src/index.ts deleted file mode 100644 index d965f906..00000000 --- a/packages/arui-scripts-docker/src/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -export * from './types'; -export * from './constants'; - -export { resolveDockerConfig } from './config'; -export { buildDockerImage, type BuildDockerImageOptions } from './build-docker-image'; -export { - renderDockerTemplates, - type DockerfileVariant, - type RenderDockerTemplatesParams, -} from './render'; - -export { - renderDockerfile, - renderDockerfileCompiled, - renderNginxConf, - renderBaseNginxConf, - renderStartScript, -} from './templates'; - -export { - applyCommandLineArguments, - dockerVersionSatisfies, - getBuildParams, - getBuildParamsFromArgs, - getDockerBuildCommand, - getPlatformFlag, - prepareFilesForDocker, - type BuildParams, -} from './utils/docker-build'; - -export { exec } from './utils/exec'; -export { - detectUseYarn, - getInstallProductionCommand, - getPruningCommand, - getYarnVersion, -} from './utils/yarn'; diff --git a/packages/arui-scripts-docker/src/utils/exec.ts b/packages/arui-scripts-docker/src/utils/exec.ts deleted file mode 100644 index a9e100d1..00000000 --- a/packages/arui-scripts-docker/src/utils/exec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import shell from 'shelljs'; - -/** - * Выполняет shell-команду, резолвится кодом возврата при успехе и реджектится им при ошибке. - */ -export function exec(command: string): Promise { - return new Promise((resolve, reject) => { - console.log(`Executing command: ${command}`); - shell.exec(command, (code: number) => { - if (code === 0) { - return resolve(code); - } - - return reject(code); - }); - }); -} - -export default exec; diff --git a/packages/arui-scripts-docker/tsconfig.eslint.json b/packages/arui-scripts-docker/tsconfig.eslint.json deleted file mode 100644 index 8f000311..00000000 --- a/packages/arui-scripts-docker/tsconfig.eslint.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "./tsconfig.json", - // для корректной работы @typescript-eslint/parser - "include": ["src", "./*.js", "./*.ts", ".eslintrc.js"] -} diff --git a/packages/arui-scripts/package.json b/packages/arui-scripts/package.json index 4fdf7c3c..60ba51d2 100644 --- a/packages/arui-scripts/package.json +++ b/packages/arui-scripts/package.json @@ -22,6 +22,7 @@ "bin": "./build/bin/index.js", "dependencies": { "@alfalab/postcss-custom-properties": "^9.1.2", + "@alfalab/scripts-artifacts": "workspace:^", "@babel/core": "7.22.10", "@babel/plugin-proposal-decorators": "^7.23.7", "@babel/plugin-proposal-export-default-from": "^7.23.3", diff --git a/packages/arui-scripts/src/commands/archive-build/index.ts b/packages/arui-scripts/src/commands/archive-build/index.ts index 31a64fb6..18f01fc2 100644 --- a/packages/arui-scripts/src/commands/archive-build/index.ts +++ b/packages/arui-scripts/src/commands/archive-build/index.ts @@ -1,88 +1,17 @@ -import path from 'path'; +import { buildArchive } from '@alfalab/scripts-artifacts'; -import fs from 'fs-extra'; -import tar from 'tar'; - -import { configs } from '../../configs/app-configs'; -import { nginxConfTemplate } from '../../templates/nginx.conf.template'; -import { startScript } from '../../templates/start.template'; -import { exec } from '../util/exec'; -import { getPruningCommand } from '../util/yarn'; - -const tempDirName = '.archive-build'; -const nginxConfFileName = 'nginx.conf'; -const startScriptFileName = 'start.sh'; -const nodeModulesDirName = 'node_modules'; -const packageJsonFileName = 'package.json'; -const pathToTempDir = path.join(configs.cwd, tempDirName); -const nginxConfPath = path.join(pathToTempDir, nginxConfFileName); -const startScriptPath = path.join(pathToTempDir, startScriptFileName); -const nodeModulesPath = path.join(configs.cwd, nodeModulesDirName); -const packageJsonPath = path.join(configs.cwd, packageJsonFileName); +import { getArtifactsOptions } from '../util/artifacts-options'; (async () => { try { - console.time('Total time'); - console.time('Setting up time'); - - await fs.emptyDir(pathToTempDir); - - const nginxConf = configs.localNginxConf - ? await fs.readFile(configs.localNginxConf, 'utf8') - : nginxConfTemplate; - - await Promise.all([ - fs.writeFile(nginxConfPath, nginxConf, 'utf8'), - fs.writeFile(startScriptPath, startScript, { encoding: 'utf8', mode: 0o555 }), - fs.remove(configs.buildPath), - ]); - - console.timeEnd('Setting up time'); - console.time('Build application time'); - // run build script - await exec('npm run build'); - - console.timeEnd('Build application time'); - console.time('Remove build dependencies time'); - const pruneCommand = getPruningCommand(); - - await exec(pruneCommand); - - console.timeEnd('Remove build dependencies time'); - console.time('Archive build time'); - await Promise.all([ - fs.copy(configs.buildPath, path.join(pathToTempDir, configs.buildPath)), - fs.copy(nodeModulesPath, path.join(pathToTempDir, nodeModulesDirName)), - fs.copy(packageJsonPath, path.join(pathToTempDir, packageJsonFileName)), - ...configs.additionalBuildPath.map((additionalPath) => - fs.copy( - path.join(configs.cwd, additionalPath), - path.join(pathToTempDir, additionalPath), - ), - ), - ]); - await tar.c( - { - file: configs.archiveName, - cwd: pathToTempDir, - }, - fs.readdirSync(pathToTempDir), - ); - - console.timeEnd('Archive build time'); - console.time('Cleanup time'); - - // remove temp directory - await fs.remove(pathToTempDir); - - console.timeEnd('Cleanup time'); - console.timeEnd('Total time'); - } catch (err) { - console.error('Error during archive-build.'); - if (configs.debug) { - console.error(err); - } - + await buildArchive({ + ...getArtifactsOptions(), + // archive-build исторически всегда удаляет dev-зависимости, независимо от + // removeDevDependenciesDuringDockerBuild + removeDevDependencies: true, + }); + } catch { + // buildArchive уже напечатал ошибку (и стек, если включен debug) process.exit(1); } })(); diff --git a/packages/arui-scripts/src/commands/build/build-wrapper.ts b/packages/arui-scripts/src/commands/build/build-wrapper.ts index c33247be..19fcaaa9 100644 --- a/packages/arui-scripts/src/commands/build/build-wrapper.ts +++ b/packages/arui-scripts/src/commands/build/build-wrapper.ts @@ -1,5 +1,6 @@ +import { type Configuration, type MultiStats, rspack, type Stats } from '@rspack/core'; import chalk from 'chalk'; -import { rspack, Stats, MultiStats, Configuration } from '@rspack/core'; + import { formatWebpackMessages } from '../util/format-webpack-messages'; type BuildResult = { @@ -9,7 +10,8 @@ type BuildResult = { }; function build(config: Configuration | Configuration[], previousFileSizes?: unknown) { - let compiler = rspack(config); + const compiler = rspack(config); + return new Promise((resolve, reject) => { compiler.run((err, stats) => { if (err) { @@ -23,6 +25,7 @@ function build(config: Configuration | Configuration[], previousFileSizes?: unkn if (messages.errors.length > 1) { messages.errors.length = 1; } + return reject(new Error(messages.errors.join('\n\n'))); } if ( @@ -36,8 +39,10 @@ function build(config: Configuration | Configuration[], previousFileSizes?: unkn 'Most CI servers set it automatically.\n', ), ); + return reject(new Error(messages.warnings.join('\n\n'))); } + return resolve({ stats: stats as Stats | MultiStats, warnings: messages.warnings, diff --git a/packages/arui-scripts/src/commands/build/client.ts b/packages/arui-scripts/src/commands/build/client.ts index a454b735..e2d0a17a 100644 --- a/packages/arui-scripts/src/commands/build/client.ts +++ b/packages/arui-scripts/src/commands/build/client.ts @@ -1,11 +1,13 @@ +import { type Configuration, type MultiStats, type Stats } from '@rspack/core'; import chalk from 'chalk'; -import { Configuration, Stats, MultiStats } from '@rspack/core'; -import build from './build-wrapper'; -import { printAssetsSizes } from '../util/client-assets-sizes'; + import { webpackClientConfig } from '../../configs/webpack.client.prod'; +import { printAssetsSizes } from '../util/client-assets-sizes'; import { loadBrowserslist } from '../util/load-browserslist'; import { printBuildError } from '../util/print-build-error'; +import build from './build-wrapper'; + loadBrowserslist(); console.log(chalk.magenta('Building client...')); diff --git a/packages/arui-scripts/src/commands/build/index.ts b/packages/arui-scripts/src/commands/build/index.ts index f83d6165..71fdfa46 100644 --- a/packages/arui-scripts/src/commands/build/index.ts +++ b/packages/arui-scripts/src/commands/build/index.ts @@ -1,5 +1,5 @@ -import { runCompilers } from '../util/run-compilers'; import { configs } from '../../configs/app-configs'; +import { runCompilers } from '../util/run-compilers'; process.env.BABEL_ENV = 'production'; process.env.NODE_ENV = 'production'; diff --git a/packages/arui-scripts/src/commands/build/server.ts b/packages/arui-scripts/src/commands/build/server.ts index 2d66f337..d96cdb6e 100644 --- a/packages/arui-scripts/src/commands/build/server.ts +++ b/packages/arui-scripts/src/commands/build/server.ts @@ -1,10 +1,11 @@ import chalk from 'chalk'; -import build from './build-wrapper'; -import { webpackServerConfig } from '../../configs/webpack.server.prod'; import { supportingNode } from '../../configs/supporting-node'; +import { webpackServerConfig } from '../../configs/webpack.server.prod'; import { printBuildError } from '../util/print-build-error'; +import build from './build-wrapper'; + process.env.BROWSERSLIST = supportingNode.join(','); console.log(chalk.magenta('Building server...')); diff --git a/packages/arui-scripts/src/commands/docker-build-compiled/index.ts b/packages/arui-scripts/src/commands/docker-build-compiled/index.ts index e5a88c27..1dcae3b2 100644 --- a/packages/arui-scripts/src/commands/docker-build-compiled/index.ts +++ b/packages/arui-scripts/src/commands/docker-build-compiled/index.ts @@ -1,50 +1,19 @@ -import fs from 'fs-extra'; +import { buildDockerImage } from '@alfalab/scripts-artifacts'; -import { configs } from '../../configs/app-configs'; -import { nginxBaseConfTemplate } from '../../templates/base-nginx.conf.template'; -import { dockerfileTemplate } from '../../templates/dockerfile-compiled.template'; -import { nginxConfTemplate } from '../../templates/nginx.conf.template'; -import { startScript } from '../../templates/start.template'; -import { - getBuildParamsFromArgs, - getDockerBuildCommand, - prepareFilesForDocker, -} from '../util/docker-build'; -import { exec } from '../util/exec'; +import { getArtifactsOptions } from '../util/artifacts-options'; (async () => { - const { imageFullName, pathToTempDir, tempDirName } = getBuildParamsFromArgs(); - try { - console.log(`Build docker image ${imageFullName}`); - console.time('Total time'); - - await prepareFilesForDocker({ - pathToTempDir, - dockerfileTemplate, - nginxConfTemplate, - nginxBaseConfTemplate, - startScriptTemplate: startScript, + await buildDockerImage({ + ...getArtifactsOptions(), + variant: 'compiled', allowLocalDockerfile: false, allowLocalStartScript: false, addNodeModulesToDockerIgnore: true, + argv: process.argv.slice(3), }); - - await exec(getDockerBuildCommand({ tempDirName, imageFullName })); - await fs.remove(pathToTempDir); - - // guard against pushing the image during tests - if (!configs.debug) { - await exec(`docker push ${imageFullName}`); - } - - console.timeEnd('Total time'); - } catch (err) { - await fs.remove(pathToTempDir); - console.error('Error during docker-build.'); - if (configs.debug) { - console.error(err); - } + } catch { + // buildDockerImage уже напечатал ошибку (и стек, если включен debug) process.exit(1); } })(); diff --git a/packages/arui-scripts/src/commands/docker-build/index.ts b/packages/arui-scripts/src/commands/docker-build/index.ts index c65a6375..75f0fcb0 100644 --- a/packages/arui-scripts/src/commands/docker-build/index.ts +++ b/packages/arui-scripts/src/commands/docker-build/index.ts @@ -1,79 +1,19 @@ -import fs from 'fs-extra'; +import { buildDockerImage } from '@alfalab/scripts-artifacts'; -import { configs } from '../../configs/app-configs'; -import { nginxBaseConfTemplate } from '../../templates/base-nginx.conf.template'; -import { dockerfileTemplate } from '../../templates/dockerfile.template'; -import { nginxConfTemplate } from '../../templates/nginx.conf.template'; -import { startScript } from '../../templates/start.template'; -import { - getBuildParamsFromArgs, - getDockerBuildCommand, - prepareFilesForDocker, -} from '../util/docker-build'; -import { exec } from '../util/exec'; -import { getPruningCommand } from '../util/yarn'; +import { getArtifactsOptions } from '../util/artifacts-options'; (async () => { - const { imageFullName, pathToTempDir, tempDirName } = getBuildParamsFromArgs(); - try { - console.log(`Build docker image ${imageFullName}`); - console.time('Total time'); - console.time('Setting up time'); - - await prepareFilesForDocker({ - pathToTempDir, - dockerfileTemplate, - nginxConfTemplate, - nginxBaseConfTemplate, - startScriptTemplate: startScript, + await buildDockerImage({ + ...getArtifactsOptions(), + variant: 'runtime', allowLocalDockerfile: true, allowLocalStartScript: true, addNodeModulesToDockerIgnore: false, + argv: process.argv.slice(3), }); - - await fs.remove(configs.buildPath); - - console.timeEnd('Setting up time'); - console.time('Build application time'); - // run build script - await exec('npm run build'); - - console.timeEnd('Build application time'); - - if (configs.removeDevDependenciesDuringDockerBuild) { - console.time('Remove dev dependencies time'); - - const pruneCommand = getPruningCommand(); - - await exec(pruneCommand); - - console.timeEnd('Remove dev dependencies time'); - } - - console.time('Build docker image time'); - - await exec(getDockerBuildCommand({ tempDirName, imageFullName })); - - console.timeEnd('Build docker image time'); - console.time('Cleanup time'); - - // remove temp directory - await fs.remove(pathToTempDir); - - // guard against pushing the image during tests - if (!configs.debug) { - await exec(`docker push ${imageFullName}`); - } - - console.timeEnd('Cleanup time'); - console.timeEnd('Total time'); - } catch (err) { - await fs.remove(pathToTempDir); - console.error('Error during docker-build.'); - if (configs.debug) { - console.error(err); - } + } catch { + // buildDockerImage уже напечатал ошибку (и стек, если включен debug) process.exit(1); } })(); diff --git a/packages/arui-scripts/src/commands/util/artifacts-options.ts b/packages/arui-scripts/src/commands/util/artifacts-options.ts new file mode 100644 index 00000000..2e69ffeb --- /dev/null +++ b/packages/arui-scripts/src/commands/util/artifacts-options.ts @@ -0,0 +1,91 @@ +import { + type ArtifactsOptions, + type DockerTemplateOverrides, + resolveArtifactsConfig, + type ResolvedArtifactsConfig, +} from '@alfalab/scripts-artifacts'; + +import { configs } from '../../configs/app-configs'; +import { applyOverrides } from '../../configs/util/apply-overrides'; + +/** + * Оверрайды шаблонов из `arui-scripts.overrides.ts`. Ключи в @alfalab/scripts-artifacts переименованы, + * поэтому здесь мы явно транслируем их в исторические имена arui-scripts. + * + * Обратите внимание: в arui-scripts `nginx` — это server-блок (`nginx.conf`), а `nginxConf` — + * базовый http-блок (`base-nginx.conf`). Имена исторически перепутаны, и эта таблица — единственное + * место, где это знание нужно. + */ +const legacyTemplateOverrides: DockerTemplateOverrides = { + dockerfile: (generated) => applyOverrides('Dockerfile', generated), + dockerfileCompiled: (generated) => applyOverrides('DockerfileCompiled', generated), + nginxConf: (generated) => applyOverrides('nginx', generated), + baseNginxConf: (generated) => applyOverrides('nginxConf', generated), + startScript: (generated) => applyOverrides('start.sh', generated), +}; + +/** + * Транслирует глобальный конфиг arui-scripts в опции @alfalab/scripts-artifacts. + * + * Это единственная точка связи между двумя пакетами: сами шаблоны и утилиты сборки живут в + * @alfalab/scripts-artifacts и ничего не знают про `configs`. + */ +export function getArtifactsOptions( + extraOptions: Partial = {}, +): ArtifactsOptions { + return { + name: configs.name, + version: configs.version, + dockerRegistry: configs.dockerRegistry, + + baseDockerImage: configs.baseDockerImage, + clientOnly: configs.clientOnly, + buildPath: configs.buildPath, + serverOutput: configs.serverOutput, + nginxRootPath: configs.nginxRootPath, + assetsPath: configs.assetsPath, + publicPath: configs.publicPath, + + clientServerPort: configs.clientServerPort, + serverPort: configs.serverPort, + + nginx: configs.nginx, + enablePreviousVersionHeaders: configs.dictionaryCompression.enablePreviousVersionHeaders, + + runFromNonRootUser: configs.runFromNonRootUser, + cwd: configs.cwd, + debug: configs.debug, + + removeDevDependencies: configs.removeDevDependenciesDuringDockerBuild, + + archiveName: configs.archiveName, + additionalBuildPath: configs.additionalBuildPath, + + useYarn: configs.useYarn, + + localFiles: { + dockerfile: configs.localDockerfile, + startScript: configs.localStartScript, + nginxConf: configs.localNginxConf, + nginxBaseConf: configs.localNginxBaseConf, + }, + + overrides: legacyTemplateOverrides, + + ...extraOptions, + }; +} + +let cachedConfig: ResolvedArtifactsConfig | null = null; + +/** + * Донасыщенный конфиг сборки, построенный из `configs`. Мемоизирован, потому что резолв читает + * package.json и версию yarn, а шаблоны вычисляются на уровне модуля. + */ +export function getResolvedArtifactsConfig(): ResolvedArtifactsConfig { + if (!cachedConfig) { + cachedConfig = resolveArtifactsConfig(getArtifactsOptions()); + } + + return cachedConfig; +} diff --git a/packages/arui-scripts/src/commands/util/docker-build.ts b/packages/arui-scripts/src/commands/util/docker-build.ts index c8fe2a39..0870374a 100644 --- a/packages/arui-scripts/src/commands/util/docker-build.ts +++ b/packages/arui-scripts/src/commands/util/docker-build.ts @@ -1,52 +1,45 @@ import path from 'path'; -import fs from 'fs-extra'; -import satisfies from 'semver/functions/satisfies'; -import shell from 'shelljs'; - -import { configs } from '../../configs/app-configs'; import { - baseNginxConfigFileName, - nginxConfigFileName, -} from '../../configs/app-configs/get-defaults'; - -export function getBuildParamsFromArgs() { - let imageVersion = configs.version; - let imageName = configs.name; - let { dockerRegistry } = configs; - const commandLineArguments = process.argv.slice(3); - - commandLineArguments.forEach((arg) => { - let [argName, argValue] = arg.split('='); - - argName = argName.toLowerCase().trim(); - argValue = argValue ? argValue.trim() : ''; - switch (argName) { - case 'version': - imageVersion = argValue; - break; - case 'name': - imageName = argValue; - break; - case 'registry': - dockerRegistry = argValue; - break; - default: - console.warn(`Unknown argument ${argName}`); - } - }); + type BuildParams, + dockerVersionSatisfies, + getBuildParams, + getBuildParamsFromArgs as dockerGetBuildParamsFromArgs, + getDockerBuildCommand as dockerGetBuildCommand, + prepareFilesForDocker as dockerPrepareFilesForDocker, +} from '@alfalab/scripts-artifacts'; + +import { getResolvedArtifactsConfig } from './artifacts-options'; + +export { dockerVersionSatisfies }; + +/** + * Совместимый слой поверх @alfalab/scripts-artifacts: сохраняет исторические сигнатуры, которыми + * пользуются внешние потребители (в первую очередь newclick-builder) и реэкспорт из `arui-scripts`. + * + * @deprecated Используйте одноименные функции из `@alfalab/scripts-artifacts` — они принимают явный + * конфиг и не зависят от глобального `configs`. + */ +export function getBuildParamsFromArgs(): BuildParams { + return dockerGetBuildParamsFromArgs(getResolvedArtifactsConfig(), process.argv.slice(3)); +} - const tempDirName = '.docker-build'; - const pathToTempDir = path.join(configs.cwd, tempDirName); - const imageFullName = `${ - dockerRegistry ? `${dockerRegistry}/` : '' - }${imageName}:${imageVersion}`; +/** + * Разбирает `registry/name:version` обратно на имя и версию, чтобы собрать конфиг, из которого + * @alfalab/scripts-artifacts соберет ровно ту же строку. + */ +function splitImageFullName(imageFullName: string) { + const lastColon = imageFullName.lastIndexOf(':'); + const lastSlash = imageFullName.lastIndexOf('/'); + + if (lastColon > lastSlash) { + return { + name: imageFullName.slice(0, lastColon), + version: imageFullName.slice(lastColon + 1), + }; + } - return { - pathToTempDir, - imageFullName, - tempDirName, - }; + return { name: imageFullName, version: '' }; } type PrepareFilesForDockerParams = { @@ -60,6 +53,9 @@ type PrepareFilesForDockerParams = { addNodeModulesToDockerIgnore: boolean; }; +/** + * @deprecated Используйте `prepareFilesForDocker` из `@alfalab/scripts-artifacts`. + */ export async function prepareFilesForDocker({ dockerfileTemplate, nginxConfTemplate, @@ -70,69 +66,22 @@ export async function prepareFilesForDocker({ allowLocalStartScript, addNodeModulesToDockerIgnore, }: PrepareFilesForDockerParams) { - await fs.emptyDir(pathToTempDir); - - let nginxBaseConf = ''; - - if (configs.nginx) { - nginxBaseConf = configs.localNginxBaseConf - ? await fs.readFile(configs.localNginxBaseConf, 'utf8') - : nginxBaseConfTemplate; - } - - const nginxConf = configs.localNginxConf - ? await fs.readFile(configs.localNginxConf, 'utf8') - : nginxConfTemplate; - - const dockerfile = - configs.localDockerfile && allowLocalDockerfile - ? await fs.readFile(configs.localDockerfile, 'utf8') - : dockerfileTemplate; - - const startScript = - configs.localStartScript && allowLocalStartScript - ? await fs.readFile(configs.localStartScript, 'utf8') - : startScriptTemplate; - - const dockerIgnoreFilePath = path.join(process.cwd(), '.dockerignore'); - - const dockerIgnoreFileContent = - addNodeModulesToDockerIgnore && - (await getAndModifyDockerIgnoreContent(dockerIgnoreFilePath)); - - await Promise.all( - [ - fs.writeFile(path.join(pathToTempDir, 'Dockerfile'), dockerfile, 'utf8'), - fs.writeFile(path.join(pathToTempDir, nginxConfigFileName), nginxConf, 'utf8'), - nginxBaseConf && - fs.writeFile( - path.join(pathToTempDir, baseNginxConfigFileName), - nginxBaseConf, - 'utf8', - ), - fs.writeFile(path.join(pathToTempDir, 'start.sh'), startScript, { - encoding: 'utf8', - mode: 0o555, - }), - addNodeModulesToDockerIgnore && - dockerIgnoreFileContent && - fs.writeFile(dockerIgnoreFilePath, dockerIgnoreFileContent, 'utf-8'), - ].filter(Boolean), - ); -} - -export function dockerVersionSatisfies(request: string) { - const dockerServerVersion = shell.exec("docker version --format '{{.Server.Version}}'", { - silent: true, - }); - const dockerClientVersion = shell.exec("docker version --format '{{.Client.Version}}'", { - silent: true, + return dockerPrepareFilesForDocker({ + config: { + ...getResolvedArtifactsConfig(), + cwd: path.dirname(pathToTempDir), + tempDirName: path.basename(pathToTempDir), + allowLocalDockerfile, + allowLocalStartScript, + addNodeModulesToDockerIgnore, + }, + templates: { + dockerfile: dockerfileTemplate, + nginxConf: nginxConfTemplate, + nginxBaseConf: nginxBaseConfTemplate, + startScript: startScriptTemplate, + }, }); - - return ( - satisfies(dockerServerVersion.toString(), request) && - satisfies(dockerClientVersion.toString(), request) - ); } type DockerBuildCommandParams = { @@ -140,30 +89,16 @@ type DockerBuildCommandParams = { imageFullName: string; }; +/** + * @deprecated Используйте `getDockerBuildCommand` из `@alfalab/scripts-artifacts`. + */ export function getDockerBuildCommand({ tempDirName, imageFullName }: DockerBuildCommandParams) { - // если пытаться собрать проект на маках с m1, докер будет пытаться вытянуть базовый образ под свою платформу и - // упадет с ошибкой. Чтобы этого избежать - достаточно использовать флаг --platform. Но он поддерживается без экспериментальных - // флагов только начиная с docker 20.10.21, а на многих серверах, используемых для сборки до сих пор живет докер 1.13.1 - // Соответственно они будут падать при наличии этого флага. - // https://docs.docker.com/engine/release-notes/20.10/ - const canUsePlatformFlag = dockerVersionSatisfies('>=20.10.21'); - - return `docker build ${canUsePlatformFlag ? '--platform linux/x86_64' : ''} \ - -f "./${tempDirName}/Dockerfile" \ - --build-arg START_SH_LOCATION="./${tempDirName}/start.sh" \ - --build-arg NGINX_CONF_LOCATION="./${tempDirName}/${nginxConfigFileName}" \ - --build-arg NGINX_BASE_CONF_LOCATION="./${tempDirName}/${baseNginxConfigFileName}" \ - -t ${imageFullName} .`; + return dockerGetBuildCommand({ + ...getResolvedArtifactsConfig(), + ...splitImageFullName(imageFullName), + dockerRegistry: '', + tempDirName, + }); } -async function getAndModifyDockerIgnoreContent(dockerIgnoreFilePath: string) { - if (fs.existsSync(dockerIgnoreFilePath)) { - return fs - .readFile(dockerIgnoreFilePath, 'utf-8') - .then((ignores) => `${ignores}\nnode_modules`); - } - - await fs.createFile(dockerIgnoreFilePath); - - return 'node_modules'; -} +export { getBuildParams }; diff --git a/packages/arui-scripts/src/commands/util/exec.ts b/packages/arui-scripts/src/commands/util/exec.ts index a9f806f6..11dafdf0 100644 --- a/packages/arui-scripts/src/commands/util/exec.ts +++ b/packages/arui-scripts/src/commands/util/exec.ts @@ -1,14 +1 @@ -import shell from 'shelljs'; - -export function exec(command: string) { - return new Promise((resolve, reject) => { - console.log(`Executing command: ${command}`); - shell.exec(command, (code) => { - if (code === 0) { - return resolve(code); - } - - return reject(code); - }); - }); -} +export { exec, ExecError } from '@alfalab/scripts-artifacts'; diff --git a/packages/arui-scripts/src/commands/util/yarn.ts b/packages/arui-scripts/src/commands/util/yarn.ts index 8c946451..d9f98a32 100644 --- a/packages/arui-scripts/src/commands/util/yarn.ts +++ b/packages/arui-scripts/src/commands/util/yarn.ts @@ -1,57 +1,29 @@ -import shell from 'shelljs'; +import { + getInstallProductionCommand as dockerGetInstallProductionCommand, + getPruningCommand as dockerGetPruningCommand, + getYarnVersion as dockerGetYarnVersion, + type YarnVersion, +} from '@alfalab/scripts-artifacts'; import { configs } from '../../configs/app-configs'; -type YarnVersion = '1' | '2+' | 'unavailable'; - +/** + * Совместимый слой поверх @alfalab/scripts-artifacts: сохраняет исторические сигнатуры без аргументов, + * подставляя значения из глобального `configs`. + * + * @deprecated Используйте одноименные функции из `@alfalab/scripts-artifacts`. + */ export function getYarnVersion(): YarnVersion { - if (configs.useYarn && shell.which('yarn')) { - const yarnVersion = shell.exec('yarn -v', { silent: true }); - const yarnMajorVersion = Number(yarnVersion.split('.')[0]); - - return yarnMajorVersion > 1 ? '2+' : '1'; - } - - return 'unavailable'; + return dockerGetYarnVersion({ useYarn: configs.useYarn }); } export function getPruningCommand(): string { - if (configs.clientOnly) { - return 'echo "Skipping pruning in client only mode"'; - } - const yarnVersion = getYarnVersion(); - - switch (yarnVersion) { - case '1': { - return 'yarn install --production --ignore-optional --frozen-lockfile --ignore-scripts --prefer-offline'; - } - case '2+': { - return 'yarn workspaces focus --production --all'; - } - case 'unavailable': { - return 'npm prune --production'; - } - default: { - return ''; - } - } + return dockerGetPruningCommand({ + yarnVersion: getYarnVersion(), + clientOnly: configs.clientOnly, + }); } export function getInstallProductionCommand(): string { - const yarnVersion = getYarnVersion(); - - switch (yarnVersion) { - case '1': { - return 'yarn install --production --ignore-optional --frozen-lockfile --ignore-scripts --prefer-offline'; - } - case '2+': { - return 'yarn workspaces focus --production --all'; - } - case 'unavailable': { - return 'npm install --production'; - } - default: { - return ''; - } - } + return dockerGetInstallProductionCommand(getYarnVersion()); } diff --git a/packages/arui-scripts/src/index.ts b/packages/arui-scripts/src/index.ts index d9486879..a3b09c29 100644 --- a/packages/arui-scripts/src/index.ts +++ b/packages/arui-scripts/src/index.ts @@ -1,7 +1,35 @@ export type { OverrideFile } from './configs/util/apply-overrides'; export type { AppConfigs, CompatModuleConfig, PackageSettings } from './configs/app-configs/types'; + +/** + * Утилиты сборки docker-образа, привязанные к глобальному конфигу arui-scripts. + * @deprecated Используйте `@alfalab/scripts-artifacts` — там те же функции принимают явный конфиг. + */ export { prepareFilesForDocker } from './commands/util/docker-build'; export { getBuildParamsFromArgs } from './commands/util/docker-build'; export { getDockerBuildCommand } from './commands/util/docker-build'; + +/** + * Опции сборки docker-образа, собранные из конфига arui-scripts. Точка входа для тех, кто хочет + * собрать образ через `@alfalab/scripts-artifacts`, но переиспользовать настройки arui-scripts. + */ +export { getArtifactsOptions, getResolvedArtifactsConfig } from './commands/util/artifacts-options'; + +export { + buildDockerImage, + getBuildParams, + renderBaseNginxConf, + renderDockerfile, + renderDockerfileCompiled, + renderTemplates, + renderNginxConf, + renderStartScript, + resolveArtifactsConfig, + type ArtifactsOptions, + type DockerTemplateOverrides, + type DockerTemplates, + type ResolvedArtifactsConfig, +} from '@alfalab/scripts-artifacts'; + export { patchMainWebpackConfigForModules } from './configs/modules'; diff --git a/packages/arui-scripts/src/templates/base-nginx.conf.template.ts b/packages/arui-scripts/src/templates/base-nginx.conf.template.ts index 44b23ab9..a314146c 100644 --- a/packages/arui-scripts/src/templates/base-nginx.conf.template.ts +++ b/packages/arui-scripts/src/templates/base-nginx.conf.template.ts @@ -1,62 +1,10 @@ -import { configs } from '../configs/app-configs'; -import { applyOverrides } from '../configs/util/apply-overrides'; - -const baseNginxConfig = { - workerProcesses: 2, - workerRlimitNoFile: 20000, - workerConnections: 19000, - eventsUse: 'epoll', - daemon: 'off', -}; -const nginx = { - ...baseNginxConfig, - ...configs.nginx, -}; - -const baseNginxTemplate = ` -worker_processes ${nginx.workerProcesses}; -worker_rlimit_nofile ${nginx.workerRlimitNoFile}; -daemon ${nginx.daemon}; - -error_log /var/log/nginx/error.log; -pid /var/run/nginx.pid; - -events { - worker_connections ${nginx.workerConnections}; - use ${nginx.eventsUse}; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; +import { renderBaseNginxConf } from '@alfalab/scripts-artifacts'; - server_tokens off; - access_log off; - keepalive_timeout 65; - proxy_read_timeout 200; - sendfile on; - tcp_nopush on; - tcp_nodelay on; - - gzip_static on; - brotli on; - brotli_static on; - brotli_types application/atom+xml application/javascript application/json application/rss+xml - application/vnd.ms-fontobject application/x-font-opentype application/x-font-truetype - application/x-font-ttf application/x-javascript application/xhtml+xml application/xml - font/eot font/opentype font/otf font/truetype image/svg+xml image/vnd.microsoft.icon - image/x-icon image/x-win-bitmap text/css text/javascript text/plain text/xml application/wasm; - - - # Only retry if there was a communication error, not a timeout - # on the Node server (to avoid propagating "queries of death" - # to all frontends) - proxy_next_upstream error; - - #cache - proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=all:32m max_size=1g; - - include /etc/nginx/conf.d/*.conf; -}`; +import { getResolvedArtifactsConfig } from '../commands/util/artifacts-options'; +import { applyOverrides } from '../configs/util/apply-overrides'; -export const nginxBaseConfTemplate = applyOverrides('nginxConf', baseNginxTemplate); +// исторически ключ оверрайда базового конфига называется `nginxConf` +export const nginxBaseConfTemplate = applyOverrides( + 'nginxConf', + renderBaseNginxConf(getResolvedArtifactsConfig()), +); diff --git a/packages/arui-scripts/src/templates/dockerfile-compiled.template.ts b/packages/arui-scripts/src/templates/dockerfile-compiled.template.ts index 5d12ae98..b8787ae5 100644 --- a/packages/arui-scripts/src/templates/dockerfile-compiled.template.ts +++ b/packages/arui-scripts/src/templates/dockerfile-compiled.template.ts @@ -1,52 +1,9 @@ -import { getInstallProductionCommand, getYarnVersion } from '../commands/util/yarn'; -import { configs } from '../configs/app-configs'; -import { applyOverrides } from '../configs/util/apply-overrides'; - -const installProductionCommand = getInstallProductionCommand(); -const yarnVersion = getYarnVersion(); - -const { nginx } = configs; - -// В зависимости от используемого мендежера зависимостей для их установки нужно копировать разный набор файлов -const filesRequiredToInstallDependencies = [ - 'package.json', - 'yarn.lock', - yarnVersion === '2+' && '.yarnrc.yml', - yarnVersion === '2+' && '.yarn', - yarnVersion === 'unavailable' && 'package-lock.json', -].filter(Boolean); - -const template = ` -FROM ${configs.baseDockerImage} -ARG START_SH_LOCATION -ARG NGINX_CONF_LOCATION -ARG NGINX_BASE_CONF_LOCATION - -WORKDIR /src +import { renderDockerfileCompiled } from '@alfalab/scripts-artifacts'; -# Полу-статичные файлы, могут легко кешироваться -ADD $START_SH_LOCATION /src/start.sh -ADD $NGINX_CONF_LOCATION /src/nginx.conf -${nginx ? 'ADD $NGINX_BASE_CONF_LOCATION /etc/nginx/nginx.conf' : ''} - -# Зависимости. При некоторой удаче могут кешироваться и соответственно кешировать установку зависимостей -${filesRequiredToInstallDependencies - .map((file) => `ADD --chown=nginx:nginx ${file} /src/${file}`) - .join('\n')} - -RUN ${installProductionCommand} && \\ - ${yarnVersion === 'unavailable' ? 'npm cache clean --force' : 'yarn cache clean --all'} - -ADD --chown=nginx:nginx . /src - -# Создаем директории для nginx и выставляем правильные права -RUN mkdir -p /var/lib/nginx && \ - chown -R nginx:nginx /var/lib/nginx && \ - chown -R nginx:nginx /var/log/nginx && \ - chown -R nginx:nginx /etc/nginx/conf.d -RUN touch /var/run/nginx.pid && \ - chown -R nginx:nginx /var/run/nginx.pid -USER nginx -`; +import { getResolvedArtifactsConfig } from '../commands/util/artifacts-options'; +import { applyOverrides } from '../configs/util/apply-overrides'; -export const dockerfileTemplate = applyOverrides('DockerfileCompiled', template); +export const dockerfileTemplate = applyOverrides( + 'DockerfileCompiled', + renderDockerfileCompiled(getResolvedArtifactsConfig()), +); diff --git a/packages/arui-scripts/src/templates/dockerfile.template.ts b/packages/arui-scripts/src/templates/dockerfile.template.ts index 2334e83c..b07007f1 100644 --- a/packages/arui-scripts/src/templates/dockerfile.template.ts +++ b/packages/arui-scripts/src/templates/dockerfile.template.ts @@ -1,46 +1,9 @@ -import { configs } from '../configs/app-configs'; -import { applyOverrides } from '../configs/util/apply-overrides'; - -const appPathToAdd = configs.clientOnly ? configs.buildPath : '.'; -const appTargetPath = configs.clientOnly ? `/src/${configs.buildPath}` : '/src'; -const nginxConfTargetLocation = configs.clientOnly - ? '/etc/nginx/conf.d/default.conf' - : '/src/nginx.conf'; -const { nginx } = configs; - -const nginxNonRootPart = configs.runFromNonRootUser - ? `RUN chown -R nginx:nginx /src && \\ - mkdir -p /var/lib/nginx && \\ - chown -R nginx:nginx /var/lib/nginx && \\ - chown -R nginx:nginx /var/log/nginx && \\ - chown -R nginx:nginx /etc/nginx/conf.d - - RUN touch /var/run/nginx.pid && \\ - chown -R nginx:nginx /var/run/nginx.pid - - USER nginx` - : ''; +import { renderDockerfile } from '@alfalab/scripts-artifacts'; -const template = ` -FROM ${configs.baseDockerImage} -ARG START_SH_LOCATION -ARG NGINX_CONF_LOCATION -ARG NGINX_BASE_CONF_LOCATION - -WORKDIR /src -ADD $START_SH_LOCATION /src/start.sh -ADD $NGINX_CONF_LOCATION ${nginxConfTargetLocation} -${nginx ? 'ADD $NGINX_BASE_CONF_LOCATION /etc/nginx/nginx.conf' : ''} - -${nginxNonRootPart} - -${ - configs.runFromNonRootUser - ? `ADD --chown=nginx:nginx ${appPathToAdd} ${appTargetPath}` - : `ADD ${appPathToAdd} ${appTargetPath}` -} -${configs.clientOnly ? 'COPY env-config.jso[n] /src/' : ''} -${configs.clientOnly ? 'CMD ["nginx"]' : ''} -`; +import { getResolvedArtifactsConfig } from '../commands/util/artifacts-options'; +import { applyOverrides } from '../configs/util/apply-overrides'; -export const dockerfileTemplate = applyOverrides('Dockerfile', template); +export const dockerfileTemplate = applyOverrides( + 'Dockerfile', + renderDockerfile(getResolvedArtifactsConfig()), +); diff --git a/packages/arui-scripts/src/templates/nginx.conf.template.ts b/packages/arui-scripts/src/templates/nginx.conf.template.ts index 608f4e01..0bf27da7 100644 --- a/packages/arui-scripts/src/templates/nginx.conf.template.ts +++ b/packages/arui-scripts/src/templates/nginx.conf.template.ts @@ -1,53 +1,10 @@ -import { configs } from '../configs/app-configs'; -import { applyOverrides } from '../configs/util/apply-overrides'; - -const nginxTemplate = `client_max_body_size 20m; - -server { - listen ${configs.clientServerPort}; - server_tokens off; - ${ - configs.dictionaryCompression.enablePreviousVersionHeaders - ? 'brotli_auto_dictionary on;' - : '' - } - - ${ - configs.clientOnly - ? `location / { - root ${configs.nginxRootPath}/${configs.buildPath}; - index index.html; - }` - : ` location / { - proxy_set_header Host $host; - proxy_pass http://127.0.0.1:${configs.serverPort}; - }` - } +import { renderNginxConf } from '@alfalab/scripts-artifacts'; - location /${configs.publicPath} { - expires max; - add_header Cache-Control public; - root ${configs.nginxRootPath}/${configs.buildPath}; - } - - location = /${configs.publicPath}remoteEntry.js { - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; - add_header Pragma "no-cache"; - add_header Expires "0"; - root ${configs.nginxRootPath}/${configs.buildPath}; - types { - text/javascript js; - } - } - - location ~ /${configs.publicPath}.*\\.js$ { - expires max; - add_header Cache-Control public; - root ${configs.nginxRootPath}/${configs.buildPath}; - types { - text/javascript js; - } - } -}`; +import { getResolvedArtifactsConfig } from '../commands/util/artifacts-options'; +import { applyOverrides } from '../configs/util/apply-overrides'; -export const nginxConfTemplate = applyOverrides('nginx', nginxTemplate); +// исторически ключ оверрайда server-блока называется `nginx`, а `nginxConf` — это базовый конфиг +export const nginxConfTemplate = applyOverrides( + 'nginx', + renderNginxConf(getResolvedArtifactsConfig()), +); diff --git a/packages/arui-scripts/src/templates/start.template.ts b/packages/arui-scripts/src/templates/start.template.ts index a208c4da..fa86a14d 100644 --- a/packages/arui-scripts/src/templates/start.template.ts +++ b/packages/arui-scripts/src/templates/start.template.ts @@ -1,73 +1,9 @@ -import { configs } from '../configs/app-configs'; -import { ENV_CONFIG_FILENAME } from '../configs/client-env-config'; -import { applyOverrides } from '../configs/util/apply-overrides'; - -const startTemplate = `#!/bin/sh - -# Подменяем env переменные в nginx конфиге перед стартом -# Сначала заменяем все слова, начинающиеся на $ но без \${} на ~~слово~~ -# затем запускаем envsubst, после этого обратно заменяем ~~слово~~ на $слово. -# Это нужно для того, чтоб специальные переменные nginx не подменялись envsubst'ом на -# пустые строки. envsubst будет заменять только \${слово}. -cat ./nginx.conf \\ - | sed 's/\\$\\([a-zA-Z0-9_-]\\{1,\\}\\)/~~\\1~~/g' \\ - | envsubst \\ - | sed 's/~~\\([a-zA-Z0-9_-]\\{1,\\}\\)~~/$\\1/g' \\ - > /etc/nginx/conf.d/default.conf - -# Достаем лимит памяти из cgroup, это то, как его докер задает. -if [[ -f /sys/fs/cgroup/cgroup.controllers ]]; then - max_total_memory=$(cat /sys/fs/cgroup/memory.max) -else - max_total_memory=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes) -fi -# Самой nodejs мы не можем отдать совсем всю память, операционная система+nginx все же требуют какого то количества. -# Поэтому мы вычитаем 100мб на нужды ос -node_memory_limit="$(($max_total_memory / 1024 / 1024 - 100))" - -# Start the nginx process in background -nginx & - -# Start nodejs process -exec node --max-old-space-size="$node_memory_limit" ./${configs.buildPath}/${configs.serverOutput} -`; - -const envConfigTargetPath = `/src/${configs.buildPath}/${ENV_CONFIG_FILENAME}`; -const envConfigPath = `/src/${ENV_CONFIG_FILENAME}`; -const htmlPath = `/src/${configs.buildPath}/index.html`; - -const clientOnlyStartTemplate = `#!/bin/sh +import { renderStartScript } from '@alfalab/scripts-artifacts'; -# Мы подставляем значения из env в env-config.json если он есть, и кладем его в публичную папку. -# Дополнительно подставляем контент полученного файла в index.html -# Так как контент env-config может быть многострочным - дополнительно обрабатываем его через awk. -if [ -f ${envConfigPath} ]; then - cat ${envConfigPath} \\ - | envsubst \\ - > ${envConfigTargetPath} - - # Define the placeholder and the file paths - PLACEHOLDER='<%= envConfig %>' - SETTINGS_FILE='${envConfigTargetPath}' - TARGET_FILE='${htmlPath}' - - # Escape the placeholder for sed usage - ESCAPED_PLACEHOLDER=$(echo "$PLACEHOLDER" | sed 's/[\\/&]/\\\\&/g') - - # Read the content of the settings and prepare it for substitution - SETTINGS_CONTENT=$(awk '{printf "%s\\\\n", $0}' "$SETTINGS_FILE") - - # Replace the placeholder in the target file with the content of settings - cat ${htmlPath} \\ - | sed "s/$ESCAPED_PLACEHOLDER/$SETTINGS_CONTENT/" \\ - > /tmp/index.html - - mv /tmp/index.html ${htmlPath} -fi - -nginx`; +import { getResolvedArtifactsConfig } from '../commands/util/artifacts-options'; +import { applyOverrides } from '../configs/util/apply-overrides'; export const startScript = applyOverrides( 'start.sh', - configs.clientOnly ? clientOnlyStartTemplate : startTemplate, + renderStartScript(getResolvedArtifactsConfig()), ); diff --git a/yarn.lock b/yarn.lock index ac0d500c..4217bc84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -147,23 +147,26 @@ __metadata: languageName: node linkType: hard -"@alfalab/scripts-docker@workspace:packages/arui-scripts-docker": +"@alfalab/scripts-artifacts@workspace:^, @alfalab/scripts-artifacts@workspace:packages/arui-scripts-artifacts": version: 0.0.0-use.local - resolution: "@alfalab/scripts-docker@workspace:packages/arui-scripts-docker" + resolution: "@alfalab/scripts-artifacts@workspace:packages/arui-scripts-artifacts" dependencies: "@types/fs-extra": "npm:^9.0.13" "@types/jest": "npm:^29.5.14" "@types/node": "npm:^20.19.0" "@types/semver": "npm:^7.5.0" "@types/shelljs": "npm:^0.8.12" + commander: "npm:^12" fs-extra: "npm:6.0.1" jest: "npm:^29.7.0" + jiti: "npm:^2.4.2" semver: "npm:^7.5.4" shelljs: "npm:0.8.5" + tar: "npm:7.5.11" ts-jest: "npm:29.1.0" typescript: "npm:6.0.2" bin: - arui-scripts-docker: ./build/bin/index.js + arui-scripts-artifacts: ./build/bin/index.js languageName: unknown linkType: soft @@ -8205,6 +8208,7 @@ __metadata: resolution: "arui-scripts@workspace:packages/arui-scripts" dependencies: "@alfalab/postcss-custom-properties": "npm:^9.1.2" + "@alfalab/scripts-artifacts": "workspace:^" "@babel/core": "npm:7.22.10" "@babel/plugin-proposal-decorators": "npm:^7.23.7" "@babel/plugin-proposal-export-default-from": "npm:^7.23.3" @@ -9503,6 +9507,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^12": + version: 12.1.0 + resolution: "commander@npm:12.1.0" + checksum: 10/cdaeb672d979816853a4eed7f1310a9319e8b976172485c2a6b437ed0db0a389a44cfb222bfbde772781efa9f215bdd1b936f80d6b249485b465c6cb906e1f93 + languageName: node + linkType: hard + "commander@npm:^2.19.0, commander@npm:^2.20.0": version: 2.20.3 resolution: "commander@npm:2.20.3" @@ -15754,6 +15765,15 @@ __metadata: languageName: node linkType: hard +"jiti@npm:^2.4.2": + version: 2.7.0 + resolution: "jiti@npm:2.7.0" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10/6d75a8dbd61dbee031aa0937fabb748ff8ddf370b971958cc704f5cf26b4c5bdc9dcd0563059b2627a2bd41d946fa0bc64f912fdc8981ca7945a9d63c74ad0f9 + languageName: node + linkType: hard + "joi@npm:*, joi@npm:^17.3.0": version: 17.9.2 resolution: "joi@npm:17.9.2" From 2fe64c9431b712dff1e7e7dc58bf044567370cdf Mon Sep 17 00:00:00 2001 From: Stepan Ivankov Date: Thu, 6 Aug 2026 17:51:24 +0300 Subject: [PATCH 3/4] feat(deploy_kube_feature): update --- .changeset/docker-package-extraction.md | 11 +- packages/arui-scripts-artifacts/README.md | 147 +++++--- .../src/__tests__/config.test.ts | 110 ------ .../arui-scripts-artifacts.ts | 3 - .../src/__tests__/fixtures/cjs-config.cjs | 1 - .../src/__tests__/fixtures/fn-config.mjs | 1 - .../src/{ => archive}/build-archive.ts | 35 +- .../src/archive/constants.ts | 8 + .../src/archive/index.ts | 6 + .../arui-scripts-artifacts/src/bin/index.ts | 20 +- .../src/{ => cli}/__tests__/cli.test.ts | 4 +- .../{ => cli}/__tests__/config-file.test.ts | 67 ++-- .../arui-scripts-artifacts.ts | 3 + .../src/cli/__tests__/fixtures/cjs-config.cjs | 1 + .../src/cli/__tests__/fixtures/fn-config.mjs | 1 + .../{ => cli}/__tests__/fixtures/ts-config.ts | 6 +- .../__tests__/load-config-file.test.ts | 11 +- .../src/{ => cli}/config-file.ts | 61 ++-- .../src/{cli.ts => cli/create-cli.ts} | 2 +- .../arui-scripts-artifacts/src/cli/index.ts | 20 ++ .../src/{ => cli}/load-config-file.ts | 0 packages/arui-scripts-artifacts/src/config.ts | 124 ------- .../src/config/__tests__/config.test.ts | 149 ++++++++ .../src/config/index.ts | 180 ++++++++++ .../src/{ => config}/types.ts | 336 ++++++++++-------- .../__tests__/docker-build.test.ts | 40 ++- .../src/docker/build-command.ts | 76 ++++ .../src/{ => docker}/build-docker-image.ts | 24 +- .../src/docker/build-params.ts | 65 ++++ .../src/{ => docker}/constants.ts | 21 +- .../src/docker/index.ts | 19 + .../src/docker/prepare-files.ts | 119 +++++++ .../templates/dockerfile-compiled.template.ts | 9 +- .../templates/dockerfile.template.ts | 9 +- packages/arui-scripts-artifacts/src/index.ts | 88 ++--- .../src/nginx/constants.ts | 22 ++ .../arui-scripts-artifacts/src/nginx/index.ts | 9 + .../templates/base-nginx.conf.template.ts | 21 +- .../templates/nginx.conf.template.ts | 23 +- .../{ => pipeline}/__tests__/render.test.ts | 39 +- .../src/{ => pipeline}/build-artifact.ts | 4 +- .../src/{ => pipeline}/host-pipeline.ts | 18 +- .../src/pipeline/index.ts | 3 + .../src/{ => pipeline}/render.ts | 43 +-- .../src/start-script/constants.ts | 5 + .../src/start-script/index.ts | 2 + .../start.template.ts | 9 +- .../src/templates/index.ts | 5 - .../src/utils/docker-build.ts | 267 -------------- .../arui-scripts-artifacts/src/utils/shell.ts | 15 + .../arui-scripts-artifacts/src/utils/yarn.ts | 2 +- packages/arui-scripts/docs/artifact.md | 10 +- packages/arui-scripts/docs/overrides.md | 22 +- packages/arui-scripts/docs/settings.md | 10 + .../src/commands/archive-build/index.ts | 13 +- .../commands/docker-build-compiled/index.ts | 9 +- .../src/commands/docker-build/index.ts | 9 +- .../__tests__/artifacts-deprecations.tests.ts | 61 ++++ .../commands/util/artifacts-deprecations.ts | 74 ++++ .../src/commands/util/artifacts-options.ts | 79 ++-- .../src/commands/util/docker-build.ts | 48 ++- .../src/configs/app-configs/get-defaults.ts | 22 +- .../src/configs/app-configs/types.ts | 46 ++- .../app-configs/validate-settings-keys.ts | 7 +- .../src/configs/util/apply-overrides.ts | 19 + packages/arui-scripts/src/index.ts | 7 +- .../src/templates/base-nginx.conf.template.ts | 10 - .../templates/dockerfile-compiled.template.ts | 9 - .../src/templates/dockerfile.template.ts | 9 - .../src/templates/nginx.conf.template.ts | 10 - .../src/templates/start.template.ts | 9 - 71 files changed, 1614 insertions(+), 1133 deletions(-) delete mode 100644 packages/arui-scripts-artifacts/src/__tests__/config.test.ts delete mode 100644 packages/arui-scripts-artifacts/src/__tests__/fixtures-autodetect/arui-scripts-artifacts.ts delete mode 100644 packages/arui-scripts-artifacts/src/__tests__/fixtures/cjs-config.cjs delete mode 100644 packages/arui-scripts-artifacts/src/__tests__/fixtures/fn-config.mjs rename packages/arui-scripts-artifacts/src/{ => archive}/build-archive.ts (79%) create mode 100644 packages/arui-scripts-artifacts/src/archive/constants.ts create mode 100644 packages/arui-scripts-artifacts/src/archive/index.ts rename packages/arui-scripts-artifacts/src/{ => cli}/__tests__/cli.test.ts (96%) rename packages/arui-scripts-artifacts/src/{ => cli}/__tests__/config-file.test.ts (59%) create mode 100644 packages/arui-scripts-artifacts/src/cli/__tests__/fixtures-autodetect/arui-scripts-artifacts.ts create mode 100644 packages/arui-scripts-artifacts/src/cli/__tests__/fixtures/cjs-config.cjs create mode 100644 packages/arui-scripts-artifacts/src/cli/__tests__/fixtures/fn-config.mjs rename packages/arui-scripts-artifacts/src/{ => cli}/__tests__/fixtures/ts-config.ts (57%) rename packages/arui-scripts-artifacts/src/{ => cli}/__tests__/load-config-file.test.ts (82%) rename packages/arui-scripts-artifacts/src/{ => cli}/config-file.ts (66%) rename packages/arui-scripts-artifacts/src/{cli.ts => cli/create-cli.ts} (99%) create mode 100644 packages/arui-scripts-artifacts/src/cli/index.ts rename packages/arui-scripts-artifacts/src/{ => cli}/load-config-file.ts (100%) delete mode 100644 packages/arui-scripts-artifacts/src/config.ts create mode 100644 packages/arui-scripts-artifacts/src/config/__tests__/config.test.ts create mode 100644 packages/arui-scripts-artifacts/src/config/index.ts rename packages/arui-scripts-artifacts/src/{ => config}/types.ts (58%) rename packages/arui-scripts-artifacts/src/{ => docker}/__tests__/docker-build.test.ts (78%) create mode 100644 packages/arui-scripts-artifacts/src/docker/build-command.ts rename packages/arui-scripts-artifacts/src/{ => docker}/build-docker-image.ts (83%) create mode 100644 packages/arui-scripts-artifacts/src/docker/build-params.ts rename packages/arui-scripts-artifacts/src/{ => docker}/constants.ts (57%) create mode 100644 packages/arui-scripts-artifacts/src/docker/index.ts create mode 100644 packages/arui-scripts-artifacts/src/docker/prepare-files.ts rename packages/arui-scripts-artifacts/src/{ => docker}/templates/dockerfile-compiled.template.ts (87%) rename packages/arui-scripts-artifacts/src/{ => docker}/templates/dockerfile.template.ts (82%) create mode 100644 packages/arui-scripts-artifacts/src/nginx/constants.ts create mode 100644 packages/arui-scripts-artifacts/src/nginx/index.ts rename packages/arui-scripts-artifacts/src/{ => nginx}/templates/base-nginx.conf.template.ts (77%) rename packages/arui-scripts-artifacts/src/{ => nginx}/templates/nginx.conf.template.ts (71%) rename packages/arui-scripts-artifacts/src/{ => pipeline}/__tests__/render.test.ts (74%) rename packages/arui-scripts-artifacts/src/{ => pipeline}/build-artifact.ts (89%) rename packages/arui-scripts-artifacts/src/{ => pipeline}/host-pipeline.ts (71%) create mode 100644 packages/arui-scripts-artifacts/src/pipeline/index.ts rename packages/arui-scripts-artifacts/src/{ => pipeline}/render.ts (56%) create mode 100644 packages/arui-scripts-artifacts/src/start-script/constants.ts create mode 100644 packages/arui-scripts-artifacts/src/start-script/index.ts rename packages/arui-scripts-artifacts/src/{templates => start-script}/start.template.ts (89%) delete mode 100644 packages/arui-scripts-artifacts/src/templates/index.ts delete mode 100644 packages/arui-scripts-artifacts/src/utils/docker-build.ts create mode 100644 packages/arui-scripts-artifacts/src/utils/shell.ts create mode 100644 packages/arui-scripts/src/commands/util/__tests__/artifacts-deprecations.tests.ts create mode 100644 packages/arui-scripts/src/commands/util/artifacts-deprecations.ts delete mode 100644 packages/arui-scripts/src/templates/base-nginx.conf.template.ts delete mode 100644 packages/arui-scripts/src/templates/dockerfile-compiled.template.ts delete mode 100644 packages/arui-scripts/src/templates/dockerfile.template.ts delete mode 100644 packages/arui-scripts/src/templates/nginx.conf.template.ts delete mode 100644 packages/arui-scripts/src/templates/start.template.ts diff --git a/.changeset/docker-package-extraction.md b/.changeset/docker-package-extraction.md index 2658d9cf..fc86fb2e 100644 --- a/.changeset/docker-package-extraction.md +++ b/.changeset/docker-package-extraction.md @@ -21,6 +21,15 @@ dev-зависимостей) у них общий. Попутно починен `archive-build`: он падал с `TypeError: Cannot read properties of undefined (reading 'c')`, потому что в `tar@7` нет default-экспорта, а код использовал `import tar from 'tar'`. +Настройки сборки артефактов в конфиге arui-scripts (`dockerRegistry`, `baseDockerImage`, +`nginxRootPath`, `nginx`, `runFromNonRootUser`, `removeDevDependenciesDuringDockerBuild`, +`archiveName`, `additionalBuildPath`) и оверрайды `Dockerfile`, `DockerfileCompiled`, `nginx`, +`nginxConf`, `start.sh` объявлены устаревшими: они продолжают работать, но команды сборки печатают +предупреждение со ссылкой на замену, а в следующей мажорной версии будут удалены. + +Настройки в конфиге @alfalab/scripts-artifacts сгруппированы по доменам: `docker`, `nginx`, `archive`, `build`, +`packageManager`, `localFiles`. Так же разложен и код пакета — по папке на домен. + Единственное отличие в поведении: `archive-build` теперь подхватывает локальный `start.sh` из корня проекта так же, как уже подхватывал `nginx.conf` (раньше игнорировал). Отключается опцией -`allowLocalStartScript: false`. +`localFiles.allowStartScript: false`. diff --git a/packages/arui-scripts-artifacts/README.md b/packages/arui-scripts-artifacts/README.md index 1469fe0c..ce67eb14 100644 --- a/packages/arui-scripts-artifacts/README.md +++ b/packages/arui-scripts-artifacts/README.md @@ -19,9 +19,13 @@ yarn add -D @alfalab/scripts-artifacts import { defineConfig } from '@alfalab/scripts-artifacts'; export default defineConfig({ - baseDockerImage: 'alfabankui/arui-scripts:24.10.0-slim', - dockerRegistry: 'registry.example.com', - nginx: { workerProcesses: 4 }, + docker: { + baseImage: 'alfabankui/arui-scripts:24.10.0-slim', + registry: 'registry.example.com', + }, + nginx: { + baseConf: { workerProcesses: 4 }, + }, }); ``` @@ -36,6 +40,32 @@ export default defineConfig({ } ``` +## Структура конфига + +Настройки сгруппированы по тому, к чему относятся. На верхнем уровне остается только то, что общее +для всех артефактов, — идентификация и форма самого приложения: + +| Секция | За что отвечает | +| ---------------- | -------------------------------------------------------------------------------- | +| _верхний уровень_ | `artifact`, `name`, `version`, `cwd`, `debug`, `clientOnly`, `buildPath`, `serverOutput`, `serverPort`, `assetsPath`, `publicPath` | +| `docker` | `variant`, `registry`, `baseImage`, `runFromNonRootUser`, `context`, `tempDirName`, `push`, `platform`, `buildArgs`, `addNodeModulesToDockerIgnore` | +| `nginx` | `port`, `rootPath`, `enablePreviousVersionHeaders`, `baseConf` (http-блок) | +| `archive` | `name`, `tempDirName`, `additionalPaths` | +| `build` | хост-пайплайн: `cleanBuildPath`, `command`, `removeDevDependencies` | +| `packageManager` | `useYarn`, `yarnVersion`, `installProductionCommand`, `pruneCommand` | +| `localFiles` | пути до `dockerfile`/`startScript`/`nginxConf`/`nginxBaseConf` и флаги `allowDockerfile`/`allowStartScript` | +| `templates`, `overrides` | кастомизация шаблонов (см. ниже) | + +Все поля опциональны — недостающие донасыщает `resolveArtifactsConfig`. Дефолты совпадают с +историческим поведением `arui-scripts`, поэтому конфиг без единой настройки соберет тот же образ, +что и `arui-scripts docker-build`. + +`nginx.baseConf` по умолчанию `null` — базовый конфиг не генерируется и не кладется в артефакт +(используется тот, что лежит в базовом образе). Любой объект включает его; `false`/`null` — выключает. + +Код пакета разложен по тем же доменам: `src/docker`, `src/nginx`, `src/archive`, `src/start-script`, +`src/config`, `src/pipeline`, `src/cli`. + ## CLI ```bash @@ -76,29 +106,29 @@ CLI сам подхватывает лежащие в корне `Dockerfile`, ` import { defineConfig } from '@alfalab/scripts-artifacts'; export default defineConfig({ - baseDockerImage: 'registry.example.com/base:2.0.0', - nginx: { workerProcesses: 4 }, + docker: { baseImage: 'registry.example.com/base:2.0.0' }, + nginx: { baseConf: { workerProcesses: 4 } }, commands: { // серверный образ: свой энтрипоинт и порт, всё остальное — из верхнего уровня 'docker-build:server': { - variant: 'compiled', + docker: { variant: 'compiled' }, serverOutput: 'server/index.js', - clientServerPort: 9090, - nginx: { workerConnections: 100 }, // сольется с верхнеуровневым workerProcesses + // сольется с верхнеуровневой секцией nginx + nginx: { port: 9090, baseConf: { workerConnections: 100 } }, }, // образ только со статикой 'docker-build:static': { clientOnly: true, - buildCommand: 'npm run build:static', + build: { command: 'npm run build:static' }, }, // еще один архив — с отдельным именем и своей сборкой 'archive-build:e2e': { artifact: 'archive', - archiveName: 'e2e.tar', - buildCommand: 'npm run build:e2e', + archive: { name: 'e2e.tar' }, + build: { command: 'npm run build:e2e' }, }, }, }); @@ -110,15 +140,15 @@ arui-scripts-artifacts docker-build:server Запуск без команды печатает список доступных — встроенные плюс объявленные в конфиге. -Вложенные объекты (`nginx`, `localFiles`, `templates`, `overrides`, `extraBuildArgs`) сливаются по -полям, остальные опции заменяются целиком. +Секции (`docker`, `nginx`, `archive`, `build`, `packageManager`, `localFiles`, `templates`, +`overrides`) сливаются по полям, скалярные опции заменяются целиком. ## Кастомизация файлов образа Каждый файл настраивается на четырех уровнях, по возрастанию приоритета: -1. **Опции** — `baseDockerImage`, `clientOnly`, `runFromNonRootUser`, `nginx`, `platform`, - `extraBuildArgs`, `buildPath`, `serverOutput`, `publicPath` и остальные из `DockerBuildOptions`. +1. **Опции** — секции `docker`, `nginx` и верхнеуровневые `clientOnly`, `buildPath`, `serverOutput`, + `publicPath` и остальные из `ArtifactsOptions`. 2. **`templates`** — полная замена рендерера: `(config) => string`. 3. **`overrides`** — точечная функция поверх сгенерированного: `(generated, config) => string`. 4. **Локальные файлы** — `Dockerfile`, `start.sh`, `nginx.conf`, `base-nginx.conf` в корне проекта. @@ -127,7 +157,7 @@ arui-scripts-artifacts docker-build:server (http-блок), `startScript`. ```ts -import { defineConfig, renderNginxConf } from '@alfalab/scripts-artifacts'; +import { defineConfig } from '@alfalab/scripts-artifacts'; export default defineConfig({ // целиком свой server-блок nginx @@ -136,7 +166,7 @@ export default defineConfig({ client_max_body_size 20m; server { - listen ${config.clientServerPort}; + listen ${config.nginx.port}; location = /health { return 200 ''; } location / { proxy_pass http://127.0.0.1:${config.serverPort}; } }`, @@ -163,11 +193,13 @@ server { import { buildArtifact } from '@alfalab/scripts-artifacts'; await buildArtifact({ - variant: 'compiled', name: 'my-app', version: '1.0.0', - dockerRegistry: 'registry.example.com', - extraBuildArgs: { COMMIT_SHA: process.env.COMMIT_SHA ?? '' }, + docker: { + variant: 'compiled', + registry: 'registry.example.com', + buildArgs: { COMMIT_SHA: process.env.COMMIT_SHA ?? '' }, + }, }); ``` @@ -179,12 +211,15 @@ import { getBuildParams, getDockerBuildCommand, prepareFilesForDocker, - renderDockerTemplates, - resolveDockerConfig, + renderTemplates, + resolveArtifactsConfig, } from '@alfalab/scripts-artifacts'; -const config = resolveDockerConfig({ variant: 'compiled', serverOutput: 'server/index.js' }); -const templates = renderDockerTemplates({ config }); +const config = resolveArtifactsConfig({ + serverOutput: 'server/index.js', + docker: { variant: 'compiled' }, +}); +const templates = renderTemplates({ config }); const { restoreDockerIgnore } = await prepareFilesForDocker({ config, templates }); @@ -196,10 +231,14 @@ await exec(`docker push ${getBuildParams(config).imageFullName}`); Разбор конфига тоже доступен отдельно — так CLI можно встроить в свой: ```ts -import { resolveCommandOptions, resolveConfigFile, parseArgv } from '@alfalab/scripts-artifacts'; +import { + extractConfigPath, + resolveCommandOptions, + resolveConfigFile, +} from '@alfalab/scripts-artifacts'; -const { configPath, rest } = parseArgv(process.argv.slice(3)); -const configFile = await resolveConfigFile(process.cwd(), configPath); +const argv = process.argv.slice(2); +const configFile = await resolveConfigFile(process.cwd(), extractConfigPath(argv)); const options = resolveCommandOptions('docker-build:server', configFile); ``` @@ -208,33 +247,41 @@ const options = resolveCommandOptions('docker-build:server', configFile); Хост-пайплайн общий для образа и архива, поэтому оба собираются из одного состояния проекта. Какие шаги выполнятся — зависит от типа артефакта, варианта и опций: -| Шаг | Опция | Дефолт `runtime` | Дефолт `compiled` | -| ------------------------- | ---------------------------------------- | ----------------- | ----------------- | -| очистка `buildPath` | `cleanBuildPath` | `true` | `false` | -| хук перед сборкой | `beforeBuild` | — | — | -| сборка приложения | `buildCommand` | `'npm run build'` | `null` | -| удаление dev-зависимостей | `removeDevDependencies` / `pruneCommand` | `true` | `false` | -| `docker build` / `tar` | — | всегда | всегда | -| `docker push` | `push` | `!debug` | `!debug` | +| Шаг | Опция | Дефолт `runtime` | Дефолт `compiled` | +| ------------------------- | ------------------------------------------------- | ----------------- | ----------------- | +| очистка `buildPath` | `build.cleanBuildPath` | `true` | `false` | +| хук перед сборкой | `beforeBuild` | — | — | +| сборка приложения | `build.command` | `'npm run build'` | `null` | +| удаление dev-зависимостей | `build.removeDevDependencies` / `packageManager.pruneCommand` | `true` | `false` | +| `docker build` / `tar` | — | всегда | всегда | +| `docker push` | `docker.push` | `!debug` | `!debug` | Для `artifact: 'archive'` хост-пайплайн включен всегда (внутри tar-а собирать нечего), поэтому -`variant` на него не влияет. +`docker.variant` на него не влияет. ## Миграция с `arui-scripts docker-build` -Поведение и шаблоны совпадают, отличаются имена настроек: - -| arui-scripts | @alfalab/scripts-artifacts | -| ------------------------------------------------------------ | ------------------------------------ | -| `configs.removeDevDependenciesDuringDockerBuild` | `removeDevDependencies` | -| `configs.archiveName`, `configs.additionalBuildPath` | `archiveName`, `additionalBuildPath` | -| `configs.dictionaryCompression.enablePreviousVersionHeaders` | `enablePreviousVersionHeaders` | -| `configs.localDockerfile` и соседние | `localFiles.dockerfile` и соседние | -| оверрайд `Dockerfile` | `overrides.dockerfile` | -| оверрайд `DockerfileCompiled` | `overrides.dockerfileCompiled` | -| оверрайд `nginx` (server-блок) | `overrides.nginxConf` | -| оверрайд `nginxConf` (базовый http-блок) | `overrides.baseNginxConf` | -| оверрайд `start.sh` | `overrides.startScript` | +Поведение и шаблоны совпадают, отличаются имена и расположение настроек: + +| arui-scripts | @alfalab/scripts-artifacts | +| ------------------------------------------------------------ | -------------------------------------- | +| `configs.dockerRegistry` | `docker.registry` | +| `configs.baseDockerImage` | `docker.baseImage` | +| `configs.runFromNonRootUser` | `docker.runFromNonRootUser` | +| `configs.clientServerPort` | `nginx.port` | +| `configs.nginxRootPath` | `nginx.rootPath` | +| `configs.nginx` (настройки базового конфига) | `nginx.baseConf` | +| `configs.dictionaryCompression.enablePreviousVersionHeaders` | `nginx.enablePreviousVersionHeaders` | +| `configs.archiveName` | `archive.name` | +| `configs.additionalBuildPath` | `archive.additionalPaths` | +| `configs.removeDevDependenciesDuringDockerBuild` | `build.removeDevDependencies` | +| `configs.useYarn` | `packageManager.useYarn` | +| `configs.localDockerfile` и соседние | `localFiles.dockerfile` и соседние | +| оверрайд `Dockerfile` | `overrides.dockerfile` | +| оверрайд `DockerfileCompiled` | `overrides.dockerfileCompiled` | +| оверрайд `nginx` (server-блок) | `overrides.nginxConf` | +| оверрайд `nginxConf` (базовый http-блок) | `overrides.baseNginxConf` | +| оверрайд `start.sh` | `overrides.startScript` | ⚠️ В `arui-scripts` имена `nginx` и `nginxConf` исторически перепутаны: `nginx` — это server-блок, а `nginxConf` — базовый конфиг. Здесь они названы по смыслу, поэтому **`nginxConf` в двух пакетах @@ -248,4 +295,4 @@ const options = resolveCommandOptions('docker-build:server', configFile); Старый `arui-scripts archive-build` подхватывал локальный `nginx.conf`, но игнорировал локальный `start.sh`. Здесь локальные файлы обрабатываются единообразно: `start.sh` из корня проекта тоже -используется. Отключается через `allowLocalStartScript: false`. +используется. Отключается через `localFiles.allowStartScript: false`. diff --git a/packages/arui-scripts-artifacts/src/__tests__/config.test.ts b/packages/arui-scripts-artifacts/src/__tests__/config.test.ts deleted file mode 100644 index dcde2d06..00000000 --- a/packages/arui-scripts-artifacts/src/__tests__/config.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { resolveArtifactsConfig } from '../config'; - -describe('resolveArtifactsConfig', () => { - it('should fill defaults matching arui-scripts historical behaviour', () => { - const config = resolveArtifactsConfig({ cwd: __dirname }); - - expect(config.baseDockerImage).toBe('alfabankui/arui-scripts:24.10.0-slim'); - expect(config.buildPath).toBe('.build'); - expect(config.serverOutput).toBe('server.js'); - expect(config.nginxRootPath).toBe('/src'); - expect(config.clientServerPort).toBe(8080); - expect(config.serverPort).toBe(3000); - expect(config.runFromNonRootUser).toBe(true); - expect(config.clientOnly).toBe(false); - expect(config.tempDirName).toBe('.docker-build'); - expect(config.platform).toBe('auto'); - expect(config.nginx).toBeNull(); - // arui-scripts считает publicPath как `${assetsPath}/` - expect(config.assetsPath).toBe('assets'); - expect(config.publicPath).toBe('assets/'); - }); - - it('should derive publicPath from a custom assetsPath', () => { - expect(resolveArtifactsConfig({ cwd: __dirname, assetsPath: 'static' }).publicPath).toBe( - 'static/', - ); - expect( - resolveArtifactsConfig({ cwd: __dirname, assetsPath: 'static', publicPath: 'cdn/' }) - .publicPath, - ).toBe('cdn/'); - }); - - it('should default the host pipeline per variant', () => { - const runtime = resolveArtifactsConfig({ cwd: __dirname }); - - expect(runtime.variant).toBe('runtime'); - expect(runtime.cleanBuildPath).toBe(true); - expect(runtime.buildCommand).toBe('npm run build'); - expect(runtime.removeDevDependencies).toBe(true); - - const compiled = resolveArtifactsConfig({ cwd: __dirname, variant: 'compiled' }); - - expect(compiled.cleanBuildPath).toBe(false); - expect(compiled.buildCommand).toBeNull(); - expect(compiled.removeDevDependencies).toBe(false); - }); - - it('should default archive options and always build on the host for archives', () => { - const archive = resolveArtifactsConfig({ cwd: __dirname, artifact: 'archive' }); - - expect(archive.archiveName).toBe('build.tar'); - expect(archive.additionalBuildPath).toEqual(['config']); - expect(archive.tempDirName).toBe('.archive-build'); - // в tar нечего собирать «внутри», поэтому хост-пайплайн включен даже с variant: compiled - expect(archive.buildCommand).toBe('npm run build'); - expect(archive.removeDevDependencies).toBe(true); - expect(archive.cleanBuildPath).toBe(true); - }); - - it('should keep the docker temp dir for docker artifacts', () => { - expect(resolveArtifactsConfig({ cwd: __dirname }).tempDirName).toBe('.docker-build'); - expect( - resolveArtifactsConfig({ cwd: __dirname, artifact: 'archive', tempDirName: '.custom' }) - .tempDirName, - ).toBe('.custom'); - }); - - it('should allow disabling the host build explicitly', () => { - expect( - resolveArtifactsConfig({ cwd: __dirname, buildCommand: false }).buildCommand, - ).toBeNull(); - expect( - resolveArtifactsConfig({ cwd: __dirname, variant: 'compiled', buildCommand: 'make' }) - .buildCommand, - ).toBe('make'); - }); - - it('should not push by default in debug mode', () => { - expect(resolveArtifactsConfig({ cwd: __dirname, debug: true }).push).toBe(false); - expect(resolveArtifactsConfig({ cwd: __dirname, debug: false }).push).toBe(true); - }); - - it('should allow explicit push override even in debug mode', () => { - expect(resolveArtifactsConfig({ cwd: __dirname, debug: true, push: true }).push).toBe(true); - }); - - it('should normalize nginx: false to null', () => { - expect(resolveArtifactsConfig({ cwd: __dirname, nginx: false }).nginx).toBeNull(); - expect( - resolveArtifactsConfig({ cwd: __dirname, nginx: { workerProcesses: 4 } }).nginx, - ).toEqual({ workerProcesses: 4 }); - }); - - it('should keep falsy but valid values (empty registry, port 0)', () => { - const config = resolveArtifactsConfig({ - cwd: __dirname, - dockerRegistry: '', - serverPort: 0, - }); - - expect(config.dockerRegistry).toBe(''); - expect(config.serverPort).toBe(0); - }); - - it('should respect explicit yarnVersion and derived commands', () => { - const config = resolveArtifactsConfig({ cwd: __dirname, yarnVersion: '2+' }); - - expect(config.installProductionCommand).toBe('yarn workspaces focus --production --all'); - }); -}); diff --git a/packages/arui-scripts-artifacts/src/__tests__/fixtures-autodetect/arui-scripts-artifacts.ts b/packages/arui-scripts-artifacts/src/__tests__/fixtures-autodetect/arui-scripts-artifacts.ts deleted file mode 100644 index 376693c3..00000000 --- a/packages/arui-scripts-artifacts/src/__tests__/fixtures-autodetect/arui-scripts-artifacts.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineConfig } from '../../config-file'; - -export default defineConfig({ baseDockerImage: 'autodetect/base:1.0.0' }); diff --git a/packages/arui-scripts-artifacts/src/__tests__/fixtures/cjs-config.cjs b/packages/arui-scripts-artifacts/src/__tests__/fixtures/cjs-config.cjs deleted file mode 100644 index 2d5f831a..00000000 --- a/packages/arui-scripts-artifacts/src/__tests__/fixtures/cjs-config.cjs +++ /dev/null @@ -1 +0,0 @@ -module.exports = { baseDockerImage: 'fixture/cjs:3.0.0' }; diff --git a/packages/arui-scripts-artifacts/src/__tests__/fixtures/fn-config.mjs b/packages/arui-scripts-artifacts/src/__tests__/fixtures/fn-config.mjs deleted file mode 100644 index 2c0f83ff..00000000 --- a/packages/arui-scripts-artifacts/src/__tests__/fixtures/fn-config.mjs +++ /dev/null @@ -1 +0,0 @@ -export default async () => ({ baseDockerImage: 'fixture/fn:2.0.0' }); diff --git a/packages/arui-scripts-artifacts/src/build-archive.ts b/packages/arui-scripts-artifacts/src/archive/build-archive.ts similarity index 79% rename from packages/arui-scripts-artifacts/src/build-archive.ts rename to packages/arui-scripts-artifacts/src/archive/build-archive.ts index f36c5839..c2935302 100644 --- a/packages/arui-scripts-artifacts/src/build-archive.ts +++ b/packages/arui-scripts-artifacts/src/archive/build-archive.ts @@ -3,24 +3,24 @@ import path from 'path'; import fs from 'fs-extra'; import { create as createTar } from 'tar'; -import { resolveArtifactsConfig } from './config'; -import { NGINX_CONFIG_FILENAME } from './constants'; -import { type BeforeBuildHook, runHostPipeline } from './host-pipeline'; -import { renderTemplates } from './render'; -import { type ArtifactsOptions, type ResolvedArtifactsConfig } from './types'; +import { resolveArtifactsConfig } from '../config'; +import { type ArtifactsOptions, type ResolvedArtifactsConfig } from '../config/types'; +import { NGINX_CONFIG_FILENAME } from '../nginx/constants'; +import { type BeforeBuildHook, runHostPipeline } from '../pipeline/host-pipeline'; +import { renderTemplates } from '../pipeline/render'; +import { START_SCRIPT_FILENAME } from '../start-script/constants'; export type BuildArchiveOptions = ArtifactsOptions & { /** Хук, вызываемый после очистки `buildPath`, но до сборки приложения. */ beforeBuild?: BeforeBuildHook; }; -const START_SCRIPT_FILENAME = 'start.sh'; const NODE_MODULES_DIR_NAME = 'node_modules'; const PACKAGE_JSON_FILENAME = 'package.json'; /** * Собирает tar-архив с production-сборкой: nginx-конфиг, start.sh, `buildPath`, `node_modules`, - * `package.json` и дополнительные директории из `additionalBuildPath`. + * `package.json` и дополнительные директории из `archive.additionalPaths`. * * Хост-пайплайн (очистка, сборка, удаление dev-зависимостей) — тот же, что у docker-образа, поэтому * архив и образ собираются из одного и того же состояния проекта. @@ -33,20 +33,11 @@ export async function buildArchive(options: BuildArchiveOptions = {}): Promise + ...archive.additionalPaths.map((additionalPath) => fs.copy(path.join(cwd, additionalPath), path.join(pathToTempDir, additionalPath)), ), ]); - await createTar({ file: archiveName, cwd: pathToTempDir }, fs.readdirSync(pathToTempDir)); + await createTar({ file: archive.name, cwd: pathToTempDir }, fs.readdirSync(pathToTempDir)); console.timeEnd('Archive build time'); console.time('Cleanup time'); diff --git a/packages/arui-scripts-artifacts/src/archive/constants.ts b/packages/arui-scripts-artifacts/src/archive/constants.ts new file mode 100644 index 00000000..6a707271 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/archive/constants.ts @@ -0,0 +1,8 @@ +/** Имя tar-архива по умолчанию. */ +export const DEFAULT_ARCHIVE_NAME = 'build.tar'; + +/** Имя временной директории, в которой собирается содержимое tar-архива. */ +export const DEFAULT_ARCHIVE_TEMP_DIR_NAME = '.archive-build'; + +/** Директории проекта, которые кладутся в архив рядом со сборкой. */ +export const DEFAULT_ARCHIVE_ADDITIONAL_PATHS = ['config']; diff --git a/packages/arui-scripts-artifacts/src/archive/index.ts b/packages/arui-scripts-artifacts/src/archive/index.ts new file mode 100644 index 00000000..3d84e4fa --- /dev/null +++ b/packages/arui-scripts-artifacts/src/archive/index.ts @@ -0,0 +1,6 @@ +export { buildArchive, type BuildArchiveOptions } from './build-archive'; +export { + DEFAULT_ARCHIVE_ADDITIONAL_PATHS, + DEFAULT_ARCHIVE_NAME, + DEFAULT_ARCHIVE_TEMP_DIR_NAME, +} from './constants'; diff --git a/packages/arui-scripts-artifacts/src/bin/index.ts b/packages/arui-scripts-artifacts/src/bin/index.ts index 1349ac39..6de8f1fb 100644 --- a/packages/arui-scripts-artifacts/src/bin/index.ts +++ b/packages/arui-scripts-artifacts/src/bin/index.ts @@ -3,12 +3,14 @@ import path from 'path'; import fs from 'fs-extra'; -import { buildArtifact } from '../build-artifact'; -import { createCli, extractConfigPath } from '../cli'; -import { resolveCommandOptions } from '../config-file'; -import { BASE_NGINX_CONFIG_FILENAME, NGINX_CONFIG_FILENAME } from '../constants'; -import { resolveConfigFile } from '../load-config-file'; -import { type ArtifactsOptions } from '../types'; +import { resolveCommandOptions } from '../cli/config-file'; +import { createCli, extractConfigPath } from '../cli/create-cli'; +import { resolveConfigFile } from '../cli/load-config-file'; +import { type LocalFilesOptions } from '../config/types'; +import { DOCKERFILE_FILENAME } from '../docker/constants'; +import { BASE_NGINX_CONFIG_FILENAME, NGINX_CONFIG_FILENAME } from '../nginx/constants'; +import { buildArtifact } from '../pipeline/build-artifact'; +import { START_SCRIPT_FILENAME } from '../start-script/constants'; // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires const { version } = require('../../package.json'); @@ -17,7 +19,7 @@ const { version } = require('../../package.json'); * Локальные файлы проекта, замещающие сгенерированные шаблоны, если они лежат в корне проекта. * Явно заданные в конфиге `localFiles` имеют приоритет над автодетектом. */ -function detectLocalFiles(cwd: string): ArtifactsOptions['localFiles'] { +function detectLocalFiles(cwd: string): LocalFilesOptions { const resolveIfExists = (fileName: string) => { const filePath = path.join(cwd, fileName); @@ -25,8 +27,8 @@ function detectLocalFiles(cwd: string): ArtifactsOptions['localFiles'] { }; return { - dockerfile: resolveIfExists('Dockerfile'), - startScript: resolveIfExists('start.sh'), + dockerfile: resolveIfExists(DOCKERFILE_FILENAME), + startScript: resolveIfExists(START_SCRIPT_FILENAME), nginxConf: resolveIfExists(NGINX_CONFIG_FILENAME), nginxBaseConf: resolveIfExists(BASE_NGINX_CONFIG_FILENAME), }; diff --git a/packages/arui-scripts-artifacts/src/__tests__/cli.test.ts b/packages/arui-scripts-artifacts/src/cli/__tests__/cli.test.ts similarity index 96% rename from packages/arui-scripts-artifacts/src/__tests__/cli.test.ts rename to packages/arui-scripts-artifacts/src/cli/__tests__/cli.test.ts index 9730bc22..70fa12df 100644 --- a/packages/arui-scripts-artifacts/src/__tests__/cli.test.ts +++ b/packages/arui-scripts-artifacts/src/cli/__tests__/cli.test.ts @@ -1,7 +1,7 @@ import { type Command } from 'commander'; -import { createCli, extractConfigPath, type RunCommandParams } from '../cli'; import { type ArtifactsConfigFile } from '../config-file'; +import { createCli, extractConfigPath, type RunCommandParams } from '../create-cli'; describe('extractConfigPath', () => { it('should return undefined when the flag is absent', () => { @@ -72,7 +72,7 @@ describe('createCli', () => { it('should register commands declared in the project config', async () => { const { program, runs } = setup({ - commands: { 'docker-build:server': { variant: 'compiled' } }, + commands: { 'docker-build:server': { docker: { variant: 'compiled' } } }, }); await program.parseAsync(['docker-build:server'], { from: 'user' }); diff --git a/packages/arui-scripts-artifacts/src/__tests__/config-file.test.ts b/packages/arui-scripts-artifacts/src/cli/__tests__/config-file.test.ts similarity index 59% rename from packages/arui-scripts-artifacts/src/__tests__/config-file.test.ts rename to packages/arui-scripts-artifacts/src/cli/__tests__/config-file.test.ts index d3d6686d..61938e27 100644 --- a/packages/arui-scripts-artifacts/src/__tests__/config-file.test.ts +++ b/packages/arui-scripts-artifacts/src/cli/__tests__/config-file.test.ts @@ -7,15 +7,13 @@ describe('resolveCommandOptions', () => { it('should provide built-in commands without any config', () => { expect(resolveCommandOptions('docker-build', {})).toMatchObject({ - variant: 'runtime', - allowLocalDockerfile: true, - addNodeModulesToDockerIgnore: false, + docker: { variant: 'runtime', addNodeModulesToDockerIgnore: false }, + localFiles: { allowDockerfile: true }, }); expect(resolveCommandOptions('docker-build:compiled', {})).toMatchObject({ - variant: 'compiled', - allowLocalDockerfile: false, - addNodeModulesToDockerIgnore: true, + docker: { variant: 'compiled', addNodeModulesToDockerIgnore: true }, + localFiles: { allowDockerfile: false }, }); }); @@ -25,22 +23,21 @@ describe('resolveCommandOptions', () => { it('should let a project declare an extra archive command', () => { const options = resolveCommandOptions('archive-build:e2e', { - archiveName: 'build.tar', + archive: { name: 'build.tar' }, commands: { - 'archive-build:e2e': { artifact: 'archive' as const, archiveName: 'e2e.tar' }, + 'archive-build:e2e': { artifact: 'archive' as const, archive: { name: 'e2e.tar' } }, }, }); - expect(options).toMatchObject({ artifact: 'archive', archiveName: 'e2e.tar' }); + expect(options).toMatchObject({ artifact: 'archive', archive: { name: 'e2e.tar' } }); }); it('should apply shared config options to every command', () => { - const configFile = { baseDockerImage: 'my/base:1.0.0', serverPort: 4000 }; + const configFile = { docker: { baseImage: 'my/base:1.0.0' }, serverPort: 4000 }; expect(resolveCommandOptions('docker-build', configFile)).toMatchObject({ - baseDockerImage: 'my/base:1.0.0', + docker: { baseImage: 'my/base:1.0.0', variant: 'runtime' }, serverPort: 4000, - variant: 'runtime', }); }); @@ -50,57 +47,65 @@ describe('resolveCommandOptions', () => { commands: { 'docker-build:compiled': { serverPort: 5000 } }, }); - expect(options).toMatchObject({ serverPort: 5000, variant: 'compiled' }); + expect(options).toMatchObject({ serverPort: 5000, docker: { variant: 'compiled' } }); }); it('should let the config override built-in command defaults', () => { const options = resolveCommandOptions('docker-build:compiled', { - commands: { 'docker-build:compiled': { allowLocalDockerfile: true } }, + commands: { 'docker-build:compiled': { localFiles: { allowDockerfile: true } } }, }); - expect(options).toMatchObject({ allowLocalDockerfile: true, variant: 'compiled' }); + expect(options).toMatchObject({ + localFiles: { allowDockerfile: true }, + docker: { variant: 'compiled' }, + }); }); it('should support fully custom commands declared in the config', () => { const configFile = { - baseDockerImage: 'my/base:1.0.0', + docker: { baseImage: 'my/base:1.0.0' }, commands: { 'docker-build:server': { - variant: 'compiled' as const, + docker: { variant: 'compiled' as const }, serverOutput: 'server/index.js', - clientServerPort: 9090, + nginx: { port: 9090 }, }, }, }; expect(resolveCommandOptions('docker-build:server', configFile)).toMatchObject({ - baseDockerImage: 'my/base:1.0.0', + docker: { baseImage: 'my/base:1.0.0', variant: 'compiled' }, serverOutput: 'server/index.js', - clientServerPort: 9090, - variant: 'compiled', + nginx: { port: 9090 }, }); }); - it('should shallow-merge nested option objects instead of replacing them', () => { + it('should merge config sections field by field instead of replacing them', () => { const options = resolveCommandOptions('docker-build', { - nginx: { workerProcesses: 4, workerConnections: 100 }, - extraBuildArgs: { A: '1' }, + nginx: { port: 8080, baseConf: { workerProcesses: 4, workerConnections: 100 } }, + docker: { buildArgs: { A: '1' } }, commands: { - 'docker-build': { nginx: { workerProcesses: 8 }, extraBuildArgs: { B: '2' } }, + 'docker-build': { + nginx: { baseConf: { workerProcesses: 8 } }, + docker: { buildArgs: { B: '2' } }, + }, }, }); - expect(options?.nginx).toEqual({ workerProcesses: 8, workerConnections: 100 }); - expect(options?.extraBuildArgs).toEqual({ A: '1', B: '2' }); + expect(options?.nginx).toEqual({ + port: 8080, + baseConf: { workerProcesses: 8, workerConnections: 100 }, + }); + expect(options?.docker?.buildArgs).toEqual({ A: '1', B: '2' }); }); - it('should not merge nginx: false into an object', () => { + it('should not merge nginx.baseConf: false into an object', () => { const options = resolveCommandOptions('docker-build', { - nginx: { workerProcesses: 4 }, - commands: { 'docker-build': { nginx: false } }, + nginx: { baseConf: { workerProcesses: 4 } }, + commands: { 'docker-build': { nginx: { baseConf: false } } }, }); - expect(options?.nginx).toBe(false); + expect(options?.nginx?.baseConf).toBe(false); }); it('should not leak the commands key into build options', () => { diff --git a/packages/arui-scripts-artifacts/src/cli/__tests__/fixtures-autodetect/arui-scripts-artifacts.ts b/packages/arui-scripts-artifacts/src/cli/__tests__/fixtures-autodetect/arui-scripts-artifacts.ts new file mode 100644 index 00000000..9273b7e6 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/cli/__tests__/fixtures-autodetect/arui-scripts-artifacts.ts @@ -0,0 +1,3 @@ +import { defineConfig } from '../../config-file'; + +export default defineConfig({ docker: { baseImage: 'autodetect/base:1.0.0' } }); diff --git a/packages/arui-scripts-artifacts/src/cli/__tests__/fixtures/cjs-config.cjs b/packages/arui-scripts-artifacts/src/cli/__tests__/fixtures/cjs-config.cjs new file mode 100644 index 00000000..5aa5c4dd --- /dev/null +++ b/packages/arui-scripts-artifacts/src/cli/__tests__/fixtures/cjs-config.cjs @@ -0,0 +1 @@ +module.exports = { docker: { baseImage: 'fixture/cjs:3.0.0' } }; diff --git a/packages/arui-scripts-artifacts/src/cli/__tests__/fixtures/fn-config.mjs b/packages/arui-scripts-artifacts/src/cli/__tests__/fixtures/fn-config.mjs new file mode 100644 index 00000000..dcde2298 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/cli/__tests__/fixtures/fn-config.mjs @@ -0,0 +1 @@ +export default async () => ({ docker: { baseImage: 'fixture/fn:2.0.0' } }); diff --git a/packages/arui-scripts-artifacts/src/__tests__/fixtures/ts-config.ts b/packages/arui-scripts-artifacts/src/cli/__tests__/fixtures/ts-config.ts similarity index 57% rename from packages/arui-scripts-artifacts/src/__tests__/fixtures/ts-config.ts rename to packages/arui-scripts-artifacts/src/cli/__tests__/fixtures/ts-config.ts index 1dcf7421..5b7b6ebc 100644 --- a/packages/arui-scripts-artifacts/src/__tests__/fixtures/ts-config.ts +++ b/packages/arui-scripts-artifacts/src/cli/__tests__/fixtures/ts-config.ts @@ -1,11 +1,11 @@ import { defineConfig } from '../../config-file'; export default defineConfig({ - baseDockerImage: 'fixture/base:1.0.0', - nginx: { workerProcesses: 7 }, + docker: { baseImage: 'fixture/base:1.0.0' }, + nginx: { baseConf: { workerProcesses: 7 } }, commands: { 'docker-build:server': { - variant: 'compiled', + docker: { variant: 'compiled' }, serverOutput: 'server/index.js', }, }, diff --git a/packages/arui-scripts-artifacts/src/__tests__/load-config-file.test.ts b/packages/arui-scripts-artifacts/src/cli/__tests__/load-config-file.test.ts similarity index 82% rename from packages/arui-scripts-artifacts/src/__tests__/load-config-file.test.ts rename to packages/arui-scripts-artifacts/src/cli/__tests__/load-config-file.test.ts index 50683469..1d0d47b8 100644 --- a/packages/arui-scripts-artifacts/src/__tests__/load-config-file.test.ts +++ b/packages/arui-scripts-artifacts/src/cli/__tests__/load-config-file.test.ts @@ -9,25 +9,24 @@ describe('loadConfigFile', () => { it('should load a TypeScript config without any extra loader', async () => { const config = await loadConfigFile(path.join(fixtures, 'ts-config.ts')); - expect(config.baseDockerImage).toBe('fixture/base:1.0.0'); - expect(config.nginx).toEqual({ workerProcesses: 7 }); + expect(config.docker?.baseImage).toBe('fixture/base:1.0.0'); + expect(config.nginx).toEqual({ baseConf: { workerProcesses: 7 } }); expect(resolveCommandOptions('docker-build:server', config)).toMatchObject({ - baseDockerImage: 'fixture/base:1.0.0', + docker: { baseImage: 'fixture/base:1.0.0', variant: 'compiled' }, serverOutput: 'server/index.js', - variant: 'compiled', }); }); it('should load an ESM config exporting an async function', async () => { const config = await loadConfigFile(path.join(fixtures, 'fn-config.mjs')); - expect(config.baseDockerImage).toBe('fixture/fn:2.0.0'); + expect(config.docker?.baseImage).toBe('fixture/fn:2.0.0'); }); it('should load a CommonJS config', async () => { const config = await loadConfigFile(path.join(fixtures, 'cjs-config.cjs')); - expect(config.baseDockerImage).toBe('fixture/cjs:3.0.0'); + expect(config.docker?.baseImage).toBe('fixture/cjs:3.0.0'); }); }); diff --git a/packages/arui-scripts-artifacts/src/config-file.ts b/packages/arui-scripts-artifacts/src/cli/config-file.ts similarity index 66% rename from packages/arui-scripts-artifacts/src/config-file.ts rename to packages/arui-scripts-artifacts/src/cli/config-file.ts index b752f621..ae7017f2 100644 --- a/packages/arui-scripts-artifacts/src/config-file.ts +++ b/packages/arui-scripts-artifacts/src/cli/config-file.ts @@ -1,5 +1,5 @@ -import { type BuildArtifactOptions } from './build-artifact'; -import { type ArtifactsOptions } from './types'; +import { type ArtifactsOptions } from '../config/types'; +import { type BuildArtifactOptions } from '../pipeline/build-artifact'; /** * Содержимое `arui-scripts-artifacts.ts` — единственный источник правды для всех команд проекта. @@ -11,7 +11,8 @@ import { type ArtifactsOptions } from './types'; export type ArtifactsConfigFile = ArtifactsOptions & { /** * Именованные команды. Имя становится аргументом CLI: `arui-scripts-artifacts <имя>`. - * Встроенные `docker-build` и `docker-build:compiled` можно донасыщать или переопределять. + * Встроенные `docker-build`, `docker-build:compiled` и `archive-build` можно донасыщать или + * переопределять. */ commands?: Record; }; @@ -36,47 +37,61 @@ export function defineConfig(config: ArtifactsConfigFileExport): ArtifactsConfig export const BUILT_IN_COMMANDS: Record = { 'docker-build': { artifact: 'docker', - variant: 'runtime', - allowLocalDockerfile: true, - allowLocalStartScript: true, - addNodeModulesToDockerIgnore: false, + docker: { variant: 'runtime', addNodeModulesToDockerIgnore: false }, + localFiles: { allowDockerfile: true, allowStartScript: true }, }, 'docker-build:compiled': { artifact: 'docker', - variant: 'compiled', - allowLocalDockerfile: false, - allowLocalStartScript: false, - addNodeModulesToDockerIgnore: true, + docker: { variant: 'compiled', addNodeModulesToDockerIgnore: true }, + localFiles: { allowDockerfile: false, allowStartScript: false }, }, 'archive-build': { artifact: 'archive', }, }; -/** Ключи, которые надо сливать по полям, а не заменять целиком. */ -const SHALLOW_MERGED_KEYS = [ +/** Секции конфига — их сливаем по полям, а не заменяем целиком. */ +const MERGED_SECTIONS = [ + 'docker', 'nginx', + 'archive', + 'build', + 'packageManager', 'localFiles', 'templates', 'overrides', - 'extraBuildArgs', ] as const; +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function mergeSection( + base: Record, + patch: Record, +): Record { + const result = { ...base, ...patch }; + + // единственная вложенная в секцию секция — `nginx.baseConf`, и ее тоже ожидаемо сливать по полям. + // `baseConf: false/null` — осмысленное «не генерировать базовый конфиг», такое значение не сливаем + Object.keys(patch).forEach((key) => { + if (isPlainObject(base[key]) && isPlainObject(patch[key])) { + result[key] = { ...base[key], ...patch[key] }; + } + }); + + return result; +} + function mergeOptions(base: ArtifactsOptions, patch: ArtifactsOptions): ArtifactsOptions { const result: ArtifactsOptions = { ...base, ...patch }; - SHALLOW_MERGED_KEYS.forEach((key) => { + MERGED_SECTIONS.forEach((key) => { const baseValue = base[key]; const patchValue = patch[key]; - // `nginx: false` — осмысленное значение «не генерировать базовый конфиг», не сливаем - if ( - baseValue && - patchValue && - typeof baseValue === 'object' && - typeof patchValue === 'object' - ) { - Object.assign(result, { [key]: { ...baseValue, ...patchValue } }); + if (isPlainObject(baseValue) && isPlainObject(patchValue)) { + Object.assign(result, { [key]: mergeSection(baseValue, patchValue) }); } }); diff --git a/packages/arui-scripts-artifacts/src/cli.ts b/packages/arui-scripts-artifacts/src/cli/create-cli.ts similarity index 99% rename from packages/arui-scripts-artifacts/src/cli.ts rename to packages/arui-scripts-artifacts/src/cli/create-cli.ts index 862c85e6..7321f921 100644 --- a/packages/arui-scripts-artifacts/src/cli.ts +++ b/packages/arui-scripts-artifacts/src/cli/create-cli.ts @@ -97,7 +97,7 @@ export function createCli({ configFile, version, run }: CreateCliParams): Comman 'after', [ '', - 'Имя образа формируется как {dockerRegistry}/{name}:{version}.', + 'Имя образа формируется как {docker.registry}/{name}:{version}.', 'name и version по умолчанию берутся из package.json, но их можно переопределить:', ' arui-scripts-artifacts docker-build name=container-name version=0.1-beta', ].join('\n'), diff --git a/packages/arui-scripts-artifacts/src/cli/index.ts b/packages/arui-scripts-artifacts/src/cli/index.ts new file mode 100644 index 00000000..4808331c --- /dev/null +++ b/packages/arui-scripts-artifacts/src/cli/index.ts @@ -0,0 +1,20 @@ +export { + createCli, + extractConfigPath, + type CreateCliParams, + type RunCommandParams, +} from './create-cli'; +export { + BUILT_IN_COMMANDS, + defineConfig, + getAvailableCommands, + resolveCommandOptions, + type ArtifactsConfigFile, + type ArtifactsConfigFileExport, +} from './config-file'; +export { + CONFIG_FILE_NAMES, + findConfigFile, + loadConfigFile, + resolveConfigFile, +} from './load-config-file'; diff --git a/packages/arui-scripts-artifacts/src/load-config-file.ts b/packages/arui-scripts-artifacts/src/cli/load-config-file.ts similarity index 100% rename from packages/arui-scripts-artifacts/src/load-config-file.ts rename to packages/arui-scripts-artifacts/src/cli/load-config-file.ts diff --git a/packages/arui-scripts-artifacts/src/config.ts b/packages/arui-scripts-artifacts/src/config.ts deleted file mode 100644 index ed7a0551..00000000 --- a/packages/arui-scripts-artifacts/src/config.ts +++ /dev/null @@ -1,124 +0,0 @@ -import path from 'path'; - -import fs from 'fs-extra'; - -import { - detectUseYarn, - getInstallProductionCommand, - getPruningCommand, - getYarnVersion, -} from './utils/yarn'; -import { - DEFAULT_ARCHIVE_NAME, - DEFAULT_ARCHIVE_TEMP_DIR_NAME, - DEFAULT_BASE_DOCKER_IMAGE, - DEFAULT_TEMP_DIR_NAME, -} from './constants'; -import { type ArtifactsOptions, type ResolvedArtifactsConfig } from './types'; - -function readPackageJson(cwd: string): { name?: string; version?: string } { - try { - return fs.readJsonSync(path.join(cwd, 'package.json')); - } catch { - return {}; - } -} - -/** - * Дефолт значения флага `undefined ? fallback : value`, но с учетом того, что `false`/`0`/`''` - * — валидные значения, которые не должны затираться дефолтом. - */ -function withDefault(value: T | undefined, fallback: T): T { - return value === undefined ? fallback : value; -} - -/** - * Донасыщает частичные опции сборки полным набором значений с дефолтами. - * - * Значения по умолчанию совпадают с историческим поведением arui-scripts, поэтому вызов без - * аргументов даст такой же образ, как команда `arui-scripts docker-build`. - */ -export function resolveArtifactsConfig(options: ArtifactsOptions = {}): ResolvedArtifactsConfig { - const cwd = options.cwd ?? process.cwd(); - const pkg = readPackageJson(cwd); - - const artifact = withDefault(options.artifact, 'docker'); - const variant = withDefault(options.variant, 'runtime'); - // архив всегда собирается на хосте — внутри tar-а собирать нечего - const isRuntime = artifact === 'archive' || variant === 'runtime'; - - const clientOnly = withDefault(options.clientOnly, false); - const debug = withDefault(options.debug, false); - - const useYarn = withDefault(options.useYarn, detectUseYarn(cwd)); - const yarnVersion = options.yarnVersion ?? getYarnVersion({ useYarn }); - - const nginx = options.nginx === false ? null : withDefault(options.nginx, null); - - const assetsPath = withDefault(options.assetsPath, 'assets'); - // arui-scripts считает publicPath как `${assetsPath}/`. Пустой publicPath дал бы второй - // `location /` в nginx-конфиге и nginx не поднялся бы с `duplicate location "/"`. - const publicPath = withDefault(options.publicPath, `${assetsPath}/`); - - const buildCommand = withDefault(options.buildCommand, isRuntime ? 'npm run build' : null); - - const localFiles = { - dockerfile: options.localFiles?.dockerfile ?? null, - startScript: options.localFiles?.startScript ?? null, - nginxConf: options.localFiles?.nginxConf ?? null, - nginxBaseConf: options.localFiles?.nginxBaseConf ?? null, - }; - - return { - artifact, - variant, - - name: options.name ?? pkg.name ?? '', - version: options.version ?? pkg.version ?? '', - dockerRegistry: withDefault(options.dockerRegistry, ''), - - baseDockerImage: withDefault(options.baseDockerImage, DEFAULT_BASE_DOCKER_IMAGE), - clientOnly, - buildPath: withDefault(options.buildPath, '.build'), - serverOutput: withDefault(options.serverOutput, 'server.js'), - nginxRootPath: withDefault(options.nginxRootPath, '/src'), - assetsPath, - publicPath, - - clientServerPort: withDefault(options.clientServerPort, 8080), - serverPort: withDefault(options.serverPort, 3000), - - nginx, - enablePreviousVersionHeaders: withDefault(options.enablePreviousVersionHeaders, false), - - runFromNonRootUser: withDefault(options.runFromNonRootUser, true), - cwd, - context: withDefault(options.context, '.'), - tempDirName: withDefault( - options.tempDirName, - artifact === 'archive' ? DEFAULT_ARCHIVE_TEMP_DIR_NAME : DEFAULT_TEMP_DIR_NAME, - ), - debug, - push: withDefault(options.push, !debug), - platform: withDefault(options.platform, 'auto'), - extraBuildArgs: withDefault(options.extraBuildArgs, {}), - - cleanBuildPath: withDefault(options.cleanBuildPath, isRuntime), - buildCommand: buildCommand || null, - removeDevDependencies: withDefault(options.removeDevDependencies, isRuntime), - - archiveName: withDefault(options.archiveName, DEFAULT_ARCHIVE_NAME), - additionalBuildPath: withDefault(options.additionalBuildPath, ['config']), - - useYarn, - yarnVersion, - installProductionCommand: - options.installProductionCommand ?? getInstallProductionCommand(yarnVersion), - pruneCommand: options.pruneCommand ?? getPruningCommand({ yarnVersion, clientOnly }), - - addNodeModulesToDockerIgnore: withDefault(options.addNodeModulesToDockerIgnore, false), - allowLocalDockerfile: withDefault(options.allowLocalDockerfile, true), - allowLocalStartScript: withDefault(options.allowLocalStartScript, true), - localFiles, - }; -} diff --git a/packages/arui-scripts-artifacts/src/config/__tests__/config.test.ts b/packages/arui-scripts-artifacts/src/config/__tests__/config.test.ts new file mode 100644 index 00000000..9207eebd --- /dev/null +++ b/packages/arui-scripts-artifacts/src/config/__tests__/config.test.ts @@ -0,0 +1,149 @@ +import { resolveArtifactsConfig } from '..'; + +describe('resolveArtifactsConfig', () => { + it('should fill defaults matching arui-scripts historical behaviour', () => { + const config = resolveArtifactsConfig({ cwd: __dirname }); + + expect(config.docker.baseImage).toBe('alfabankui/arui-scripts:24.10.0-slim'); + expect(config.buildPath).toBe('.build'); + expect(config.serverOutput).toBe('server.js'); + expect(config.serverPort).toBe(3000); + expect(config.clientOnly).toBe(false); + expect(config.nginx.rootPath).toBe('/src'); + expect(config.nginx.port).toBe(8080); + expect(config.nginx.baseConf).toBeNull(); + expect(config.docker.runFromNonRootUser).toBe(true); + expect(config.docker.tempDirName).toBe('.docker-build'); + expect(config.docker.platform).toBe('auto'); + // arui-scripts считает publicPath как `${assetsPath}/` + expect(config.assetsPath).toBe('assets'); + expect(config.publicPath).toBe('assets/'); + }); + + it('should derive publicPath from a custom assetsPath', () => { + expect(resolveArtifactsConfig({ cwd: __dirname, assetsPath: 'static' }).publicPath).toBe( + 'static/', + ); + expect( + resolveArtifactsConfig({ cwd: __dirname, assetsPath: 'static', publicPath: 'cdn/' }) + .publicPath, + ).toBe('cdn/'); + }); + + it('should default the host pipeline per docker variant', () => { + const runtime = resolveArtifactsConfig({ cwd: __dirname }); + + expect(runtime.docker.variant).toBe('runtime'); + expect(runtime.build.cleanBuildPath).toBe(true); + expect(runtime.build.command).toBe('npm run build'); + expect(runtime.build.removeDevDependencies).toBe(true); + + const compiled = resolveArtifactsConfig({ + cwd: __dirname, + docker: { variant: 'compiled' }, + }); + + expect(compiled.build.cleanBuildPath).toBe(false); + expect(compiled.build.command).toBeNull(); + expect(compiled.build.removeDevDependencies).toBe(false); + }); + + it('should default archive options and always build on the host for archives', () => { + const archive = resolveArtifactsConfig({ + cwd: __dirname, + artifact: 'archive', + docker: { variant: 'compiled' }, + }); + + expect(archive.archive.name).toBe('build.tar'); + expect(archive.archive.additionalPaths).toEqual(['config']); + expect(archive.archive.tempDirName).toBe('.archive-build'); + // в tar нечего собирать «внутри», поэтому хост-пайплайн включен даже с variant: compiled + expect(archive.build.command).toBe('npm run build'); + expect(archive.build.removeDevDependencies).toBe(true); + expect(archive.build.cleanBuildPath).toBe(true); + }); + + it('should keep temp dirs of docker and archive independent', () => { + const config = resolveArtifactsConfig({ + cwd: __dirname, + artifact: 'archive', + archive: { tempDirName: '.custom' }, + }); + + expect(config.archive.tempDirName).toBe('.custom'); + expect(config.docker.tempDirName).toBe('.docker-build'); + }); + + it('should allow disabling the host build explicitly', () => { + expect( + resolveArtifactsConfig({ cwd: __dirname, build: { command: false } }).build.command, + ).toBeNull(); + expect( + resolveArtifactsConfig({ + cwd: __dirname, + docker: { variant: 'compiled' }, + build: { command: 'make' }, + }).build.command, + ).toBe('make'); + }); + + it('should not push by default in debug mode', () => { + expect(resolveArtifactsConfig({ cwd: __dirname, debug: true }).docker.push).toBe(false); + expect(resolveArtifactsConfig({ cwd: __dirname, debug: false }).docker.push).toBe(true); + }); + + it('should allow explicit push override even in debug mode', () => { + expect( + resolveArtifactsConfig({ cwd: __dirname, debug: true, docker: { push: true } }).docker + .push, + ).toBe(true); + }); + + it('should normalize nginx.baseConf: false to null and fill base conf defaults', () => { + expect( + resolveArtifactsConfig({ cwd: __dirname, nginx: { baseConf: false } }).nginx.baseConf, + ).toBeNull(); + + expect( + resolveArtifactsConfig({ cwd: __dirname, nginx: { baseConf: { workerProcesses: 4 } } }) + .nginx.baseConf, + ).toEqual({ + workerProcesses: 4, + workerRlimitNoFile: 20000, + workerConnections: 19000, + eventsUse: 'epoll', + daemon: 'off', + }); + }); + + it('should keep falsy but valid values (empty registry, port 0)', () => { + const config = resolveArtifactsConfig({ + cwd: __dirname, + serverPort: 0, + docker: { registry: '' }, + }); + + expect(config.docker.registry).toBe(''); + expect(config.serverPort).toBe(0); + }); + + it('should respect explicit yarnVersion and derived commands', () => { + const config = resolveArtifactsConfig({ + cwd: __dirname, + packageManager: { yarnVersion: '2+' }, + }); + + expect(config.packageManager.installProductionCommand).toBe( + 'yarn workspaces focus --production --all', + ); + }); + + it('should default local file substitution to allowed', () => { + const { localFiles } = resolveArtifactsConfig({ cwd: __dirname }); + + expect(localFiles.allowDockerfile).toBe(true); + expect(localFiles.allowStartScript).toBe(true); + expect(localFiles.dockerfile).toBeNull(); + }); +}); diff --git a/packages/arui-scripts-artifacts/src/config/index.ts b/packages/arui-scripts-artifacts/src/config/index.ts new file mode 100644 index 00000000..4cb66019 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/config/index.ts @@ -0,0 +1,180 @@ +import path from 'path'; + +import fs from 'fs-extra'; + +import { + DEFAULT_ARCHIVE_ADDITIONAL_PATHS, + DEFAULT_ARCHIVE_NAME, + DEFAULT_ARCHIVE_TEMP_DIR_NAME, +} from '../archive/constants'; +import { DEFAULT_BASE_DOCKER_IMAGE, DEFAULT_TEMP_DIR_NAME } from '../docker/constants'; +import { + DEFAULT_NGINX_BASE_CONF, + DEFAULT_NGINX_PORT, + DEFAULT_NGINX_ROOT_PATH, +} from '../nginx/constants'; +import { + detectUseYarn, + getInstallProductionCommand, + getPruningCommand, + getYarnVersion, +} from '../utils/yarn'; + +import { + type ArtifactsOptions, + type ResolvedArchiveConfig, + type ResolvedArtifactsConfig, + type ResolvedBuildConfig, + type ResolvedDockerConfig, + type ResolvedLocalFilesConfig, + type ResolvedNginxConfig, + type ResolvedPackageManagerConfig, +} from './types'; + +function readPackageJson(cwd: string): { name?: string; version?: string } { + try { + return fs.readJsonSync(path.join(cwd, 'package.json')); + } catch { + return {}; + } +} + +/** + * Дефолт значения флага `undefined ? fallback : value`, но с учетом того, что `false`/`0`/`''` + * — валидные значения, которые не должны затираться дефолтом. + */ +function withDefault(value: T | undefined, fallback: T): T { + return value === undefined ? fallback : value; +} + +/** + * Донасыщает частичные опции сборки полным набором значений с дефолтами. + * + * Все дефолты артефактов живут здесь (и в константах доменных папок) — потребители, включая + * arui-scripts, только транслируют то, что задал пользователь. Значения по умолчанию совпадают с + * историческим поведением arui-scripts, поэтому вызов без аргументов даст такой же образ, как + * команда `arui-scripts docker-build`. + */ +export function resolveArtifactsConfig(options: ArtifactsOptions = {}): ResolvedArtifactsConfig { + const cwd = options.cwd ?? process.cwd(); + const pkg = readPackageJson(cwd); + + const artifact = withDefault(options.artifact, 'docker'); + const clientOnly = withDefault(options.clientOnly, false); + const debug = withDefault(options.debug, false); + const buildPath = withDefault(options.buildPath, '.build'); + const assetsPath = withDefault(options.assetsPath, 'assets'); + + return { + artifact, + + name: options.name ?? pkg.name ?? '', + version: options.version ?? pkg.version ?? '', + cwd, + debug, + + clientOnly, + buildPath, + serverOutput: withDefault(options.serverOutput, 'server.js'), + serverPort: withDefault(options.serverPort, 3000), + assetsPath, + // arui-scripts считает publicPath как `${assetsPath}/`. Пустой publicPath дал бы второй + // `location /` в nginx-конфиге и nginx не поднялся бы с `duplicate location "/"`. + publicPath: withDefault(options.publicPath, `${assetsPath}/`), + + docker: resolveDocker(options, debug), + nginx: resolveNginx(options), + archive: resolveArchive(options), + build: resolveBuild(options, artifact), + packageManager: resolvePackageManager(options, cwd, clientOnly), + localFiles: resolveLocalFiles(options), + }; +} + +function resolveDocker(options: ArtifactsOptions, debug: boolean): ResolvedDockerConfig { + const docker = options.docker ?? {}; + + return { + variant: withDefault(docker.variant, 'runtime'), + registry: withDefault(docker.registry, ''), + baseImage: withDefault(docker.baseImage, DEFAULT_BASE_DOCKER_IMAGE), + runFromNonRootUser: withDefault(docker.runFromNonRootUser, true), + context: withDefault(docker.context, '.'), + tempDirName: withDefault(docker.tempDirName, DEFAULT_TEMP_DIR_NAME), + push: withDefault(docker.push, !debug), + platform: withDefault(docker.platform, 'auto'), + buildArgs: withDefault(docker.buildArgs, {}), + addNodeModulesToDockerIgnore: withDefault(docker.addNodeModulesToDockerIgnore, false), + }; +} + +function resolveNginx(options: ArtifactsOptions): ResolvedNginxConfig { + const nginx = options.nginx ?? {}; + // `false` — то же самое, что `null`: базовый конфиг не генерируется + const baseConf = nginx.baseConf || null; + + return { + port: withDefault(nginx.port, DEFAULT_NGINX_PORT), + rootPath: withDefault(nginx.rootPath, DEFAULT_NGINX_ROOT_PATH), + enablePreviousVersionHeaders: withDefault(nginx.enablePreviousVersionHeaders, false), + baseConf: baseConf && { ...DEFAULT_NGINX_BASE_CONF, ...baseConf }, + }; +} + +function resolveArchive(options: ArtifactsOptions): ResolvedArchiveConfig { + const archive = options.archive ?? {}; + + return { + name: withDefault(archive.name, DEFAULT_ARCHIVE_NAME), + tempDirName: withDefault(archive.tempDirName, DEFAULT_ARCHIVE_TEMP_DIR_NAME), + additionalPaths: withDefault(archive.additionalPaths, DEFAULT_ARCHIVE_ADDITIONAL_PATHS), + }; +} + +function resolveBuild( + options: ArtifactsOptions, + artifact: ResolvedArtifactsConfig['artifact'], +): ResolvedBuildConfig { + const build = options.build ?? {}; + // архив всегда собирается на хосте — внутри tar-а собирать нечего + const isHostBuild = + artifact === 'archive' || withDefault(options.docker?.variant, 'runtime') === 'runtime'; + const command = withDefault(build.command, isHostBuild ? 'npm run build' : null); + + return { + cleanBuildPath: withDefault(build.cleanBuildPath, isHostBuild), + command: command || null, + removeDevDependencies: withDefault(build.removeDevDependencies, isHostBuild), + }; +} + +function resolvePackageManager( + options: ArtifactsOptions, + cwd: string, + clientOnly: boolean, +): ResolvedPackageManagerConfig { + const packageManager = options.packageManager ?? {}; + const useYarn = withDefault(packageManager.useYarn, detectUseYarn(cwd)); + const yarnVersion = packageManager.yarnVersion ?? getYarnVersion({ useYarn }); + + return { + useYarn, + yarnVersion, + installProductionCommand: + packageManager.installProductionCommand ?? getInstallProductionCommand(yarnVersion), + pruneCommand: packageManager.pruneCommand ?? getPruningCommand({ yarnVersion, clientOnly }), + }; +} + +function resolveLocalFiles(options: ArtifactsOptions): ResolvedLocalFilesConfig { + const localFiles = options.localFiles ?? {}; + + return { + dockerfile: localFiles.dockerfile ?? null, + startScript: localFiles.startScript ?? null, + nginxConf: localFiles.nginxConf ?? null, + nginxBaseConf: localFiles.nginxBaseConf ?? null, + allowDockerfile: withDefault(localFiles.allowDockerfile, true), + allowStartScript: withDefault(localFiles.allowStartScript, true), + }; +} diff --git a/packages/arui-scripts-artifacts/src/types.ts b/packages/arui-scripts-artifacts/src/config/types.ts similarity index 58% rename from packages/arui-scripts-artifacts/src/types.ts rename to packages/arui-scripts-artifacts/src/config/types.ts index 8fbe015d..4adb01ec 100644 --- a/packages/arui-scripts-artifacts/src/types.ts +++ b/packages/arui-scripts-artifacts/src/config/types.ts @@ -1,16 +1,5 @@ export type YarnVersion = '1' | '2+' | 'unavailable'; -/** - * Настройки, которые прокидываются в базовый nginx-конфиг (http-блок). - */ -export type NginxConfig = { - workerProcesses?: number; - workerRlimitNoFile?: number; - workerConnections?: number; - eventsUse?: string; - daemon?: string; -}; - /** * Управление флагом `--platform` в команде `docker build`: * - `'auto'` — историческое поведение: флаг подставляется только если версия docker его поддерживает; @@ -25,6 +14,122 @@ export type DockerfileVariant = 'runtime' | 'compiled'; /** Тип собираемого артефакта поставки. */ export type ArtifactKind = 'docker' | 'archive'; +/* ------------------------------------------------------------------------------------------------- + * Секции конфига + * ---------------------------------------------------------------------------------------------- */ + +/** Настройки docker-образа. Не используются при `artifact: 'archive'`. */ +export type DockerOptions = { + /** + * Вариант сборки. `runtime` (по умолчанию) — приложение собирается на хосте и результат кладется + * в образ; `compiled` — зависимости и сборка выполняются внутри образа. От варианта зависят + * дефолты хост-пайплайна (см. {@link BuildOptions}). + */ + variant?: DockerfileVariant; + /** Docker registry, к которому будет добавлен образ (`registry/name:version`). */ + registry?: string; + /** Базовый docker-образ (`FROM`). */ + baseImage?: string; + /** Запускать ли процессы в образе от пользователя nginx (не root). */ + runFromNonRootUser?: boolean; + /** Контекст сборки docker (последний аргумент `docker build`). */ + context?: string; + /** Имя временной директории для сгенерированных файлов. */ + tempDirName?: string; + /** Выполнять ли `docker push` после сборки. По умолчанию — да, кроме `debug`. */ + push?: boolean; + /** Управление флагом `--platform`. */ + platform?: DockerPlatform; + /** Дополнительные `--build-arg` для `docker build`. */ + buildArgs?: Record; + /** Добавлять ли `node_modules` в `.dockerignore` (нужно для compiled-образа). */ + addNodeModulesToDockerIgnore?: boolean; +}; + +/** Настройки базового nginx-конфига (http-блок, `/etc/nginx/nginx.conf`). */ +export type NginxBaseConfOptions = { + workerProcesses?: number; + workerRlimitNoFile?: number; + workerConnections?: number; + eventsUse?: string; + daemon?: string; +}; + +/** Настройки nginx: и server-блока, который генерируется всегда, и базового http-блока. */ +export type NginxOptions = { + /** Порт, который слушает nginx внутри контейнера. */ + port?: number; + /** Корень, из которого nginx раздает статику. */ + rootPath?: string; + /** Добавлять ли заголовки для предыдущей версии словаря brotli. */ + enablePreviousVersionHeaders?: boolean; + /** + * Базовый конфиг (http-блок). `null`/`false` (по умолчанию) — не генерировать и не класть в + * образ: используется тот, что лежит в базовом образе. + */ + baseConf?: NginxBaseConfOptions | null | false; +}; + +/** Настройки tar-архива. Не используются при `artifact: 'docker'`. */ +export type ArchiveOptions = { + /** Имя итогового tar-архива. */ + name?: string; + /** Имя временной директории, в которой собирается содержимое архива. */ + tempDirName?: string; + /** Дополнительные директории проекта, которые кладутся в архив рядом со сборкой. */ + additionalPaths?: string[]; +}; + +/** Хост-пайплайн: что выполняется на машине сборки перед упаковкой артефакта. */ +export type BuildOptions = { + /** + * Удалять ли `buildPath` перед сборкой приложения. + * По умолчанию — `true` для `runtime` и `false` для `compiled`. + */ + cleanBuildPath?: boolean; + /** + * Команда сборки приложения на хосте. `null`/`false` — не собирать. + * По умолчанию — `'npm run build'` для `runtime` и `null` для `compiled` (там сборка идет в образе). + */ + command?: string | null | false; + /** + * Удалять ли dev-зависимости ({@link PackageManagerOptions.pruneCommand}) перед упаковкой. + * По умолчанию — `true` для `runtime` и `false` для `compiled`. + */ + removeDevDependencies?: boolean; +}; + +/** Менеджер зависимостей: как ставить production-зависимости и как выкидывать dev-зависимости. */ +export type PackageManagerOptions = { + /** Использовать ли yarn (если доступен). По умолчанию — по наличию `yarn.lock`. */ + useYarn?: boolean; + /** Явно заданная версия yarn. По умолчанию определяется автоматически. */ + yarnVersion?: YarnVersion; + /** Команда установки production-зависимостей внутри образа (для `compiled`). */ + installProductionCommand?: string; + /** Команда очистки dev-зависимостей на хосте перед упаковкой артефакта. */ + pruneCommand?: string; +}; + +/** + * Локальные файлы проекта, которые (если существуют и разрешены) используются вместо сгенерированных + * шаблонов. + */ +export type LocalFilesOptions = { + dockerfile?: string | null; + startScript?: string | null; + nginxConf?: string | null; + nginxBaseConf?: string | null; + /** Разрешить подмену Dockerfile локальным файлом. */ + allowDockerfile?: boolean; + /** Разрешить подмену start.sh локальным файлом. */ + allowStartScript?: boolean; +}; + +/* ------------------------------------------------------------------------------------------------- + * Шаблоны + * ---------------------------------------------------------------------------------------------- */ + /** * Функция-оверрайд шаблона. Получает сгенерированную по умолчанию строку и итоговый конфиг, * возвращает новую строку. Позволяет точечно донасыщать/подменять любой из шаблонов. @@ -39,7 +144,7 @@ export type TemplateOverride = ( */ export type TemplateRenderer = (config: ResolvedArtifactsConfig) => string; -export type DockerTemplateKey = +export type TemplateKey = | 'dockerfile' | 'dockerfileCompiled' | 'nginxConf' @@ -49,61 +154,63 @@ export type DockerTemplateKey = /** * Кастомные рендереры шаблонов. Любой из них можно переопределить целиком. */ -export type DockerTemplates = Partial>; +export type ArtifactTemplates = Partial>; /** * Точечные оверрайды поверх сгенерированных по умолчанию шаблонов. */ -export type DockerTemplateOverrides = Partial>; +export type ArtifactTemplateOverrides = Partial>; /** - * Пути к локальным файлам, которые (если существуют) будут использованы вместо сгенерированных шаблонов. + * Готовые (отрендеренные) содержимые файлов, которые кладутся в артефакт. */ -export type LocalFiles = { - dockerfile?: string | null; - startScript?: string | null; - nginxConf?: string | null; - nginxBaseConf?: string | null; +export type RenderedTemplates = { + dockerfile: string; + nginxConf: string; + nginxBaseConf: string; + startScript: string; }; +/* ------------------------------------------------------------------------------------------------- + * Конфиг целиком + * ---------------------------------------------------------------------------------------------- */ + /** - * Полный набор опций сборки docker-образа. Все поля опциональны — недостающие донасыщаются дефолтами + * Полный набор опций сборки артефакта. Все поля опциональны — недостающие донасыщаются дефолтами * в {@link resolveArtifactsConfig}. Это единственная точка входа для кастомизации: любой потребитель - * (arui-scripts, newclick-builder, собственные скрипты) может собрать образ, передав сюда свои значения. + * (arui-scripts, сторонние сборки, собственные скрипты) может собрать артефакт, передав сюда свои + * значения. + * + * Настройки сгруппированы по тому, к чему относятся: `docker`, `nginx`, `archive`, `build`, + * `packageManager`, `localFiles`. На верхнем уровне остается только то, что общее для всех + * артефактов, — идентификация и форма самого приложения. */ export type ArtifactsOptions = { /** * Что собирать: docker-образ (по умолчанию) или tar-архив с production-сборкой. - * Docker-специфичные опции (`baseDockerImage`, `platform`, `extraBuildArgs`, …) при - * `artifact: 'archive'` не используются. + * Секция `docker` при `artifact: 'archive'` не используется, и наоборот. */ artifact?: ArtifactKind; - /** - * Вариант сборки. `runtime` (по умолчанию) — приложение собирается на хосте и результат кладется - * в образ; `compiled` — зависимости и сборка выполняются внутри образа. От варианта зависят - * дефолты хост-пайплайна ({@link ArtifactsOptions.buildCommand} и соседние). - */ - variant?: DockerfileVariant; - /* --- Идентификация образа --- */ - /** Имя образа. По умолчанию берется из `package.json` в `cwd`. */ + /* --- Идентификация --- */ + /** Имя артефакта (образа). По умолчанию берется из `package.json` в `cwd`. */ name?: string; - /** Версия/тег образа. По умолчанию берется из `package.json` в `cwd`. */ + /** Версия/тег. По умолчанию берется из `package.json` в `cwd`. */ version?: string; - /** Docker registry, к которому будет добавлен образ (`registry/name:version`). */ - dockerRegistry?: string; + /** Рабочая директория проекта. */ + cwd?: string; + /** Режим отладки: не пушить образ, печатать стек ошибок. */ + debug?: boolean; - /* --- Базовый образ и форма приложения --- */ - /** Базовый docker-образ (`FROM`). */ - baseDockerImage?: string; + /* --- Форма приложения --- */ /** Собирается ли только клиентская часть (nginx без nodejs-сервера). */ clientOnly?: boolean; /** Путь к директории со сборкой приложения относительно корня проекта. */ buildPath?: string; /** Путь к серверному бандлу относительно `buildPath`. */ serverOutput?: string; - /** Корень, из которого nginx раздает статику. */ - nginxRootPath?: string; + /** Порт, на котором поднимается nodejs-сервер приложения. */ + serverPort?: number; /** Директория со статикой внутри `buildPath`. Из нее выводится дефолт `publicPath`. */ assetsPath?: string; /** @@ -113,144 +220,57 @@ export type ArtifactsOptions = { */ publicPath?: string; - /* --- Порты --- */ - clientServerPort?: number; - serverPort?: number; - - /* --- Nginx --- */ - /** Настройки базового nginx-конфига. `null`/`false` — не генерировать базовый конфиг. */ - nginx?: NginxConfig | null | false; - /** Добавлять ли заголовки для предыдущей версии словаря brotli. */ - enablePreviousVersionHeaders?: boolean; - - /* --- Поведение сборки --- */ - /** Запускать ли процессы в образе от пользователя nginx (не root). */ - runFromNonRootUser?: boolean; - /** Рабочая директория проекта. */ - cwd?: string; - /** Контекст сборки docker (последний аргумент `docker build`). */ - context?: string; - /** Имя временной директории для сгенерированных файлов. */ - tempDirName?: string; - /** Режим отладки: не пушить образ, печатать стек ошибок. */ - debug?: boolean; - /** Выполнять ли `docker push` после сборки. По умолчанию — да, кроме `debug`. */ - push?: boolean; - /** Управление флагом `--platform`. */ - platform?: DockerPlatform; - /** Дополнительные `--build-arg` для `docker build`. */ - extraBuildArgs?: Record; - - /* --- Хост-пайплайн (выполняется перед `docker build`) --- */ - /** - * Удалять ли `buildPath` перед сборкой приложения. - * По умолчанию — `true` для `runtime` и `false` для `compiled`. - */ - cleanBuildPath?: boolean; - /** - * Команда сборки приложения на хосте. `null`/`false` — не собирать. - * По умолчанию — `'npm run build'` для `runtime` и `null` для `compiled` (там сборка идет в образе). - */ - buildCommand?: string | null | false; - /** - * Удалять ли dev-зависимости ({@link ArtifactsOptions.pruneCommand}) перед `docker build`. - * Аналог `removeDevDependenciesDuringDockerBuild` в arui-scripts. - * По умолчанию — `true` для `runtime` и `false` для `compiled`. - */ - removeDevDependencies?: boolean; - - /* --- tar-архив (`artifact: 'archive'`) --- */ - /** Имя итогового tar-архива. */ - archiveName?: string; - /** Дополнительные директории проекта, которые кладутся в архив рядом со сборкой. */ - additionalBuildPath?: string[]; - - /* --- Менеджер зависимостей --- */ - /** Использовать ли yarn (если доступен). */ - useYarn?: boolean; - /** Явно заданная версия yarn. По умолчанию определяется автоматически. */ - yarnVersion?: YarnVersion; - /** Команда установки production-зависимостей внутри образа (для `compiled`). */ - installProductionCommand?: string; - /** Команда очистки dev-зависимостей на хосте перед сборкой образа. */ - pruneCommand?: string; - - /* --- Кастомизация файлов --- */ - /** Добавлять ли `node_modules` в `.dockerignore` (нужно для compiled-образа). */ - addNodeModulesToDockerIgnore?: boolean; - /** Разрешить подмену Dockerfile локальным файлом. */ - allowLocalDockerfile?: boolean; - /** Разрешить подмену start.sh локальным файлом. */ - allowLocalStartScript?: boolean; - /** Пути к локальным файлам, замещающим сгенерированные шаблоны. */ - localFiles?: LocalFiles; + /* --- Секции --- */ + docker?: DockerOptions; + nginx?: NginxOptions; + archive?: ArchiveOptions; + build?: BuildOptions; + packageManager?: PackageManagerOptions; + localFiles?: LocalFilesOptions; /* --- Шаблоны --- */ /** Полная замена рендереров шаблонов. */ - templates?: DockerTemplates; + templates?: ArtifactTemplates; /** Точечные оверрайды поверх сгенерированных шаблонов. */ - overrides?: DockerTemplateOverrides; + overrides?: ArtifactTemplateOverrides; }; +export type ResolvedDockerConfig = Required; +export type ResolvedNginxBaseConf = Required; +export type ResolvedNginxConfig = Required> & { + /** `null` — базовый конфиг не генерируется и не кладется в артефакт. */ + baseConf: ResolvedNginxBaseConf | null; +}; +export type ResolvedArchiveConfig = Required; +export type ResolvedBuildConfig = Required> & { + command: string | null; +}; +export type ResolvedPackageManagerConfig = Required; +export type ResolvedLocalFilesConfig = Required; + /** * Полностью донасыщенный конфиг сборки. С ним работают все шаблоны и утилиты — они не знают ничего * о том, откуда пришли значения, что делает их независимыми и легко тестируемыми. */ export type ResolvedArtifactsConfig = { artifact: ArtifactKind; - variant: DockerfileVariant; name: string; version: string; - dockerRegistry: string; + cwd: string; + debug: boolean; - baseDockerImage: string; clientOnly: boolean; buildPath: string; serverOutput: string; - nginxRootPath: string; + serverPort: number; assetsPath: string; publicPath: string; - clientServerPort: number; - serverPort: number; - - nginx: NginxConfig | null; - enablePreviousVersionHeaders: boolean; - - runFromNonRootUser: boolean; - cwd: string; - context: string; - tempDirName: string; - debug: boolean; - push: boolean; - platform: DockerPlatform; - extraBuildArgs: Record; - - cleanBuildPath: boolean; - buildCommand: string | null; - removeDevDependencies: boolean; - - archiveName: string; - additionalBuildPath: string[]; - - useYarn: boolean; - yarnVersion: YarnVersion; - installProductionCommand: string; - pruneCommand: string; - - addNodeModulesToDockerIgnore: boolean; - allowLocalDockerfile: boolean; - allowLocalStartScript: boolean; - localFiles: Required; -}; - -/** - * Готовые (отрендеренные) содержимого файлов, которые будут положены в образ. - */ -export type RenderedTemplates = { - dockerfile: string; - nginxConf: string; - nginxBaseConf: string; - startScript: string; + docker: ResolvedDockerConfig; + nginx: ResolvedNginxConfig; + archive: ResolvedArchiveConfig; + build: ResolvedBuildConfig; + packageManager: ResolvedPackageManagerConfig; + localFiles: ResolvedLocalFilesConfig; }; diff --git a/packages/arui-scripts-artifacts/src/__tests__/docker-build.test.ts b/packages/arui-scripts-artifacts/src/docker/__tests__/docker-build.test.ts similarity index 78% rename from packages/arui-scripts-artifacts/src/__tests__/docker-build.test.ts rename to packages/arui-scripts-artifacts/src/docker/__tests__/docker-build.test.ts index 3e564aa5..df4a5a66 100644 --- a/packages/arui-scripts-artifacts/src/__tests__/docker-build.test.ts +++ b/packages/arui-scripts-artifacts/src/docker/__tests__/docker-build.test.ts @@ -1,11 +1,7 @@ -import { resolveArtifactsConfig } from '../config'; -import { - applyCommandLineArguments, - getBuildParams, - getDockerBuildCommand, - getPlatformFlag, - shellQuote, -} from '../utils/docker-build'; +import { resolveArtifactsConfig } from '../../config'; +import { shellQuote } from '../../utils/shell'; +import { getDockerBuildCommand, getPlatformFlag } from '../build-command'; +import { applyCommandLineArguments, getBuildParams } from '../build-params'; const baseOptions = { cwd: '/tmp/project', name: 'app', version: '1.0.0' }; @@ -19,7 +15,7 @@ describe('getBuildParams', () => { it('should build image name with registry', () => { const config = resolveArtifactsConfig({ ...baseOptions, - dockerRegistry: 'registry.example.com', + docker: { registry: 'registry.example.com' }, }); expect(getBuildParams(config).imageFullName).toBe('registry.example.com/app:1.0.0'); @@ -43,17 +39,28 @@ describe('applyCommandLineArguments', () => { expect(getBuildParams(next).imageFullName).toBe('r.example.com/other:2.0.0'); }); + + it('should not mutate the source config', () => { + const config = resolveArtifactsConfig(baseOptions); + + applyCommandLineArguments(config, ['registry=r.example.com']); + + expect(config.docker.registry).toBe(''); + }); }); describe('getPlatformFlag', () => { it('should return empty string when platform is false', () => { - const config = resolveArtifactsConfig({ ...baseOptions, platform: false }); + const config = resolveArtifactsConfig({ ...baseOptions, docker: { platform: false } }); expect(getPlatformFlag(config)).toBe(''); }); it('should use explicit platform', () => { - const config = resolveArtifactsConfig({ ...baseOptions, platform: 'linux/arm64' }); + const config = resolveArtifactsConfig({ + ...baseOptions, + docker: { platform: 'linux/arm64' }, + }); expect(getPlatformFlag(config)).toBe('--platform linux/arm64'); }); @@ -61,7 +68,7 @@ describe('getPlatformFlag', () => { describe('getDockerBuildCommand', () => { it('should include dockerfile, build-args and context', () => { - const config = resolveArtifactsConfig({ ...baseOptions, platform: false }); + const config = resolveArtifactsConfig({ ...baseOptions, docker: { platform: false } }); const command = getDockerBuildCommand(config); expect(command).toContain('-f ./.docker-build/Dockerfile'); @@ -72,8 +79,7 @@ describe('getDockerBuildCommand', () => { it('should include extra build args', () => { const config = resolveArtifactsConfig({ ...baseOptions, - platform: false, - extraBuildArgs: { COMMIT_SHA: 'abc123' }, + docker: { platform: false, buildArgs: { COMMIT_SHA: 'abc123' } }, }); expect(getDockerBuildCommand(config)).toContain('--build-arg COMMIT_SHA=abc123'); @@ -82,8 +88,7 @@ describe('getDockerBuildCommand', () => { it('should escape extra build args so they cannot break out of the command', () => { const config = resolveArtifactsConfig({ ...baseOptions, - platform: false, - extraBuildArgs: { EVIL: 'a"; touch /tmp/pwned; #' }, + docker: { platform: false, buildArgs: { EVIL: 'a"; touch /tmp/pwned; #' } }, }); const command = getDockerBuildCommand(config); @@ -95,9 +100,8 @@ describe('getDockerBuildCommand', () => { it('should escape image name and context', () => { const config = resolveArtifactsConfig({ ...baseOptions, - platform: false, name: 'app; rm -rf /', - context: './my context', + docker: { platform: false, context: './my context' }, }); const command = getDockerBuildCommand(config); diff --git a/packages/arui-scripts-artifacts/src/docker/build-command.ts b/packages/arui-scripts-artifacts/src/docker/build-command.ts new file mode 100644 index 00000000..deece702 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/docker/build-command.ts @@ -0,0 +1,76 @@ +import satisfies from 'semver/functions/satisfies'; +import shell from 'shelljs'; + +import { type ResolvedArtifactsConfig } from '../config/types'; +import { BASE_NGINX_CONFIG_FILENAME, NGINX_CONFIG_FILENAME } from '../nginx/constants'; +import { START_SCRIPT_FILENAME } from '../start-script/constants'; +import { shellQuote } from '../utils/shell'; + +import { getBuildParams } from './build-params'; +import { + DEFAULT_PLATFORM, + DOCKERFILE_FILENAME, + PLATFORM_FLAG_MIN_DOCKER_VERSION, +} from './constants'; + +/** + * Проверяет, что версия docker (клиент и сервер) удовлетворяет semver-диапазону. + */ +export function dockerVersionSatisfies(request: string) { + const dockerServerVersion = shell.exec("docker version --format '{{.Server.Version}}'", { + silent: true, + }); + const dockerClientVersion = shell.exec("docker version --format '{{.Client.Version}}'", { + silent: true, + }); + + return ( + satisfies(dockerServerVersion.toString(), request) && + satisfies(dockerClientVersion.toString(), request) + ); +} + +/** + * Вычисляет значение флага `--platform` согласно настройке `docker.platform` в конфиге. + */ +export function getPlatformFlag(config: ResolvedArtifactsConfig): string { + const { platform } = config.docker; + + if (platform === 'auto') { + // на маках с m1 без флага docker пытается вытянуть базовый образ под свою платформу и падает, + // но сам флаг поддерживается без экспериментальных флагов только начиная с docker 20.10.21. + return dockerVersionSatisfies(PLATFORM_FLAG_MIN_DOCKER_VERSION) + ? `--platform ${DEFAULT_PLATFORM}` + : ''; + } + + if (platform) { + return `--platform ${platform}`; + } + + return ''; +} + +/** + * Формирует команду `docker build` для сгенерированной ранее временной директории. + */ +export function getDockerBuildCommand(config: ResolvedArtifactsConfig): string { + const { tempDirName, context, buildArgs } = config.docker; + const { imageFullName } = getBuildParams(config); + + const platformFlag = getPlatformFlag(config); + + const extraArgs = Object.entries(buildArgs) + .map(([key, value]) => `--build-arg ${key}=${shellQuote(value)}`) + .join(' '); + + return `docker build ${platformFlag} \ + -f ${shellQuote(`./${tempDirName}/${DOCKERFILE_FILENAME}`)} \ + --build-arg START_SH_LOCATION=${shellQuote(`./${tempDirName}/${START_SCRIPT_FILENAME}`)} \ + --build-arg NGINX_CONF_LOCATION=${shellQuote(`./${tempDirName}/${NGINX_CONFIG_FILENAME}`)} \ + --build-arg NGINX_BASE_CONF_LOCATION=${shellQuote( + `./${tempDirName}/${BASE_NGINX_CONFIG_FILENAME}`, + )} \ + ${extraArgs} \ + -t ${shellQuote(imageFullName)} ${shellQuote(context)}`; +} diff --git a/packages/arui-scripts-artifacts/src/build-docker-image.ts b/packages/arui-scripts-artifacts/src/docker/build-docker-image.ts similarity index 83% rename from packages/arui-scripts-artifacts/src/build-docker-image.ts rename to packages/arui-scripts-artifacts/src/docker/build-docker-image.ts index a92cef73..a37ff3ee 100644 --- a/packages/arui-scripts-artifacts/src/build-docker-image.ts +++ b/packages/arui-scripts-artifacts/src/docker/build-docker-image.ts @@ -1,23 +1,21 @@ import fs from 'fs-extra'; -import { - applyCommandLineArguments, - getBuildParams, - getDockerBuildCommand, - prepareFilesForDocker, -} from './utils/docker-build'; -import { exec } from './utils/exec'; -import { resolveArtifactsConfig } from './config'; -import { type BeforeBuildHook, runHostPipeline } from './host-pipeline'; -import { renderTemplates } from './render'; -import { type ArtifactsOptions } from './types'; +import { resolveArtifactsConfig } from '../config'; +import { type ArtifactsOptions } from '../config/types'; +import { type BeforeBuildHook, runHostPipeline } from '../pipeline/host-pipeline'; +import { renderTemplates } from '../pipeline/render'; +import { exec } from '../utils/exec'; + +import { getDockerBuildCommand } from './build-command'; +import { applyCommandLineArguments, getBuildParams } from './build-params'; +import { prepareFilesForDocker } from './prepare-files'; export type BuildDockerImageOptions = ArtifactsOptions & { /** Аргументы командной строки (`name=... version=... registry=...`), накладываются поверх опций. */ argv?: string[]; /** * Хук, вызываемый после подготовки файлов и очистки `buildPath`, но до сборки приложения и - * `docker build`. Если приложение собирается им, выставьте `buildCommand: null`. + * `docker build`. Если приложение собирается им, выставьте `build.command: null`. */ beforeBuild?: BeforeBuildHook; }; @@ -69,7 +67,7 @@ export async function buildDockerImage(options: BuildDockerImageOptions = {}): P await restoreDockerIgnore(); restoreDockerIgnore = null; - if (config.push) { + if (config.docker.push) { await exec(`docker push ${imageFullName}`); } diff --git a/packages/arui-scripts-artifacts/src/docker/build-params.ts b/packages/arui-scripts-artifacts/src/docker/build-params.ts new file mode 100644 index 00000000..14873af9 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/docker/build-params.ts @@ -0,0 +1,65 @@ +import path from 'path'; + +import { type ResolvedArtifactsConfig } from '../config/types'; + +export type BuildParams = { + pathToTempDir: string; + imageFullName: string; + tempDirName: string; +}; + +/** + * Собирает параметры сборки (полное имя образа и пути) из конфига. + */ +export function getBuildParams(config: ResolvedArtifactsConfig): BuildParams { + const { name, version, cwd, docker } = config; + const { registry, tempDirName } = docker; + const pathToTempDir = path.join(cwd, tempDirName); + const imageFullName = `${registry ? `${registry}/` : ''}${name}:${version}`; + + return { pathToTempDir, imageFullName, tempDirName }; +} + +/** + * Разбирает аргументы командной строки вида `name=... version=... registry=...` и накладывает их + * поверх конфига. Неизвестные аргументы игнорируются с предупреждением. + */ +export function applyCommandLineArguments( + config: ResolvedArtifactsConfig, + commandLineArguments: string[], +): ResolvedArtifactsConfig { + const next = { ...config, docker: { ...config.docker } }; + + commandLineArguments.forEach((arg) => { + let [argName, argValue] = arg.split('='); + + argName = argName.toLowerCase().trim(); + argValue = argValue ? argValue.trim() : ''; + switch (argName) { + case 'version': + next.version = argValue; + break; + case 'name': + next.name = argValue; + break; + case 'registry': + next.docker.registry = argValue; + break; + default: + console.warn(`Unknown argument ${argName}`); + } + }); + + return next; +} + +/** + * Как {@link getBuildParams}, но с учетом аргументов командной строки (по умолчанию — `process.argv`, + * начиная с третьего, как в CLI arui-scripts). + */ +export function getBuildParamsFromArgs( + config: ResolvedArtifactsConfig, + argv: string[] = process.argv.slice(3), +): BuildParams { + return getBuildParams(applyCommandLineArguments(config, argv)); +} diff --git a/packages/arui-scripts-artifacts/src/constants.ts b/packages/arui-scripts-artifacts/src/docker/constants.ts similarity index 57% rename from packages/arui-scripts-artifacts/src/constants.ts rename to packages/arui-scripts-artifacts/src/docker/constants.ts index 7baf6b25..e93644ff 100644 --- a/packages/arui-scripts-artifacts/src/constants.ts +++ b/packages/arui-scripts-artifacts/src/docker/constants.ts @@ -4,24 +4,15 @@ */ export const DEFAULT_TEMP_DIR_NAME = '.docker-build'; -/** Имя временной директории, в которой собирается содержимое tar-архива. */ -export const DEFAULT_ARCHIVE_TEMP_DIR_NAME = '.archive-build'; - -/** Имя tar-архива по умолчанию. */ -export const DEFAULT_ARCHIVE_NAME = 'build.tar'; - -/** Имя файла с nginx-конфигом сервера (server-блок), который кладется в образ. */ -export const NGINX_CONFIG_FILENAME = 'nginx.conf'; - -/** Имя файла с базовым nginx-конфигом (http-блок), который кладется в образ. */ -export const BASE_NGINX_CONFIG_FILENAME = 'base-nginx.conf'; - -/** Имя файла с рантайм-конфигом клиентского приложения. */ -export const ENV_CONFIG_FILENAME = 'env-config.json'; - /** Базовый docker-образ по умолчанию. */ export const DEFAULT_BASE_DOCKER_IMAGE = 'alfabankui/arui-scripts:24.10.0-slim'; +/** Имя Dockerfile во временной директории. */ +export const DOCKERFILE_FILENAME = 'Dockerfile'; + +/** Имя файла со списком исключений сборочного контекста. */ +export const DOCKERIGNORE_FILENAME = '.dockerignore'; + /** * Минимальная версия docker, начиная с которой флаг `--platform` поддерживается без экспериментальных * флагов. На многих серверах до сих пор живет docker 1.13.1, который упадет при наличии этого флага. diff --git a/packages/arui-scripts-artifacts/src/docker/index.ts b/packages/arui-scripts-artifacts/src/docker/index.ts new file mode 100644 index 00000000..5e336f81 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/docker/index.ts @@ -0,0 +1,19 @@ +export { buildDockerImage, type BuildDockerImageOptions } from './build-docker-image'; +export { + applyCommandLineArguments, + getBuildParams, + getBuildParamsFromArgs, + type BuildParams, +} from './build-params'; +export { dockerVersionSatisfies, getDockerBuildCommand, getPlatformFlag } from './build-command'; +export { prepareFilesForDocker, type PrepareFilesForDockerResult } from './prepare-files'; +export { renderDockerfile } from './templates/dockerfile.template'; +export { renderDockerfileCompiled } from './templates/dockerfile-compiled.template'; +export { + DEFAULT_BASE_DOCKER_IMAGE, + DEFAULT_PLATFORM, + DEFAULT_TEMP_DIR_NAME, + DOCKERFILE_FILENAME, + DOCKERIGNORE_FILENAME, + PLATFORM_FLAG_MIN_DOCKER_VERSION, +} from './constants'; diff --git a/packages/arui-scripts-artifacts/src/docker/prepare-files.ts b/packages/arui-scripts-artifacts/src/docker/prepare-files.ts new file mode 100644 index 00000000..f3e173a3 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/docker/prepare-files.ts @@ -0,0 +1,119 @@ +import path from 'path'; + +import fs from 'fs-extra'; + +import { type RenderedTemplates, type ResolvedArtifactsConfig } from '../config/types'; +import { BASE_NGINX_CONFIG_FILENAME, NGINX_CONFIG_FILENAME } from '../nginx/constants'; +import { START_SCRIPT_FILENAME } from '../start-script/constants'; + +import { getBuildParams } from './build-params'; +import { DOCKERFILE_FILENAME, DOCKERIGNORE_FILENAME } from './constants'; + +type PrepareFilesForDockerParams = { + config: ResolvedArtifactsConfig; + templates: RenderedTemplates; +}; + +export type PrepareFilesForDockerResult = { + /** + * Возвращает `.dockerignore` проекта в исходное состояние. Вызывать после `docker build` — + * иначе дописанный `node_modules` останется в рабочей копии пользователя. + */ + restoreDockerIgnore: () => Promise; +}; + +/** + * Готовит временную директорию со всеми файлами, необходимыми для `docker build`: Dockerfile, + * nginx-конфиги и start.sh. Локальные файлы проекта (если разрешены и заданы) имеют приоритет над + * сгенерированными шаблонами. + */ +export async function prepareFilesForDocker({ + config, + templates, +}: PrepareFilesForDockerParams): Promise { + const { cwd, nginx, localFiles, docker } = config; + const { addNodeModulesToDockerIgnore } = docker; + const { pathToTempDir } = getBuildParams(config); + + await fs.emptyDir(pathToTempDir); + + let nginxBaseConf = ''; + + if (nginx.baseConf) { + nginxBaseConf = localFiles.nginxBaseConf + ? await fs.readFile(localFiles.nginxBaseConf, 'utf8') + : templates.nginxBaseConf; + } + + const nginxConf = localFiles.nginxConf + ? await fs.readFile(localFiles.nginxConf, 'utf8') + : templates.nginxConf; + + const dockerfile = + localFiles.dockerfile && localFiles.allowDockerfile + ? await fs.readFile(localFiles.dockerfile, 'utf8') + : templates.dockerfile; + + const startScript = + localFiles.startScript && localFiles.allowStartScript + ? await fs.readFile(localFiles.startScript, 'utf8') + : templates.startScript; + + const dockerIgnoreFilePath = path.join(cwd, DOCKERIGNORE_FILENAME); + // запоминаем исходное состояние, чтобы вернуть файл как было: `node_modules` нужен только на + // время сборки образа и не должен оставаться в рабочей копии + const originalDockerIgnore = + addNodeModulesToDockerIgnore && fs.existsSync(dockerIgnoreFilePath) + ? await fs.readFile(dockerIgnoreFilePath, 'utf-8') + : null; + + const dockerIgnoreFileContent = + addNodeModulesToDockerIgnore && + (await getAndModifyDockerIgnoreContent(dockerIgnoreFilePath)); + + await Promise.all( + [ + fs.writeFile(path.join(pathToTempDir, DOCKERFILE_FILENAME), dockerfile, 'utf8'), + fs.writeFile(path.join(pathToTempDir, NGINX_CONFIG_FILENAME), nginxConf, 'utf8'), + nginxBaseConf && + fs.writeFile( + path.join(pathToTempDir, BASE_NGINX_CONFIG_FILENAME), + nginxBaseConf, + 'utf8', + ), + fs.writeFile(path.join(pathToTempDir, START_SCRIPT_FILENAME), startScript, { + encoding: 'utf8', + mode: 0o555, + }), + addNodeModulesToDockerIgnore && + dockerIgnoreFileContent && + fs.writeFile(dockerIgnoreFilePath, dockerIgnoreFileContent, 'utf-8'), + ].filter(Boolean), + ); + + return { + restoreDockerIgnore: async () => { + if (!addNodeModulesToDockerIgnore) { + return; + } + + if (originalDockerIgnore === null) { + await fs.remove(dockerIgnoreFilePath); + } else { + await fs.writeFile(dockerIgnoreFilePath, originalDockerIgnore, 'utf-8'); + } + }, + }; +} + +async function getAndModifyDockerIgnoreContent(dockerIgnoreFilePath: string) { + if (fs.existsSync(dockerIgnoreFilePath)) { + return fs + .readFile(dockerIgnoreFilePath, 'utf-8') + .then((ignores) => `${ignores}\nnode_modules`); + } + + await fs.createFile(dockerIgnoreFilePath); + + return 'node_modules'; +} diff --git a/packages/arui-scripts-artifacts/src/templates/dockerfile-compiled.template.ts b/packages/arui-scripts-artifacts/src/docker/templates/dockerfile-compiled.template.ts similarity index 87% rename from packages/arui-scripts-artifacts/src/templates/dockerfile-compiled.template.ts rename to packages/arui-scripts-artifacts/src/docker/templates/dockerfile-compiled.template.ts index 1defcb84..b0626074 100644 --- a/packages/arui-scripts-artifacts/src/templates/dockerfile-compiled.template.ts +++ b/packages/arui-scripts-artifacts/src/docker/templates/dockerfile-compiled.template.ts @@ -1,11 +1,12 @@ -import { type ResolvedArtifactsConfig } from '../types'; +import { type ResolvedArtifactsConfig } from '../../config/types'; /** * Dockerfile для compiled-образа: зависимости и сборка приложения происходят внутри образа, что * позволяет кешировать установку зависимостей на уровне docker-слоев. */ export function renderDockerfileCompiled(config: ResolvedArtifactsConfig): string { - const { baseDockerImage, nginx, yarnVersion, installProductionCommand } = config; + const { docker, nginx, packageManager } = config; + const { yarnVersion, installProductionCommand } = packageManager; // В зависимости от используемого менеджера зависимостей для их установки нужно копировать разный // набор файлов @@ -18,7 +19,7 @@ export function renderDockerfileCompiled(config: ResolvedArtifactsConfig): strin ].filter(Boolean) as string[]; return ` -FROM ${baseDockerImage} +FROM ${docker.baseImage} ARG START_SH_LOCATION ARG NGINX_CONF_LOCATION ARG NGINX_BASE_CONF_LOCATION @@ -28,7 +29,7 @@ WORKDIR /src # Полу-статичные файлы, могут легко кешироваться ADD $START_SH_LOCATION /src/start.sh ADD $NGINX_CONF_LOCATION /src/nginx.conf -${nginx ? 'ADD $NGINX_BASE_CONF_LOCATION /etc/nginx/nginx.conf' : ''} +${nginx.baseConf ? 'ADD $NGINX_BASE_CONF_LOCATION /etc/nginx/nginx.conf' : ''} # Зависимости. При некоторой удаче могут кешироваться и соответственно кешировать установку зависимостей ${filesRequiredToInstallDependencies diff --git a/packages/arui-scripts-artifacts/src/templates/dockerfile.template.ts b/packages/arui-scripts-artifacts/src/docker/templates/dockerfile.template.ts similarity index 82% rename from packages/arui-scripts-artifacts/src/templates/dockerfile.template.ts rename to packages/arui-scripts-artifacts/src/docker/templates/dockerfile.template.ts index 7d650ff8..24e45438 100644 --- a/packages/arui-scripts-artifacts/src/templates/dockerfile.template.ts +++ b/packages/arui-scripts-artifacts/src/docker/templates/dockerfile.template.ts @@ -1,10 +1,11 @@ -import { type ResolvedArtifactsConfig } from '../types'; +import { type ResolvedArtifactsConfig } from '../../config/types'; /** * Dockerfile для «сырого» образа: приложение собирается на хосте, в образ кладется результат сборки. */ export function renderDockerfile(config: ResolvedArtifactsConfig): string { - const { clientOnly, buildPath, runFromNonRootUser, baseDockerImage, nginx } = config; + const { clientOnly, buildPath, docker, nginx } = config; + const { runFromNonRootUser, baseImage } = docker; const appPathToAdd = clientOnly ? buildPath : '.'; const appTargetPath = clientOnly ? `/src/${buildPath}` : '/src'; @@ -26,7 +27,7 @@ export function renderDockerfile(config: ResolvedArtifactsConfig): string { : ''; return ` -FROM ${baseDockerImage} +FROM ${baseImage} ARG START_SH_LOCATION ARG NGINX_CONF_LOCATION ARG NGINX_BASE_CONF_LOCATION @@ -34,7 +35,7 @@ ARG NGINX_BASE_CONF_LOCATION WORKDIR /src ADD $START_SH_LOCATION /src/start.sh ADD $NGINX_CONF_LOCATION ${nginxConfTargetLocation} -${nginx ? 'ADD $NGINX_BASE_CONF_LOCATION /etc/nginx/nginx.conf' : ''} +${nginx.baseConf ? 'ADD $NGINX_BASE_CONF_LOCATION /etc/nginx/nginx.conf' : ''} ${nginxNonRootPart} diff --git a/packages/arui-scripts-artifacts/src/index.ts b/packages/arui-scripts-artifacts/src/index.ts index 66cb66e7..bd7935c8 100644 --- a/packages/arui-scripts-artifacts/src/index.ts +++ b/packages/arui-scripts-artifacts/src/index.ts @@ -1,50 +1,56 @@ -export * from './types'; -export * from './constants'; +/** + * Публичное API пакета. Реэкспортит доменные модули: конфиг, общий пайплайн и по одному модулю на + * тип артефакта (`docker`, `archive`) и на файлы, которые в них кладутся (`nginx`, `start-script`). + */ +/* Конфиг */ export { resolveArtifactsConfig } from './config'; -export { - BUILT_IN_COMMANDS, - defineConfig, - getAvailableCommands, - resolveCommandOptions, - type ArtifactsConfigFile, - type ArtifactsConfigFileExport, -} from './config-file'; -export { - CONFIG_FILE_NAMES, - findConfigFile, - loadConfigFile, - resolveConfigFile, -} from './load-config-file'; -export { createCli, extractConfigPath, type CreateCliParams, type RunCommandParams } from './cli'; -export { buildArtifact, type BuildArtifactOptions } from './build-artifact'; -export { buildDockerImage, type BuildDockerImageOptions } from './build-docker-image'; -export { buildArchive, type BuildArchiveOptions } from './build-archive'; -export { runHostPipeline, type BeforeBuildHook } from './host-pipeline'; -export { renderTemplates, type RenderTemplatesParams } from './render'; +export type { + ArchiveOptions, + ArtifactKind, + ArtifactsOptions, + ArtifactTemplateOverrides, + ArtifactTemplates, + BuildOptions, + DockerfileVariant, + DockerOptions, + DockerPlatform, + LocalFilesOptions, + NginxBaseConfOptions, + NginxOptions, + PackageManagerOptions, + RenderedTemplates, + ResolvedArchiveConfig, + ResolvedArtifactsConfig, + ResolvedBuildConfig, + ResolvedDockerConfig, + ResolvedLocalFilesConfig, + ResolvedNginxBaseConf, + ResolvedNginxConfig, + ResolvedPackageManagerConfig, + TemplateKey, + TemplateOverride, + TemplateRenderer, + YarnVersion, +} from './config/types'; -export { - renderDockerfile, - renderDockerfileCompiled, - renderNginxConf, - renderBaseNginxConf, - renderStartScript, -} from './templates'; +/* Общий пайплайн сборки артефакта */ +export * from './pipeline'; -export { - applyCommandLineArguments, - dockerVersionSatisfies, - getBuildParams, - getBuildParamsFromArgs, - getDockerBuildCommand, - getPlatformFlag, - prepareFilesForDocker, - shellQuote, - type BuildParams, - type PrepareFilesForDockerResult, -} from './utils/docker-build'; +/* Артефакты */ +export * from './docker'; +export * from './archive'; + +/* Файлы, которые кладутся в артефакт */ +export * from './nginx'; +export * from './start-script'; + +/* CLI и файл конфига */ +export * from './cli'; +/* Утилиты */ export { exec, ExecError } from './utils/exec'; +export { shellQuote } from './utils/shell'; export { detectUseYarn, getInstallProductionCommand, diff --git a/packages/arui-scripts-artifacts/src/nginx/constants.ts b/packages/arui-scripts-artifacts/src/nginx/constants.ts new file mode 100644 index 00000000..4aea2c81 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/nginx/constants.ts @@ -0,0 +1,22 @@ +import { type ResolvedNginxBaseConf } from '../config/types'; + +/** Имя файла с nginx-конфигом сервера (server-блок), который кладется в артефакт. */ +export const NGINX_CONFIG_FILENAME = 'nginx.conf'; + +/** Имя файла с базовым nginx-конфигом (http-блок), который кладется в артефакт. */ +export const BASE_NGINX_CONFIG_FILENAME = 'base-nginx.conf'; + +/** Порт, который nginx слушает внутри контейнера. */ +export const DEFAULT_NGINX_PORT = 8080; + +/** Корень, из которого nginx раздает статику. */ +export const DEFAULT_NGINX_ROOT_PATH = '/src'; + +/** Значения по умолчанию для базового конфига (http-блок). */ +export const DEFAULT_NGINX_BASE_CONF: ResolvedNginxBaseConf = { + workerProcesses: 2, + workerRlimitNoFile: 20000, + workerConnections: 19000, + eventsUse: 'epoll', + daemon: 'off', +}; diff --git a/packages/arui-scripts-artifacts/src/nginx/index.ts b/packages/arui-scripts-artifacts/src/nginx/index.ts new file mode 100644 index 00000000..9c60f31b --- /dev/null +++ b/packages/arui-scripts-artifacts/src/nginx/index.ts @@ -0,0 +1,9 @@ +export { renderNginxConf } from './templates/nginx.conf.template'; +export { renderBaseNginxConf } from './templates/base-nginx.conf.template'; +export { + BASE_NGINX_CONFIG_FILENAME, + DEFAULT_NGINX_BASE_CONF, + DEFAULT_NGINX_PORT, + DEFAULT_NGINX_ROOT_PATH, + NGINX_CONFIG_FILENAME, +} from './constants'; diff --git a/packages/arui-scripts-artifacts/src/templates/base-nginx.conf.template.ts b/packages/arui-scripts-artifacts/src/nginx/templates/base-nginx.conf.template.ts similarity index 77% rename from packages/arui-scripts-artifacts/src/templates/base-nginx.conf.template.ts rename to packages/arui-scripts-artifacts/src/nginx/templates/base-nginx.conf.template.ts index 307ca5cd..44570286 100644 --- a/packages/arui-scripts-artifacts/src/templates/base-nginx.conf.template.ts +++ b/packages/arui-scripts-artifacts/src/nginx/templates/base-nginx.conf.template.ts @@ -1,22 +1,13 @@ -import { type ResolvedArtifactsConfig } from '../types'; - -const baseNginxConfig = { - workerProcesses: 2, - workerRlimitNoFile: 20000, - workerConnections: 19000, - eventsUse: 'epoll', - daemon: 'off', -}; +import { type ResolvedArtifactsConfig } from '../../config/types'; +import { DEFAULT_NGINX_BASE_CONF } from '../constants'; /** - * http-блок nginx-конфига (базовый nginx.conf). Значения по умолчанию можно переопределить через - * `nginx` в опциях сборки. + * http-блок nginx-конфига (базовый nginx.conf). Значения приходят из `nginx.baseConf` уже + * донасыщенными дефолтами; если базовый конфиг выключен (`baseConf: null`), рендерится с дефолтами — + * решение о том, класть ли его в артефакт, принимает `renderTemplates`. */ export function renderBaseNginxConf(config: ResolvedArtifactsConfig): string { - const nginx = { - ...baseNginxConfig, - ...config.nginx, - }; + const nginx = config.nginx.baseConf ?? DEFAULT_NGINX_BASE_CONF; return ` worker_processes ${nginx.workerProcesses}; diff --git a/packages/arui-scripts-artifacts/src/templates/nginx.conf.template.ts b/packages/arui-scripts-artifacts/src/nginx/templates/nginx.conf.template.ts similarity index 71% rename from packages/arui-scripts-artifacts/src/templates/nginx.conf.template.ts rename to packages/arui-scripts-artifacts/src/nginx/templates/nginx.conf.template.ts index 1282c8b1..dda5b6fc 100644 --- a/packages/arui-scripts-artifacts/src/templates/nginx.conf.template.ts +++ b/packages/arui-scripts-artifacts/src/nginx/templates/nginx.conf.template.ts @@ -1,30 +1,23 @@ -import { type ResolvedArtifactsConfig } from '../types'; +import { type ResolvedArtifactsConfig } from '../../config/types'; /** * server-блок nginx-конфига, который раздает статику и (в не-clientOnly режиме) проксирует на nodejs. */ export function renderNginxConf(config: ResolvedArtifactsConfig): string { - const { - clientServerPort, - enablePreviousVersionHeaders, - clientOnly, - nginxRootPath, - buildPath, - serverPort, - publicPath, - } = config; + const { clientOnly, buildPath, serverPort, publicPath, nginx } = config; + const { port, rootPath, enablePreviousVersionHeaders } = nginx; return `client_max_body_size 20m; server { - listen ${clientServerPort}; + listen ${port}; server_tokens off; ${enablePreviousVersionHeaders ? 'brotli_auto_dictionary on;' : ''} ${ clientOnly ? `location / { - root ${nginxRootPath}/${buildPath}; + root ${rootPath}/${buildPath}; index index.html; }` : ` location / { @@ -36,14 +29,14 @@ server { location /${publicPath} { expires max; add_header Cache-Control public; - root ${nginxRootPath}/${buildPath}; + root ${rootPath}/${buildPath}; } location = /${publicPath}remoteEntry.js { add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; add_header Pragma "no-cache"; add_header Expires "0"; - root ${nginxRootPath}/${buildPath}; + root ${rootPath}/${buildPath}; types { text/javascript js; } @@ -52,7 +45,7 @@ server { location ~ /${publicPath}.*\\.js$ { expires max; add_header Cache-Control public; - root ${nginxRootPath}/${buildPath}; + root ${rootPath}/${buildPath}; types { text/javascript js; } diff --git a/packages/arui-scripts-artifacts/src/__tests__/render.test.ts b/packages/arui-scripts-artifacts/src/pipeline/__tests__/render.test.ts similarity index 74% rename from packages/arui-scripts-artifacts/src/__tests__/render.test.ts rename to packages/arui-scripts-artifacts/src/pipeline/__tests__/render.test.ts index 959d657d..07a7bffc 100644 --- a/packages/arui-scripts-artifacts/src/__tests__/render.test.ts +++ b/packages/arui-scripts-artifacts/src/pipeline/__tests__/render.test.ts @@ -1,11 +1,14 @@ -import { resolveArtifactsConfig } from '../config'; +import { resolveArtifactsConfig } from '../../config'; import { renderTemplates } from '../render'; const baseOptions = { cwd: __dirname, name: 'app', version: '1.0.0' }; describe('renderTemplates', () => { it('should render runtime dockerfile with the base image and start.sh', () => { - const config = resolveArtifactsConfig({ ...baseOptions, baseDockerImage: 'my/base:1.0.0' }); + const config = resolveArtifactsConfig({ + ...baseOptions, + docker: { baseImage: 'my/base:1.0.0' }, + }); const templates = renderTemplates({ config, variant: 'runtime' }); expect(templates.dockerfile).toContain('FROM my/base:1.0.0'); @@ -14,25 +17,44 @@ describe('renderTemplates', () => { }); it('should render compiled dockerfile with install command', () => { - const config = resolveArtifactsConfig({ ...baseOptions, yarnVersion: 'unavailable' }); + const config = resolveArtifactsConfig({ + ...baseOptions, + packageManager: { yarnVersion: 'unavailable' }, + }); const templates = renderTemplates({ config, variant: 'compiled' }); expect(templates.dockerfile).toContain('npm install --production'); expect(templates.dockerfile).toContain('npm cache clean --force'); }); - it('should not render base nginx conf when nginx is disabled', () => { - const config = resolveArtifactsConfig({ ...baseOptions, nginx: false }); + it('should not render base nginx conf when it is disabled', () => { + const config = resolveArtifactsConfig({ ...baseOptions, nginx: { baseConf: false } }); const templates = renderTemplates({ config }); expect(templates.nginxBaseConf).toBe(''); }); it('should render base nginx conf with custom worker processes', () => { - const config = resolveArtifactsConfig({ ...baseOptions, nginx: { workerProcesses: 9 } }); + const config = resolveArtifactsConfig({ + ...baseOptions, + nginx: { baseConf: { workerProcesses: 9 } }, + }); const templates = renderTemplates({ config }); expect(templates.nginxBaseConf).toContain('worker_processes 9;'); + // остальные значения донасыщаются дефолтами + expect(templates.nginxBaseConf).toContain('worker_connections 19000;'); + }); + + it('should render nginx server block with the nginx port and root path', () => { + const config = resolveArtifactsConfig({ + ...baseOptions, + nginx: { port: 9090, rootPath: '/app' }, + }); + const { nginxConf } = renderTemplates({ config }); + + expect(nginxConf).toContain('listen 9090;'); + expect(nginxConf).toContain('root /app/.build;'); }); it('should render client-only start script when clientOnly is set', () => { @@ -60,7 +82,10 @@ describe('renderTemplates', () => { }); it('should use compiled dockerfile when the variant comes from the config', () => { - const config = resolveArtifactsConfig({ ...baseOptions, variant: 'compiled' }); + const config = resolveArtifactsConfig({ + ...baseOptions, + docker: { variant: 'compiled' }, + }); const templates = renderTemplates({ config }); expect(templates.dockerfile).toContain('ADD --chown=nginx:nginx package.json'); diff --git a/packages/arui-scripts-artifacts/src/build-artifact.ts b/packages/arui-scripts-artifacts/src/pipeline/build-artifact.ts similarity index 89% rename from packages/arui-scripts-artifacts/src/build-artifact.ts rename to packages/arui-scripts-artifacts/src/pipeline/build-artifact.ts index 1e67ddc8..e5f3ab18 100644 --- a/packages/arui-scripts-artifacts/src/build-artifact.ts +++ b/packages/arui-scripts-artifacts/src/pipeline/build-artifact.ts @@ -1,5 +1,5 @@ -import { buildArchive } from './build-archive'; -import { buildDockerImage, type BuildDockerImageOptions } from './build-docker-image'; +import { buildArchive } from '../archive/build-archive'; +import { buildDockerImage, type BuildDockerImageOptions } from '../docker/build-docker-image'; export type BuildArtifactOptions = BuildDockerImageOptions; diff --git a/packages/arui-scripts-artifacts/src/host-pipeline.ts b/packages/arui-scripts-artifacts/src/pipeline/host-pipeline.ts similarity index 71% rename from packages/arui-scripts-artifacts/src/host-pipeline.ts rename to packages/arui-scripts-artifacts/src/pipeline/host-pipeline.ts index 80856790..e2178c2b 100644 --- a/packages/arui-scripts-artifacts/src/host-pipeline.ts +++ b/packages/arui-scripts-artifacts/src/pipeline/host-pipeline.ts @@ -2,8 +2,8 @@ import path from 'path'; import fs from 'fs-extra'; -import { exec } from './utils/exec'; -import { type ResolvedArtifactsConfig } from './types'; +import { type ResolvedArtifactsConfig } from '../config/types'; +import { exec } from '../utils/exec'; /** Хук, вызываемый после очистки `buildPath`, но до сборки приложения. */ export type BeforeBuildHook = (config: ResolvedArtifactsConfig) => void | Promise; @@ -11,13 +11,15 @@ export type BeforeBuildHook = (config: ResolvedArtifactsConfig) => void | Promis /** * Шаги, которые выполняются на хосте перед упаковкой артефакта: очистка прошлой сборки, сборка * приложения и удаление dev-зависимостей. Общие для docker-образа и tar-архива — какие именно шаги - * выполнятся, определяют `cleanBuildPath`, `buildCommand` и `removeDevDependencies`. + * выполнятся, определяет секция `build` конфига. */ export async function runHostPipeline( config: ResolvedArtifactsConfig, beforeBuild?: BeforeBuildHook, ): Promise { - if (config.cleanBuildPath) { + const { build, packageManager } = config; + + if (build.cleanBuildPath) { await fs.remove(path.resolve(config.cwd, config.buildPath)); } @@ -25,15 +27,15 @@ export async function runHostPipeline( await beforeBuild(config); } - if (config.buildCommand) { + if (build.command) { console.time('Build application time'); - await exec(config.buildCommand); + await exec(build.command); console.timeEnd('Build application time'); } - if (config.removeDevDependencies && config.pruneCommand) { + if (build.removeDevDependencies && packageManager.pruneCommand) { console.time('Remove dev dependencies time'); - await exec(config.pruneCommand); + await exec(packageManager.pruneCommand); console.timeEnd('Remove dev dependencies time'); } } diff --git a/packages/arui-scripts-artifacts/src/pipeline/index.ts b/packages/arui-scripts-artifacts/src/pipeline/index.ts new file mode 100644 index 00000000..5024115e --- /dev/null +++ b/packages/arui-scripts-artifacts/src/pipeline/index.ts @@ -0,0 +1,3 @@ +export { buildArtifact, type BuildArtifactOptions } from './build-artifact'; +export { runHostPipeline, type BeforeBuildHook } from './host-pipeline'; +export { renderTemplates, type RenderTemplatesParams } from './render'; diff --git a/packages/arui-scripts-artifacts/src/render.ts b/packages/arui-scripts-artifacts/src/pipeline/render.ts similarity index 56% rename from packages/arui-scripts-artifacts/src/render.ts rename to packages/arui-scripts-artifacts/src/pipeline/render.ts index d0476f0a..845964df 100644 --- a/packages/arui-scripts-artifacts/src/render.ts +++ b/packages/arui-scripts-artifacts/src/pipeline/render.ts @@ -1,34 +1,30 @@ import { - renderBaseNginxConf, - renderDockerfile, - renderDockerfileCompiled, - renderNginxConf, - renderStartScript, -} from './templates'; -import { + type ArtifactTemplateOverrides, + type ArtifactTemplates, type DockerfileVariant, - type DockerTemplateKey, - type DockerTemplateOverrides, - type DockerTemplates, type RenderedTemplates, type ResolvedArtifactsConfig, + type TemplateKey, type TemplateRenderer, -} from './types'; - -export { type DockerfileVariant }; +} from '../config/types'; +import { renderDockerfile } from '../docker/templates/dockerfile.template'; +import { renderDockerfileCompiled } from '../docker/templates/dockerfile-compiled.template'; +import { renderBaseNginxConf } from '../nginx/templates/base-nginx.conf.template'; +import { renderNginxConf } from '../nginx/templates/nginx.conf.template'; +import { renderStartScript } from '../start-script/start.template'; export type RenderTemplatesParams = { config: ResolvedArtifactsConfig; - /** Какой Dockerfile генерировать. По умолчанию — `config.variant`. */ + /** Какой Dockerfile генерировать. По умолчанию — `config.docker.variant`. */ variant?: DockerfileVariant; /** Полная замена рендереров отдельных шаблонов. */ - templates?: DockerTemplates; + templates?: ArtifactTemplates; /** Точечные оверрайды поверх сгенерированных шаблонов. */ - overrides?: DockerTemplateOverrides; + overrides?: ArtifactTemplateOverrides; }; function renderTemplate( - key: DockerTemplateKey, + key: TemplateKey, defaultRenderer: TemplateRenderer, params: RenderTemplatesParams, ): string { @@ -47,23 +43,22 @@ function renderTemplate( } /** - * Рендерит все файлы, которые кладутся в образ, применяя (в порядке приоритета) кастомные рендереры - * из `templates` и точечные оверрайды из `overrides`. Результат готов к передаче в + * Рендерит все файлы, которые кладутся в артефакт, применяя (в порядке приоритета) кастомные + * рендереры из `templates` и точечные оверрайды из `overrides`. Результат готов к передаче в * `prepareFilesForDocker`. */ export function renderTemplates(params: RenderTemplatesParams): RenderedTemplates { - const { config, variant = config.variant } = params; + const { config, variant = config.docker.variant } = params; - const dockerfileKey: DockerTemplateKey = - variant === 'compiled' ? 'dockerfileCompiled' : 'dockerfile'; + const dockerfileKey: TemplateKey = variant === 'compiled' ? 'dockerfileCompiled' : 'dockerfile'; const dockerfileRenderer = variant === 'compiled' ? renderDockerfileCompiled : renderDockerfile; return { dockerfile: renderTemplate(dockerfileKey, dockerfileRenderer, params), nginxConf: renderTemplate('nginxConf', renderNginxConf, params), - // при выключенном nginx базовый конфиг не попадает в образ, поэтому и рендерить (и звать + // при выключенном базовом конфиге он не попадает в артефакт, поэтому и рендерить (и звать // оверрайд, результат которого все равно будет отброшен) нечего - nginxBaseConf: config.nginx + nginxBaseConf: config.nginx.baseConf ? renderTemplate('baseNginxConf', renderBaseNginxConf, params) : '', startScript: renderTemplate('startScript', renderStartScript, params), diff --git a/packages/arui-scripts-artifacts/src/start-script/constants.ts b/packages/arui-scripts-artifacts/src/start-script/constants.ts new file mode 100644 index 00000000..32a00760 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/start-script/constants.ts @@ -0,0 +1,5 @@ +/** Имя entrypoint-скрипта, который кладется в артефакт. */ +export const START_SCRIPT_FILENAME = 'start.sh'; + +/** Имя файла с рантайм-конфигом клиентского приложения. */ +export const ENV_CONFIG_FILENAME = 'env-config.json'; diff --git a/packages/arui-scripts-artifacts/src/start-script/index.ts b/packages/arui-scripts-artifacts/src/start-script/index.ts new file mode 100644 index 00000000..d5f8c5bc --- /dev/null +++ b/packages/arui-scripts-artifacts/src/start-script/index.ts @@ -0,0 +1,2 @@ +export { renderStartScript } from './start.template'; +export { ENV_CONFIG_FILENAME, START_SCRIPT_FILENAME } from './constants'; diff --git a/packages/arui-scripts-artifacts/src/templates/start.template.ts b/packages/arui-scripts-artifacts/src/start-script/start.template.ts similarity index 89% rename from packages/arui-scripts-artifacts/src/templates/start.template.ts rename to packages/arui-scripts-artifacts/src/start-script/start.template.ts index 7bd33d18..f58ab178 100644 --- a/packages/arui-scripts-artifacts/src/templates/start.template.ts +++ b/packages/arui-scripts-artifacts/src/start-script/start.template.ts @@ -1,9 +1,10 @@ -import { ENV_CONFIG_FILENAME } from '../constants'; -import { type ResolvedArtifactsConfig } from '../types'; +import { type ResolvedArtifactsConfig } from '../config/types'; + +import { ENV_CONFIG_FILENAME } from './constants'; /** - * start.sh — entrypoint образа. Для serverful-режима поднимает nginx + nodejs, для clientOnly — - * подставляет env-config и запускает nginx. + * start.sh — entrypoint артефакта: используется и docker-образом, и tar-архивом. Для serverful-режима + * поднимает nginx + nodejs, для clientOnly — подставляет env-config и запускает nginx. */ export function renderStartScript(config: ResolvedArtifactsConfig): string { const { clientOnly, buildPath, serverOutput } = config; diff --git a/packages/arui-scripts-artifacts/src/templates/index.ts b/packages/arui-scripts-artifacts/src/templates/index.ts deleted file mode 100644 index 126a829e..00000000 --- a/packages/arui-scripts-artifacts/src/templates/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { renderDockerfile } from './dockerfile.template'; -export { renderDockerfileCompiled } from './dockerfile-compiled.template'; -export { renderNginxConf } from './nginx.conf.template'; -export { renderBaseNginxConf } from './base-nginx.conf.template'; -export { renderStartScript } from './start.template'; diff --git a/packages/arui-scripts-artifacts/src/utils/docker-build.ts b/packages/arui-scripts-artifacts/src/utils/docker-build.ts deleted file mode 100644 index 4585f4df..00000000 --- a/packages/arui-scripts-artifacts/src/utils/docker-build.ts +++ /dev/null @@ -1,267 +0,0 @@ -import path from 'path'; - -import fs from 'fs-extra'; -import satisfies from 'semver/functions/satisfies'; -import shell from 'shelljs'; - -import { - BASE_NGINX_CONFIG_FILENAME, - DEFAULT_PLATFORM, - NGINX_CONFIG_FILENAME, - PLATFORM_FLAG_MIN_DOCKER_VERSION, -} from '../constants'; -import { type RenderedTemplates, type ResolvedArtifactsConfig } from '../types'; - -export type BuildParams = { - pathToTempDir: string; - imageFullName: string; - tempDirName: string; -}; - -/** Символы, которые шелл не трактует специальным образом — такие значения можно не экранировать. */ -const SHELL_SAFE = /^[\w.:/=@+-]+$/; - -/** - * Экранирует значение для подстановки в shell-команду. Нужно потому, что итоговая строка - * выполняется через шелл, а значения (`extraBuildArgs`, имя образа, контекст) приходят из - * пользовательского конфига и переменных окружения CI. - */ -export function shellQuote(value: string): string { - if (SHELL_SAFE.test(value)) { - return value; - } - - return `'${value.replace(/'/g, "'\\''")}'`; -} - -/** - * Собирает параметры сборки (полное имя образа и пути) из конфига. - */ -export function getBuildParams(config: ResolvedArtifactsConfig): BuildParams { - const { dockerRegistry, name, version, cwd, tempDirName } = config; - const pathToTempDir = path.join(cwd, tempDirName); - const imageFullName = `${dockerRegistry ? `${dockerRegistry}/` : ''}${name}:${version}`; - - return { pathToTempDir, imageFullName, tempDirName }; -} - -/** - * Разбирает аргументы командной строки вида `name=... version=... registry=...` и накладывает их - * поверх конфига. Неизвестные аргументы игнорируются с предупреждением. - */ -export function applyCommandLineArguments( - config: ResolvedArtifactsConfig, - commandLineArguments: string[], -): ResolvedArtifactsConfig { - const next = { ...config }; - - commandLineArguments.forEach((arg) => { - let [argName, argValue] = arg.split('='); - - argName = argName.toLowerCase().trim(); - argValue = argValue ? argValue.trim() : ''; - switch (argName) { - case 'version': - next.version = argValue; - break; - case 'name': - next.name = argValue; - break; - case 'registry': - next.dockerRegistry = argValue; - break; - default: - console.warn(`Unknown argument ${argName}`); - } - }); - - return next; -} - -/** - * Как {@link getBuildParams}, но с учетом аргументов командной строки (по умолчанию — `process.argv`, - * начиная с третьего, как в CLI arui-scripts). - */ -export function getBuildParamsFromArgs( - config: ResolvedArtifactsConfig, - argv: string[] = process.argv.slice(3), -): BuildParams { - return getBuildParams(applyCommandLineArguments(config, argv)); -} - -type PrepareFilesForDockerParams = { - config: ResolvedArtifactsConfig; - templates: RenderedTemplates; -}; - -export type PrepareFilesForDockerResult = { - /** - * Возвращает `.dockerignore` проекта в исходное состояние. Вызывать после `docker build` — - * иначе дописанный `node_modules` останется в рабочей копии пользователя. - */ - restoreDockerIgnore: () => Promise; -}; - -/** - * Готовит временную директорию со всеми файлами, необходимыми для `docker build`: Dockerfile, - * nginx-конфиги и start.sh. Локальные файлы проекта (если разрешены и заданы) имеют приоритет над - * сгенерированными шаблонами. - */ -export async function prepareFilesForDocker({ - config, - templates, -}: PrepareFilesForDockerParams): Promise { - const { - cwd, - nginx, - localFiles, - allowLocalDockerfile, - allowLocalStartScript, - addNodeModulesToDockerIgnore, - } = config; - const { pathToTempDir } = getBuildParams(config); - - await fs.emptyDir(pathToTempDir); - - let nginxBaseConf = ''; - - if (nginx) { - nginxBaseConf = localFiles.nginxBaseConf - ? await fs.readFile(localFiles.nginxBaseConf, 'utf8') - : templates.nginxBaseConf; - } - - const nginxConf = localFiles.nginxConf - ? await fs.readFile(localFiles.nginxConf, 'utf8') - : templates.nginxConf; - - const dockerfile = - localFiles.dockerfile && allowLocalDockerfile - ? await fs.readFile(localFiles.dockerfile, 'utf8') - : templates.dockerfile; - - const startScript = - localFiles.startScript && allowLocalStartScript - ? await fs.readFile(localFiles.startScript, 'utf8') - : templates.startScript; - - const dockerIgnoreFilePath = path.join(cwd, '.dockerignore'); - // запоминаем исходное состояние, чтобы вернуть файл как было: `node_modules` нужен только на - // время сборки образа и не должен оставаться в рабочей копии - const originalDockerIgnore = - addNodeModulesToDockerIgnore && fs.existsSync(dockerIgnoreFilePath) - ? await fs.readFile(dockerIgnoreFilePath, 'utf-8') - : null; - - const dockerIgnoreFileContent = - addNodeModulesToDockerIgnore && - (await getAndModifyDockerIgnoreContent(dockerIgnoreFilePath)); - - await Promise.all( - [ - fs.writeFile(path.join(pathToTempDir, 'Dockerfile'), dockerfile, 'utf8'), - fs.writeFile(path.join(pathToTempDir, NGINX_CONFIG_FILENAME), nginxConf, 'utf8'), - nginxBaseConf && - fs.writeFile( - path.join(pathToTempDir, BASE_NGINX_CONFIG_FILENAME), - nginxBaseConf, - 'utf8', - ), - fs.writeFile(path.join(pathToTempDir, 'start.sh'), startScript, { - encoding: 'utf8', - mode: 0o555, - }), - addNodeModulesToDockerIgnore && - dockerIgnoreFileContent && - fs.writeFile(dockerIgnoreFilePath, dockerIgnoreFileContent, 'utf-8'), - ].filter(Boolean), - ); - - return { - restoreDockerIgnore: async () => { - if (!addNodeModulesToDockerIgnore) { - return; - } - - if (originalDockerIgnore === null) { - await fs.remove(dockerIgnoreFilePath); - } else { - await fs.writeFile(dockerIgnoreFilePath, originalDockerIgnore, 'utf-8'); - } - }, - }; -} - -/** - * Проверяет, что версия docker (клиент и сервер) удовлетворяет semver-диапазону. - */ -export function dockerVersionSatisfies(request: string) { - const dockerServerVersion = shell.exec("docker version --format '{{.Server.Version}}'", { - silent: true, - }); - const dockerClientVersion = shell.exec("docker version --format '{{.Client.Version}}'", { - silent: true, - }); - - return ( - satisfies(dockerServerVersion.toString(), request) && - satisfies(dockerClientVersion.toString(), request) - ); -} - -/** - * Вычисляет значение флага `--platform` согласно настройке `platform` в конфиге. - */ -export function getPlatformFlag(config: ResolvedArtifactsConfig): string { - const { platform } = config; - - if (platform === 'auto') { - // на маках с m1 без флага docker пытается вытянуть базовый образ под свою платформу и падает, - // но сам флаг поддерживается без экспериментальных флагов только начиная с docker 20.10.21. - return dockerVersionSatisfies(PLATFORM_FLAG_MIN_DOCKER_VERSION) - ? `--platform ${DEFAULT_PLATFORM}` - : ''; - } - - if (platform) { - return `--platform ${platform}`; - } - - return ''; -} - -/** - * Формирует команду `docker build` для сгенерированной ранее временной директории. - */ -export function getDockerBuildCommand(config: ResolvedArtifactsConfig): string { - const { tempDirName, context, extraBuildArgs } = config; - const { imageFullName } = getBuildParams(config); - - const platformFlag = getPlatformFlag(config); - - const extraArgs = Object.entries(extraBuildArgs) - .map(([key, value]) => `--build-arg ${key}=${shellQuote(value)}`) - .join(' '); - - return `docker build ${platformFlag} \ - -f ${shellQuote(`./${tempDirName}/Dockerfile`)} \ - --build-arg START_SH_LOCATION=${shellQuote(`./${tempDirName}/start.sh`)} \ - --build-arg NGINX_CONF_LOCATION=${shellQuote(`./${tempDirName}/${NGINX_CONFIG_FILENAME}`)} \ - --build-arg NGINX_BASE_CONF_LOCATION=${shellQuote( - `./${tempDirName}/${BASE_NGINX_CONFIG_FILENAME}`, - )} \ - ${extraArgs} \ - -t ${shellQuote(imageFullName)} ${shellQuote(context)}`; -} - -async function getAndModifyDockerIgnoreContent(dockerIgnoreFilePath: string) { - if (fs.existsSync(dockerIgnoreFilePath)) { - return fs - .readFile(dockerIgnoreFilePath, 'utf-8') - .then((ignores) => `${ignores}\nnode_modules`); - } - - await fs.createFile(dockerIgnoreFilePath); - - return 'node_modules'; -} diff --git a/packages/arui-scripts-artifacts/src/utils/shell.ts b/packages/arui-scripts-artifacts/src/utils/shell.ts new file mode 100644 index 00000000..908ead1e --- /dev/null +++ b/packages/arui-scripts-artifacts/src/utils/shell.ts @@ -0,0 +1,15 @@ +/** Символы, которые шелл не трактует специальным образом — такие значения можно не экранировать. */ +const SHELL_SAFE = /^[\w.:/=@+-]+$/; + +/** + * Экранирует значение для подстановки в shell-команду. Нужно потому, что итоговая строка + * выполняется через шелл, а значения (`buildArgs`, имя образа, контекст) приходят из + * пользовательского конфига и переменных окружения CI. + */ +export function shellQuote(value: string): string { + if (SHELL_SAFE.test(value)) { + return value; + } + + return `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/packages/arui-scripts-artifacts/src/utils/yarn.ts b/packages/arui-scripts-artifacts/src/utils/yarn.ts index d9da16e8..42b653cf 100644 --- a/packages/arui-scripts-artifacts/src/utils/yarn.ts +++ b/packages/arui-scripts-artifacts/src/utils/yarn.ts @@ -3,7 +3,7 @@ import path from 'path'; import fs from 'fs-extra'; import shell from 'shelljs'; -import { type YarnVersion } from '../types'; +import { type YarnVersion } from '../config/types'; type GetYarnVersionParams = { useYarn: boolean; diff --git a/packages/arui-scripts/docs/artifact.md b/packages/arui-scripts/docs/artifact.md index ef515416..40c16012 100644 --- a/packages/arui-scripts/docs/artifact.md +++ b/packages/arui-scripts/docs/artifact.md @@ -1,6 +1,10 @@ Настройки сборки артефакта === +> ⚠️ Сборка артефактов живет в отдельном пакете [@alfalab/scripts-artifacts](../../arui-scripts-artifacts/README.md). +> Настройки и оверрайды артефакта в конфиге arui-scripts объявлены устаревшими и будут удалены в +> следующей мажорной версии — новые проекты настраивают сборку через `arui-scripts-artifacts.ts`. + ## docker По-умолчанию, базовым образом для сборки артефакта является [alpine-node-nginx](../../alpine-node-nginx). @@ -9,13 +13,13 @@ Вы также можете переопределить полностью процесс сборки docker-образа используя механизм [overrides](#тонкая-настройка) или создав в корневой директории проекта `Dockerfile` содержащий необходимый набор инструкций. -Пример [Dockerfile](src/templates/dockerfile.template.ts). +Пример [Dockerfile](../../arui-scripts-artifacts/src/docker/templates/dockerfile.template.ts). `Dockerfile` в корне проекта имеет приоритет над overrides. Чтобы переопределить скрипт запуска, воспользуйтесь механизмом [overrides](#тонкая-настройка) или создайте в корневой директории проекта `start.sh` файл содержащий необходимый набор инструкций. -Пример [start.sh](src/templates/start.template.ts). +Пример [start.sh](../../arui-scripts-artifacts/src/start-script/start.template.ts). `start.sh` в корне проекта имеет приоритет над overrides. @@ -23,7 +27,7 @@ ## Концигурация nginx Несмотря на то, что nginx имеет готовый конфиг с роутингом, иногда возникает необходимость добавлять свои роуты. Вы можете использовать механизм [overrides](overrides.md). -Так же вы можете создать `nginx.conf` на уровне проекта со своими роутами. Пример конфига [тут](../src/templates/nginx.conf.template.ts). +Так же вы можете создать `nginx.conf` на уровне проекта со своими роутами. Пример конфига [тут](../../arui-scripts-artifacts/src/nginx/templates/nginx.conf.template.ts). Файл nginx.conf имеет приоритет над оверрайдами. ### Использование env переменных в nginx.conf diff --git a/packages/arui-scripts/docs/overrides.md b/packages/arui-scripts/docs/overrides.md index d8763ce1..e64c8bc8 100644 --- a/packages/arui-scripts/docs/overrides.md +++ b/packages/arui-scripts/docs/overrides.md @@ -64,17 +64,17 @@ export default overrides; Альтернативно вы можете использовать любые методы передачи списка браузеров, поддерживаемые пакетом browserslist. Ключи: `browsers`, `supportingBrowsers` - `supportingNode` - список поддерживаемых версий nodejs в формате [browserslist](https://github.com/browserslist/browserslist). -- `Dockerfile` - докерфайл, который будет использоваться для сборки контейнера. - Базовый шаблон [тут](../src/templates/dockerfile.template.ts). +- `Dockerfile` - :warning: устарел, см. врезку ниже. Докерфайл, который будет использоваться для сборки контейнера. + Базовый шаблон [тут](../../arui-scripts-artifacts/src/docker/templates/dockerfile.template.ts). [`Dockerfile` в корне проекта](#docker) имеет приоритет над overrides. -- `DockerfileCompiled` - докерфайл, который будет использоваться для сборки контейнера при использовании команды `arui-scripts docker-build:compiled` -- `nginx` - шаблон конфигурации для nginx внутри контейнера. - Базовый шаблон [тут](../src/templates/nginx.conf.template.ts). +- `DockerfileCompiled` - :warning: устарел. Докерфайл, который будет использоваться для сборки контейнера при использовании команды `arui-scripts docker-build:compiled` +- `nginx` - :warning: устарел. Шаблон конфигурации для nginx внутри контейнера. + Базовый шаблон [тут](../../arui-scripts-artifacts/src/nginx/templates/nginx.conf.template.ts). [Файл `nginx.conf`](nginx.md) в корне имеет приоритет над оверрайдами. -- `nginxConf` - шаблон базовой конфигурации для nginx внутри контейнера - Базовый шаблон аналогичный тому, который добавлется в базовый образ [тут](../src/templates/base-nginx.conf.template.ts). +- `nginxConf` - :warning: устарел. Шаблон базовой конфигурации для nginx внутри контейнера + Базовый шаблон аналогичный тому, который добавлется в базовый образ [тут](../../arui-scripts-artifacts/src/nginx/templates/base-nginx.conf.template.ts). [Файл `base-nginx.conf`](base-nginx.md) в корне имеет приоритет над оверрайдами. -- `start.sh` - шаблон entrypoint докер контейнера. Базовый шаблон [тут](../src/templates/start.template.ts). +- `start.sh` - :warning: устарел. Шаблон entrypoint докер контейнера. Базовый шаблон [тут](../../arui-scripts-artifacts/src/start-script/start.template.ts). - `serverExternalsExemptions` - список модулей, которые не будут добавлены в список внешних зависимостей сервера. [Подробнее](caveats.md#node-externals). - `html` - шаблон для htmlWebpackPlugin, будет использоваться только в режиме [`clientOnly`](./settings.md#clientonly). - `swc-client` - конфигурация `swc` для клиентского кода. Ключи: `swc`, `swcClient`. @@ -83,6 +83,12 @@ export default overrides; Для некоторых конфигураций определены несколько ключей, они будут применяться в том порядке, в котором они приведены в этом файле. +> ⚠️ Оверрайды файлов артефакта поставки (`Dockerfile`, `DockerfileCompiled`, `nginx`, `nginxConf`, +> `start.sh`) объявлены устаревшими и будут удалены в следующей мажорной версии arui-scripts. +> Переносите их в секцию `overrides` конфига `arui-scripts-artifacts.ts` +> ([таблица соответствия](../../arui-scripts-artifacts/README.md#миграция-с-arui-scripts-docker-build)). +> Команды сборки предупреждают о таких оверрайдах в консоли. + ### Создание дополнительных конфигураций для webpack На некоторых проектах может потребоваться создать дополнительные конфигурации для webpack. Например, для создания service worker'а (или любых других кейсов). Для этого можно использовать функцию-хелпер `createSingleWebpackConfig`: diff --git a/packages/arui-scripts/docs/settings.md b/packages/arui-scripts/docs/settings.md index b7d3db7d..d928c037 100644 --- a/packages/arui-scripts/docs/settings.md +++ b/packages/arui-scripts/docs/settings.md @@ -132,6 +132,9 @@ export default settings; #### additionalBuildPath Массив путей, которые попадут в архив при использовании [команды archive-build](./commands.md#archive-build). По умолчанию `['config']`. +:warning: Настройка устарела, см. [настройки сборки артефактов](#настройки-сборки-артефактов). +Замена — `archive.additionalPaths` в конфиге `arui-scripts-artifacts.ts`. + #### statsOutputFilename Имя [stats-файла](https://webpack.js.org/api/stats/), которое будет использоваться в [bundle-analyze команде](./commands.md#bundle-analyze). По умолчанию `stats.json` @@ -166,6 +169,13 @@ const settings = { ### Настройки сборки артефактов +> ⚠️ **Объявлены устаревшими.** Эти настройки arui-scripts просто транслирует в +> [@alfalab/scripts-artifacts](../../arui-scripts-artifacts/README.md), где и живут их значения по +> умолчанию. В следующей мажорной версии arui-scripts они будут удалены — переносите их в конфиг +> `arui-scripts-artifacts.ts` в корне проекта (таблица соответствия — в +> [README пакета](../../arui-scripts-artifacts/README.md#миграция-с-arui-scripts-docker-build)). +> Команды сборки предупреждают о таких настройках в консоли. + #### dockerRegistry Адрес используемого docker registry, по умолчанию `''`, то есть используется публичный registry diff --git a/packages/arui-scripts/src/commands/archive-build/index.ts b/packages/arui-scripts/src/commands/archive-build/index.ts index 18f01fc2..4e0d4b9e 100644 --- a/packages/arui-scripts/src/commands/archive-build/index.ts +++ b/packages/arui-scripts/src/commands/archive-build/index.ts @@ -4,12 +4,13 @@ import { getArtifactsOptions } from '../util/artifacts-options'; (async () => { try { - await buildArchive({ - ...getArtifactsOptions(), - // archive-build исторически всегда удаляет dev-зависимости, независимо от - // removeDevDependenciesDuringDockerBuild - removeDevDependencies: true, - }); + await buildArchive( + getArtifactsOptions({ + // archive-build исторически всегда удаляет dev-зависимости, независимо от + // removeDevDependenciesDuringDockerBuild + build: { removeDevDependencies: true }, + }), + ); } catch { // buildArchive уже напечатал ошибку (и стек, если включен debug) process.exit(1); diff --git a/packages/arui-scripts/src/commands/docker-build-compiled/index.ts b/packages/arui-scripts/src/commands/docker-build-compiled/index.ts index 1dcae3b2..7ce3e82d 100644 --- a/packages/arui-scripts/src/commands/docker-build-compiled/index.ts +++ b/packages/arui-scripts/src/commands/docker-build-compiled/index.ts @@ -5,11 +5,10 @@ import { getArtifactsOptions } from '../util/artifacts-options'; (async () => { try { await buildDockerImage({ - ...getArtifactsOptions(), - variant: 'compiled', - allowLocalDockerfile: false, - allowLocalStartScript: false, - addNodeModulesToDockerIgnore: true, + ...getArtifactsOptions({ + docker: { variant: 'compiled', addNodeModulesToDockerIgnore: true }, + localFiles: { allowDockerfile: false, allowStartScript: false }, + }), argv: process.argv.slice(3), }); } catch { diff --git a/packages/arui-scripts/src/commands/docker-build/index.ts b/packages/arui-scripts/src/commands/docker-build/index.ts index 75f0fcb0..b9f2d307 100644 --- a/packages/arui-scripts/src/commands/docker-build/index.ts +++ b/packages/arui-scripts/src/commands/docker-build/index.ts @@ -5,11 +5,10 @@ import { getArtifactsOptions } from '../util/artifacts-options'; (async () => { try { await buildDockerImage({ - ...getArtifactsOptions(), - variant: 'runtime', - allowLocalDockerfile: true, - allowLocalStartScript: true, - addNodeModulesToDockerIgnore: false, + ...getArtifactsOptions({ + docker: { variant: 'runtime', addNodeModulesToDockerIgnore: false }, + localFiles: { allowDockerfile: true, allowStartScript: true }, + }), argv: process.argv.slice(3), }); } catch { diff --git a/packages/arui-scripts/src/commands/util/__tests__/artifacts-deprecations.tests.ts b/packages/arui-scripts/src/commands/util/__tests__/artifacts-deprecations.tests.ts new file mode 100644 index 00000000..aac4337a --- /dev/null +++ b/packages/arui-scripts/src/commands/util/__tests__/artifacts-deprecations.tests.ts @@ -0,0 +1,61 @@ +/* eslint-disable global-require */ +/* eslint-disable @typescript-eslint/no-var-requires */ +jest.mock('../../../configs/app-configs', () => ({ + configs: { + overridesPath: ['overrides'], + // настройки сборки артефактов, которые проект не задавал, приезжают сюда как undefined + dockerRegistry: undefined, + baseDockerImage: 'my/base:1.0.0', + nginx: undefined, + }, +})); + +beforeEach(() => { + jest.resetModules(); + jest.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +it('should warn about deprecated settings the project actually sets', () => { + jest.doMock('overrides', () => ({}), { virtual: true }); + + const { warnAboutArtifactsDeprecations } = require('../artifacts-deprecations'); + + warnAboutArtifactsDeprecations(); + + expect(console.warn).toHaveBeenCalledTimes(1); + + const message = (console.warn as jest.Mock).mock.calls[0][0]; + + expect(message).toContain('baseDockerImage → docker.baseImage'); + // не заданные проектом настройки в предупреждение не попадают + expect(message).not.toContain('dockerRegistry'); +}); + +it('should warn about deprecated template override keys', () => { + jest.doMock('overrides', () => ({ 'start.sh': (config: string) => config }), { + virtual: true, + }); + + const { warnAboutArtifactsDeprecations } = require('../artifacts-deprecations'); + + warnAboutArtifactsDeprecations(); + + const message = (console.warn as jest.Mock).mock.calls[0][0]; + + expect(message).toContain('оверрайд start.sh → overrides.startScript'); +}); + +it('should warn only once per process', () => { + jest.doMock('overrides', () => ({}), { virtual: true }); + + const { warnAboutArtifactsDeprecations } = require('../artifacts-deprecations'); + + warnAboutArtifactsDeprecations(); + warnAboutArtifactsDeprecations(); + + expect(console.warn).toHaveBeenCalledTimes(1); +}); diff --git a/packages/arui-scripts/src/commands/util/artifacts-deprecations.ts b/packages/arui-scripts/src/commands/util/artifacts-deprecations.ts new file mode 100644 index 00000000..b52a4388 --- /dev/null +++ b/packages/arui-scripts/src/commands/util/artifacts-deprecations.ts @@ -0,0 +1,74 @@ +import { configs } from '../../configs/app-configs'; +import { hasOverride } from '../../configs/util/apply-overrides'; + +/** + * Настройки сборки артефактов, которые остались в arui-scripts только ради обратной совместимости. + * В следующей мажорной версии они будут удалены — их место в конфиге `arui-scripts-artifacts.ts`. + * + * Значение — путь до той же настройки в @alfalab/scripts-artifacts. + */ +const DEPRECATED_SETTINGS = { + dockerRegistry: 'docker.registry', + baseDockerImage: 'docker.baseImage', + runFromNonRootUser: 'docker.runFromNonRootUser', + nginxRootPath: 'nginx.rootPath', + nginx: 'nginx.baseConf', + archiveName: 'archive.name', + additionalBuildPath: 'archive.additionalPaths', + removeDevDependenciesDuringDockerBuild: 'build.removeDevDependencies', +} as const; + +/** + * Ключи оверрайдов шаблонов из `arui-scripts.overrides.ts` и их замена в конфиге артефактов. + * + * Напоминание: в arui-scripts `nginx` — это server-блок, а `nginxConf` — базовый http-блок; в + * @alfalab/scripts-artifacts они названы по смыслу, поэтому имена не совпадают. + */ +const DEPRECATED_OVERRIDES = { + Dockerfile: 'overrides.dockerfile', + DockerfileCompiled: 'overrides.dockerfileCompiled', + nginx: 'overrides.nginxConf', + nginxConf: 'overrides.baseNginxConf', + 'start.sh': 'overrides.startScript', +} as const; + +let warned = false; + +/** + * Предупреждает о настройках и оверрайдах сборки артефактов, которые задает проект. Вызывается из + * команд сборки (docker-build, docker-build:compiled, archive-build), поэтому при обычной разработке + * в консоль ничего не сыпется. + * + * Дефолты этих настроек живут в @alfalab/scripts-artifacts, а в конфиге arui-scripts они + * `undefined` — так что «настройка задана» здесь означает именно «проект ее переопределил». + */ +export function warnAboutArtifactsDeprecations() { + if (warned) { + return; + } + warned = true; + + const usedSettings = (Object.keys(DEPRECATED_SETTINGS) as Array) + .filter((setting) => configs[setting] !== undefined) + .map((setting) => ` ${setting} → ${DEPRECATED_SETTINGS[setting]}`); + + const usedOverrides = (Object.keys(DEPRECATED_OVERRIDES) as Array< + keyof typeof DEPRECATED_OVERRIDES + >) + .filter((key) => hasOverride(key)) + .map((key) => ` оверрайд ${key} → ${DEPRECATED_OVERRIDES[key]}`); + + if (!usedSettings.length && !usedOverrides.length) { + return; + } + + console.warn( + [ + 'Настройки сборки артефактов в arui-scripts объявлены устаревшими и будут удалены в следующей мажорной версии.', + 'Перенесите их в конфиг @alfalab/scripts-artifacts (arui-scripts-artifacts.ts в корне проекта):', + ...usedSettings, + ...usedOverrides, + 'Подробнее: https://github.com/core-ds/arui-scripts/tree/master/packages/arui-scripts-artifacts#миграция-с-arui-scripts-docker-build', + ].join('\n'), + ); +} diff --git a/packages/arui-scripts/src/commands/util/artifacts-options.ts b/packages/arui-scripts/src/commands/util/artifacts-options.ts index 2e69ffeb..d21fa19f 100644 --- a/packages/arui-scripts/src/commands/util/artifacts-options.ts +++ b/packages/arui-scripts/src/commands/util/artifacts-options.ts @@ -1,6 +1,6 @@ import { type ArtifactsOptions, - type DockerTemplateOverrides, + type ArtifactTemplateOverrides, resolveArtifactsConfig, type ResolvedArtifactsConfig, } from '@alfalab/scripts-artifacts'; @@ -8,15 +8,20 @@ import { import { configs } from '../../configs/app-configs'; import { applyOverrides } from '../../configs/util/apply-overrides'; +import { warnAboutArtifactsDeprecations } from './artifacts-deprecations'; + /** * Оверрайды шаблонов из `arui-scripts.overrides.ts`. Ключи в @alfalab/scripts-artifacts переименованы, * поэтому здесь мы явно транслируем их в исторические имена arui-scripts. * + * @deprecated Слой обратной совместимости: в следующей мажорной версии оверрайды шаблонов останутся + * только в конфиге @alfalab/scripts-artifacts (`overrides`). + * * Обратите внимание: в arui-scripts `nginx` — это server-блок (`nginx.conf`), а `nginxConf` — * базовый http-блок (`base-nginx.conf`). Имена исторически перепутаны, и эта таблица — единственное * место, где это знание нужно. */ -const legacyTemplateOverrides: DockerTemplateOverrides = { +const legacyTemplateOverrides: ArtifactTemplateOverrides = { dockerfile: (generated) => applyOverrides('Dockerfile', generated), dockerfileCompiled: (generated) => applyOverrides('DockerfileCompiled', generated), nginxConf: (generated) => applyOverrides('nginx', generated), @@ -25,43 +30,61 @@ const legacyTemplateOverrides: DockerTemplateOverrides = { }; /** - * Транслирует глобальный конфиг arui-scripts в опции @alfalab/scripts-artifacts. + * Транслирует плоский конфиг arui-scripts в сгруппированные по доменам опции + * @alfalab/scripts-artifacts. * * Это единственная точка связи между двумя пакетами: сами шаблоны и утилиты сборки живут в * @alfalab/scripts-artifacts и ничего не знают про `configs`. + * + * Здесь происходит только маппинг значений. Дефолты docker/nginx/archive-настроек не дублируются: + * если пользователь ничего не задал, сюда приезжает `undefined` и значение подставит + * `resolveArtifactsConfig`. + * + * @deprecated Сам маппинг — слой обратной совместимости. В следующей мажорной версии настройки + * сборки артефактов будут жить только в конфиге @alfalab/scripts-artifacts. */ -export function getArtifactsOptions( - extraOptions: Partial = {}, -): ArtifactsOptions { - return { +export function getArtifactsOptions(extraOptions: ArtifactsOptions = {}): ArtifactsOptions { + warnAboutArtifactsDeprecations(); + + const options: ArtifactsOptions = { name: configs.name, version: configs.version, - dockerRegistry: configs.dockerRegistry, + cwd: configs.cwd, + debug: configs.debug, - baseDockerImage: configs.baseDockerImage, clientOnly: configs.clientOnly, buildPath: configs.buildPath, serverOutput: configs.serverOutput, - nginxRootPath: configs.nginxRootPath, + serverPort: configs.serverPort, assetsPath: configs.assetsPath, publicPath: configs.publicPath, - clientServerPort: configs.clientServerPort, - serverPort: configs.serverPort, - - nginx: configs.nginx, - enablePreviousVersionHeaders: configs.dictionaryCompression.enablePreviousVersionHeaders, + docker: { + registry: configs.dockerRegistry, + baseImage: configs.baseDockerImage, + runFromNonRootUser: configs.runFromNonRootUser, + }, - runFromNonRootUser: configs.runFromNonRootUser, - cwd: configs.cwd, - debug: configs.debug, + nginx: { + port: configs.clientServerPort, + rootPath: configs.nginxRootPath, + enablePreviousVersionHeaders: + configs.dictionaryCompression.enablePreviousVersionHeaders, + baseConf: configs.nginx, + }, - removeDevDependencies: configs.removeDevDependenciesDuringDockerBuild, + archive: { + name: configs.archiveName, + additionalPaths: configs.additionalBuildPath, + }, - archiveName: configs.archiveName, - additionalBuildPath: configs.additionalBuildPath, + build: { + removeDevDependencies: configs.removeDevDependenciesDuringDockerBuild, + }, - useYarn: configs.useYarn, + packageManager: { + useYarn: configs.useYarn, + }, localFiles: { dockerfile: configs.localDockerfile, @@ -71,8 +94,18 @@ export function getArtifactsOptions( }, overrides: legacyTemplateOverrides, + }; + // секции сливаем по полям: команда донасыщает то, что уже смаплено из configs + return { + ...options, ...extraOptions, + docker: { ...options.docker, ...extraOptions.docker }, + nginx: { ...options.nginx, ...extraOptions.nginx }, + archive: { ...options.archive, ...extraOptions.archive }, + build: { ...options.build, ...extraOptions.build }, + packageManager: { ...options.packageManager, ...extraOptions.packageManager }, + localFiles: { ...options.localFiles, ...extraOptions.localFiles }, }; } @@ -80,7 +113,7 @@ let cachedConfig: ResolvedArtifactsConfig | null = null; /** * Донасыщенный конфиг сборки, построенный из `configs`. Мемоизирован, потому что резолв читает - * package.json и версию yarn, а шаблоны вычисляются на уровне модуля. + * package.json и версию yarn. */ export function getResolvedArtifactsConfig(): ResolvedArtifactsConfig { if (!cachedConfig) { diff --git a/packages/arui-scripts/src/commands/util/docker-build.ts b/packages/arui-scripts/src/commands/util/docker-build.ts index 0870374a..58916bae 100644 --- a/packages/arui-scripts/src/commands/util/docker-build.ts +++ b/packages/arui-scripts/src/commands/util/docker-build.ts @@ -4,9 +4,9 @@ import { type BuildParams, dockerVersionSatisfies, getBuildParams, - getBuildParamsFromArgs as dockerGetBuildParamsFromArgs, - getDockerBuildCommand as dockerGetBuildCommand, - prepareFilesForDocker as dockerPrepareFilesForDocker, + getBuildParamsFromArgs as artifactsGetBuildParamsFromArgs, + getDockerBuildCommand as artifactsGetBuildCommand, + prepareFilesForDocker as artifactsPrepareFilesForDocker, } from '@alfalab/scripts-artifacts'; import { getResolvedArtifactsConfig } from './artifacts-options'; @@ -15,13 +15,13 @@ export { dockerVersionSatisfies }; /** * Совместимый слой поверх @alfalab/scripts-artifacts: сохраняет исторические сигнатуры, которыми - * пользуются внешние потребители (в первую очередь newclick-builder) и реэкспорт из `arui-scripts`. + * пользуются сторонние сборки и реэкспорт из `arui-scripts`. * * @deprecated Используйте одноименные функции из `@alfalab/scripts-artifacts` — они принимают явный - * конфиг и не зависят от глобального `configs`. + * конфиг и не зависят от глобального `configs`. В следующей мажорной версии реэкспорт будет удален. */ export function getBuildParamsFromArgs(): BuildParams { - return dockerGetBuildParamsFromArgs(getResolvedArtifactsConfig(), process.argv.slice(3)); + return artifactsGetBuildParamsFromArgs(getResolvedArtifactsConfig(), process.argv.slice(3)); } /** @@ -54,7 +54,8 @@ type PrepareFilesForDockerParams = { }; /** - * @deprecated Используйте `prepareFilesForDocker` из `@alfalab/scripts-artifacts`. + * @deprecated Используйте `prepareFilesForDocker` из `@alfalab/scripts-artifacts`. В следующей + * мажорной версии реэкспорт будет удален. */ export async function prepareFilesForDocker({ dockerfileTemplate, @@ -66,14 +67,22 @@ export async function prepareFilesForDocker({ allowLocalStartScript, addNodeModulesToDockerIgnore, }: PrepareFilesForDockerParams) { - return dockerPrepareFilesForDocker({ + const config = getResolvedArtifactsConfig(); + + return artifactsPrepareFilesForDocker({ config: { - ...getResolvedArtifactsConfig(), + ...config, cwd: path.dirname(pathToTempDir), - tempDirName: path.basename(pathToTempDir), - allowLocalDockerfile, - allowLocalStartScript, - addNodeModulesToDockerIgnore, + docker: { + ...config.docker, + tempDirName: path.basename(pathToTempDir), + addNodeModulesToDockerIgnore, + }, + localFiles: { + ...config.localFiles, + allowDockerfile: allowLocalDockerfile, + allowStartScript: allowLocalStartScript, + }, }, templates: { dockerfile: dockerfileTemplate, @@ -90,14 +99,17 @@ type DockerBuildCommandParams = { }; /** - * @deprecated Используйте `getDockerBuildCommand` из `@alfalab/scripts-artifacts`. + * @deprecated Используйте `getDockerBuildCommand` из `@alfalab/scripts-artifacts`. В следующей + * мажорной версии реэкспорт будет удален. */ export function getDockerBuildCommand({ tempDirName, imageFullName }: DockerBuildCommandParams) { - return dockerGetBuildCommand({ - ...getResolvedArtifactsConfig(), + const config = getResolvedArtifactsConfig(); + + return artifactsGetBuildCommand({ + ...config, ...splitImageFullName(imageFullName), - dockerRegistry: '', - tempDirName, + // имя образа уже содержит registry, второй раз подставлять его не нужно + docker: { ...config.docker, registry: '', tempDirName }, }); } diff --git a/packages/arui-scripts/src/configs/app-configs/get-defaults.ts b/packages/arui-scripts/src/configs/app-configs/get-defaults.ts index 5b595606..ed4fd5c3 100644 --- a/packages/arui-scripts/src/configs/app-configs/get-defaults.ts +++ b/packages/arui-scripts/src/configs/app-configs/get-defaults.ts @@ -31,22 +31,24 @@ export function getDefaultAppConfig(): AppConfigs { // paths buildPath: '.build', assetsPath: 'assets', - additionalBuildPath: ['config'], statsOutputFilename: 'stats.json', serverEntry: path.resolve(absoluteSrcPath, 'server/index'), serverOutput: 'server.js', clientPolyfillsEntry: null, clientEntry: path.resolve(absoluteSrcPath, 'index'), - // docker compilation configs - dockerRegistry: '', - baseDockerImage: 'alfabankui/arui-scripts:24.10.0-slim', - nginxRootPath: '/src', - nginx: null, - runFromNonRootUser: true, - removeDevDependenciesDuringDockerBuild: true, - // archive compilation configs - archiveName: 'build.tar', + // docker/archive compilation configs + // Дефолты этих настроек живут в @alfalab/scripts-artifacts (resolveArtifactsConfig). + // Здесь ключи объявлены без значений, чтобы настройка из package.json/конфига не считалась + // неизвестной, а `undefined` доехал до scripts-artifacts и был заполнен там. + dockerRegistry: undefined, + baseDockerImage: undefined, + nginxRootPath: undefined, + nginx: undefined, + runFromNonRootUser: undefined, + removeDevDependenciesDuringDockerBuild: undefined, + archiveName: undefined, + additionalBuildPath: undefined, dictionaryCompression: { dictionaryPath: [], diff --git a/packages/arui-scripts/src/configs/app-configs/types.ts b/packages/arui-scripts/src/configs/app-configs/types.ts index df220b0e..4771d173 100644 --- a/packages/arui-scripts/src/configs/app-configs/types.ts +++ b/packages/arui-scripts/src/configs/app-configs/types.ts @@ -3,6 +3,8 @@ import { type Configuration as DevServerConfiguration } from '@rspack/dev-server import { type PluginOptions as ReactCompilerOptions } from 'babel-plugin-react-compiler'; import type webpackNodeExternals from 'webpack-node-externals'; +import { type NginxBaseConfOptions } from '@alfalab/scripts-artifacts'; + /** * Конфигурация arui-scripts, которая может быть переопределена приложением */ @@ -21,28 +23,40 @@ export type AppConfigs = { // paths buildPath: string; assetsPath: string; - additionalBuildPath: string[]; statsOutputFilename: string; serverEntry: string | string[] | Record; serverOutput: string; clientPolyfillsEntry: null | string | string[]; clientEntry: string | string[] | Record; - // docker compilation configs - dockerRegistry: string; - baseDockerImage: string; - nginxRootPath: string; - nginx: { - workerProcesses?: number; - workerRlimitNoFile?: number; - workerConnections?: number; - eventsUse?: string; - daemon?: string; - } | null; - runFromNonRootUser: boolean; - removeDevDependenciesDuringDockerBuild: boolean; - // archive compilation configs - archiveName: string; + /* + * Настройки сборки docker-образа и tar-архива. + * + * Значения по умолчанию задаются в @alfalab/scripts-artifacts (`resolveArtifactsConfig`), + * поэтому здесь они опциональны: arui-scripts только транслирует то, что задал пользователь. + * + * Все они объявлены устаревшими и будут удалены в следующей мажорной версии — их место в + * конфиге `arui-scripts-artifacts.ts`. + */ + /** @deprecated Используйте `docker.registry` в конфиге @alfalab/scripts-artifacts. */ + dockerRegistry?: string; + /** @deprecated Используйте `docker.baseImage` в конфиге @alfalab/scripts-artifacts. */ + baseDockerImage?: string; + /** @deprecated Используйте `nginx.rootPath` в конфиге @alfalab/scripts-artifacts. */ + nginxRootPath?: string; + /** @deprecated Используйте `nginx.baseConf` в конфиге @alfalab/scripts-artifacts. */ + nginx?: NginxBaseConfOptions | null; + /** @deprecated Используйте `docker.runFromNonRootUser` в конфиге @alfalab/scripts-artifacts. */ + runFromNonRootUser?: boolean; + /** @deprecated Используйте `build.removeDevDependencies` в конфиге @alfalab/scripts-artifacts. */ + removeDevDependenciesDuringDockerBuild?: boolean; + /** @deprecated Используйте `archive.name` в конфиге @alfalab/scripts-artifacts. */ + archiveName?: string; + /** + * Директории проекта, которые кладутся в tar-архив рядом со сборкой. + * @deprecated Используйте `archive.additionalPaths` в конфиге @alfalab/scripts-artifacts. + */ + additionalBuildPath?: string[]; dictionaryCompression: { dictionaryPath: string[]; diff --git a/packages/arui-scripts/src/configs/app-configs/validate-settings-keys.ts b/packages/arui-scripts/src/configs/app-configs/validate-settings-keys.ts index f176d9f9..1241b694 100644 --- a/packages/arui-scripts/src/configs/app-configs/validate-settings-keys.ts +++ b/packages/arui-scripts/src/configs/app-configs/validate-settings-keys.ts @@ -1,5 +1,8 @@ /** - * Функция проверяет что все ключи объекта settingsObject есть в уже существующей конфигурации + * Функция проверяет что все ключи объекта settingsObject есть в уже существующей конфигурации. + * + * Проверяется именно наличие ключа, а не значения: у части настроек (например, docker-специфичных) + * дефолт живет в @alfalab/scripts-artifacts, поэтому в конфиге они лежат со значением `undefined`. */ export function validateSettingsKeys( existingConfig: Record, @@ -7,7 +10,7 @@ export function validateSettingsKeys( source?: string, ) { Object.keys(settingsObject).forEach((setting) => { - if (typeof existingConfig[setting] === 'undefined') { + if (!Object.prototype.hasOwnProperty.call(existingConfig, setting)) { console.warn(`Неизвестная настройка "${setting}" в ${source || 'конфигурации'}`); } }); diff --git a/packages/arui-scripts/src/configs/util/apply-overrides.ts b/packages/arui-scripts/src/configs/util/apply-overrides.ts index 7813cc9e..76b8f0cb 100644 --- a/packages/arui-scripts/src/configs/util/apply-overrides.ts +++ b/packages/arui-scripts/src/configs/util/apply-overrides.ts @@ -40,10 +40,19 @@ type Overrides = { supportingBrowsers: string[]; supportingNode: string[]; + /* + * Оверрайды файлов артефакта поставки. Объявлены устаревшими и будут удалены в следующей + * мажорной версии — их место в секции `overrides` конфига `arui-scripts-artifacts.ts`. + */ + /** @deprecated Используйте `overrides.dockerfile` в конфиге @alfalab/scripts-artifacts. */ Dockerfile: string; + /** @deprecated Используйте `overrides.dockerfileCompiled` в конфиге @alfalab/scripts-artifacts. */ DockerfileCompiled: string; + /** @deprecated Используйте `overrides.nginxConf` в конфиге @alfalab/scripts-artifacts. */ nginx: string; + /** @deprecated Используйте `overrides.baseNginxConf` в конфиге @alfalab/scripts-artifacts. */ nginxConf: string; + /** @deprecated Используйте `overrides.startScript` в конфиге @alfalab/scripts-artifacts. */ 'start.sh': string; serverExternalsExemptions: Array; @@ -117,6 +126,16 @@ overrides = configs.overridesPath.map((path) => { } }); +/** + * Объявлен ли оверрайд с таким ключом хоть в одном файле оверрайдов. Нужно, чтобы предупреждать об + * устаревших ключах только тем, кто ими действительно пользуется. + */ +export function hasOverride(overridesKey: keyof Overrides): boolean { + return overrides.some((override) => + Object.prototype.hasOwnProperty.call(override, overridesKey), + ); +} + /** * * @param {String|String[]} overridesKey Ключи оверрайда diff --git a/packages/arui-scripts/src/index.ts b/packages/arui-scripts/src/index.ts index a3b09c29..10416a8a 100644 --- a/packages/arui-scripts/src/index.ts +++ b/packages/arui-scripts/src/index.ts @@ -5,6 +5,7 @@ export type { AppConfigs, CompatModuleConfig, PackageSettings } from './configs/ /** * Утилиты сборки docker-образа, привязанные к глобальному конфигу arui-scripts. * @deprecated Используйте `@alfalab/scripts-artifacts` — там те же функции принимают явный конфиг. + * В следующей мажорной версии эти реэкспорты будут удалены. */ export { prepareFilesForDocker } from './commands/util/docker-build'; export { getBuildParamsFromArgs } from './commands/util/docker-build'; @@ -13,6 +14,8 @@ export { getDockerBuildCommand } from './commands/util/docker-build'; /** * Опции сборки docker-образа, собранные из конфига arui-scripts. Точка входа для тех, кто хочет * собрать образ через `@alfalab/scripts-artifacts`, но переиспользовать настройки arui-scripts. + * @deprecated Слой обратной совместимости: в следующей мажорной версии настройки сборки артефактов + * будут жить только в конфиге `arui-scripts-artifacts.ts`. */ export { getArtifactsOptions, getResolvedArtifactsConfig } from './commands/util/artifacts-options'; @@ -27,8 +30,8 @@ export { renderStartScript, resolveArtifactsConfig, type ArtifactsOptions, - type DockerTemplateOverrides, - type DockerTemplates, + type ArtifactTemplateOverrides, + type ArtifactTemplates, type ResolvedArtifactsConfig, } from '@alfalab/scripts-artifacts'; diff --git a/packages/arui-scripts/src/templates/base-nginx.conf.template.ts b/packages/arui-scripts/src/templates/base-nginx.conf.template.ts deleted file mode 100644 index a314146c..00000000 --- a/packages/arui-scripts/src/templates/base-nginx.conf.template.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { renderBaseNginxConf } from '@alfalab/scripts-artifacts'; - -import { getResolvedArtifactsConfig } from '../commands/util/artifacts-options'; -import { applyOverrides } from '../configs/util/apply-overrides'; - -// исторически ключ оверрайда базового конфига называется `nginxConf` -export const nginxBaseConfTemplate = applyOverrides( - 'nginxConf', - renderBaseNginxConf(getResolvedArtifactsConfig()), -); diff --git a/packages/arui-scripts/src/templates/dockerfile-compiled.template.ts b/packages/arui-scripts/src/templates/dockerfile-compiled.template.ts deleted file mode 100644 index b8787ae5..00000000 --- a/packages/arui-scripts/src/templates/dockerfile-compiled.template.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { renderDockerfileCompiled } from '@alfalab/scripts-artifacts'; - -import { getResolvedArtifactsConfig } from '../commands/util/artifacts-options'; -import { applyOverrides } from '../configs/util/apply-overrides'; - -export const dockerfileTemplate = applyOverrides( - 'DockerfileCompiled', - renderDockerfileCompiled(getResolvedArtifactsConfig()), -); diff --git a/packages/arui-scripts/src/templates/dockerfile.template.ts b/packages/arui-scripts/src/templates/dockerfile.template.ts deleted file mode 100644 index b07007f1..00000000 --- a/packages/arui-scripts/src/templates/dockerfile.template.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { renderDockerfile } from '@alfalab/scripts-artifacts'; - -import { getResolvedArtifactsConfig } from '../commands/util/artifacts-options'; -import { applyOverrides } from '../configs/util/apply-overrides'; - -export const dockerfileTemplate = applyOverrides( - 'Dockerfile', - renderDockerfile(getResolvedArtifactsConfig()), -); diff --git a/packages/arui-scripts/src/templates/nginx.conf.template.ts b/packages/arui-scripts/src/templates/nginx.conf.template.ts deleted file mode 100644 index 0bf27da7..00000000 --- a/packages/arui-scripts/src/templates/nginx.conf.template.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { renderNginxConf } from '@alfalab/scripts-artifacts'; - -import { getResolvedArtifactsConfig } from '../commands/util/artifacts-options'; -import { applyOverrides } from '../configs/util/apply-overrides'; - -// исторически ключ оверрайда server-блока называется `nginx`, а `nginxConf` — это базовый конфиг -export const nginxConfTemplate = applyOverrides( - 'nginx', - renderNginxConf(getResolvedArtifactsConfig()), -); diff --git a/packages/arui-scripts/src/templates/start.template.ts b/packages/arui-scripts/src/templates/start.template.ts deleted file mode 100644 index fa86a14d..00000000 --- a/packages/arui-scripts/src/templates/start.template.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { renderStartScript } from '@alfalab/scripts-artifacts'; - -import { getResolvedArtifactsConfig } from '../commands/util/artifacts-options'; -import { applyOverrides } from '../configs/util/apply-overrides'; - -export const startScript = applyOverrides( - 'start.sh', - renderStartScript(getResolvedArtifactsConfig()), -); From 9e9d80e884e4279bae3c6ae41078d4161a6f3b86 Mon Sep 17 00:00:00 2001 From: Stepan Ivankov Date: Thu, 6 Aug 2026 17:53:26 +0300 Subject: [PATCH 4/4] feat(deploy_kube_feature): skip_e2e deploy --- .../src/commands/util/artifacts-deprecations.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/arui-scripts/src/commands/util/artifacts-deprecations.ts b/packages/arui-scripts/src/commands/util/artifacts-deprecations.ts index b52a4388..27a49d73 100644 --- a/packages/arui-scripts/src/commands/util/artifacts-deprecations.ts +++ b/packages/arui-scripts/src/commands/util/artifacts-deprecations.ts @@ -48,13 +48,15 @@ export function warnAboutArtifactsDeprecations() { } warned = true; - const usedSettings = (Object.keys(DEPRECATED_SETTINGS) as Array) + const usedSettings = ( + Object.keys(DEPRECATED_SETTINGS) as Array + ) .filter((setting) => configs[setting] !== undefined) .map((setting) => ` ${setting} → ${DEPRECATED_SETTINGS[setting]}`); - const usedOverrides = (Object.keys(DEPRECATED_OVERRIDES) as Array< - keyof typeof DEPRECATED_OVERRIDES - >) + const usedOverrides = ( + Object.keys(DEPRECATED_OVERRIDES) as Array + ) .filter((key) => hasOverride(key)) .map((key) => ` оверрайд ${key} → ${DEPRECATED_OVERRIDES[key]}`);