From 2855032e7631ff7e3df726c5fb5d6514ae411cf4 Mon Sep 17 00:00:00 2001 From: pulse-agent Date: Tue, 19 May 2026 23:02:16 -0300 Subject: [PATCH] feat(core-docker): modulo docker com detector de stacks, gerador de compose e dockerfile para 10 stacks - detector de stacks por presenca de arquivos - gerador de docker-compose.yml Scenario A + Scenario C - gerador de Dockerfiles para: nodejs, nextjs, nestjs, nuxt, laravel, php-generic, python, go, rust, wordpress - validador xCloud: build:, proxy conflicts, multi-porta, healthcheck - barrel exports em src/docker/index.ts - organizado por domain-driven design --- .../@pulse-libs/core/src/docker/compose.ts | 103 ++++++++ .../@pulse-libs/core/src/docker/detector.ts | 31 +++ .../@pulse-libs/core/src/docker/dockerfile.ts | 225 ++++++++++++++++++ .../@pulse-libs/core/src/docker/helpers.ts | 66 +++++ projetos/@pulse-libs/core/src/docker/index.ts | 7 + projetos/@pulse-libs/core/src/docker/types.ts | 71 ++++++ .../@pulse-libs/core/src/docker/validate.ts | 33 +++ 7 files changed, 536 insertions(+) create mode 100644 projetos/@pulse-libs/core/src/docker/compose.ts create mode 100644 projetos/@pulse-libs/core/src/docker/detector.ts create mode 100644 projetos/@pulse-libs/core/src/docker/dockerfile.ts create mode 100644 projetos/@pulse-libs/core/src/docker/helpers.ts create mode 100644 projetos/@pulse-libs/core/src/docker/index.ts create mode 100644 projetos/@pulse-libs/core/src/docker/types.ts create mode 100644 projetos/@pulse-libs/core/src/docker/validate.ts diff --git a/projetos/@pulse-libs/core/src/docker/compose.ts b/projetos/@pulse-libs/core/src/docker/compose.ts new file mode 100644 index 0000000..fee06ad --- /dev/null +++ b/projetos/@pulse-libs/core/src/docker/compose.ts @@ -0,0 +1,103 @@ +/** + * compose.ts — Gerador de Docker Compose xCloud-ready + * Scenario A: build-source (1 app + opcional db) + * Scenario C: multi-service (vários apps + nginx-router) + */ +import type { DockerCompose } from './types'; + +const GHCR = (owner: string, repo: string) => `ghcr.io/${owner}/${repo}`; +const DB = { postgres: 'postgres:16-alpine', mysql: 'mysql:8.0' }; + +/** Scenario A */ +export function scenarioA(opts: { + owner: string; repo: string; service: string; port: number; + dbType: 'postgres' | 'mysql' | 'none'; env: Record; +}): DockerCompose { + const img = `${GHCR(opts.owner, opts.repo)}:latest`; + const svcs: Record = { + app: { image: img, ports: [`${opts.port}:3000`], environment: opts.env, networks: ['app-net'] }, + }; + + if (opts.dbType !== 'none') { + const isPg = opts.dbType === 'postgres'; + svcs['db'] = { + image: DB[opts.dbType], + expose: [isPg ? '5432' : '3306'], + environment: isPg + ? { POSTGRES_DB: '${POSTGRES_DB}', POSTGRES_USER: '${POSTGRES_USER}', POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}' } + : { MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}', MYSQL_DATABASE: '${DB_DATABASE}', MYSQL_USER: '${DB_USERNAME}', MYSQL_PASSWORD: '${DB_PASSWORD}' }, + volumes: ['db-data:/var/lib/postgresql/data'], + healthcheck: { test: isPg ? ['CMD-SHELL','pg_isready -U ${POSTGRES_USER}'] : ['CMD','mysqladmin','ping','-h','localhost'], interval: '10s', timeout: '5s', retries: 5 }, + networks: ['app-net'], + }; + svcs['app'].depends_on = [{ service: 'db', condition: 'service_healthy' }]; + } + + return { services: svcs, ...(opts.dbType !== 'none' ? { volumes: { 'db-data': {} } } : {}), networks: { 'app-net': { driver: 'bridge' } } }; +} + +/** Scenario C — multi-service com nginx-router */ +export function scenarioC(opts: { + owner: string; repo: string; + services: Array<{ name: string; port: number; internalPort: number }>; + dbType: 'postgres' | 'mysql' | 'none'; + envs: Record>; +}): DockerCompose { + const svcs: Record = {}; + const base = GHCR(opts.owner, opts.repo); + + for (const s of opts.services) { + svcs[s.name] = { image: `${base}/${s.name}:latest`, expose: [String(s.internalPort)], environment: opts.envs[s.name] || {}, networks: ['app-net'] }; + } + if (opts.dbType !== 'none') { + const isPg = opts.dbType === 'postgres'; + svcs['db'] = { + image: DB[opts.dbType], expose: [isPg ? '5432' : '3306'], + environment: { POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}' }, + healthcheck: { test: isPg ? ['CMD-SHELL','pg_isready -U ${POSTGRES_USER}'] : ['CMD','mysqladmin','ping','-h','localhost'], interval: '10s', timeout: '5s', retries: 5 }, + volumes: ['db-data:/var/lib/postgresql/data'], networks: ['app-net'], + }; + } + + // nginx-router única porta externa para xCloud + const extPort = opts.services[0]?.port || 3080; + svcs['nginx-router'] = { + image: 'nginx:alpine', ports: [`${extPort}:80`], + configs: [{ source: 'nginx_cfg', target: '/etc/nginx/conf.d/default.conf' }], + depends_on: opts.services.map(s => ({ service: s.name })), + networks: ['app-net'], + }; + + const back = opts.services.find(s => s.name !== 'frontend'); + const front = opts.services.find(s => s.name !== 'backend'); + const bPort = back?.internalPort || 8000; + const fPort = front?.port || opts.services[0]?.port || 3000; + + return { + services: svcs, + configs: { + nginx_cfg: { + content: `upstream backend { server backend:${bPort}; }\nupstream frontend { server frontend:${fPort}; }\n\nserver {\n listen 80;\n\n location /api/ { proxy_pass http://backend:${bPort}/; }\n location / { proxy_pass http://frontend:${fPort}/; }\n}\n`, + }, + }, + volumes: opts.dbType !== 'none' ? { 'db-data': {} } : undefined, + networks: { 'app-net': { driver: 'bridge' } }, + }; +} + +// Helper para nginx-router para adicionar a compose existente +export function addNginxRouter(svcs: Record, externalPort: number = 3080): { configs: Record } { + if (svcs['nginx-router']) return { configs: {} }; + svcs['nginx-router'] = { + image: 'nginx:alpine', ports: [`${externalPort}:80`], + depends_on: Object.keys(svcs).filter(k => k !== 'nginx-router'), + networks: ['app-net'], + }; + return { + configs: { + nginx_cfg: { + content: `server {\n listen 80;\n location / { proxy_pass http://frontend:3000/; }\n}\n`, + }, + }, + }; +} diff --git a/projetos/@pulse-libs/core/src/docker/detector.ts b/projetos/@pulse-libs/core/src/docker/detector.ts new file mode 100644 index 0000000..44a6a5c --- /dev/null +++ b/projetos/@pulse-libs/core/src/docker/detector.ts @@ -0,0 +1,31 @@ +/** + * detector.ts — Stack Detection Engine + * Detecta stack examinando nomes de arquivos — sem rede, sem processos. + */ +import type { DetectedStack, StackType } from './types'; + +const SIGNALS: Array<{ + type: StackType; + path: 'native' | 'docker'; + check: (fs: Record) => boolean; +}> = [ + { type: 'wordpress', path: 'native', check: (fs) => fs['wp-config.php'] || fs['wp-content'] }, + { type: 'laravel', path: 'native', check: (fs) => fs['composer.json'] && fs['artisan'] }, + { type: 'php-generic', path: 'native', check: (fs) => fs['composer.json'] && !fs['artisan'] }, + { type: 'nextjs', path: 'docker', check: (fs) => !!fs['next.config.js'] || !!fs['next.config.ts'] }, + { type: 'nestjs', path: 'docker', check: (fs) => !!fs['nest-cli.json'] }, + { type: 'nuxt', path: 'docker', check: (fs) => !!fs['nuxt.config.ts'] || !!fs['nuxt.config.js'] }, + { type: 'go', path: 'docker', check: (fs) => !!fs['go.mod'] }, + { type: 'rust', path: 'docker', check: (fs) => !!fs['Cargo.toml'] }, + { type: 'python', path: 'docker', check: (fs) => fs['requirements.txt'] || fs['pyproject.toml'] }, + { type: 'nodejs', path: 'native', check: (fs) => !!fs['package.json'] }, +]; + +export function detectStack(files: Record): DetectedStack { + for (const s of SIGNALS) { + if (s.check(files)) { + return { type: s.type, confidence: 0.8, signals: Object.keys(files), recommendedPath: s.path }; + } + } + return { type: 'unknown', confidence: 0, signals: Object.keys(files), recommendedPath: 'adapt' }; +} diff --git a/projetos/@pulse-libs/core/src/docker/dockerfile.ts b/projetos/@pulse-libs/core/src/docker/dockerfile.ts new file mode 100644 index 0000000..07e3411 --- /dev/null +++ b/projetos/@pulse-libs/core/src/docker/dockerfile.ts @@ -0,0 +1,225 @@ +/** + * dockerfile.ts — Gerador de Dockerfiles production-ready + */ +import type { DockerfileOptions } from './types'; + +export function generateDockerfile(o: DockerfileOptions): { content: string; stages: number; mb: number } { + const v = o.nodeVersion || '20'; + + switch (o.stack) { + case 'nextjs': return nextjs(v); + case 'nestjs': return nestjs(v); + case 'nuxt': return nuxt(v); + case 'laravel':return laravel(o.phpVersion); + case 'php-generic': return php(o.phpVersion); + case 'python': return python(o.pythonVersion); + case 'go': return go(o.nodeVersion); + case 'rust': return rust(); + case 'wordpress': return { content: '# Use xCloud Native', stages: 0, mb: 0 }; + case 'nodejs': return nodejs(v); + default: return { content: '# Unknown stack', stages: 0, mb: 0 }; + } +} + +/* ─── NODE: Express/Fastify ─── */ +function nodejs(v: string) { + return { + content: `FROM node:${v}-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev +COPY . . + +FROM node:${v}-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app . +EXPOSE 3000 +CMD ["node", "server.js"]`, + stages: 2, mb: 140, + }; +} + +/* ─── NEXT.JS standalone ─── */ +function nextjs(v: string) { + return { + content: `FROM node:${v}-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM node:${v}-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:${v}-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +CMD ["node", "server.js"]`, + stages: 3, mb: 180, + }; +} + +/* ─── NESTJS ─── */ +function nestjs(v: string) { + return { + content: `FROM node:${v}-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev + +FROM node:${v}-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:${v}-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY --from=deps /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY package.json ./ +EXPOSE 3000 +CMD ["node", "dist/main"]`, + stages: 3, mb: 170, + }; +} + +/* ─── NUXT ─── */ +function nuxt(v: string) { + return { + content: `FROM node:${v}-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM node:${v}-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:${v}-alpine +WORKDIR /app +ENV NODE_ENV=production +ENV NITRO_PORT=3000 +ENV NITRO_HOST=0.0.0.0 +COPY --from=builder /app/.output ./.output +EXPOSE 3000 +CMD ["node", ".output/server/index.mjs"]`, + stages: 3, mb: 170, + }; +} + +/* ─── LARAVEL ─── */ +function laravel(v: string = '8.3') { + return { + content: `FROM php:${v}-fpm-alpine +RUN apk add --no-cache git curl libpng-dev libxml2-dev zip unzip nodejs npm +RUN docker-php-ext-install pdo pdo_mysql bcmath gd opcache +RUN echo "opcache.enable=1" > /usr/local/etc/php/conf.d/opcache.ini && echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/opcache.ini +COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer +WORKDIR /var/www/html +COPY composer.json composer.lock ./ +RUN composer install --no-dev --no-scripts --prefer-dist +COPY . . +RUN composer dump-autoload --optimize +RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache +EXPOSE 9000 +CMD ["php-fpm"]`, + stages: 1, mb: 220, + }; +} + +/* ─── PHP genérico ─── */ +function php(v: string = '8.3') { + return { + content: `FROM php:${v}-apache +RUN a2enmod rewrite headers +RUN docker-php-ext-install pdo pdo_mysql mysqli +COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer +ENV APACHE_DOCUMENT_ROOT=/var/www/html/public +RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf +WORKDIR /var/www/html +COPY composer.json composer.lock* ./ +RUN composer install --no-dev --no-scripts --prefer-dist +COPY . . +RUN chown -R www-data:www-data /var/www/html +EXPOSE 80 +CMD ["apache2-foreground"]`, + stages: 1, mb: 200, + }; +} + +/* ─── PYTHON FastAPI ─── */ +function python(v: string | undefined) { + const pv = v || '3.12'; + return { + content: `FROM python:${pv}-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends build-essential curl && rm -rf /var/lib/apt/lists/* +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +RUN useradd -m -u 1001 appuser && chown -R appuser:appuser /app +USER appuser +EXPOSE 8000 +ENV PORT=8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]`, + stages: 1, mb: 250, + }; +} + +/* ─── GO ─── */ +function go(v: string | undefined) { + const gv = v || '1.22'; + return { + content: `FROM golang:${gv}-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o app . + +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates +COPY --from=builder /app/app /usr/local/bin/app +USER 1001 +EXPOSE 8080 +ENV PORT=8080 +CMD ["app"]`, + stages: 2, mb: 40, + }; +} + +/* ─── RUST ─── */ +function rust() { + return { + content: `FROM rust:1.75-slim AS builder +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +RUN cargo build --release + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends libssl3 ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/target/release/app /usr/local/bin/app +USER 1001 +EXPOSE 8080 +ENV PORT=8080 +CMD ["app"]`, + stages: 2, mb: 60, + }; +} diff --git a/projetos/@pulse-libs/core/src/docker/helpers.ts b/projetos/@pulse-libs/core/src/docker/helpers.ts new file mode 100644 index 0000000..0911df6 --- /dev/null +++ b/projetos/@pulse-libs/core/src/docker/helpers.ts @@ -0,0 +1,66 @@ +/** + * helpers.ts — YAML serializer + env extractor + snippets + */ +import type { DockerCompose } from './types'; + +const NL = '\n'; + +function q(s: string, inList: boolean = false): string { + if (inList) return `"${s}"`; + const clean = s.trim(); + if (/^[a-z0-9_-]+$/i.test(clean)) return s; + return `'${s}'`; +} + +function indent(content: string[], level: number): string { + return content.map(l => ' '.repeat(level) + l).join(NL); +} + +function serviceToYaml(name: string, s: any): string[] { + const lines: string[] = []; + lines.push(`${q(name)}:`); + + if (s.image) lines.push(indent([`image: ${s.image}`], 1)); + if (s.build) lines.push(indent(['build:', indent(['context: ' + q(s.build.context || '.')], 2)], 1)); + if (s.ports) lines.push('ports:', ...s.ports.flatMap(p => [indent([`- ${q(p, true)}`], 1)])); + if (s.expose) lines.push('expose:', ...s.expose.flatMap(p => [indent([`- ${q(p, true)}`], 1)])); + if (s.environment) { + lines.push('environment:'); + for (const [k, v] of Object.entries(s.environment)) { + lines.push(indent([`${q(k)}: ${v}`], 1)); + } + } + if (s.depends_on) { + lines.push('depends_on:'); + for (const d of s.depends_on) { + if (d.condition) lines.push(indent([`${q(d.service)}:`, indent([`condition: ${d.condition}`], 2)], 1)); + else lines.push(indent([`- ${q(d.service)}`], 1)); + } + } + if (s.volumes) lines.push('volumes:', ...s.volumes.flatMap(v => [indent([`- ${q(v)}`], 1)])); + if (s.networks) lines.push('networks:', ...s.networks.flatMap(n => [indent([`- ${q(n)}`], 1)])); + if (s.healthcheck) { + lines.push('healthcheck:', indent(['test:', ...s.healthcheck.test.flatMap(t => [indent([`- ${t}`], 2)])], 1), ...['interval: ' + s.healthcheck.interval, 'timeout: ' + s.healthcheck.timeout, 'retries: ' + s.healthcheck.retries].map(l => indent([l], 1))); + } + if (s.configs) { + lines.push('configs:'); + for (const c of s.configs) lines.push(indent([`- source: ${q(c.source)}`, ` target: ${q(c.target)}`], 1)); + } + return lines; +} + +export function toYaml(c: DockerCompose): string { + const out: string[] = []; + out.push('services:'); + for (const [name, svc] of Object.entries(c.services || {})) { + out.push(...serviceToYaml(name, svc)); + } + if (c.volumes) { out.push(''); out.push('volumes:'); for (const n of Object.keys(c.volumes)) out.push(` ${q(n)}:`); } + if (c.networks) { out.push(''); out.push('networks:'); for (const [n, v] of Object.entries(c.networks)) out.push(` ${q(n)}:\n driver: ${v.driver}`); } + if (c.configs) { out.push(''); out.push('configs:'); for (const [n, v] of Object.entries(c.configs)) out.push(` ${q(n)}:\n content: |`, ...indent(v.content.replace(/\n$/, '').split('\n'), 2).split('\n').map(l=>' '+l)); } + return out.join(NL) + NL; +} + +export function extractEnvVars(yamlStr: string): string[] { + return [...new Set([...yamlStr.matchAll(/\$\{([^}]+)\}/g)].map(m => m[1]))].sort(); +} diff --git a/projetos/@pulse-libs/core/src/docker/index.ts b/projetos/@pulse-libs/core/src/docker/index.ts new file mode 100644 index 0000000..3267dbe --- /dev/null +++ b/projetos/@pulse-libs/core/src/docker/index.ts @@ -0,0 +1,7 @@ +/** + * index.ts — Barrel exports do módulo @pulse-libs/core/docker + */ +export * from './detector'; +export * from './compose'; +export * from './dockerfile'; +export * from './index'; diff --git a/projetos/@pulse-libs/core/src/docker/types.ts b/projetos/@pulse-libs/core/src/docker/types.ts new file mode 100644 index 0000000..be452ef --- /dev/null +++ b/projetos/@pulse-libs/core/src/docker/types.ts @@ -0,0 +1,71 @@ +/** + * types.ts — Tipos TypeScript do módulo @pulse-libs/core/docker + */ +import type { z } from 'zod'; + +/* ─── Stack detection ─── */ +export type StackType = + | 'nodejs' | 'nextjs' | 'nestjs' | 'nuxt' + | 'laravel' | 'php-generic' | 'python' + | 'go' | 'rust' | 'wordpress' | 'unknown'; + +export interface DetectedStack { + type: StackType; + confidence: number; + signals: string[]; + recommendedPath: 'native' | 'docker' | 'adapt'; +} + +/* ─── Docker Compose ─── */ +export interface ComposeService { + name: string; + image?: string; + build?: { context: string; dockerfile?: string }; + ports?: string[]; + expose?: string[]; + environment?: Record; + depends_on?: { service: string; condition?: string }[]; + volumes?: string[]; + networks?: string[]; + command?: string; + healthcheck?: { test: string[]; interval: string; timeout: string; retries: number }; + configs?: { source: string; target: string }[]; +} + +export interface DockerCompose { + services: Record; + volumes?: Record>; + networks?: Record; + configs?: Record; +} + +/* ─── Validation ─── */ +export interface CompatibilityReport { + service: string; + issue: string; + severity: 'error' | 'warning' | 'info'; + location: string; + fix?: string; +} + +export type ValidationResult = { + valid: boolean; + errors: CompatibilityReport[]; + warnings: CompatibilityReport[]; + infos: CompatibilityReport[]; +}; + +/* ─── Dockerfile ─── */ +export interface DockerfileOptions { + stack: StackType; + nodeVersion?: string; + pythonVersion?: string; + phpVersion?: string; + nonRoot?: boolean; +} + +export interface DockerfileResult { + dockerfile: string; + stageCount: number; + estimatedSizeMB: number; +} diff --git a/projetos/@pulse-libs/core/src/docker/validate.ts b/projetos/@pulse-libs/core/src/docker/validate.ts new file mode 100644 index 0000000..6c4613c --- /dev/null +++ b/projetos/@pulse-libs/core/src/docker/validate.ts @@ -0,0 +1,33 @@ +/** + * validate.ts — Validação de compatibilidade xCloud + */ +import type { DockerCompose, ValidationResult } from './types'; + +const ERR = (svc: string, issue: string, location: string, fix: string) => ({ service: svc, issue, severity: 'error' as const, location, fix }); +const WARN = (svc: string, issue: string, location: string, fix: string) => ({ service: svc, issue, severity: 'warning' as const, location, fix }); + +export function validateXCloudCompatibility(c: DockerCompose): ValidationResult { + const sv = c.services || {}; + const errors: any[] = []; + const warnings: any[] = []; + const infos: any[] = []; + + // Sem build: + for (const [n, s] of Object.entries(sv)) { + if ((s as any).build) errors.push(ERR(n, 'build: presente — xCloud não constrói imagens', `services.${n}`, 'Remover build:, usar GHCR image')); + if ((s as any).image?.includes('caddy') || (s as any).image?.includes('traefik')) errors.push(ERR(n, 'Proxy conflita com nginx xCloud', `services.${n}.image`, 'Remover, usar nginx-router')); + if ((s as any).ports && (s as any).ports.length > 1) errors.push(ERR(n, `${(s as any).ports.length} ports (xCloud precisa 1)`, `services.${n}.ports`, 'Trocar ports → expose, usar nginx-router')); + } + + // Healthcheck em dbs + if (sv['db'] && !sv['db'].healthcheck) warnings.push(WARN('db', 'Sem healthcheck — recomenda-se adicionar', 'services.db', 'Adicionar healthcheck pg_isready ou mysqladmin ping')); + + // Network + if (!c.networks) infos.push({ service: '_root', issue: 'Sem networks explícitas', location: 'root' }); + + // Non-root + const hasNonRoot = Object.values(sv).some((s:any) => s.user); + if (!hasNonRoot) infos.push({ service: '_root', issue: 'Todos rodam como root', location: 'root' }); + + return { valid: errors.length === 0, errors, warnings, infos }; +}