diff --git a/.changeset/docker-package-extraction.md b/.changeset/docker-package-extraction.md new file mode 100644 index 00000000..fc86fb2e --- /dev/null +++ b/.changeset/docker-package-extraction.md @@ -0,0 +1,35 @@ +--- +'@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'`. + +Настройки сборки артефактов в конфиге 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` (раньше игнорировал). Отключается опцией +`localFiles.allowStartScript: false`. diff --git a/packages/arui-scripts-artifacts/.eslintignore b/packages/arui-scripts-artifacts/.eslintignore new file mode 100644 index 00000000..b97cc924 --- /dev/null +++ b/packages/arui-scripts-artifacts/.eslintignore @@ -0,0 +1,2 @@ +build +.turbo diff --git a/packages/arui-scripts-artifacts/.eslintrc.js b/packages/arui-scripts-artifacts/.eslintrc.js new file mode 100644 index 00000000..5c1f5886 --- /dev/null +++ b/packages/arui-scripts-artifacts/.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-artifacts/.gitignore b/packages/arui-scripts-artifacts/.gitignore new file mode 100644 index 00000000..dd87e2d7 --- /dev/null +++ b/packages/arui-scripts-artifacts/.gitignore @@ -0,0 +1,2 @@ +node_modules +build diff --git a/packages/arui-scripts-artifacts/.npmignore b/packages/arui-scripts-artifacts/.npmignore new file mode 100644 index 00000000..825b96da --- /dev/null +++ b/packages/arui-scripts-artifacts/.npmignore @@ -0,0 +1,3 @@ +src +build/tsconfig.tsbuildinfo +__tests__ diff --git a/packages/arui-scripts-artifacts/.prettierignore b/packages/arui-scripts-artifacts/.prettierignore new file mode 100644 index 00000000..0d6a1e94 --- /dev/null +++ b/packages/arui-scripts-artifacts/.prettierignore @@ -0,0 +1,2 @@ +.turbo +build 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..ce67eb14 --- /dev/null +++ b/packages/arui-scripts-artifacts/README.md @@ -0,0 +1,298 @@ +# @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({ + docker: { + baseImage: 'alfabankui/arui-scripts:24.10.0-slim', + registry: 'registry.example.com', + }, + nginx: { + baseConf: { workerProcesses: 4 }, + }, +}); +``` + +`package.json`: + +```json +{ + "scripts": { + "docker-build": "arui-scripts-artifacts docker-build", + "docker-build:compiled": "arui-scripts-artifacts docker-build:compiled" + } +} +``` + +## Структура конфига + +Настройки сгруппированы по тому, к чему относятся. На верхнем уровне остается только то, что общее +для всех артефактов, — идентификация и форма самого приложения: + +| Секция | За что отвечает | +| ---------------- | -------------------------------------------------------------------------------- | +| _верхний уровень_ | `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 +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({ + docker: { baseImage: 'registry.example.com/base:2.0.0' }, + nginx: { baseConf: { workerProcesses: 4 } }, + + commands: { + // серверный образ: свой энтрипоинт и порт, всё остальное — из верхнего уровня + 'docker-build:server': { + docker: { variant: 'compiled' }, + serverOutput: 'server/index.js', + // сольется с верхнеуровневой секцией nginx + nginx: { port: 9090, baseConf: { workerConnections: 100 } }, + }, + + // образ только со статикой + 'docker-build:static': { + clientOnly: true, + build: { command: 'npm run build:static' }, + }, + + // еще один архив — с отдельным именем и своей сборкой + 'archive-build:e2e': { + artifact: 'archive', + archive: { name: 'e2e.tar' }, + build: { command: 'npm run build:e2e' }, + }, + }, +}); +``` + +```bash +arui-scripts-artifacts docker-build:server +``` + +Запуск без команды печатает список доступных — встроенные плюс объявленные в конфиге. + +Секции (`docker`, `nginx`, `archive`, `build`, `packageManager`, `localFiles`, `templates`, +`overrides`) сливаются по полям, скалярные опции заменяются целиком. + +## Кастомизация файлов образа + +Каждый файл настраивается на четырех уровнях, по возрастанию приоритета: + +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` в корне проекта. + +Ключи шаблонов: `dockerfile`, `dockerfileCompiled`, `nginxConf` (server-блок), `baseNginxConf` +(http-блок), `startScript`. + +```ts +import { defineConfig } from '@alfalab/scripts-artifacts'; + +export default defineConfig({ + // целиком свой server-блок nginx + templates: { + nginxConf: (config) => ` +client_max_body_size 20m; + +server { + listen ${config.nginx.port}; + 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({ + name: 'my-app', + version: '1.0.0', + docker: { + variant: 'compiled', + registry: 'registry.example.com', + buildArgs: { COMMIT_SHA: process.env.COMMIT_SHA ?? '' }, + }, +}); +``` + +Если нужен контроль над каждым шагом — те же чистые функции по отдельности: + +```ts +import { + exec, + getBuildParams, + getDockerBuildCommand, + prepareFilesForDocker, + renderTemplates, + resolveArtifactsConfig, +} from '@alfalab/scripts-artifacts'; + +const config = resolveArtifactsConfig({ + serverOutput: 'server/index.js', + docker: { variant: 'compiled' }, +}); +const templates = renderTemplates({ config }); + +const { restoreDockerIgnore } = await prepareFilesForDocker({ config, templates }); + +await exec(getDockerBuildCommand(config)); +await restoreDockerIgnore(); +await exec(`docker push ${getBuildParams(config).imageFullName}`); +``` + +Разбор конфига тоже доступен отдельно — так CLI можно встроить в свой: + +```ts +import { + extractConfigPath, + resolveCommandOptions, + resolveConfigFile, +} from '@alfalab/scripts-artifacts'; + +const argv = process.argv.slice(2); +const configFile = await resolveConfigFile(process.cwd(), extractConfigPath(argv)); +const options = resolveCommandOptions('docker-build:server', configFile); +``` + +## Пайплайн сборки + +Хост-пайплайн общий для образа и архива, поэтому оба собираются из одного состояния проекта. +Какие шаги выполнятся — зависит от типа артефакта, варианта и опций: + +| Шаг | Опция | Дефолт `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-а собирать нечего), поэтому +`docker.variant` на него не влияет. + +## Миграция с `arui-scripts docker-build` + +Поведение и шаблоны совпадают, отличаются имена и расположение настроек: + +| 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` в двух пакетах +означает разные файлы**. Переносить оверрайды по таблице выше, а не по совпадению имен. + +`publicPath` по умолчанию — `` `${assetsPath}/` `` (то есть `assets/`), как это считает `arui-scripts`. +Пустой `publicPath` дал бы в nginx-конфиге второй `location /` и nginx не поднялся бы с +`duplicate location "/"`. + +### Отличие в `archive-build` + +Старый `arui-scripts archive-build` подхватывал локальный `nginx.conf`, но игнорировал локальный +`start.sh`. Здесь локальные файлы обрабатываются единообразно: `start.sh` из корня проекта тоже +используется. Отключается через `localFiles.allowStartScript: false`. diff --git a/packages/arui-scripts-artifacts/jest.config.js b/packages/arui-scripts-artifacts/jest.config.js new file mode 100644 index 00000000..cb7bb311 --- /dev/null +++ b/packages/arui-scripts-artifacts/jest.config.js @@ -0,0 +1,8 @@ +/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + // фикстуры лежат рядом с тестами и сами тестами не являются + testMatch: ['**/__tests__/**/*.test.ts'], + testPathIgnorePatterns: ['/node_modules/', '/build/'], +}; diff --git a/packages/arui-scripts-artifacts/package.json b/packages/arui-scripts-artifacts/package.json new file mode 100644 index 00000000..3e762581 --- /dev/null +++ b/packages/arui-scripts-artifacts/package.json @@ -0,0 +1,50 @@ +{ + "name": "@alfalab/scripts-artifacts", + "version": "1.0.0", + "description": "Максимально кастомизируемые скрипты и шаблоны для сборки артефактов поставки (docker-образ, tar-архив) приложений, основанных на arui-scripts", + "main": "./build/index.js", + "typings": "./build/index.d.ts", + "bin": { + "arui-scripts-artifacts": "./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-artifacts#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": { + "commander": "^12", + "fs-extra": "6.0.1", + "jiti": "^2.4.2", + "semver": "^7.5.4", + "shelljs": "0.8.5", + "tar": "7.5.11" + }, + "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-artifacts/src/archive/build-archive.ts b/packages/arui-scripts-artifacts/src/archive/build-archive.ts new file mode 100644 index 00000000..c2935302 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/archive/build-archive.ts @@ -0,0 +1,103 @@ +import path from 'path'; + +import fs from 'fs-extra'; +import { create as createTar } from 'tar'; + +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 NODE_MODULES_DIR_NAME = 'node_modules'; +const PACKAGE_JSON_FILENAME = 'package.json'; + +/** + * Собирает tar-архив с production-сборкой: nginx-конфиг, start.sh, `buildPath`, `node_modules`, + * `package.json` и дополнительные директории из `archive.additionalPaths`. + * + * Хост-пайплайн (очистка, сборка, удаление dev-зависимостей) — тот же, что у docker-образа, поэтому + * архив и образ собираются из одного и того же состояния проекта. + */ +export async function buildArchive(options: BuildArchiveOptions = {}): Promise { + const { beforeBuild, templates, overrides, ...rest } = options; + + const config: ResolvedArtifactsConfig = resolveArtifactsConfig({ + ...rest, + artifact: 'archive', + }); + + const { cwd, buildPath, archive, localFiles } = config; + const pathToTempDir = path.join(cwd, archive.tempDirName); + + try { + console.log(`Build archive ${archive.name}`); + 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 && localFiles.allowStartScript + ? 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), + ), + ...archive.additionalPaths.map((additionalPath) => + fs.copy(path.join(cwd, additionalPath), path.join(pathToTempDir, additionalPath)), + ), + ]); + + await createTar({ file: archive.name, 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/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 new file mode 100644 index 00000000..6de8f1fb --- /dev/null +++ b/packages/arui-scripts-artifacts/src/bin/index.ts @@ -0,0 +1,66 @@ +#! /usr/bin/env node +import path from 'path'; + +import fs from 'fs-extra'; + +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'); + +/** + * Локальные файлы проекта, замещающие сгенерированные шаблоны, если они лежат в корне проекта. + * Явно заданные в конфиге `localFiles` имеют приоритет над автодетектом. + */ +function detectLocalFiles(cwd: string): LocalFilesOptions { + const resolveIfExists = (fileName: string) => { + const filePath = path.join(cwd, fileName); + + return fs.existsSync(filePath) ? filePath : null; + }; + + return { + dockerfile: resolveIfExists(DOCKERFILE_FILENAME), + startScript: resolveIfExists(START_SCRIPT_FILENAME), + 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/cli/__tests__/cli.test.ts b/packages/arui-scripts-artifacts/src/cli/__tests__/cli.test.ts new file mode 100644 index 00000000..70fa12df --- /dev/null +++ b/packages/arui-scripts-artifacts/src/cli/__tests__/cli.test.ts @@ -0,0 +1,99 @@ +import { type Command } from 'commander'; + +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', () => { + 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': { docker: { 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/cli/__tests__/config-file.test.ts b/packages/arui-scripts-artifacts/src/cli/__tests__/config-file.test.ts new file mode 100644 index 00000000..61938e27 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/cli/__tests__/config-file.test.ts @@ -0,0 +1,133 @@ +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({ + docker: { variant: 'runtime', addNodeModulesToDockerIgnore: false }, + localFiles: { allowDockerfile: true }, + }); + + expect(resolveCommandOptions('docker-build:compiled', {})).toMatchObject({ + docker: { variant: 'compiled', addNodeModulesToDockerIgnore: true }, + localFiles: { allowDockerfile: false }, + }); + }); + + 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', { + archive: { name: 'build.tar' }, + commands: { + 'archive-build:e2e': { artifact: 'archive' as const, archive: { name: 'e2e.tar' } }, + }, + }); + + expect(options).toMatchObject({ artifact: 'archive', archive: { name: 'e2e.tar' } }); + }); + + it('should apply shared config options to every command', () => { + const configFile = { docker: { baseImage: 'my/base:1.0.0' }, serverPort: 4000 }; + + expect(resolveCommandOptions('docker-build', configFile)).toMatchObject({ + docker: { baseImage: 'my/base:1.0.0', variant: 'runtime' }, + serverPort: 4000, + }); + }); + + 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, docker: { variant: 'compiled' } }); + }); + + it('should let the config override built-in command defaults', () => { + const options = resolveCommandOptions('docker-build:compiled', { + commands: { 'docker-build:compiled': { localFiles: { allowDockerfile: true } } }, + }); + + expect(options).toMatchObject({ + localFiles: { allowDockerfile: true }, + docker: { variant: 'compiled' }, + }); + }); + + it('should support fully custom commands declared in the config', () => { + const configFile = { + docker: { baseImage: 'my/base:1.0.0' }, + commands: { + 'docker-build:server': { + docker: { variant: 'compiled' as const }, + serverOutput: 'server/index.js', + nginx: { port: 9090 }, + }, + }, + }; + + expect(resolveCommandOptions('docker-build:server', configFile)).toMatchObject({ + docker: { baseImage: 'my/base:1.0.0', variant: 'compiled' }, + serverOutput: 'server/index.js', + nginx: { port: 9090 }, + }); + }); + + it('should merge config sections field by field instead of replacing them', () => { + const options = resolveCommandOptions('docker-build', { + nginx: { port: 8080, baseConf: { workerProcesses: 4, workerConnections: 100 } }, + docker: { buildArgs: { A: '1' } }, + commands: { + 'docker-build': { + nginx: { baseConf: { workerProcesses: 8 } }, + docker: { buildArgs: { 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.baseConf: false into an object', () => { + const options = resolveCommandOptions('docker-build', { + nginx: { baseConf: { workerProcesses: 4 } }, + commands: { 'docker-build': { nginx: { baseConf: false } } }, + }); + + expect(options?.nginx?.baseConf).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/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/cli/__tests__/fixtures/ts-config.ts b/packages/arui-scripts-artifacts/src/cli/__tests__/fixtures/ts-config.ts new file mode 100644 index 00000000..5b7b6ebc --- /dev/null +++ b/packages/arui-scripts-artifacts/src/cli/__tests__/fixtures/ts-config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from '../../config-file'; + +export default defineConfig({ + docker: { baseImage: 'fixture/base:1.0.0' }, + nginx: { baseConf: { workerProcesses: 7 } }, + commands: { + 'docker-build:server': { + docker: { variant: 'compiled' }, + serverOutput: 'server/index.js', + }, + }, +}); diff --git a/packages/arui-scripts-artifacts/src/cli/__tests__/load-config-file.test.ts b/packages/arui-scripts-artifacts/src/cli/__tests__/load-config-file.test.ts new file mode 100644 index 00000000..1d0d47b8 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/cli/__tests__/load-config-file.test.ts @@ -0,0 +1,53 @@ +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.docker?.baseImage).toBe('fixture/base:1.0.0'); + expect(config.nginx).toEqual({ baseConf: { workerProcesses: 7 } }); + expect(resolveCommandOptions('docker-build:server', config)).toMatchObject({ + docker: { baseImage: 'fixture/base:1.0.0', variant: 'compiled' }, + serverOutput: 'server/index.js', + }); + }); + + it('should load an ESM config exporting an async function', async () => { + const config = await loadConfigFile(path.join(fixtures, 'fn-config.mjs')); + + 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.docker?.baseImage).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/cli/config-file.ts b/packages/arui-scripts-artifacts/src/cli/config-file.ts new file mode 100644 index 00000000..ae7017f2 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/cli/config-file.ts @@ -0,0 +1,135 @@ +import { type ArtifactsOptions } from '../config/types'; +import { type BuildArtifactOptions } from '../pipeline/build-artifact'; + +/** + * Содержимое `arui-scripts-artifacts.ts` — единственный источник правды для всех команд проекта. + * + * Верхний уровень описывает общие для проекта настройки, `commands` — конкретные сборки. Команда + * наследует верхний уровень, поэтому «еще один образ с другим портом» — это несколько строк в + * конфиге, а не отдельный скрипт. + */ +export type ArtifactsConfigFile = ArtifactsOptions & { + /** + * Именованные команды. Имя становится аргументом CLI: `arui-scripts-artifacts <имя>`. + * Встроенные `docker-build`, `docker-build:compiled` и `archive-build` можно донасыщать или + * переопределять. + */ + 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', + docker: { variant: 'runtime', addNodeModulesToDockerIgnore: false }, + localFiles: { allowDockerfile: true, allowStartScript: true }, + }, + 'docker-build:compiled': { + artifact: 'docker', + docker: { variant: 'compiled', addNodeModulesToDockerIgnore: true }, + localFiles: { allowDockerfile: false, allowStartScript: false }, + }, + 'archive-build': { + artifact: 'archive', + }, +}; + +/** Секции конфига — их сливаем по полям, а не заменяем целиком. */ +const MERGED_SECTIONS = [ + 'docker', + 'nginx', + 'archive', + 'build', + 'packageManager', + 'localFiles', + 'templates', + 'overrides', +] 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 }; + + MERGED_SECTIONS.forEach((key) => { + const baseValue = base[key]; + const patchValue = patch[key]; + + if (isPlainObject(baseValue) && isPlainObject(patchValue)) { + Object.assign(result, { [key]: mergeSection(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-artifacts/src/cli/create-cli.ts b/packages/arui-scripts-artifacts/src/cli/create-cli.ts new file mode 100644 index 00000000..7321f921 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/cli/create-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', + [ + '', + 'Имя образа формируется как {docker.registry}/{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/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/cli/load-config-file.ts b/packages/arui-scripts-artifacts/src/cli/load-config-file.ts new file mode 100644 index 00000000..a493d6d6 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/cli/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-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/config/types.ts b/packages/arui-scripts-artifacts/src/config/types.ts new file mode 100644 index 00000000..4adb01ec --- /dev/null +++ b/packages/arui-scripts-artifacts/src/config/types.ts @@ -0,0 +1,276 @@ +export type YarnVersion = '1' | '2+' | 'unavailable'; + +/** + * Управление флагом `--platform` в команде `docker build`: + * - `'auto'` — историческое поведение: флаг подставляется только если версия docker его поддерживает; + * - `false` — никогда не добавлять флаг; + * - строка (например `'linux/amd64'`) — всегда использовать указанную платформу. + */ +export type DockerPlatform = 'auto' | false | string; + +/** Вариант Dockerfile: «сырой» (сборка на хосте) или compiled (сборка внутри образа). */ +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; +}; + +/* ------------------------------------------------------------------------------------------------- + * Шаблоны + * ---------------------------------------------------------------------------------------------- */ + +/** + * Функция-оверрайд шаблона. Получает сгенерированную по умолчанию строку и итоговый конфиг, + * возвращает новую строку. Позволяет точечно донасыщать/подменять любой из шаблонов. + */ +export type TemplateOverride = ( + generatedContent: string, + config: ResolvedArtifactsConfig, +) => string; + +/** + * Функция-рендерер шаблона. Полностью заменяет генерацию шаблона по умолчанию. + */ +export type TemplateRenderer = (config: ResolvedArtifactsConfig) => string; + +export type TemplateKey = + | 'dockerfile' + | 'dockerfileCompiled' + | 'nginxConf' + | 'baseNginxConf' + | 'startScript'; + +/** + * Кастомные рендереры шаблонов. Любой из них можно переопределить целиком. + */ +export type ArtifactTemplates = Partial>; + +/** + * Точечные оверрайды поверх сгенерированных по умолчанию шаблонов. + */ +export type ArtifactTemplateOverrides = Partial>; + +/** + * Готовые (отрендеренные) содержимые файлов, которые кладутся в артефакт. + */ +export type RenderedTemplates = { + dockerfile: string; + nginxConf: string; + nginxBaseConf: string; + startScript: string; +}; + +/* ------------------------------------------------------------------------------------------------- + * Конфиг целиком + * ---------------------------------------------------------------------------------------------- */ + +/** + * Полный набор опций сборки артефакта. Все поля опциональны — недостающие донасыщаются дефолтами + * в {@link resolveArtifactsConfig}. Это единственная точка входа для кастомизации: любой потребитель + * (arui-scripts, сторонние сборки, собственные скрипты) может собрать артефакт, передав сюда свои + * значения. + * + * Настройки сгруппированы по тому, к чему относятся: `docker`, `nginx`, `archive`, `build`, + * `packageManager`, `localFiles`. На верхнем уровне остается только то, что общее для всех + * артефактов, — идентификация и форма самого приложения. + */ +export type ArtifactsOptions = { + /** + * Что собирать: docker-образ (по умолчанию) или tar-архив с production-сборкой. + * Секция `docker` при `artifact: 'archive'` не используется, и наоборот. + */ + artifact?: ArtifactKind; + + /* --- Идентификация --- */ + /** Имя артефакта (образа). По умолчанию берется из `package.json` в `cwd`. */ + name?: string; + /** Версия/тег. По умолчанию берется из `package.json` в `cwd`. */ + version?: string; + /** Рабочая директория проекта. */ + cwd?: string; + /** Режим отладки: не пушить образ, печатать стек ошибок. */ + debug?: boolean; + + /* --- Форма приложения --- */ + /** Собирается ли только клиентская часть (nginx без nodejs-сервера). */ + clientOnly?: boolean; + /** Путь к директории со сборкой приложения относительно корня проекта. */ + buildPath?: string; + /** Путь к серверному бандлу относительно `buildPath`. */ + serverOutput?: string; + /** Порт, на котором поднимается nodejs-сервер приложения. */ + serverPort?: number; + /** Директория со статикой внутри `buildPath`. Из нее выводится дефолт `publicPath`. */ + assetsPath?: string; + /** + * Публичный префикс путей до статики — используется в `location` блоках nginx-конфига. + * По умолчанию `` `${assetsPath}/` ``, как это считает arui-scripts. Пустая строка приведет к + * дублирующемуся `location /` в nginx-конфиге, поэтому переопределять стоит осознанно. + */ + publicPath?: string; + + /* --- Секции --- */ + docker?: DockerOptions; + nginx?: NginxOptions; + archive?: ArchiveOptions; + build?: BuildOptions; + packageManager?: PackageManagerOptions; + localFiles?: LocalFilesOptions; + + /* --- Шаблоны --- */ + /** Полная замена рендереров шаблонов. */ + templates?: ArtifactTemplates; + /** Точечные оверрайды поверх сгенерированных шаблонов. */ + 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; + + name: string; + version: string; + cwd: string; + debug: boolean; + + clientOnly: boolean; + buildPath: string; + serverOutput: string; + serverPort: number; + assetsPath: string; + publicPath: string; + + docker: ResolvedDockerConfig; + nginx: ResolvedNginxConfig; + archive: ResolvedArchiveConfig; + build: ResolvedBuildConfig; + packageManager: ResolvedPackageManagerConfig; + localFiles: ResolvedLocalFilesConfig; +}; diff --git a/packages/arui-scripts-artifacts/src/docker/__tests__/docker-build.test.ts b/packages/arui-scripts-artifacts/src/docker/__tests__/docker-build.test.ts new file mode 100644 index 00000000..df4a5a66 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/docker/__tests__/docker-build.test.ts @@ -0,0 +1,127 @@ +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' }; + +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, + docker: { registry: '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'); + }); + + 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, docker: { platform: false } }); + + expect(getPlatformFlag(config)).toBe(''); + }); + + it('should use explicit platform', () => { + const config = resolveArtifactsConfig({ + ...baseOptions, + docker: { platform: 'linux/arm64' }, + }); + + expect(getPlatformFlag(config)).toBe('--platform linux/arm64'); + }); +}); + +describe('getDockerBuildCommand', () => { + it('should include dockerfile, build-args and context', () => { + const config = resolveArtifactsConfig({ ...baseOptions, docker: { 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, + docker: { platform: false, buildArgs: { 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, + docker: { platform: false, buildArgs: { 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, + name: 'app; rm -rf /', + docker: { platform: false, 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/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/docker/build-docker-image.ts b/packages/arui-scripts-artifacts/src/docker/build-docker-image.ts new file mode 100644 index 00000000..a37ff3ee --- /dev/null +++ b/packages/arui-scripts-artifacts/src/docker/build-docker-image.ts @@ -0,0 +1,85 @@ +import fs from 'fs-extra'; + +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`. Если приложение собирается им, выставьте `build.command: 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.docker.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/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/docker/constants.ts b/packages/arui-scripts-artifacts/src/docker/constants.ts new file mode 100644 index 00000000..e93644ff --- /dev/null +++ b/packages/arui-scripts-artifacts/src/docker/constants.ts @@ -0,0 +1,24 @@ +/** + * Имя временной директории, в которую складываются сгенерированные файлы (Dockerfile, nginx-конфиги, + * start.sh) перед запуском `docker build`. + */ +export const DEFAULT_TEMP_DIR_NAME = '.docker-build'; + +/** Базовый 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, который упадет при наличии этого флага. + * @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-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/docker/templates/dockerfile-compiled.template.ts b/packages/arui-scripts-artifacts/src/docker/templates/dockerfile-compiled.template.ts new file mode 100644 index 00000000..b0626074 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/docker/templates/dockerfile-compiled.template.ts @@ -0,0 +1,53 @@ +import { type ResolvedArtifactsConfig } from '../../config/types'; + +/** + * Dockerfile для compiled-образа: зависимости и сборка приложения происходят внутри образа, что + * позволяет кешировать установку зависимостей на уровне docker-слоев. + */ +export function renderDockerfileCompiled(config: ResolvedArtifactsConfig): string { + const { docker, nginx, packageManager } = config; + const { yarnVersion, installProductionCommand } = packageManager; + + // В зависимости от используемого менеджера зависимостей для их установки нужно копировать разный + // набор файлов + const filesRequiredToInstallDependencies = [ + 'package.json', + 'yarn.lock', + yarnVersion === '2+' && '.yarnrc.yml', + yarnVersion === '2+' && '.yarn', + yarnVersion === 'unavailable' && 'package-lock.json', + ].filter(Boolean) as string[]; + + return ` +FROM ${docker.baseImage} +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.baseConf ? '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-artifacts/src/docker/templates/dockerfile.template.ts b/packages/arui-scripts-artifacts/src/docker/templates/dockerfile.template.ts new file mode 100644 index 00000000..24e45438 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/docker/templates/dockerfile.template.ts @@ -0,0 +1,50 @@ +import { type ResolvedArtifactsConfig } from '../../config/types'; + +/** + * Dockerfile для «сырого» образа: приложение собирается на хосте, в образ кладется результат сборки. + */ +export function renderDockerfile(config: ResolvedArtifactsConfig): string { + const { clientOnly, buildPath, docker, nginx } = config; + const { runFromNonRootUser, baseImage } = docker; + + 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 ${baseImage} +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.baseConf ? '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-artifacts/src/index.ts b/packages/arui-scripts-artifacts/src/index.ts new file mode 100644 index 00000000..bd7935c8 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/index.ts @@ -0,0 +1,59 @@ +/** + * Публичное API пакета. Реэкспортит доменные модули: конфиг, общий пайплайн и по одному модулю на + * тип артефакта (`docker`, `archive`) и на файлы, которые в них кладутся (`nginx`, `start-script`). + */ + +/* Конфиг */ +export { resolveArtifactsConfig } from './config'; +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 * from './pipeline'; + +/* Артефакты */ +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, + getPruningCommand, + getYarnVersion, +} from './utils/yarn'; 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/src/templates/base-nginx.conf.template.ts b/packages/arui-scripts-artifacts/src/nginx/templates/base-nginx.conf.template.ts similarity index 70% rename from packages/arui-scripts/src/templates/base-nginx.conf.template.ts rename to packages/arui-scripts-artifacts/src/nginx/templates/base-nginx.conf.template.ts index 44b23ab9..44570286 100644 --- a/packages/arui-scripts/src/templates/base-nginx.conf.template.ts +++ b/packages/arui-scripts-artifacts/src/nginx/templates/base-nginx.conf.template.ts @@ -1,19 +1,15 @@ -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 = ` +import { type ResolvedArtifactsConfig } from '../../config/types'; +import { DEFAULT_NGINX_BASE_CONF } from '../constants'; + +/** + * http-блок nginx-конфига (базовый nginx.conf). Значения приходят из `nginx.baseConf` уже + * донасыщенными дефолтами; если базовый конфиг выключен (`baseConf: null`), рендерится с дефолтами — + * решение о том, класть ли его в артефакт, принимает `renderTemplates`. + */ +export function renderBaseNginxConf(config: ResolvedArtifactsConfig): string { + const nginx = config.nginx.baseConf ?? DEFAULT_NGINX_BASE_CONF; + + return ` worker_processes ${nginx.workerProcesses}; worker_rlimit_nofile ${nginx.workerRlimitNoFile}; daemon ${nginx.daemon}; @@ -58,5 +54,4 @@ http { include /etc/nginx/conf.d/*.conf; }`; - -export const nginxBaseConfTemplate = applyOverrides('nginxConf', baseNginxTemplate); +} diff --git a/packages/arui-scripts-artifacts/src/nginx/templates/nginx.conf.template.ts b/packages/arui-scripts-artifacts/src/nginx/templates/nginx.conf.template.ts new file mode 100644 index 00000000..dda5b6fc --- /dev/null +++ b/packages/arui-scripts-artifacts/src/nginx/templates/nginx.conf.template.ts @@ -0,0 +1,54 @@ +import { type ResolvedArtifactsConfig } from '../../config/types'; + +/** + * server-блок nginx-конфига, который раздает статику и (в не-clientOnly режиме) проксирует на nodejs. + */ +export function renderNginxConf(config: ResolvedArtifactsConfig): string { + const { clientOnly, buildPath, serverPort, publicPath, nginx } = config; + const { port, rootPath, enablePreviousVersionHeaders } = nginx; + + return `client_max_body_size 20m; + +server { + listen ${port}; + server_tokens off; + ${enablePreviousVersionHeaders ? 'brotli_auto_dictionary on;' : ''} + + ${ + clientOnly + ? `location / { + root ${rootPath}/${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 ${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 ${rootPath}/${buildPath}; + types { + text/javascript js; + } + } + + location ~ /${publicPath}.*\\.js$ { + expires max; + add_header Cache-Control public; + root ${rootPath}/${buildPath}; + types { + text/javascript js; + } + } +}`; +} diff --git a/packages/arui-scripts-artifacts/src/pipeline/__tests__/render.test.ts b/packages/arui-scripts-artifacts/src/pipeline/__tests__/render.test.ts new file mode 100644 index 00000000..07a7bffc --- /dev/null +++ b/packages/arui-scripts-artifacts/src/pipeline/__tests__/render.test.ts @@ -0,0 +1,126 @@ +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, + docker: { baseImage: '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, + 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 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: { 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', () => { + 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, + docker: { 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/pipeline/build-artifact.ts b/packages/arui-scripts-artifacts/src/pipeline/build-artifact.ts new file mode 100644 index 00000000..e5f3ab18 --- /dev/null +++ b/packages/arui-scripts-artifacts/src/pipeline/build-artifact.ts @@ -0,0 +1,17 @@ +import { buildArchive } from '../archive/build-archive'; +import { buildDockerImage, type BuildDockerImageOptions } from '../docker/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/pipeline/host-pipeline.ts b/packages/arui-scripts-artifacts/src/pipeline/host-pipeline.ts new file mode 100644 index 00000000..e2178c2b --- /dev/null +++ b/packages/arui-scripts-artifacts/src/pipeline/host-pipeline.ts @@ -0,0 +1,41 @@ +import path from 'path'; + +import fs from 'fs-extra'; + +import { type ResolvedArtifactsConfig } from '../config/types'; +import { exec } from '../utils/exec'; + +/** Хук, вызываемый после очистки `buildPath`, но до сборки приложения. */ +export type BeforeBuildHook = (config: ResolvedArtifactsConfig) => void | Promise; + +/** + * Шаги, которые выполняются на хосте перед упаковкой артефакта: очистка прошлой сборки, сборка + * приложения и удаление dev-зависимостей. Общие для docker-образа и tar-архива — какие именно шаги + * выполнятся, определяет секция `build` конфига. + */ +export async function runHostPipeline( + config: ResolvedArtifactsConfig, + beforeBuild?: BeforeBuildHook, +): Promise { + const { build, packageManager } = config; + + if (build.cleanBuildPath) { + await fs.remove(path.resolve(config.cwd, config.buildPath)); + } + + if (beforeBuild) { + await beforeBuild(config); + } + + if (build.command) { + console.time('Build application time'); + await exec(build.command); + console.timeEnd('Build application time'); + } + + if (build.removeDevDependencies && packageManager.pruneCommand) { + console.time('Remove dev dependencies time'); + 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/pipeline/render.ts b/packages/arui-scripts-artifacts/src/pipeline/render.ts new file mode 100644 index 00000000..845964df --- /dev/null +++ b/packages/arui-scripts-artifacts/src/pipeline/render.ts @@ -0,0 +1,66 @@ +import { + type ArtifactTemplateOverrides, + type ArtifactTemplates, + type DockerfileVariant, + type RenderedTemplates, + type ResolvedArtifactsConfig, + type TemplateKey, + type TemplateRenderer, +} 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.docker.variant`. */ + variant?: DockerfileVariant; + /** Полная замена рендереров отдельных шаблонов. */ + templates?: ArtifactTemplates; + /** Точечные оверрайды поверх сгенерированных шаблонов. */ + overrides?: ArtifactTemplateOverrides; +}; + +function renderTemplate( + key: TemplateKey, + defaultRenderer: TemplateRenderer, + params: RenderTemplatesParams, +): 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 renderTemplates(params: RenderTemplatesParams): RenderedTemplates { + const { config, variant = config.docker.variant } = params; + + const dockerfileKey: TemplateKey = variant === 'compiled' ? 'dockerfileCompiled' : 'dockerfile'; + const dockerfileRenderer = variant === 'compiled' ? renderDockerfileCompiled : renderDockerfile; + + return { + dockerfile: renderTemplate(dockerfileKey, dockerfileRenderer, params), + nginxConf: renderTemplate('nginxConf', renderNginxConf, params), + // при выключенном базовом конфиге он не попадает в артефакт, поэтому и рендерить (и звать + // оверрайд, результат которого все равно будет отброшен) нечего + 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/src/templates/start.template.ts b/packages/arui-scripts-artifacts/src/start-script/start.template.ts similarity index 73% rename from packages/arui-scripts/src/templates/start.template.ts rename to packages/arui-scripts-artifacts/src/start-script/start.template.ts index a208c4da..f58ab178 100644 --- a/packages/arui-scripts/src/templates/start.template.ts +++ b/packages/arui-scripts-artifacts/src/start-script/start.template.ts @@ -1,8 +1,15 @@ -import { configs } from '../configs/app-configs'; -import { ENV_CONFIG_FILENAME } from '../configs/client-env-config'; -import { applyOverrides } from '../configs/util/apply-overrides'; +import { type ResolvedArtifactsConfig } from '../config/types'; -const startTemplate = `#!/bin/sh +import { ENV_CONFIG_FILENAME } from './constants'; + +/** + * start.sh — entrypoint артефакта: используется и docker-образом, и tar-архивом. Для serverful-режима + * поднимает nginx + nodejs, для clientOnly — подставляет env-config и запускает nginx. + */ +export function renderStartScript(config: ResolvedArtifactsConfig): string { + const { clientOnly, buildPath, serverOutput } = config; + + const serverStartTemplate = `#!/bin/sh # Подменяем env переменные в nginx конфиге перед стартом # Сначала заменяем все слова, начинающиеся на $ но без \${} на ~~слово~~ @@ -29,14 +36,14 @@ node_memory_limit="$(($max_total_memory / 1024 / 1024 - 100))" nginx & # Start nodejs process -exec node --max-old-space-size="$node_memory_limit" ./${configs.buildPath}/${configs.serverOutput} +exec node --max-old-space-size="$node_memory_limit" ./${buildPath}/${serverOutput} `; -const envConfigTargetPath = `/src/${configs.buildPath}/${ENV_CONFIG_FILENAME}`; -const envConfigPath = `/src/${ENV_CONFIG_FILENAME}`; -const htmlPath = `/src/${configs.buildPath}/index.html`; + const envConfigTargetPath = `/src/${buildPath}/${ENV_CONFIG_FILENAME}`; + const envConfigPath = `/src/${ENV_CONFIG_FILENAME}`; + const htmlPath = `/src/${buildPath}/index.html`; -const clientOnlyStartTemplate = `#!/bin/sh + const clientOnlyStartTemplate = `#!/bin/sh # Мы подставляем значения из env в env-config.json если он есть, и кладем его в публичную папку. # Дополнительно подставляем контент полученного файла в index.html @@ -67,7 +74,5 @@ fi nginx`; -export const startScript = applyOverrides( - 'start.sh', - configs.clientOnly ? clientOnlyStartTemplate : startTemplate, -); + return clientOnly ? clientOnlyStartTemplate : serverStartTemplate; +} 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-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 new file mode 100644 index 00000000..42b653cf --- /dev/null +++ b/packages/arui-scripts-artifacts/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 '../config/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-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-artifacts/tsconfig.json b/packages/arui-scripts-artifacts/tsconfig.json new file mode 100644 index 00000000..d32e8d27 --- /dev/null +++ b/packages/arui-scripts-artifacts/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", "src/__tests__"] +} 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/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..4e0d4b9e 100644 --- a/packages/arui-scripts/src/commands/archive-build/index.ts +++ b/packages/arui-scripts/src/commands/archive-build/index.ts @@ -1,88 +1,18 @@ -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), + await buildArchive( + getArtifactsOptions({ + // archive-build исторически всегда удаляет dev-зависимости, независимо от + // removeDevDependenciesDuringDockerBuild + build: { removeDevDependencies: true }, + }), ); - - 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); - } - + } 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..7ce3e82d 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,18 @@ -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, - allowLocalDockerfile: false, - allowLocalStartScript: false, - addNodeModulesToDockerIgnore: true, + await buildDockerImage({ + ...getArtifactsOptions({ + docker: { variant: 'compiled', addNodeModulesToDockerIgnore: true }, + localFiles: { allowDockerfile: false, allowStartScript: false }, + }), + 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..b9f2d307 100644 --- a/packages/arui-scripts/src/commands/docker-build/index.ts +++ b/packages/arui-scripts/src/commands/docker-build/index.ts @@ -1,79 +1,18 @@ -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, - allowLocalDockerfile: true, - allowLocalStartScript: true, - addNodeModulesToDockerIgnore: false, + await buildDockerImage({ + ...getArtifactsOptions({ + docker: { variant: 'runtime', addNodeModulesToDockerIgnore: false }, + localFiles: { allowDockerfile: true, allowStartScript: true }, + }), + 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/__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..27a49d73 --- /dev/null +++ b/packages/arui-scripts/src/commands/util/artifacts-deprecations.ts @@ -0,0 +1,76 @@ +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 + ) + .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 new file mode 100644 index 00000000..d21fa19f --- /dev/null +++ b/packages/arui-scripts/src/commands/util/artifacts-options.ts @@ -0,0 +1,124 @@ +import { + type ArtifactsOptions, + type ArtifactTemplateOverrides, + resolveArtifactsConfig, + type ResolvedArtifactsConfig, +} from '@alfalab/scripts-artifacts'; + +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: ArtifactTemplateOverrides = { + 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`. + * + * Здесь происходит только маппинг значений. Дефолты docker/nginx/archive-настроек не дублируются: + * если пользователь ничего не задал, сюда приезжает `undefined` и значение подставит + * `resolveArtifactsConfig`. + * + * @deprecated Сам маппинг — слой обратной совместимости. В следующей мажорной версии настройки + * сборки артефактов будут жить только в конфиге @alfalab/scripts-artifacts. + */ +export function getArtifactsOptions(extraOptions: ArtifactsOptions = {}): ArtifactsOptions { + warnAboutArtifactsDeprecations(); + + const options: ArtifactsOptions = { + name: configs.name, + version: configs.version, + cwd: configs.cwd, + debug: configs.debug, + + clientOnly: configs.clientOnly, + buildPath: configs.buildPath, + serverOutput: configs.serverOutput, + serverPort: configs.serverPort, + assetsPath: configs.assetsPath, + publicPath: configs.publicPath, + + docker: { + registry: configs.dockerRegistry, + baseImage: configs.baseDockerImage, + runFromNonRootUser: configs.runFromNonRootUser, + }, + + nginx: { + port: configs.clientServerPort, + rootPath: configs.nginxRootPath, + enablePreviousVersionHeaders: + configs.dictionaryCompression.enablePreviousVersionHeaders, + baseConf: configs.nginx, + }, + + archive: { + name: configs.archiveName, + additionalPaths: configs.additionalBuildPath, + }, + + build: { + removeDevDependencies: configs.removeDevDependenciesDuringDockerBuild, + }, + + packageManager: { + useYarn: configs.useYarn, + }, + + localFiles: { + dockerfile: configs.localDockerfile, + startScript: configs.localStartScript, + nginxConf: configs.localNginxConf, + nginxBaseConf: configs.localNginxBaseConf, + }, + + 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 }, + }; +} + +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..58916bae 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 artifactsGetBuildParamsFromArgs, + getDockerBuildCommand as artifactsGetBuildCommand, + prepareFilesForDocker as artifactsPrepareFilesForDocker, +} from '@alfalab/scripts-artifacts'; + +import { getResolvedArtifactsConfig } from './artifacts-options'; + +export { dockerVersionSatisfies }; + +/** + * Совместимый слой поверх @alfalab/scripts-artifacts: сохраняет исторические сигнатуры, которыми + * пользуются сторонние сборки и реэкспорт из `arui-scripts`. + * + * @deprecated Используйте одноименные функции из `@alfalab/scripts-artifacts` — они принимают явный + * конфиг и не зависят от глобального `configs`. В следующей мажорной версии реэкспорт будет удален. + */ +export function getBuildParamsFromArgs(): BuildParams { + return artifactsGetBuildParamsFromArgs(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,10 @@ type PrepareFilesForDockerParams = { addNodeModulesToDockerIgnore: boolean; }; +/** + * @deprecated Используйте `prepareFilesForDocker` из `@alfalab/scripts-artifacts`. В следующей + * мажорной версии реэкспорт будет удален. + */ export async function prepareFilesForDocker({ dockerfileTemplate, nginxConfTemplate, @@ -70,69 +67,30 @@ 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, + const config = getResolvedArtifactsConfig(); + + return artifactsPrepareFilesForDocker({ + config: { + ...config, + cwd: path.dirname(pathToTempDir), + docker: { + ...config.docker, + tempDirName: path.basename(pathToTempDir), + addNodeModulesToDockerIgnore, + }, + localFiles: { + ...config.localFiles, + allowDockerfile: allowLocalDockerfile, + allowStartScript: allowLocalStartScript, + }, + }, + templates: { + dockerfile: dockerfileTemplate, + nginxConf: nginxConfTemplate, + nginxBaseConf: nginxBaseConfTemplate, + startScript: startScriptTemplate, + }, }); - - return ( - satisfies(dockerServerVersion.toString(), request) && - satisfies(dockerClientVersion.toString(), request) - ); } type DockerBuildCommandParams = { @@ -140,30 +98,19 @@ 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'); + const config = getResolvedArtifactsConfig(); - 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 artifactsGetBuildCommand({ + ...config, + ...splitImageFullName(imageFullName), + // имя образа уже содержит registry, второй раз подставлять его не нужно + docker: { ...config.docker, registry: '', 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/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 d9486879..10416a8a 100644 --- a/packages/arui-scripts/src/index.ts +++ b/packages/arui-scripts/src/index.ts @@ -1,7 +1,38 @@ 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. + * @deprecated Слой обратной совместимости: в следующей мажорной версии настройки сборки артефактов + * будут жить только в конфиге `arui-scripts-artifacts.ts`. + */ +export { getArtifactsOptions, getResolvedArtifactsConfig } from './commands/util/artifacts-options'; + +export { + buildDockerImage, + getBuildParams, + renderBaseNginxConf, + renderDockerfile, + renderDockerfileCompiled, + renderTemplates, + renderNginxConf, + renderStartScript, + resolveArtifactsConfig, + type ArtifactsOptions, + type ArtifactTemplateOverrides, + type ArtifactTemplates, + type ResolvedArtifactsConfig, +} from '@alfalab/scripts-artifacts'; + export { patchMainWebpackConfigForModules } from './configs/modules'; 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 5d12ae98..00000000 --- a/packages/arui-scripts/src/templates/dockerfile-compiled.template.ts +++ /dev/null @@ -1,52 +0,0 @@ -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 - -# Полу-статичные файлы, могут легко кешироваться -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 -`; - -export const dockerfileTemplate = applyOverrides('DockerfileCompiled', template); 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 2334e83c..00000000 --- a/packages/arui-scripts/src/templates/dockerfile.template.ts +++ /dev/null @@ -1,46 +0,0 @@ -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` - : ''; - -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"]' : ''} -`; - -export const dockerfileTemplate = applyOverrides('Dockerfile', template); 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 608f4e01..00000000 --- a/packages/arui-scripts/src/templates/nginx.conf.template.ts +++ /dev/null @@ -1,53 +0,0 @@ -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}; - }` - } - - 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; - } - } -}`; - -export const nginxConfTemplate = applyOverrides('nginx', nginxTemplate); diff --git a/yarn.lock b/yarn.lock index 048043be..4217bc84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -147,6 +147,29 @@ __metadata: languageName: node linkType: hard +"@alfalab/scripts-artifacts@workspace:^, @alfalab/scripts-artifacts@workspace:packages/arui-scripts-artifacts": + version: 0.0.0-use.local + 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-artifacts: ./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" @@ -8185,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" @@ -9483,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" @@ -15734,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"