commit f8e9aca1950a7f596f5f4294b4cde7d71c7b0102 Author: Alexander Sabino <32822107+asabino2@users.noreply.github.com> Date: Mon May 4 17:15:51 2026 +0100 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a8274b6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +npm-debug.log +data/*.json +data/tmpnode_modules +npm-debug.log +data/store.json \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c54eff --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +data/ +node_modules/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ce071b6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-bookworm-slim + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY public ./public +COPY src ./src +#COPY data ./data + +ENV PORT=3000 +ENV DATA_DIR=/app/data +ENV HELPER_IMAGE=dockerbackup-app + +EXPOSE 3000 + +CMD ["npm", "start"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5cffc2e --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Docker Backup App + +⚠️ **AVISO CRÍTICO:** Esta é uma aplicação em estágio inicial de desenvolvimento, não use em produção de forma alguma, há risco de perda de dados ⚠️ + +Aplicacao web para cadastrar profiles de backup de containers Docker, executar backup full ou incremental e restaurar snapshots de volumes e bind mounts. + +## Como funciona + +- O app lista os containers via Docker socket. +- Cada backup processa um container por vez: para, executa o backup dos mounts elegiveis e sobe novamente se ele estava rodando. +- O backup usa um container auxiliar com GNU tar e `--listed-incremental` para gerar arquivos compactados `.tar.gz`. +- Quando o app roda dentro de Docker, backup e restore sao feitos via Docker API (`getArchive`/`putArchive`) sem criar helper e sem mapear o root do host. +- Ha dois escopos por profile: `somente volumes` (comportamento tradicional) e `container inteiro` (tar unico por container a partir de `/`). +- O restore aplica a cadeia full + incrementais sobre os mounts atuais do container, limpando o conteudo antes de reconstituir o snapshot escolhido. +- Ao restaurar um backup, e possivel escolher quais containers do backup serao restaurados. + +## Requisitos + +- Docker Engine com acesso ao socket em `/var/run/docker.sock`. +- O diretorio de backup informado no profile precisa ser visivel para o Docker daemon. +- Em Docker Desktop no Windows, quando o app roda fora de container, paths como `C:\backups` sao convertidos automaticamente para `/run/desktop/mnt/host/c/backups`. +- Quando o app roda dentro de container, use um caminho absoluto interno do container (ex.: `/app/data/backups`). +- O escopo `container inteiro` exige que o app esteja rodando em Docker para usar backup/restore nativos sem helper. + +## Executando com Docker Compose + +```bash +docker compose up --build +``` + +Abra `http://localhost:3000`. + +O arquivo `docker-compose.example.yml` foi mantido como referencia equivalente ao compose principal. + +## Observacoes + +- O restore valida se o conjunto de mounts do container continua igual ao do backup selecionado. +- O catalogo de profiles e historico de backups fica salvo em `./data/store.json`. +- Os arquivos `.tar.gz` sao gravados no diretorio de backup configurado em cada profile. \ No newline at end of file diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..e3b855f --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,15 @@ +services: + dockerbackup: + image: dockerbackup-app + build: + context: . + ports: + - "3000:3000" + environment: + PORT: 3000 + DATA_DIR: /app/data + HELPER_IMAGE: dockerbackup-app + volumes: + - ./data:/app/data + - /var/run/docker.sock:/var/run/docker.sock + restart: unless-stopped \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bc360a0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + dockerbackup: + image: dockerbackup-app + build: + context: . + ports: + - "33000:3000" + environment: + PORT: 3000 + DATA_DIR: /app/data + HELPER_IMAGE: dockerbackup-app + volumes: + - /mnt/f/ChromeDownloads/dockerbackup/data:/app/data + - /var/run/docker.sock:/var/run/docker.sock + # restart: unless-stopped \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..28ce46c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1573 @@ +{ + "name": "dockerbackup-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dockerbackup-app", + "version": "1.0.0", + "dependencies": { + "dockerode": "^4.0.7", + "express": "^4.21.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/docker-modem": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz", + "integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==", + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.7", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nan": { + "version": "2.26.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "license": "MIT", + "optional": true + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", + "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.1", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "license": "ISC" + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4267c84 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "dockerbackup-app", + "version": "1.0.0", + "description": "Aplicacao web para backup e restauracao de volumes Docker", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js", + "check": "node --check src/server.js && node --check src/config.js && node --check src/dockerService.js && node --check src/store.js && node --check src/backupService.js && node --check public/app.js" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "dockerode": "^4.0.7", + "express": "^4.21.2" + } +} \ No newline at end of file diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..7abc256 --- /dev/null +++ b/public/app.js @@ -0,0 +1,596 @@ +const state = { + containers: [], + profiles: [], + activeRuns: new Map(), +}; + +const elements = { + containerCount: document.querySelector('#containerCount'), + profileCount: document.querySelector('#profileCount'), + profileForm: document.querySelector('#profileForm'), + profileId: document.querySelector('#profileId'), + profileName: document.querySelector('#profileName'), + backupDir: document.querySelector('#backupDir'), + containerOptions: document.querySelector('#containerOptions'), + profilesList: document.querySelector('#profilesList'), + formModeBadge: document.querySelector('#formModeBadge'), + toast: document.querySelector('#toast'), + restoreModal: document.querySelector('#restoreModal'), + restoreModalSubtitle: document.querySelector('#restoreModalSubtitle'), + restoreContainerOptions: document.querySelector('#restoreContainerOptions'), + restoreModalConfirm: document.querySelector('#restoreModalConfirm'), + restoreModalClose: document.querySelector('#restoreModalClose'), + restoreModalSelectAll: document.querySelector('#restoreModalSelectAll'), +}; + +async function api(path, options = {}) { + const response = await fetch(path, { + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + ...options, + }); + + if (response.status === 204) { + return null; + } + + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Falha na requisicao'); + } + + return payload; +} + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function showToast(message, isError = false) { + elements.toast.textContent = message; + elements.toast.classList.remove('hidden', 'error'); + if (isError) { + elements.toast.classList.add('error'); + } + + window.clearTimeout(showToast.timer); + showToast.timer = window.setTimeout(() => { + elements.toast.classList.add('hidden'); + }, 3200); +} + +function getSelectedContainerIds() { + return [...document.querySelectorAll('input[name="containerIds"]:checked')].map((input) => input.value); +} + +function renderContainers() { + elements.containerCount.textContent = String(state.containers.length); + + if (!state.containers.length) { + elements.containerOptions.innerHTML = '

Nenhum container encontrado.

'; + return; + } + + const selected = new Set(getSelectedContainerIds()); + elements.containerOptions.innerHTML = state.containers.map((container) => ` + + `).join(''); +} + +function backupButtons(profile) { + const isRunning = state.activeRuns.has(profile.id); + return ` +
+ +
+ + + +
+
+ `; +} + +function getRunMode(profileId) { + const selector = document.querySelector(`select[data-run-mode="${profileId}"]`); + const value = selector?.value; + return value === 'incremental' ? 'incremental' : 'full'; +} + +function getProfileScopeLabel(scope) { + return scope === 'container' ? 'container inteiro' : 'somente volumes'; +} + +function askRestoreContainerSelection(profile, backup) { + const restorable = (backup.containers || []).filter((item) => item.status === 'ok'); + if (!restorable.length) { + throw new Error('Nao ha containers validos neste backup para restaurar.'); + } + + elements.restoreModalSubtitle.textContent = `${profile.name} - ${new Date(backup.createdAt).toLocaleString('pt-BR')}`; + elements.restoreContainerOptions.innerHTML = restorable.map((item) => ` + + `).join(''); + + elements.restoreModal.classList.remove('hidden'); + elements.restoreModal.setAttribute('aria-hidden', 'false'); + + return new Promise((resolve, reject) => { + const closeModal = () => { + elements.restoreModal.classList.add('hidden'); + elements.restoreModal.setAttribute('aria-hidden', 'true'); + elements.restoreContainerOptions.innerHTML = ''; + }; + + const cleanup = () => { + elements.restoreModalConfirm.removeEventListener('click', onConfirm); + elements.restoreModalClose.removeEventListener('click', onCancel); + elements.restoreModalSelectAll.removeEventListener('click', onSelectAll); + elements.restoreModal.removeEventListener('click', onBackdropClick); + }; + + const onConfirm = () => { + const selected = [...elements.restoreContainerOptions.querySelectorAll('input[name="restoreContainerIds"]:checked')] + .map((input) => input.value); + + if (!selected.length) { + showToast('Selecione ao menos um container para restaurar.', true); + return; + } + + cleanup(); + closeModal(); + resolve(selected); + }; + + const onCancel = () => { + cleanup(); + closeModal(); + resolve(null); + }; + + const onSelectAll = () => { + for (const input of elements.restoreContainerOptions.querySelectorAll('input[name="restoreContainerIds"]')) { + input.checked = true; + } + }; + + const onBackdropClick = (event) => { + const closeTrigger = event.target.closest('[data-action="close-restore-modal"]'); + if (closeTrigger) { + onCancel(); + } + }; + + elements.restoreModalConfirm.addEventListener('click', onConfirm); + elements.restoreModalClose.addEventListener('click', onCancel); + elements.restoreModalSelectAll.addEventListener('click', onSelectAll); + elements.restoreModal.addEventListener('click', onBackdropClick); + }); +} + +function formatBackupFailures(backup) { + const failures = (backup.containers || []).filter((item) => item.status === 'error'); + if (!failures.length) { + return ''; + } + + return ` + + Falhas: ${failures.map((item) => `${escapeHtml(item.containerName || item.containerId || 'container')}: ${escapeHtml(item.error || 'erro desconhecido')}`).join(' | ')} + + `; +} + +function progressBar(percent) { + const normalized = Math.max(0, Math.min(100, Number(percent) || 0)); + return ` +
+ +
+ `; +} + +function renderRunProgress(profileId) { + const run = state.activeRuns.get(profileId); + const host = document.querySelector(`[data-run-progress="${profileId}"]`); + if (!host) { + return; + } + + if (!run || !run.progress) { + host.innerHTML = ''; + host.classList.add('hidden'); + return; + } + + const overall = run.progress.overall || { total: 0, completed: 0, pending: 0, percent: 0 }; + const currentContainer = run.progress.currentContainer; + const file = currentContainer?.file || { current: 0, total: 0, percent: 0, currentFile: null }; + const containerPercent = Number.isFinite(currentContainer?.percent) ? currentContainer.percent : 0; + const stepLabel = currentContainer?.step || 'aguardando'; + const stepMessage = currentContainer?.message || 'Aguardando processamento de arquivo...'; + const logs = Array.isArray(currentContainer?.logs) ? currentContainer.logs.slice(-8) : []; + const operation = run?.kind === 'restore' || run?.progress?.operation === 'restore' ? 'restore' : 'backup'; + const operationTitle = operation === 'restore' ? 'Progresso do restore' : 'Progresso do backup'; + + host.classList.remove('hidden'); + host.innerHTML = ` +
+
+ ${escapeHtml(operationTitle)} + ${escapeHtml(run.status)} +
+ +
+
+ Containers: ${escapeHtml(String(overall.completed))}/${escapeHtml(String(overall.total))} concluido(s) + Faltam ${escapeHtml(String(overall.pending))} +
+ ${progressBar(overall.percent)} +
+ +
+
+ Container atual: ${escapeHtml(currentContainer?.containerName || currentContainer?.containerId || '-')} + ${escapeHtml(String(Math.round(containerPercent)))}% +
+ ${progressBar(containerPercent)} +
+ +
+
+ Arquivos: ${escapeHtml(String(file.current || 0))}/${escapeHtml(String(file.total || 0))} + ${escapeHtml(String(Math.round(file.percent || 0)))}% +
+ ${progressBar(file.percent || 0)} + Etapa: ${escapeHtml(stepLabel)} · ${escapeHtml(file.currentFile || stepMessage)} +
+ +
+
+ Log detalhado + ${escapeHtml(String(logs.length))} evento(s) +
+
+ ${logs.length + ? logs.map((line) => `${escapeHtml(line)}`).join('') + : 'Nenhum evento detalhado ainda.'} +
+
+
+ `; +} + +function renderAllRunProgress() { + for (const profile of state.profiles) { + renderRunProgress(profile.id); + } +} + +async function pollRun(profileId, runId) { + const doneStatus = new Set(['completed', 'completed-with-errors', 'error']); + + while (true) { + const run = await api(`/api/runs/${runId}`); + state.activeRuns.set(profileId, run); + renderRunProgress(profileId); + + if (doneStatus.has(run.status)) { + return run; + } + + await new Promise((resolve) => { + window.setTimeout(resolve, 700); + }); + } +} + +function restoreButtons(profile, backups) { + if (!backups.length) { + return '

Nenhum backup executado ainda.

'; + } + + return ` +
+ ${backups.map((backup) => ` + + `).join('')} +
+ `; +} + +async function renderProfiles() { + elements.profileCount.textContent = String(state.profiles.length); + + if (!state.profiles.length) { + elements.profilesList.innerHTML = '

Nenhum profile salvo.

'; + return; + } + + const backupsByProfile = await Promise.all( + state.profiles.map(async (profile) => [profile.id, await api(`/api/profiles/${profile.id}/backups`)]) + ); + const backupMap = new Map(backupsByProfile); + + elements.profilesList.innerHTML = state.profiles.map((profile) => ` +
+
+
+

${escapeHtml(profile.name)}

+

${escapeHtml(String(profile.containerIds.length))} container(es) · ${escapeHtml(getProfileScopeLabel(profile.backupScope))}

+ ${escapeHtml(profile.backupDir)} +
+ ${backupButtons(profile)} +
+
+ ${profile.containerIds.map((containerId) => { + const container = state.containers.find((item) => item.id === containerId); + return `${escapeHtml(container ? container.name : containerId.slice(0, 12))}`; + }).join('')} +
+ +
+

Restaurar

+ ${restoreButtons(profile, backupMap.get(profile.id) || [])} +
+
+ `).join(''); + + renderAllRunProgress(); +} + +function resetForm() { + elements.profileForm.reset(); + elements.profileId.value = ''; + elements.formModeBadge.textContent = 'criar'; + renderContainers(); +} + +function fillForm(profile) { + elements.profileId.value = profile.id; + elements.profileName.value = profile.name; + elements.backupDir.value = profile.backupDir; + const backupScope = profile.backupScope === 'container' ? 'container' : 'volumes'; + document.querySelector(`input[name="backupScope"][value="${backupScope}"]`).checked = true; + elements.formModeBadge.textContent = 'editar'; + renderContainers(); + for (const containerId of profile.containerIds) { + const input = document.querySelector(`input[name="containerIds"][value="${containerId}"]`); + if (input) { + input.checked = true; + } + } + window.scrollTo({ top: 0, behavior: 'smooth' }); +} + +async function loadContainers() { + state.containers = await api('/api/containers'); + renderContainers(); +} + +async function loadProfiles() { + state.profiles = await api('/api/profiles'); + await renderProfiles(); +} + +async function saveProfile(event) { + event.preventDefault(); + const payload = { + id: elements.profileId.value || undefined, + name: elements.profileName.value, + backupDir: elements.backupDir.value, + containerIds: getSelectedContainerIds(), + backupScope: document.querySelector('input[name="backupScope"]:checked').value, + }; + + try { + await api('/api/profiles', { + method: 'POST', + body: JSON.stringify(payload), + }); + await loadProfiles(); + resetForm(); + showToast('Profile salvo.'); + } catch (error) { + showToast(error.message, true); + } +} + +async function handleProfileAction(event) { + const button = event.target.closest('button[data-action]'); + if (!button) { + return; + } + + const { action, profileId, backupId } = button.dataset; + const profile = state.profiles.find((item) => item.id === profileId); + if (!profile) { + return; + } + + try { + if (action === 'edit') { + fillForm(profile); + return; + } + + if (action === 'delete') { + if (!window.confirm(`Excluir o profile ${profile.name}?`)) { + return; + } + await api(`/api/profiles/${profileId}`, { method: 'DELETE' }); + await loadProfiles(); + showToast('Profile removido.'); + return; + } + + if (action === 'run') { + button.disabled = true; + button.textContent = 'Executando...'; + const mode = getRunMode(profileId); + + const start = await api(`/api/profiles/${profileId}/run`, { method: 'POST', body: JSON.stringify({ mode }) }); + state.activeRuns.set(profileId, { + id: start.runId, + profileId, + status: 'running', + progress: { + overall: { total: profile.containerIds.length, completed: 0, pending: profile.containerIds.length, percent: 0 }, + currentContainer: null, + }, + }); + renderRunProgress(profileId); + + const run = await pollRun(profileId, start.runId); + state.activeRuns.delete(profileId); + await loadProfiles(); + if (run.status === 'error') { + showToast(run.error || 'Falha durante a execucao do backup.', true); + } else { + const backupStatus = run.result?.status; + const failures = (run.result?.containers || []).filter((item) => item.status === 'error'); + if (failures.length) { + const details = failures + .map((item) => `${item.containerName || item.containerId || 'container'}: ${item.error || 'erro desconhecido'}`) + .join(' | '); + showToast(`Backup com falhas. ${details}`, true); + } else { + showToast( + backupStatus === 'ok' ? 'Backup concluido.' : 'Backup concluido com falhas parciais.', + backupStatus !== 'ok', + ); + } + } + return; + } + + if (action === 'restore') { + if (!window.confirm(`Restaurar o backup selecionado para o profile ${profile.name}?`)) { + return; + } + + const backups = await api(`/api/profiles/${profileId}/backups`); + const selectedBackup = backups.find((item) => item.id === backupId); + if (!selectedBackup) { + throw new Error('Backup selecionado nao encontrado.'); + } + + const selectedContainerIds = await askRestoreContainerSelection(profile, selectedBackup); + if (!selectedContainerIds) { + return; + } + + button.disabled = true; + button.textContent = 'Restaurando...'; + + const start = await api(`/api/profiles/${profileId}/restore`, { + method: 'POST', + body: JSON.stringify({ backupId, containerIds: selectedContainerIds }), + }); + state.activeRuns.set(profileId, { + id: start.runId, + kind: 'restore', + profileId, + status: 'running', + progress: { + operation: 'restore', + overall: { + total: selectedContainerIds.length, + completed: 0, + pending: selectedContainerIds.length, + percent: 0, + }, + currentContainer: null, + }, + }); + renderRunProgress(profileId); + + const run = await pollRun(profileId, start.runId); + state.activeRuns.delete(profileId); + await loadProfiles(); + + if (run.status === 'error') { + showToast(run.error || 'Falha durante a execucao do restore.', true); + } else { + const restoreStatus = run.result?.status; + const restoreStatsLines = (run.result?.containers || []) + .filter((item) => item.status === 'ok' && item.stats) + .map((item) => `${item.containerName}: apagados ${item.stats.deleted}, criados ${item.stats.created}, modificados ${item.stats.modified}`); + + const failures = (run.result?.containers || []).filter((item) => item.status === 'error'); + if (failures.length) { + const details = failures + .map((item) => `${item.containerName || item.containerId || 'container'}: ${item.error || 'erro desconhecido'}`) + .join(' | '); + const statsSummary = restoreStatsLines.length ? ` ${restoreStatsLines.join(' | ')}` : ''; + showToast(`Restore com falhas. ${details}.${statsSummary}`, true); + } else { + const summary = restoreStatsLines.length ? ` ${restoreStatsLines.join(' | ')}` : ''; + showToast( + `${restoreStatus === 'ok' ? 'Restore concluido.' : 'Restore concluido com falhas parciais.'}${summary}`, + restoreStatus !== 'ok', + ); + } + } + } + } catch (error) { + showToast(error.message, true); + } finally { + button.disabled = false; + } +} + +async function init() { + try { + await Promise.all([loadContainers(), loadProfiles()]); + } catch (error) { + showToast(error.message, true); + } +} + +elements.profileForm.addEventListener('submit', saveProfile); +document.querySelector('#refreshContainers').addEventListener('click', init); +document.querySelector('#reloadProfiles').addEventListener('click', loadProfiles); +document.querySelector('#clearForm').addEventListener('click', resetForm); +elements.profilesList.addEventListener('click', handleProfileAction); + +init(); \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..9956fcd --- /dev/null +++ b/public/index.html @@ -0,0 +1,113 @@ + + + + + + Docker Backup Profiles + + + + + + +
+
+
+

Docker volume backup

+

Profiles de backup com parada controlada por container.

+

+ O app usa a Docker API para parar cada container, gerar backup compactado dos mounts e religar apenas quem estava rodando antes. +

+
+
+
+ Containers detectados + 0 +
+
+ Profiles salvos + 0 +
+
+
+ +
+
+
+

Novo profile

+ criar +
+ +
+ + + + + + +
+ Escopo do backup +
+ + +
+
+ +
+
+

Containers

+ +
+
+
+ +
+ + +
+
+
+ +
+
+

Profiles salvos

+ +
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..e3dbb16 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,548 @@ +:root { + --bg: #f3efe6; + --bg-soft: #fffaf2; + --surface: rgba(255, 252, 245, 0.78); + --line: rgba(69, 55, 37, 0.14); + --text: #2e2418; + --muted: #7b6c58; + --accent: #156669; + --accent-strong: #0b4d50; + --danger: #a03838; + --shadow: 0 20px 60px rgba(63, 43, 17, 0.12); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: 'Space Grotesk', sans-serif; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(21, 102, 105, 0.18), transparent 30%), + radial-gradient(circle at 85% 20%, rgba(206, 131, 76, 0.18), transparent 22%), + linear-gradient(180deg, #fbf7ef 0%, #efe6d5 100%); +} + +button, +input { + font: inherit; +} + +.page-shell { + max-width: 1240px; + margin: 0 auto; + padding: 32px 20px 64px; +} + +.hero { + display: grid; + grid-template-columns: 1.6fr 0.8fr; + gap: 24px; + margin-bottom: 28px; + align-items: stretch; +} + +.eyebrow { + margin: 0 0 8px; + text-transform: uppercase; + letter-spacing: 0.2em; + color: var(--accent); + font-size: 0.78rem; +} + +.hero h1 { + margin: 0; + font-size: clamp(2.2rem, 4vw, 4.4rem); + line-height: 0.96; + max-width: 10ch; +} + +.hero-copy { + max-width: 64ch; + color: var(--muted); + font-size: 1.02rem; + margin-top: 18px; +} + +.hero-card, +.panel, +.toast { + backdrop-filter: blur(14px); + background: var(--surface); + border: 1px solid var(--line); + box-shadow: var(--shadow); +} + +.hero-card { + border-radius: 28px; + padding: 24px; + display: grid; + gap: 18px; + align-content: center; +} + +.metric-label { + display: block; + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); +} + +.hero-card strong { + font-size: 2.6rem; +} + +.layout { + display: grid; + grid-template-columns: minmax(360px, 420px) 1fr; + gap: 20px; +} + +.panel { + border-radius: 28px; + padding: 22px; +} + +.panel-header, +.picker-header, +.profile-card-top { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.panel-header h2, +.picker-header h3, +.profile-card h3, +.restore-block h4 { + margin: 0; +} + +.badge, +.chip, +.state, +.profile-card code { + font-family: 'IBM Plex Mono', monospace; +} + +.badge, +.chip { + border-radius: 999px; + padding: 6px 10px; + background: rgba(21, 102, 105, 0.1); + color: var(--accent-strong); +} + +form, +.restore-block, +.profiles-list { + display: grid; + gap: 18px; +} + +label, +fieldset { + display: grid; + gap: 8px; + border: 0; + padding: 0; + margin: 0; +} + +label span, +legend { + font-weight: 700; +} + +input[type='text'] { + width: 100%; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.72); +} + +.radio-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.radio-card, +.container-option, +.backup-item { + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.65); + border-radius: 18px; +} + +.radio-card { + padding: 14px; +} + +.radio-card input { + margin-bottom: 10px; +} + +.radio-card small, +.container-option small, +.profile-card p, +.empty-state, +.empty-inline { + color: var(--muted); +} + +.container-options, +.chips, +.backup-history { + display: grid; + gap: 10px; +} + +.container-option { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 12px; + align-items: center; + padding: 14px; +} + +.state { + text-transform: uppercase; + font-size: 0.78rem; +} + +.state.running { + color: var(--accent-strong); +} + +.state.exited, +.state.created { + color: var(--muted); +} + +.primary-button, +.secondary-button, +.ghost-button, +.backup-item { + border: 0; + border-radius: 16px; + cursor: pointer; + transition: transform 180ms ease, opacity 180ms ease, background 180ms ease; +} + +.primary-button, +.secondary-button, +.ghost-button { + padding: 12px 14px; +} + +.small { + padding: 10px 12px; + font-size: 0.92rem; +} + +.primary-button { + background: var(--accent); + color: white; +} + +.secondary-button { + background: rgba(21, 102, 105, 0.12); + color: var(--accent-strong); +} + +.ghost-button { + background: rgba(160, 56, 56, 0.08); + color: var(--danger); +} + +.primary-button:hover, +.secondary-button:hover, +.ghost-button:hover, +.backup-item:hover { + transform: translateY(-1px); +} + +.primary-button:disabled, +.secondary-button:disabled, +.ghost-button:disabled, +.backup-item:disabled { + opacity: 0.6; + cursor: wait; +} + +.form-actions, +.card-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.run-actions { + display: grid; + gap: 10px; + justify-items: end; +} + +.mode-picker { + display: grid; + gap: 6px; + min-width: 180px; +} + +.mode-picker span { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); + font-weight: 700; +} + +.mode-select { + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.72); + color: var(--text); + padding: 8px 10px; +} + +.profile-card { + border: 1px solid var(--line); + border-radius: 22px; + padding: 18px; + background: rgba(255, 255, 255, 0.68); + display: grid; + gap: 16px; +} + +.profile-card p, +.profile-card code { + margin: 6px 0 0; +} + +.profile-card code { + display: inline-block; + padding: 4px 0; +} + +.backup-item { + padding: 14px 16px; + display: flex; + justify-content: space-between; + gap: 12px; + text-align: left; +} + +.backup-item em { + align-self: center; + color: var(--accent-strong); + font-style: normal; + font-weight: 700; +} + +.backup-error { + display: block; + margin-top: 6px; + color: var(--danger); +} + +.run-progress { + display: grid; +} + +.progress-card { + border: 1px solid var(--line); + border-radius: 16px; + padding: 14px; + background: rgba(240, 252, 252, 0.72); + display: grid; + gap: 12px; +} + +.progress-header, +.progress-label-row { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} + +.progress-header small, +.progress-label-row, +.current-file { + color: var(--muted); +} + +.progress-block { + display: grid; + gap: 7px; +} + +.progress-track { + width: 100%; + height: 10px; + border-radius: 999px; + overflow: hidden; + background: rgba(21, 102, 105, 0.16); +} + +.progress-fill { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #17a2a8 0%, #156669 100%); + transition: width 220ms ease; +} + +.current-file { + font-size: 0.82rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.progress-log { + max-height: 140px; + overflow: auto; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.55); + padding: 8px; + display: grid; + gap: 4px; +} + +.progress-log small { + color: var(--muted); + font-family: 'IBM Plex Mono', monospace; + font-size: 0.76rem; + line-height: 1.35; +} + +.toast { + position: fixed; + right: 24px; + bottom: 24px; + max-width: 360px; + padding: 14px 18px; + border-radius: 18px; +} + +.toast.error { + border-color: rgba(160, 56, 56, 0.24); + color: var(--danger); +} + +.modal { + position: fixed; + inset: 0; + z-index: 30; + display: grid; + place-items: center; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(20, 16, 10, 0.45); +} + +.modal-card { + position: relative; + width: min(640px, calc(100vw - 24px)); + max-height: min(80vh, 620px); + overflow: auto; + border-radius: 18px; + border: 1px solid var(--line); + background: var(--bg-soft); + box-shadow: var(--shadow); + padding: 18px; + display: grid; + gap: 12px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.modal-header h3, +.modal-subtitle { + margin: 0; +} + +.modal-subtitle { + color: var(--muted); +} + +.modal-options { + display: grid; + gap: 8px; +} + +.modal-option { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px; + align-items: center; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.66); +} + +.modal-option small { + color: var(--muted); +} + +.modal-actions { + display: flex; + justify-content: space-between; + gap: 10px; +} + +.hidden { + display: none; +} + +@media (max-width: 960px) { + .hero, + .layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .page-shell { + padding-inline: 14px; + } + + .panel, + .hero-card { + border-radius: 22px; + } + + .radio-grid { + grid-template-columns: 1fr; + } + + .container-option, + .profile-card-top, + .backup-item { + grid-template-columns: 1fr; + display: grid; + } + + .run-actions, + .mode-picker { + justify-items: stretch; + } +} \ No newline at end of file diff --git a/src/backupService.js b/src/backupService.js new file mode 100644 index 0000000..3053b7b --- /dev/null +++ b/src/backupService.js @@ -0,0 +1,850 @@ +const path = require('path'); +const fs = require('fs/promises'); +const { randomUUID } = require('crypto'); + +function shellQuote(value) { + return `'${String(value).replace(/'/g, `"'"'`)}'`; +} + +function slugify(value) { + return value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'item'; +} + +function formatStamp(date = new Date()) { + return date.toISOString().replace(/[:.]/g, '-'); +} + +function normalizeDockerHostPath(inputPath) { + const value = String(inputPath || '').trim(); + if (/^[A-Za-z]:[\\/]/.test(value)) { + const drive = value[0].toLowerCase(); + const suffix = value.slice(2).replace(/\\/g, '/').replace(/^\/+/, ''); + return `/run/desktop/mnt/host/${drive}/${suffix}`; + } + + return value.replace(/\\/g, '/'); +} + +function normalizeContainerPath(inputPath) { + const value = String(inputPath || '').trim().replace(/\\/g, '/'); + if (!value.startsWith('/')) { + throw new Error('Em execucao via Docker, o diretorio de backup deve ser absoluto dentro do container (ex.: /app/data/backups).'); + } + + return value; +} + +function normalizeMounts(containerInspect) { + return (containerInspect.Mounts || []) + .filter((mount) => mount.Type === 'bind' || mount.Type === 'volume') + .map((mount) => ({ + type: mount.Type, + name: mount.Name || null, + source: mount.Source, + destination: mount.Destination, + rw: mount.RW, + })) + .sort((left, right) => { + const leftKey = `${left.destination}|${left.type}|${left.name || left.source}`; + const rightKey = `${right.destination}|${right.type}|${right.name || right.source}`; + return leftKey.localeCompare(rightKey); + }); +} + +function sameMountSignature(leftMounts, rightMounts) { + if (leftMounts.length !== rightMounts.length) { + return false; + } + + return leftMounts.every((left, index) => { + const right = rightMounts[index]; + return ( + left.type === right.type + && left.name === right.name + && left.source === right.source + && left.destination === right.destination + ); + }); +} + +function getMountBindingSource(mount) { + return mount.type === 'volume' ? mount.name : mount.source; +} + +function normalizeBackupScope(value) { + return value === 'container' ? 'container' : 'volumes'; +} + +function containerSnapshotPath(profileId, containerId, scope) { + return `/tmp/dockerbackup-${slugify(profileId)}-${containerId.slice(0, 12)}-${scope}.snar`; +} + +function toContainerRelPath(absPath) { + return String(absPath || '').replace(/^\/+/, '') || '.'; +} + +function parseManifestLines(rawOutput) { + const map = new Map(); + for (const line of String(rawOutput || '').split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const [relativePath, sizeRaw, mtimeRaw, scopeRaw] = trimmed.split('|'); + if (!relativePath) { + continue; + } + + const key = scopeRaw + ? `${toContainerRelPath(scopeRaw)}/${relativePath}`.replace(/\/\//g, '/') + : relativePath; + + map.set(key, { + size: Number(sizeRaw || 0), + mtime: Number(mtimeRaw || 0), + }); + } + + return map; +} + +function calculateManifestDiff(beforeMap, afterMap) { + let deleted = 0; + let created = 0; + let modified = 0; + + for (const [filePath, beforeEntry] of beforeMap.entries()) { + const afterEntry = afterMap.get(filePath); + if (!afterEntry) { + deleted += 1; + continue; + } + + if (beforeEntry.size !== afterEntry.size || beforeEntry.mtime !== afterEntry.mtime) { + modified += 1; + } + } + + for (const filePath of afterMap.keys()) { + if (!beforeMap.has(filePath)) { + created += 1; + } + } + + return { deleted, created, modified }; +} + +class BackupService { + constructor({ dockerService, store }) { + this.dockerService = dockerService; + this.store = store; + } + + async runProfile(profileId, options = {}) { + const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {}; + const profile = await this.store.getProfile(profileId); + if (!profile) { + throw new Error('Profile nao encontrado.'); + } + + if (!profile.containerIds.length) { + throw new Error('Selecione ao menos um container no profile.'); + } + + const effectiveMode = ['full', 'incremental'].includes(options.mode) + ? options.mode + : (['full', 'incremental'].includes(profile.mode) ? profile.mode : 'full'); + + const backupScope = normalizeBackupScope(profile.backupScope); + + const backupRun = { + id: randomUUID(), + profileId: profile.id, + profileName: profile.name, + createdAt: new Date().toISOString(), + mode: effectiveMode, + backupScope, + backupDir: profile.backupDir, + status: 'ok', + containers: [], + }; + + const progress = { + profileId: profile.id, + profileName: profile.name, + startedAt: backupRun.createdAt, + status: 'running', + overall: { + total: profile.containerIds.length, + completed: 0, + pending: profile.containerIds.length, + percent: 0, + }, + currentContainer: null, + }; + + const emitProgress = () => { + onProgress(JSON.parse(JSON.stringify(progress))); + }; + + emitProgress(); + + for (const [index, containerId] of profile.containerIds.entries()) { + progress.currentContainer = { + containerId, + index: index + 1, + total: profile.containerIds.length, + containerName: null, + status: 'running', + step: 'iniciando', + message: 'Preparando backup do container.', + logs: [], + percent: 0, + file: { + current: 0, + total: 0, + currentFile: null, + percent: 0, + }, + }; + emitProgress(); + + const containerBackup = await this.backupContainer(profile, containerId, backupRun.createdAt, { + mode: effectiveMode, + backupScope, + onProgress: (containerProgress) => { + progress.currentContainer = { + ...progress.currentContainer, + ...containerProgress, + }; + emitProgress(); + }, + }); + + backupRun.containers.push(containerBackup); + if (containerBackup.status !== 'ok') { + backupRun.status = 'partial'; + } + + progress.overall.completed += 1; + progress.overall.pending = Math.max(0, progress.overall.total - progress.overall.completed); + progress.overall.percent = progress.overall.total + ? Math.round((progress.overall.completed / progress.overall.total) * 100) + : 100; + progress.currentContainer = { + ...(progress.currentContainer || {}), + status: containerBackup.status, + percent: 100, + }; + emitProgress(); + } + + await this.store.addBackup(backupRun); + + progress.status = backupRun.status === 'ok' ? 'completed' : 'completed-with-errors'; + progress.finishedAt = new Date().toISOString(); + progress.currentContainer = null; + progress.overall.percent = 100; + progress.overall.pending = 0; + emitProgress(); + + return backupRun; + } + + async backupContainer(profile, containerId, runDateIso, options = {}) { + const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {}; + const runMode = ['full', 'incremental'].includes(options.mode) + ? options.mode + : (['full', 'incremental'].includes(profile.mode) ? profile.mode : 'full'); + const backupScope = normalizeBackupScope(options.backupScope || profile.backupScope); + + let inspect; + let mounts = []; + let containerName = containerId.slice(0, 12); + + try { + inspect = await this.dockerService.inspectContainer(containerId); + mounts = normalizeMounts(inspect); + containerName = inspect.Name.replace(/^\//, ''); + } catch (error) { + onProgress({ containerName, status: 'error', percent: 100, step: 'erro', message: error.message }); + return { + containerId, + containerName, + status: 'error', + mode: runMode, + error: `Falha ao inspecionar container: ${error.message}`, + }; + } + + if (backupScope === 'volumes' && !mounts.length) { + onProgress({ + containerName, + status: 'skipped', + step: 'concluido', + message: 'Container sem volumes elegiveis.', + percent: 100, + file: { current: 0, total: 0, currentFile: null, percent: 100 }, + }); + return { + containerId: inspect.Id, + containerName, + status: 'skipped', + mode: runMode, + message: 'Container sem volumes ou bind mounts elegiveis.', + }; + } + + const runInDocker = this.dockerService.isRunningInDocker(); + const backupRoot = runInDocker + ? normalizeContainerPath(profile.backupDir) + : normalizeDockerHostPath(profile.backupDir); + const safeContainerName = slugify(containerName); + const safeProfileName = slugify(profile.name); + const stamp = formatStamp(new Date(runDateIso)); + const archiveRelativePath = path.posix.join(safeProfileName, safeContainerName, `${stamp}-${runMode}.tar.gz`); + const snapshotRelativePath = path.posix.join(safeProfileName, safeContainerName, 'latest.snar'); + + const containerBackup = { + containerId: inspect.Id, + containerName, + backupScope, + backupPaths: backupScope === 'container' ? ['/'] : mounts.map((mount) => mount.destination), + mountSignature: mounts, + archiveRelativePath, + snapshotRelativePath, + wasRunning: inspect.State?.Running === true, + mode: runMode, + status: 'ok', + }; + + const logs = []; + const pushLog = (message, step = 'processando') => { + const line = `[${new Date().toLocaleTimeString('pt-BR')}] ${message}`; + logs.push(line); + while (logs.length > 40) { + logs.shift(); + } + + onProgress({ + containerName, + step, + message, + logs: [...logs], + }); + }; + + let fileTotal = 0; + let fileCurrent = 0; + const updateFileProgress = (currentFile = null) => { + const filePercent = fileTotal > 0 ? Math.min(100, Math.round((fileCurrent / fileTotal) * 100)) : 0; + onProgress({ + containerName, + status: 'running', + step: 'processando', + percent: filePercent, + file: { + current: fileCurrent, + total: fileTotal, + currentFile, + percent: filePercent, + }, + }); + }; + + try { + if (backupScope === 'container' && !runInDocker) { + throw new Error('Backup do container inteiro requer app executando via Docker.'); + } + + pushLog(`Escopo selecionado: ${backupScope === 'container' ? 'container inteiro' : 'somente volumes'}.`, 'preparando'); + + if (runInDocker) { + await this.dockerService.ensureLocalDirectory(backupRoot); + pushLog(`Diretorio de backup pronto em ${backupRoot}.`, 'preparando'); + + const originalRunning = inspect.State?.Running === true; + let tempStarted = false; + try { + if (originalRunning) { + pushLog('Container ativo detectado. Parando antes do backup.', 'preparando'); + await this.dockerService.stopContainer(containerId); + } + + pushLog('Iniciando container temporariamente para snapshot.', 'preparando'); + await this.dockerService.startContainer(containerId); + tempStarted = true; + + const sourcePaths = backupScope === 'container' + ? ['/'] + : mounts.map((mount) => mount.destination); + const relSourcePaths = sourcePaths.map((item) => toContainerRelPath(item)); + + if (backupScope === 'volumes') { + pushLog('Contando arquivos para barra de progresso.', 'contando'); + const countCmd = `set -eu; TOTAL=0; for p in ${relSourcePaths.map((item) => shellQuote(item)).join(' ')}; do if [ -e \"/$p\" ]; then C=$(find \"/$p\" -type f 2>/dev/null | wc -l | tr -d \" \" ); TOTAL=$((TOTAL + C)); fi; done; echo \"$TOTAL\"`; + const output = await this.dockerService.runContainerCommand(containerId, countCmd); + const parsed = Number(output.split(/\r?\n/).pop()); + fileTotal = Number.isFinite(parsed) ? parsed : 0; + pushLog(`Total de arquivos identificado: ${fileTotal}.`, 'contando'); + } + + const absoluteArchivePath = path.posix.join(backupRoot, archiveRelativePath); + + let newerMtimeFlag = ''; + if (runMode === 'incremental') { + const lastTime = await this.store.getLastContainerBackupTime(profile.id, containerId); + if (lastTime) { + const unixSec = Math.floor(new Date(lastTime).getTime() / 1000); + newerMtimeFlag = `--newer-mtime=@${unixSec}`; + pushLog(`Backup incremental: incluindo arquivos modificados apos ${lastTime}.`, 'preparando'); + } else { + pushLog('Aviso: nenhum backup anterior encontrado, gerando full.', 'preparando'); + } + } + + const tarParts = [ + 'set -eu', + 'umask 077', + 'echo "__DBKP_TAR_BEGIN__" 1>&2', + ]; + + if (backupScope === 'container') { + tarParts.push( + `tar --warning=no-file-changed --ignore-failed-read ${newerMtimeFlag} -czvf - -C / --exclude=proc --exclude=sys --exclude=dev --exclude=run --exclude=tmp .` + ); + } else { + tarParts.push( + `tar --warning=no-file-changed --ignore-failed-read ${newerMtimeFlag} -czvf - -C / ${relSourcePaths.map((item) => shellQuote(item)).join(' ')}` + ); + } + + updateFileProgress(); + pushLog('Iniciando compactacao tar do container.', 'gerando-tar'); + + await this.dockerService.streamContainerCommandToFile(containerId, tarParts.join(' && '), absoluteArchivePath, { + onOutput: (line, stream) => { + const normalizedLine = String(line || '').trim(); + if (!normalizedLine || stream !== 'stderr' || normalizedLine.startsWith('__DBKP_TAR_BEGIN__')) { + return; + } + + if (!normalizedLine.startsWith('tar:')) { + fileCurrent += 1; + updateFileProgress(normalizedLine); + } else { + pushLog(`Aviso do tar: ${normalizedLine}`, 'gerando-tar'); + } + }, + }); + + pushLog(`Arquivo gerado: ${absoluteArchivePath}`, 'finalizando'); + } finally { + if (tempStarted) { + pushLog('Encerrando container apos backup.', 'finalizando'); + await this.dockerService.stopContainer(containerId).catch(() => null); + } + + if (originalRunning) { + pushLog('Reiniciando container (estava ativo antes do backup).', 'finalizando'); + await this.dockerService.startContainer(containerId).catch(() => null); + } + } + + onProgress({ + containerName, + status: 'ok', + step: 'concluido', + message: 'Backup concluido com sucesso.', + percent: 100, + file: { + current: Math.max(fileCurrent, fileTotal), + total: fileTotal, + currentFile: null, + percent: 100, + }, + }); + + return containerBackup; + } + + if (backupScope === 'container') { + throw new Error('Backup de container inteiro sem Docker nativo nao e suportado.'); + } + + await this.dockerService.ensureHostDirectory(backupRoot); + const wasRunning = inspect.State?.Running === true; + if (wasRunning) { + await this.dockerService.stopContainer(containerId); + } + + const binds = [`${backupRoot}:/backuproot`]; + for (const [index, mount] of mounts.entries()) { + binds.push(`${getMountBindingSource(mount)}:/payload/m${index}:ro`); + } + + const archivePath = `/backuproot/${archiveRelativePath}`; + const snapshotPath = `/backuproot/${snapshotRelativePath}`; + const parentDir = path.posix.dirname(archivePath); + const cmdParts = ['set -eu', `mkdir -p ${shellQuote(parentDir)}`]; + if (runMode === 'full') { + cmdParts.push(`rm -f ${shellQuote(snapshotPath)}`); + } + cmdParts.push('TOTAL_FILES=$(find /payload -type f | wc -l | tr -d " ")'); + cmdParts.push('echo "__DBKP_TOTAL_FILES__=${TOTAL_FILES}"'); + cmdParts.push(`tar --listed-incremental=${shellQuote(snapshotPath)} -czvf ${shellQuote(archivePath)} -C /payload .`); + + await this.dockerService.runHelper({ + binds, + cmd: cmdParts.join(' && '), + onOutput: (line, stream) => { + const normalizedLine = String(line || '').trim(); + if (!normalizedLine) { + return; + } + + if (normalizedLine.startsWith('__DBKP_TOTAL_FILES__=')) { + const parsed = Number(normalizedLine.split('=')[1]); + fileTotal = Number.isFinite(parsed) ? parsed : 0; + updateFileProgress(); + return; + } + + if (stream === 'stdout' && !normalizedLine.startsWith('tar:')) { + fileCurrent += 1; + updateFileProgress(normalizedLine); + } + }, + }); + + if (wasRunning) { + await this.dockerService.startContainer(containerId).catch(() => null); + } + + onProgress({ + containerName, + status: 'ok', + step: 'concluido', + message: 'Backup concluido com sucesso.', + percent: 100, + file: { + current: Math.max(fileCurrent, fileTotal), + total: fileTotal, + currentFile: null, + percent: 100, + }, + }); + + return containerBackup; + } catch (error) { + onProgress({ + containerName, + status: 'error', + step: 'erro', + message: error.message, + percent: 100, + }); + return { + ...containerBackup, + status: 'error', + error: error.message, + }; + } + } + + async restoreBackup(profileId, backupId, options = {}) { + const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {}; + const selectedContainerIds = options.selectedContainerIds; + + const profile = await this.store.getProfile(profileId); + if (!profile) { + throw new Error('Profile nao encontrado.'); + } + + const backupRun = await this.store.getBackup(backupId); + if (!backupRun || backupRun.profileId !== profileId) { + throw new Error('Backup nao encontrado para este profile.'); + } + + const allowedContainerIds = new Set(Array.isArray(selectedContainerIds) && selectedContainerIds.length + ? selectedContainerIds + : backupRun.containers.map((item) => item.containerId)); + + const targets = backupRun.containers.filter((item) => item.status === 'ok' && allowedContainerIds.has(item.containerId)); + if (!targets.length) { + throw new Error('Nenhum container valido foi selecionado para restore.'); + } + + const progress = { + profileId: profile.id, + profileName: profile.name, + operation: 'restore', + startedAt: new Date().toISOString(), + status: 'running', + overall: { + total: targets.length, + completed: 0, + pending: targets.length, + percent: 0, + }, + currentContainer: null, + }; + + const emitProgress = () => { + onProgress(JSON.parse(JSON.stringify(progress))); + }; + + emitProgress(); + + const results = []; + + for (const containerEntry of targets) { + const logs = []; + const pushLog = (message, step = 'restaurando') => { + const line = `[${new Date().toLocaleTimeString('pt-BR')}] ${message}`; + logs.push(line); + while (logs.length > 40) { + logs.shift(); + } + + progress.currentContainer = { + ...(progress.currentContainer || {}), + message, + step, + logs: [...logs], + }; + emitProgress(); + }; + + progress.currentContainer = { + containerId: containerEntry.containerId, + containerName: containerEntry.containerName, + status: 'running', + step: 'preparando', + message: 'Preparando restauracao do container.', + logs: [], + percent: 0, + file: { + current: 0, + total: 0, + currentFile: null, + percent: 0, + }, + }; + emitProgress(); + + try { + const chain = await this.store.getBackupsForContainer(profileId, containerEntry.containerId, backupId); + if (!chain.length || chain[0].mode !== 'full') { + throw new Error(`Nao existe cadeia full + incremental valida para ${containerEntry.containerName}.`); + } + + pushLog(`Cadeia de restore encontrada com ${chain.length} arquivo(s).`, 'preparando'); + + const restoreInfo = await this.restoreContainer(profile, containerEntry, chain, { + onProgress: (snapshot) => { + progress.currentContainer = { + ...progress.currentContainer, + ...snapshot, + }; + emitProgress(); + }, + pushLog, + }); + + progress.currentContainer = { + ...progress.currentContainer, + status: 'ok', + step: 'concluido', + message: 'Restore concluido com sucesso.', + percent: 100, + file: { + ...(progress.currentContainer?.file || {}), + percent: 100, + }, + }; + emitProgress(); + + results.push({ + containerId: containerEntry.containerId, + containerName: containerEntry.containerName, + status: 'ok', + stats: restoreInfo?.stats || null, + }); + } catch (error) { + pushLog(`Falha no restore: ${error.message}`, 'erro'); + progress.currentContainer = { + ...progress.currentContainer, + status: 'error', + step: 'erro', + message: error.message, + percent: 100, + }; + emitProgress(); + + results.push({ + containerId: containerEntry.containerId, + containerName: containerEntry.containerName, + status: 'error', + error: error.message, + }); + } + + progress.overall.completed += 1; + progress.overall.pending = Math.max(0, progress.overall.total - progress.overall.completed); + progress.overall.percent = progress.overall.total + ? Math.round((progress.overall.completed / progress.overall.total) * 100) + : 100; + emitProgress(); + } + + progress.status = results.every((item) => item.status === 'ok') ? 'completed' : 'completed-with-errors'; + progress.finishedAt = new Date().toISOString(); + progress.currentContainer = null; + progress.overall.percent = 100; + progress.overall.pending = 0; + emitProgress(); + + return { + backupId, + status: results.every((item) => item.status === 'ok') ? 'ok' : 'partial', + containers: results, + }; + } + + async restoreContainer(profile, targetEntry, chain, options = {}) { + const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {}; + const pushLog = typeof options.pushLog === 'function' ? options.pushLog : () => {}; + + const inspect = await this.dockerService.inspectContainer(targetEntry.containerId); + const backupScope = normalizeBackupScope(targetEntry.backupScope || profile.backupScope); + const currentMounts = normalizeMounts(inspect); + + if (backupScope === 'volumes' && !sameMountSignature(targetEntry.mountSignature, currentMounts)) { + throw new Error(`Os mounts atuais do container ${targetEntry.containerName} nao batem com o backup selecionado.`); + } + + const runInDocker = this.dockerService.isRunningInDocker(); + const useNativeRestore = runInDocker; + + if (useNativeRestore) { + const backupRoot = normalizeContainerPath(profile.backupDir); + const originalWasRunning = inspect.State?.Running === true; + const restoreStats = { deleted: 0, created: 0, modified: 0 }; + + try { + if (originalWasRunning) { + pushLog('Container ativo detectado. Parando antes do restore.', 'preparando'); + await this.dockerService.stopContainer(targetEntry.containerId); + } + + if (backupScope === 'volumes') { + const restorePaths = (chain[0]?.backupPaths && chain[0].backupPaths.length) + ? chain[0].backupPaths + : currentMounts.map((mount) => mount.destination); + + // Validar que todos os archives existem antes de tocar nos dados. + for (const entry of chain) { + await fs.access(path.posix.join(backupRoot, entry.archiveRelativePath)); + } + + // Restaurar via Docker API (putArchive) — funciona com container parado. + // O archive foi gerado com -C / incluindo os caminhos relativos dos volumes, + // portanto o destino do putArchive e sempre /. + for (const [index, entry] of chain.entries()) { + const absoluteArchivePath = path.posix.join(backupRoot, entry.archiveRelativePath); + + onProgress({ + step: 'restaurando', + file: { + current: index + 1, + total: chain.length, + currentFile: entry.archiveRelativePath, + percent: Math.round(((index + 1) / chain.length) * 100), + }, + percent: Math.round(((index + 1) / chain.length) * 100), + }); + pushLog(`Aplicando arquivo ${index + 1}/${chain.length}: ${entry.archiveRelativePath}`, 'restaurando'); + + await this.dockerService.putCompressedArchiveFromFile( + targetEntry.containerId, + '/', + absoluteArchivePath, + ); + } + + pushLog('Restore de volumes concluido.', 'finalizando'); + } else { + // Escopo container inteiro. + for (const entry of chain) { + await fs.access(path.posix.join(backupRoot, entry.archiveRelativePath)); + } + + for (const [index, entry] of chain.entries()) { + const absoluteArchivePath = path.posix.join(backupRoot, entry.archiveRelativePath); + + onProgress({ + step: 'restaurando', + file: { + current: index + 1, + total: chain.length, + currentFile: entry.archiveRelativePath, + percent: Math.round(((index + 1) / chain.length) * 100), + }, + percent: Math.round(((index + 1) / chain.length) * 100), + }); + pushLog(`Aplicando arquivo ${index + 1}/${chain.length}: ${entry.archiveRelativePath}`, 'restaurando'); + + await this.dockerService.putCompressedArchiveFromFile(targetEntry.containerId, '/', absoluteArchivePath); + } + + pushLog('Restore do container concluido.', 'finalizando'); + } + } finally { + if (originalWasRunning) { + pushLog('Reiniciando container (estava ativo antes do restore).', 'finalizando'); + await this.dockerService.startContainer(targetEntry.containerId).catch(() => null); + } + } + + return { stats: restoreStats }; + } + + if (backupScope === 'container') { + throw new Error('Restore do container inteiro requer app executando via Docker.'); + } + + const backupRoot = normalizeDockerHostPath(profile.backupDir); + const wasRunning = inspect.State?.Running === true; + const binds = [`${backupRoot}:/backuproot:ro`]; + for (const [index, mount] of currentMounts.entries()) { + binds.push(`${getMountBindingSource(mount)}:/restore/m${index}`); + } + + const cleanupCommands = currentMounts.map((_mount, index) => ( + `find ${shellQuote(`/restore/m${index}`)} -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +` + )); + + const restoreCommands = chain.map((entry) => ( + `tar --listed-incremental=/dev/null -xzf ${shellQuote(`/backuproot/${entry.archiveRelativePath}`)} -C /restore` + )); + + try { + if (wasRunning) { + await this.dockerService.stopContainer(targetEntry.containerId); + } + + const cmd = ['set -eu', ...cleanupCommands, ...restoreCommands].join(' && '); + await this.dockerService.runHelper({ binds, cmd }); + } finally { + if (wasRunning) { + await this.dockerService.startContainer(targetEntry.containerId).catch(() => null); + } + } + + return { stats: null }; + } +} + +module.exports = BackupService; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..b2a8528 --- /dev/null +++ b/src/config.js @@ -0,0 +1,11 @@ +const path = require('path'); + +const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data'); + +module.exports = { + port: Number(process.env.PORT || 3000), + dataDir, + storePath: path.join(dataDir, 'store.json'), + dockerSocketPath: process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock', + helperImage: process.env.HELPER_IMAGE || 'node:20-bookworm-slim', +}; \ No newline at end of file diff --git a/src/dockerService.js b/src/dockerService.js new file mode 100644 index 0000000..1257ed7 --- /dev/null +++ b/src/dockerService.js @@ -0,0 +1,486 @@ +const Docker = require('dockerode'); +const { PassThrough } = require('stream'); +const fs = require('fs'); +const fsp = require('fs/promises'); +const path = require('path'); +const zlib = require('zlib'); +const { spawn } = require('child_process'); +const { pipeline } = require('stream/promises'); + +function detectRunningInContainer() { + if (fs.existsSync('/.dockerenv')) { + return true; + } + + try { + const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8'); + return /(docker|containerd|kubepods|cri-o)/i.test(cgroup); + } catch { + return false; + } +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, `"'"'`)}'`; +} + +class DockerService { + constructor({ socketPath, helperImage }) { + this.docker = new Docker({ socketPath }); + this.helperImage = helperImage; + this.runningInContainer = detectRunningInContainer(); + } + + isRunningInDocker() { + return this.runningInContainer; + } + + async listContainers() { + const containers = await this.docker.listContainers({ all: true }); + return containers + .map((container) => ({ + id: container.Id, + name: (container.Names[0] || '').replace(/^\//, ''), + image: container.Image, + state: container.State, + status: container.Status, + })) + .sort((left, right) => left.name.localeCompare(right.name)); + } + + async inspectContainer(containerId) { + return this.docker.getContainer(containerId).inspect(); + } + + async stopContainer(containerId) { + await this.docker.getContainer(containerId).stop(); + } + + async startContainer(containerId) { + await this.docker.getContainer(containerId).start(); + } + + async ensureImage(imageName = this.helperImage) { + try { + await this.docker.getImage(imageName).inspect(); + return; + } catch { + const stream = await this.docker.pull(imageName); + await new Promise((resolve, reject) => { + this.docker.modem.followProgress(stream, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + } + + async ensureHostDirectory(hostPath) { + const normalized = String(hostPath || '').trim().replace(/\\/g, '/'); + if (!normalized || !normalized.startsWith('/')) { + throw new Error(`Diretorio de backup invalido: ${hostPath}`); + } + + if (this.runningInContainer) { + throw new Error('App rodando em container nao deve criar helper para backup/restore.'); + } + + await this.ensureImage(); + + const container = await this.docker.createContainer({ + Image: this.helperImage, + Cmd: ['sh', '-lc', `mkdir -p ${shellQuote(`/hostfs${normalized}`)}`], + Tty: false, + HostConfig: { + Binds: ['/:/hostfs'], + AutoRemove: false, + NetworkMode: 'none', + }, + }); + + try { + await container.start(); + const [result, logs] = await Promise.all([ + container.wait(), + container.logs({ stdout: true, stderr: true, follow: false }), + ]); + + if (result.StatusCode !== 0) { + throw new Error(logs.toString('utf8').trim() || 'Falha ao criar diretorio de backup no host.'); + } + } finally { + try { + await container.remove({ force: true }); + } catch { + } + } + } + + async ensureLocalDirectory(localPath) { + const normalized = String(localPath || '').trim().replace(/\\/g, '/'); + if (!normalized || !normalized.startsWith('/')) { + throw new Error(`Diretorio de backup invalido: ${localPath}`); + } + + await fsp.mkdir(normalized, { recursive: true }); + } + + async exportContainerPathArchive(containerId, containerPath, targetFilePath) { + const container = this.docker.getContainer(containerId); + const archiveStream = await container.getArchive({ path: containerPath }); + await fsp.mkdir(path.dirname(targetFilePath), { recursive: true }); + await pipeline(archiveStream, fs.createWriteStream(targetFilePath)); + } + + async putArchiveFromFile(containerId, destinationPath, sourceFilePath) { + const container = this.docker.getContainer(containerId); + const source = fs.createReadStream(sourceFilePath); + await container.putArchive(source, { path: destinationPath }); + } + + async putCompressedArchiveFromFile(containerId, destinationPath, sourceFilePath) { + const container = this.docker.getContainer(containerId); + const source = fs.createReadStream(sourceFilePath).pipe(zlib.createGunzip()); + await container.putArchive(source, { path: destinationPath }); + } + + async runContainerCommand(containerId, cmd) { + const container = this.docker.getContainer(containerId); + const exec = await container.exec({ + AttachStdout: true, + AttachStderr: true, + Tty: false, + Cmd: ['sh', '-lc', cmd], + }); + + const stream = await exec.start({ hijack: true, stdin: false }); + const stdoutStream = new PassThrough(); + const stderrStream = new PassThrough(); + this.docker.modem.demuxStream(stream, stdoutStream, stderrStream); + + let output = ''; + stdoutStream.on('data', (chunk) => { + output += chunk.toString('utf8'); + }); + stderrStream.on('data', (chunk) => { + output += chunk.toString('utf8'); + }); + + let info = await exec.inspect(); + while (info.Running) { + await new Promise((resolve) => setTimeout(resolve, 120)); + info = await exec.inspect(); + } + + if (typeof stream.destroy === 'function') { + stream.destroy(); + } + stdoutStream.end(); + stderrStream.end(); + + const trimmed = output.trim(); + if (info.ExitCode !== 0) { + throw new Error(output.trim() || `Comando em container terminou com codigo ${info.ExitCode}`); + } + + return trimmed; + } + + async streamContainerCommandToFile(containerId, cmd, targetFilePath, options = {}) { + const onOutput = typeof options.onOutput === 'function' ? options.onOutput : () => {}; + const container = this.docker.getContainer(containerId); + const exec = await container.exec({ + AttachStdout: true, + AttachStderr: true, + Tty: false, + Cmd: ['sh', '-lc', cmd], + }); + + const stream = await exec.start({ hijack: true, stdin: false }); + const stdoutStream = new PassThrough(); + const stderrStream = new PassThrough(); + this.docker.modem.demuxStream(stream, stdoutStream, stderrStream); + + await fsp.mkdir(path.dirname(targetFilePath), { recursive: true }); + const writeStream = fs.createWriteStream(targetFilePath); + stdoutStream.pipe(writeStream); + + const stderrDone = new Promise((resolve) => { + let buffer = ''; + + stderrStream.on('data', (chunk) => { + const text = chunk.toString('utf8'); + buffer += text; + const parts = buffer.split(/\r?\n/); + buffer = parts.pop() || ''; + for (const line of parts) { + onOutput(line, 'stderr'); + } + }); + + stderrStream.on('end', () => { + if (buffer) { + onOutput(buffer, 'stderr'); + } + resolve(); + }); + }); + + let info = await exec.inspect(); + while (info.Running) { + await new Promise((resolve) => setTimeout(resolve, 120)); + info = await exec.inspect(); + } + + if (typeof stream.destroy === 'function') { + stream.destroy(); + } + stdoutStream.end(); + stderrStream.end(); + + await Promise.all([ + stderrDone, + new Promise((resolve, reject) => { + writeStream.on('finish', resolve); + writeStream.on('error', reject); + }), + ]); + + if (info.ExitCode !== 0) { + throw new Error(`Comando de stream em container terminou com codigo ${info.ExitCode}`); + } + } + + async copyFileToContainer(containerId, sourceFilePath, destinationDirInContainer) { + const container = this.docker.getContainer(containerId); + const tarProc = spawn('tar', ['-cf', '-', '-C', path.dirname(sourceFilePath), path.basename(sourceFilePath)], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stderr = ''; + tarProc.stderr.on('data', (chunk) => { + stderr += chunk.toString('utf8'); + }); + + await container.putArchive(tarProc.stdout, { path: destinationDirInContainer }); + + await new Promise((resolve, reject) => { + tarProc.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + + reject(new Error(stderr.trim() || `Falha ao empacotar arquivo para copia (codigo ${code})`)); + }); + tarProc.on('error', reject); + }); + } + + async streamArchiveToContainer(containerId, archiveFilePath) { + const container = this.docker.getContainer(containerId); + const exec = await container.exec({ + AttachStdin: true, + AttachStdout: false, + AttachStderr: true, + Tty: false, + Cmd: ['sh', '-lc', 'tar --listed-incremental=/dev/null -xzf - -C /'], + }); + + const attachStream = await exec.start({ hijack: true, stdin: true }); + const stderrStream = new PassThrough(); + this.docker.modem.demuxStream(attachStream, new PassThrough(), stderrStream); + + let stderrOutput = ''; + stderrStream.on('data', (chunk) => { + stderrOutput += chunk.toString('utf8'); + }); + + // Pipar o arquivo diretamente para o stdin do tar no container + const fileReadStream = fs.createReadStream(archiveFilePath); + await new Promise((resolve, reject) => { + fileReadStream.on('error', reject); + fileReadStream.on('end', () => { + try { attachStream.end(); } catch { /* ignore */ } + resolve(); + }); + fileReadStream.pipe(attachStream, { end: false }); + }); + + // Aguardar tar finalizar + let info = await exec.inspect(); + while (info.Running) { + await new Promise((r) => setTimeout(r, 120)); + info = await exec.inspect(); + } + + if (typeof attachStream.destroy === 'function') { + attachStream.destroy(); + } + + if (info.ExitCode !== 0) { + throw new Error(stderrOutput.trim() || `Falha ao restaurar archive no container (codigo ${info.ExitCode})`); + } + } + + async copyFileFromContainer(containerId, containerFilePath, localFilePath) { + const container = this.docker.getContainer(containerId); + let archiveStream; + try { + archiveStream = await container.getArchive({ path: containerFilePath }); + } catch { + return false; + } + + await fsp.mkdir(path.dirname(localFilePath), { recursive: true }); + + await new Promise((resolve, reject) => { + const tarProc = spawn('tar', ['-xOf', '-'], { stdio: ['pipe', 'pipe', 'pipe'] }); + const writeStream = fs.createWriteStream(localFilePath); + archiveStream.pipe(tarProc.stdin); + tarProc.stdout.pipe(writeStream); + tarProc.on('close', (code) => { + if (code === 0) { resolve(); return; } + reject(new Error(`Falha ao extrair arquivo do container (codigo ${code})`)); + }); + tarProc.on('error', reject); + writeStream.on('error', reject); + }); + + return true; + } + + async runHostCommand({ cmd, onOutput }) { + await new Promise((resolve, reject) => { + const child = spawn('sh', ['-lc', cmd], { stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + + const streamOutput = (stream, streamName) => { + let buffer = ''; + + stream.on('data', (chunk) => { + const text = chunk.toString('utf8'); + output += text; + + if (typeof onOutput !== 'function') { + return; + } + + buffer += text; + const parts = buffer.split(/\r?\n/); + buffer = parts.pop() || ''; + for (const line of parts) { + onOutput(line, streamName); + } + }); + + stream.on('end', () => { + if (buffer && typeof onOutput === 'function') { + onOutput(buffer, streamName); + } + }); + }; + + streamOutput(child.stdout, 'stdout'); + streamOutput(child.stderr, 'stderr'); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + const trimmedOutput = output.trim(); + if (code !== 0) { + reject(new Error(trimmedOutput || `Comando local terminou com codigo ${code}`)); + return; + } + + resolve(trimmedOutput); + }); + }); + } + + async runHelper({ binds, cmd, onOutput }) { + await this.ensureImage(); + + const container = await this.docker.createContainer({ + Image: this.helperImage, + Cmd: ['sh', '-lc', cmd], + Tty: false, + HostConfig: { + Binds: binds, + AutoRemove: false, + NetworkMode: 'none', + }, + }); + + let attachStream; + + try { + attachStream = await container.attach({ stream: true, stdout: true, stderr: true }); + const stdoutStream = new PassThrough(); + const stderrStream = new PassThrough(); + this.docker.modem.demuxStream(attachStream, stdoutStream, stderrStream); + + const parseOutput = (stream, streamName) => new Promise((resolve) => { + let buffer = ''; + + stream.on('data', (chunk) => { + const text = chunk.toString('utf8'); + output += text; + + if (typeof onOutput !== 'function') { + return; + } + + buffer += text; + const parts = buffer.split(/\r?\n/); + buffer = parts.pop() || ''; + + for (const line of parts) { + onOutput(line, streamName); + } + }); + + stream.on('end', () => { + if (buffer && typeof onOutput === 'function') { + onOutput(buffer, streamName); + } + resolve(); + }); + }); + + let output = ''; + const outputPromises = [ + parseOutput(stdoutStream, 'stdout'), + parseOutput(stderrStream, 'stderr'), + ]; + + await container.start(); + const result = await container.wait(); + await Promise.all(outputPromises); + + output = output.trim(); + + if (result.StatusCode !== 0) { + throw new Error(output || `Helper container terminou com codigo ${result.StatusCode}`); + } + + return output; + } finally { + if (attachStream && typeof attachStream.destroy === 'function') { + attachStream.destroy(); + } + + try { + await container.remove({ force: true }); + } catch { + } + } + } +} + +module.exports = DockerService; \ No newline at end of file diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..0ed0f6b --- /dev/null +++ b/src/server.js @@ -0,0 +1,271 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs/promises'); +const crypto = require('crypto'); + +const config = require('./config'); +const JsonStore = require('./store'); +const DockerService = require('./dockerService'); +const BackupService = require('./backupService'); + +async function main() { + await fs.mkdir(config.dataDir, { recursive: true }); + + const store = new JsonStore(config.storePath); + await store.init(); + + const dockerService = new DockerService({ + socketPath: config.dockerSocketPath, + helperImage: config.helperImage, + }); + + const backupService = new BackupService({ dockerService, store }); + const runJobs = new Map(); + + const app = express(); + + app.use(express.json()); + app.use(express.static(path.join(process.cwd(), 'public'))); + + app.get('/api/health', (_request, response) => { + response.json({ ok: true }); + }); + + app.get('/api/containers', async (_request, response) => { + try { + const containers = await dockerService.listContainers(); + response.json(containers); + } catch (error) { + response.status(500).json({ error: error.message }); + } + }); + + app.get('/api/profiles', async (_request, response) => { + try { + const profiles = await store.listProfiles(); + response.json(profiles); + } catch (error) { + response.status(500).json({ error: error.message }); + } + }); + + app.post('/api/profiles', async (request, response) => { + try { + const payload = request.body || {}; + if (!payload.name || !payload.backupDir || !Array.isArray(payload.containerIds) || !payload.containerIds.length) { + response.status(400).json({ error: 'Informe nome, diretorio de backup e ao menos um container.' }); + return; + } + + if (payload.backupScope && !['volumes', 'container'].includes(payload.backupScope)) { + response.status(400).json({ error: 'Tipo de backup invalido.' }); + return; + } + + const existing = payload.id ? await store.getProfile(payload.id) : null; + const profile = await store.saveProfile({ + id: payload.id, + createdAt: existing?.createdAt, + name: payload.name.trim(), + backupDir: payload.backupDir.trim(), + containerIds: payload.containerIds, + mode: existing?.mode || 'full', + backupScope: payload.backupScope || existing?.backupScope || 'volumes', + }); + + response.status(payload.id ? 200 : 201).json(profile); + } catch (error) { + response.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/profiles/:profileId', async (request, response) => { + try { + const profile = await store.getProfile(request.params.profileId); + await store.deleteProfile(request.params.profileId); + + if (profile?.backupDir) { + const slugify = (value) => value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'item'; + const profileBackupDir = path.join(profile.backupDir, slugify(profile.name)); + await fs.rm(profileBackupDir, { recursive: true, force: true }); + } + + response.status(204).end(); + } catch (error) { + response.status(500).json({ error: error.message }); + } + }); + + app.get('/api/profiles/:profileId/backups', async (request, response) => { + try { + const backups = await store.listBackups(request.params.profileId); + response.json(backups); + } catch (error) { + response.status(500).json({ error: error.message }); + } + }); + + app.post('/api/profiles/:profileId/run', async (request, response) => { + try { + const profileId = request.params.profileId; + const requestedMode = request.body?.mode; + if (requestedMode && !['full', 'incremental'].includes(requestedMode)) { + response.status(400).json({ error: 'Modo de backup invalido.' }); + return; + } + + const runningJob = [...runJobs.values()].find((job) => job.profileId === profileId && job.status === 'running'); + if (runningJob) { + response.status(409).json({ error: 'Ja existe um backup em execucao para este profile.', runId: runningJob.id }); + return; + } + + const runId = crypto.randomUUID(); + const job = { + id: runId, + profileId, + kind: 'backup', + status: 'running', + startedAt: new Date().toISOString(), + progress: null, + result: null, + error: null, + }; + + runJobs.set(runId, job); + + void backupService.runProfile(profileId, { + mode: requestedMode, + onProgress: (progressSnapshot) => { + const currentJob = runJobs.get(runId); + if (!currentJob) { + return; + } + + currentJob.progress = progressSnapshot; + }, + }).then((backupRun) => { + const currentJob = runJobs.get(runId); + if (!currentJob) { + return; + } + + currentJob.status = backupRun.status === 'ok' ? 'completed' : 'completed-with-errors'; + currentJob.result = backupRun; + currentJob.finishedAt = new Date().toISOString(); + }).catch((error) => { + const currentJob = runJobs.get(runId); + if (!currentJob) { + return; + } + + currentJob.status = 'error'; + currentJob.error = error.message; + currentJob.finishedAt = new Date().toISOString(); + }); + + response.status(202).json({ runId, status: 'running' }); + } catch (error) { + response.status(500).json({ error: error.message }); + } + }); + + app.get('/api/runs/:runId', (request, response) => { + const job = runJobs.get(request.params.runId); + if (!job) { + response.status(404).json({ error: 'Execucao nao encontrada.' }); + return; + } + + response.json({ + id: job.id, + profileId: job.profileId, + kind: job.kind || 'backup', + status: job.status, + startedAt: job.startedAt, + finishedAt: job.finishedAt, + progress: job.progress, + result: job.result, + error: job.error, + }); + }); + + app.post('/api/profiles/:profileId/restore', async (request, response) => { + try { + const profileId = request.params.profileId; + if (!request.body?.backupId) { + response.status(400).json({ error: 'Informe o backup a ser restaurado.' }); + return; + } + + const selectedContainerIds = request.body?.containerIds; + if (selectedContainerIds && (!Array.isArray(selectedContainerIds) || !selectedContainerIds.length)) { + response.status(400).json({ error: 'Selecione ao menos um container para restaurar.' }); + return; + } + + const runningJob = [...runJobs.values()].find((job) => job.profileId === profileId && job.status === 'running'); + if (runningJob) { + response.status(409).json({ error: 'Ja existe uma execucao em andamento para este profile.', runId: runningJob.id }); + return; + } + + const runId = crypto.randomUUID(); + const job = { + id: runId, + profileId, + kind: 'restore', + status: 'running', + startedAt: new Date().toISOString(), + progress: null, + result: null, + error: null, + }; + + runJobs.set(runId, job); + + void backupService.restoreBackup(profileId, request.body.backupId, { + selectedContainerIds, + onProgress: (progressSnapshot) => { + const currentJob = runJobs.get(runId); + if (!currentJob) { + return; + } + + currentJob.progress = progressSnapshot; + }, + }).then((restoreResult) => { + const currentJob = runJobs.get(runId); + if (!currentJob) { + return; + } + + currentJob.status = restoreResult.status === 'ok' ? 'completed' : 'completed-with-errors'; + currentJob.result = restoreResult; + currentJob.finishedAt = new Date().toISOString(); + }).catch((error) => { + const currentJob = runJobs.get(runId); + if (!currentJob) { + return; + } + + currentJob.status = 'error'; + currentJob.error = error.message; + currentJob.finishedAt = new Date().toISOString(); + }); + + response.status(202).json({ runId, status: 'running', kind: 'restore' }); + } catch (error) { + response.status(500).json({ error: error.message }); + } + }); + + app.listen(config.port, () => { + console.log(`Docker Backup app ouvindo na porta ${config.port}`); + }); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..457bbe1 --- /dev/null +++ b/src/store.js @@ -0,0 +1,157 @@ +const fs = require('fs/promises'); +const path = require('path'); +const { randomUUID } = require('crypto'); + +class JsonStore { + constructor(filePath) { + this.filePath = filePath; + this.writeQueue = Promise.resolve(); + } + + async init() { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + try { + await fs.access(this.filePath); + } catch { + await fs.writeFile(this.filePath, JSON.stringify({ profiles: [], backups: [] }, null, 2)); + } + } + + async read() { + const raw = await fs.readFile(this.filePath, 'utf8'); + const parsed = JSON.parse(raw); + parsed.profiles ||= []; + parsed.backups ||= []; + return parsed; + } + + async write(mutator) { + this.writeQueue = this.writeQueue.then(async () => { + const current = await this.read(); + const next = await mutator(current); + await fs.writeFile(this.filePath, JSON.stringify(next, null, 2)); + return next; + }); + + return this.writeQueue; + } + + async listProfiles() { + const data = await this.read(); + return data.profiles; + } + + async getProfile(profileId) { + const data = await this.read(); + return data.profiles.find((profile) => profile.id === profileId) || null; + } + + async saveProfile(profileInput) { + const now = new Date().toISOString(); + const profile = { + id: profileInput.id || randomUUID(), + name: profileInput.name, + containerIds: profileInput.containerIds, + mode: profileInput.mode, + backupScope: profileInput.backupScope || 'volumes', + backupDir: profileInput.backupDir, + updatedAt: now, + createdAt: profileInput.createdAt || now, + }; + + await this.write((data) => { + const index = data.profiles.findIndex((item) => item.id === profile.id); + if (index >= 0) { + data.profiles[index] = profile; + } else { + data.profiles.push(profile); + } + return data; + }); + + return profile; + } + + async deleteProfile(profileId) { + await this.write((data) => { + data.profiles = data.profiles.filter((profile) => profile.id !== profileId); + data.backups = data.backups.filter((backup) => backup.profileId !== profileId); + return data; + }); + } + + async addBackup(backupRun) { + await this.write((data) => { + data.backups.unshift(backupRun); + return data; + }); + return backupRun; + } + + async listBackups(profileId) { + const data = await this.read(); + return data.backups.filter((backup) => backup.profileId === profileId); + } + + async getBackup(backupId) { + const data = await this.read(); + return data.backups.find((backup) => backup.id === backupId) || null; + } + + async getBackupsForContainer(profileId, containerId, upToBackupId) { + const backups = await this.listBackups(profileId); + const ordered = backups + .slice() + .sort((left, right) => new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime()); + + const chain = []; + + for (const backup of ordered) { + const containerBackup = backup.containers.find( + (item) => item.containerId === containerId && item.status === 'ok' && item.archiveRelativePath, + ); + + if (!containerBackup) { + if (backup.id === upToBackupId) { + return chain; + } + continue; + } + + if (containerBackup.mode === 'full') { + chain.length = 0; + } + + chain.push({ + backupId: backup.id, + createdAt: backup.createdAt, + ...containerBackup, + }); + + if (backup.id === upToBackupId) { + return chain; + } + } + + return []; + } + async getLastContainerBackupTime(profileId, containerId) { + const backups = await this.listBackups(profileId); + const ordered = backups + .slice() + .sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime()); + + for (const backup of ordered) { + const found = backup.containers.find( + (item) => item.containerId === containerId && item.status === 'ok', + ); + if (found) { + return backup.createdAt; + } + } + + return null; + } +} + +module.exports = JsonStore; \ No newline at end of file