diff --git a/backend/src/__tests__/authored-compose-args.test.ts b/backend/src/__tests__/authored-compose-args.test.ts index 609dbb8a..60f8be83 100644 --- a/backend/src/__tests__/authored-compose-args.test.ts +++ b/backend/src/__tests__/authored-compose-args.test.ts @@ -10,18 +10,20 @@ * - any spec file path or context dir that is absolute / contains ".." throws * before any args are returned (it is spliced straight into child-process argv) */ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import path from 'path'; +import fs from 'fs'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; let tmpDir: string; let authoredComposeFileArgs: typeof import('../utils/authoredComposeArgs').authoredComposeFileArgs; +let authoredComposeEnvFileArgs: typeof import('../utils/authoredComposeArgs').authoredComposeEnvFileArgs; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry; beforeAll(async () => { tmpDir = await setupTestDb(); - ({ authoredComposeFileArgs } = await import('../utils/authoredComposeArgs')); + ({ authoredComposeFileArgs, authoredComposeEnvFileArgs } = await import('../utils/authoredComposeArgs')); ({ DatabaseService } = await import('../services/DatabaseService')); ({ NodeRegistry } = await import('../services/NodeRegistry')); }); @@ -136,3 +138,93 @@ describe('authoredComposeFileArgs', () => { expect(() => authoredComposeFileArgs(stackName)).toThrow(); }); }); + +/** + * authoredComposeEnvFileArgs: the `--env-file /.env` flag a multi-file + * Git deploy needs when a context dir is set. With `--project-directory `, + * Compose treats the context dir as the project directory and stops auto-finding + * the root `.env` Sencho writes, so validation (which passes --env-file) and + * deploy would otherwise resolve different env. Single-file / no-context stacks + * keep Compose's default `.env` discovery from the stack dir, so they get no flag. + */ +describe('authoredComposeEnvFileArgs', () => { + /** Create the on-disk stack directory and optionally a root .env for it. */ + function makeStackDir(stackName: string, withEnv: boolean): string { + const baseDir = NodeRegistry.getInstance().getComposeDir(NodeRegistry.getInstance().getDefaultNodeId()); + const stackDir = path.join(baseDir, stackName); + fs.mkdirSync(stackDir, { recursive: true }); + if (withEnv) fs.writeFileSync(path.join(stackDir, '.env'), 'TAG=1\n', 'utf-8'); + else fs.rmSync(path.join(stackDir, '.env'), { force: true }); + return stackDir; + } + + it('returns [] for a stack with no git source at all', async () => { + expect(await authoredComposeEnvFileArgs('no-such-stack')).toEqual([]); + }); + + it('returns [] when the context dir is set but no .env exists', async () => { + const stackName = 'ctx-no-env'; + seedSource(stackName, ['compose.yaml', 'infra/prod.yml']); + DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { + files: ['compose.yaml', 'infra/prod.yml'], + contextDir: 'app', + }); + makeStackDir(stackName, false); + expect(await authoredComposeEnvFileArgs(stackName)).toEqual([]); + }); + + it('returns [] when a .env exists but the spec has no context dir', async () => { + // No --project-directory, so the project dir stays the stack dir (cwd) and + // Compose auto-discovers the root .env; an explicit flag is not needed. + const stackName = 'env-no-ctx'; + seedSource(stackName, ['compose.yaml', 'infra/prod.yml']); + DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { + files: ['compose.yaml', 'infra/prod.yml'], + contextDir: null, + }); + makeStackDir(stackName, true); + expect(await authoredComposeEnvFileArgs(stackName)).toEqual([]); + }); + + it('returns --env-file /.env when a context dir is set and a .env exists', async () => { + const stackName = 'ctx-with-env'; + seedSource(stackName, ['compose.yaml', 'infra/prod.yml']); + DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { + files: ['compose.yaml', 'infra/prod.yml'], + contextDir: 'app', + }); + const stackDir = makeStackDir(stackName, true); + expect(await authoredComposeEnvFileArgs(stackName)).toEqual([ + '--env-file', path.join(stackDir, '.env'), + ]); + }); + + it('returns [] for a stack name that escapes the compose base (path-injection guard)', async () => { + // The inline barrier rejects a traversal name before the fs access, so no + // --env-file is emitted for a path outside the compose base. + const stackName = '../escape'; + seedSource(stackName, ['infra/base.yml', 'infra/prod.yml']); + DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { + files: ['compose.yaml', 'infra/prod.yml'], + contextDir: 'app', + }); + expect(await authoredComposeEnvFileArgs(stackName)).toEqual([]); + }); + + it('rethrows a non-ENOENT access error instead of silently dropping the env file', async () => { + // An EACCES on an existing .env must surface, not be treated as "no env + // file": dropping --env-file there would deploy a different effective config + // than the one validated. + const stackName = 'ctx-eacces'; + seedSource(stackName, ['compose.yaml', 'infra/prod.yml']); + DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { + files: ['compose.yaml', 'infra/prod.yml'], + contextDir: 'app', + }); + const spy = vi.spyOn(fs.promises, 'access').mockRejectedValueOnce( + Object.assign(new Error('permission denied'), { code: 'EACCES' }), + ); + await expect(authoredComposeEnvFileArgs(stackName)).rejects.toThrow(/permission denied/); + spy.mockRestore(); + }); +}); diff --git a/backend/src/__tests__/effective-anatomy-route.test.ts b/backend/src/__tests__/effective-anatomy-route.test.ts new file mode 100644 index 00000000..a8dff17e --- /dev/null +++ b/backend/src/__tests__/effective-anatomy-route.test.ts @@ -0,0 +1,113 @@ +/** + * GET /api/stacks/:stackName/effective-anatomy: returns the merged effective + * facts, requires stack:read, 404s a missing stack, surfaces a structural (never + * raw) error on render failure, and never leaks an env or label value. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { ComposeService } from '../services/ComposeService'; + +let tmpDir: string; +let app: import('express').Express; +let authHeader: string; + +const STACK = 'effanat'; +const ENV_SECRET = 'env-secret-71bd-value'; +const LABEL_SECRET = 'label-secret-22ce-value'; + +function stubRender(rendered: string | null, stderr = '') { + vi.spyOn(ComposeService, 'getInstance').mockReturnValue({ + renderConfig: vi.fn().mockResolvedValue({ rendered, stderr, code: rendered === null ? 1 : 0, timedOut: false }), + } as unknown as ComposeService); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +describe('effective-anatomy route', () => { + let stackDir: string; + beforeEach(() => { + stackDir = path.join(process.env.COMPOSE_DIR as string, STACK); + fs.mkdirSync(stackDir, { recursive: true }); + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n'); + }); + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(stackDir, { recursive: true, force: true }); + }); + + it('returns merged effective facts for a renderable stack', async () => { + stubRender(JSON.stringify({ + name: STACK, + services: { + web: { + image: 'nginx:latest', + restart: 'always', + ports: [{ target: 80, published: '8080', protocol: 'tcp' }], + volumes: [{ type: 'volume', source: 'data', target: '/data' }], + networks: { backend: null }, + }, + }, + networks: { backend: {}, default: {} }, + volumes: { data: {} }, + })); + const res = await request(app).get(`/api/stacks/${STACK}/effective-anatomy`).set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body.renderable).toBe(true); + expect(res.body.services).toEqual(['web']); + expect(res.body.ports.web).toEqual([{ host: '8080', container: '80', proto: 'tcp', published: true }]); + expect(res.body.volumes.web).toEqual([{ host: 'data', container: '/data' }]); + expect(res.body.restart).toBe('always'); + expect(res.body.networks).toEqual(['backend', 'default']); + }); + + it('surfaces a structural error and never raw stderr on render failure', async () => { + stubRender(null, `error: the "${ENV_SECRET}" variable is not set`); + const res = await request(app).get(`/api/stacks/${STACK}/effective-anatomy`).set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body.renderable).toBe(false); + expect(JSON.stringify(res.body)).not.toContain(ENV_SECRET); + }); + + it('falls back to a structural error when the render is not valid JSON', async () => { + stubRender('this is not json {'); + const res = await request(app).get(`/api/stacks/${STACK}/effective-anatomy`).set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body.renderable).toBe(false); + expect(res.body.services).toEqual([]); + }); + + it('never leaks env or label values into the facts', async () => { + stubRender(JSON.stringify({ + name: STACK, + services: { web: { image: 'nginx:latest', environment: { TOKEN: ENV_SECRET }, labels: { 'x.secret': LABEL_SECRET } } }, + networks: {}, + volumes: {}, + })); + const res = await request(app).get(`/api/stacks/${STACK}/effective-anatomy`).set('Authorization', authHeader); + expect(res.status).toBe(200); + const body = JSON.stringify(res.body); + expect(body).not.toContain(ENV_SECRET); + expect(body).not.toContain(LABEL_SECRET); + }); + + it('rejects an unauthenticated request', async () => { + const res = await request(app).get(`/api/stacks/${STACK}/effective-anatomy`); + expect(res.status).toBe(401); + }); + + it('returns 404 for a stack that does not exist', async () => { + stubRender(JSON.stringify({ name: 'x', services: {}, networks: {}, volumes: {} })); + const res = await request(app).get('/api/stacks/nope-not-here/effective-anatomy').set('Authorization', authHeader); + expect(res.status).toBe(404); + }); +}); diff --git a/backend/src/__tests__/effective-anatomy.test.ts b/backend/src/__tests__/effective-anatomy.test.ts new file mode 100644 index 00000000..f584dd5c --- /dev/null +++ b/backend/src/__tests__/effective-anatomy.test.ts @@ -0,0 +1,116 @@ +/** + * Unit tests for parseEffectiveAnatomy: the secret-safe structural extractor that + * maps `docker compose config --format json` (the fully-merged effective model) + * to the same anatomy facts the frontend derives from a single compose file, so a + * multi-file Git source's dossier and doc-drift reflect every override file. + * + * The extractor must read ONLY structural fields (service keys, ports, volumes, + * restart, network keys) and never an environment, label, or command value. + */ +import { describe, it, expect } from 'vitest'; +import { parseEffectiveAnatomy } from '../services/effectiveAnatomy'; + +describe('parseEffectiveAnatomy', () => { + it('returns an empty model for null / non-object input', () => { + const empty = { services: [], ports: {}, volumes: {}, restart: null, networks: [] }; + expect(parseEffectiveAnatomy(null)).toEqual(empty); + expect(parseEffectiveAnatomy('nope')).toEqual(empty); + expect(parseEffectiveAnatomy(42)).toEqual(empty); + }); + + it('extracts services, published ports, and volumes from the long-form render', () => { + const rendered = { + name: 'demo', + services: { + web: { + image: 'nginx', + restart: 'always', + ports: [ + { mode: 'ingress', host_ip: '0.0.0.0', target: 80, published: '8080', protocol: 'tcp' }, + ], + volumes: [ + { type: 'bind', source: '/srv/web', target: '/usr/share/nginx/html' }, + { type: 'volume', source: 'webdata', target: '/var/cache' }, + ], + networks: { default: null }, + }, + }, + networks: { default: { name: 'demo_default' } }, + }; + const anatomy = parseEffectiveAnatomy(rendered); + expect(anatomy.services).toEqual(['web']); + expect(anatomy.ports).toEqual({ + web: [{ host: '8080', container: '80', proto: 'tcp', published: true }], + }); + expect(anatomy.volumes).toEqual({ + web: [ + { host: '/srv/web', container: '/usr/share/nginx/html' }, + { host: 'webdata', container: '/var/cache' }, + ], + }); + expect(anatomy.restart).toBe('always'); + expect(anatomy.networks).toEqual(['default']); + }); + + it('merges ports that only an override file publishes (the blocker case)', () => { + // The root file declared `app` with no ports; an override published 9000. + // The rendered model is the merge, so the published port must appear here. + const rendered = { + services: { + app: { + ports: [{ target: 9000, published: '9000', protocol: 'tcp' }], + }, + }, + }; + const anatomy = parseEffectiveAnatomy(rendered); + expect(anatomy.ports.app).toEqual([{ host: '9000', container: '9000', proto: 'tcp', published: true }]); + }); + + it('marks a container-only port as unpublished and preserves UDP', () => { + const rendered = { + services: { + svc: { + ports: [ + { target: 53, published: '53', protocol: 'udp' }, + { target: 9090, published: '', protocol: 'tcp' }, + ], + }, + }, + }; + const anatomy = parseEffectiveAnatomy(rendered); + expect(anatomy.ports.svc).toEqual([ + { host: '53', container: '53', proto: 'udp', published: true }, + { host: '', container: '9090', proto: 'tcp', published: false }, + ]); + }); + + it('collects network keys from services and the top level, deduped', () => { + const rendered = { + services: { + a: { networks: { frontend: null, backend: null } }, + b: { networks: ['backend'] }, + }, + networks: { frontend: {}, backend: {}, default: {} }, + }; + const anatomy = parseEffectiveAnatomy(rendered); + expect(anatomy.networks).toEqual(['frontend', 'backend', 'default']); + }); + + it('never surfaces environment, label, or command values', () => { + const rendered = { + services: { + web: { + environment: { DB_PASSWORD: 'super-secret', API_KEY: 'leak-me' }, + labels: { 'traefik.http.routers.web.rule': 'Host(`secret.example.com`)' }, + command: ['--token', 'do-not-leak'], + ports: [{ target: 80, published: '80', protocol: 'tcp' }], + }, + }, + }; + const serialized = JSON.stringify(parseEffectiveAnatomy(rendered)); + expect(serialized).not.toContain('super-secret'); + expect(serialized).not.toContain('leak-me'); + expect(serialized).not.toContain('do-not-leak'); + expect(serialized).not.toContain('secret.example.com'); + }); +}); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index a0d91aa2..6170473b 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -17,6 +17,7 @@ import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } f import { DriftLedgerService, type DriftTemporal } from '../services/DriftLedgerService'; import { ComposeDoctorService } from '../services/ComposeDoctorService'; import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector'; +import { buildEffectiveAnatomy } from '../services/effectiveAnatomy'; import { EXPOSURE_INTENTS, type ExposureIntent } from '../services/network/types'; import { UpdateGuardService } from '../services/UpdateGuardService'; import { HealthGateService } from '../services/HealthGateService'; @@ -1142,6 +1143,25 @@ stacksRouter.get('/:stackName/networking', async (req: Request, res: Response) = } }); +// Effective Stack Anatomy: structural facts (services, ports, volumes, networks, +// restart) from the fully-merged effective model, so a multi-file Git source's +// dossier and doc-drift reflect every override file, not just the root compose. +// Read-only and advisory; auto-proxies to the active node. Secret-safe: the +// response carries only structural fields; resolved env, label, and command +// values in the rendered model are never extracted into the payload. +stacksRouter.get('/:stackName/effective-anatomy', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; + if (!(await requireStackExists(req.nodeId, stackName, res))) return; + try { + res.json(await buildEffectiveAnatomy(req.nodeId, stackName)); + } catch (error) { + console.error('[Stacks] Failed to build effective anatomy for %s:', sanitizeForLog(stackName), + sanitizeForLog(inspect(error, { depth: 4 }))); + res.status(500).json({ error: 'Failed to build effective anatomy' }); + } +}); + // Exposure intent: the user's per-stack (service '') and per-service exposure // classification, stored separately from generated facts so mismatches stay // detectable. Rows are stored independently; precedence (a service row taking diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 512b4ed2..df5c4fae 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -16,7 +16,7 @@ import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; import { describeSpawnError } from '../utils/spawnErrors'; import { isPathWithinBase, isValidStackName } from '../utils/validation'; -import { authoredComposeFileArgs } from '../utils/authoredComposeArgs'; +import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs'; import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog'; export class ComposeRollbackError extends Error { @@ -104,6 +104,9 @@ export class ComposeService { const args: string[] = ['compose']; const filePrefix = authoredComposeFileArgs(stackName, this.nodeId); args.push(...filePrefix); + // Pin env resolution to the root .env when a context dir shifts the project + // directory, so deploy/update resolve the same effective config the validator did. + args.push(...await authoredComposeEnvFileArgs(stackName, this.nodeId)); let overridePath: string | null = null; try { @@ -653,7 +656,8 @@ export class ComposeService { // Use the authored multi-file model (no mesh override) so override-only image // refs are scanned by the policy gate; single-file stacks get an empty prefix. const filePrefix = authoredComposeFileArgs(stackName, this.nodeId); - const stdout = await this.captureCompose([...filePrefix, 'config', '--images'], stackDir); + const envFileArgs = await authoredComposeEnvFileArgs(stackName, this.nodeId); + const stdout = await this.captureCompose([...filePrefix, ...envFileArgs, 'config', '--images'], stackDir); const seen = new Set(); const images: string[] = []; for (const raw of stdout.split(/\r?\n/)) { @@ -708,11 +712,11 @@ export class ComposeService { * finding rather than an exception. Bounded by a timeout and an output cap. * Rejects only when the docker binary cannot be spawned. */ - public renderConfig( + public async renderConfig( stackName: string, ): Promise<{ rendered: string | null; stderr: string; code: number | null; timedOut: boolean }> { if (!isValidStackName(stackName)) { - return Promise.reject(new Error('Invalid stack path')); + throw new Error('Invalid stack path'); } // Canonical inline js/path-injection barrier, kept in the same scope as the // spawn cwd sink below. CodeQL credits neither the wrapped isPathWithinBase @@ -722,17 +726,19 @@ export class ComposeService { const baseResolved = path.resolve(this.baseDir); const stackDir = path.resolve(baseResolved, stackName); if (!stackDir.startsWith(baseResolved + path.sep)) { - return Promise.reject(new Error('Invalid stack path')); + throw new Error('Invalid stack path'); } // Render the authored multi-file model (no mesh override) so the Compose Doctor - // sees every override file; single-file stacks get an empty prefix. + // sees every override file; single-file stacks get an empty prefix. The env-file + // flag keeps render resolving the same root .env the validator and deploy use. let filePrefix: string[]; try { filePrefix = authoredComposeFileArgs(stackName, this.nodeId); } catch (err) { - return Promise.reject(err instanceof Error ? err : new Error(String(err))); + throw err instanceof Error ? err : new Error(String(err)); } - const child = spawn('docker', ['compose', ...filePrefix, 'config', '--format', 'json'], { + const envFileArgs = await authoredComposeEnvFileArgs(stackName, this.nodeId); + const child = spawn('docker', ['compose', ...filePrefix, ...envFileArgs, 'config', '--format', 'json'], { cwd: stackDir, env: { ...process.env, diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 40d1c38f..29ac5ce8 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -13,7 +13,7 @@ import { isPathWithinBase } from '../utils/validation'; import { isDebugEnabled } from '../utils/debug'; import { sanitizeForLog } from '../utils/safeLog'; import { describeSpawnError } from '../utils/spawnErrors'; -import { authoredComposeFileArgs } from '../utils/authoredComposeArgs'; +import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs'; const execFileAsync = promisify(execFile); const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose'; @@ -1289,9 +1289,10 @@ class DockerController { // empty prefix and behave exactly as before. execFile avoids shell quoting on // the absolute --project-directory path. const filePrefix = authoredComposeFileArgs(stackName, this.nodeId); + const envFileArgs = await authoredComposeEnvFileArgs(stackName, this.nodeId); const { stdout, stderr } = await execFileAsync( 'docker', - ['compose', ...filePrefix, 'ps', '--format', 'json', '-a'], + ['compose', ...filePrefix, ...envFileArgs, 'ps', '--format', 'json', '-a'], { cwd: stackDir, env: { diff --git a/backend/src/services/effectiveAnatomy.ts b/backend/src/services/effectiveAnatomy.ts new file mode 100644 index 00000000..8866e60f --- /dev/null +++ b/backend/src/services/effectiveAnatomy.ts @@ -0,0 +1,183 @@ +/** + * Effective Stack Anatomy: the structural facts a stack's Dossier and doc-drift + * read, derived from the FULLY-MERGED effective model (`docker compose config + * --format json`) instead of a single compose file. For a multi-file Git source, + * a service, port, network, or volume that only an override file adds is invisible + * to a root-only parse, so the dossier and its doc-drift would show misleading + * facts. Rendering the merged model and extracting the same anatomy shape keeps + * those signals honest. + * + * Secret-safe by construction: the extractor reads only structural fields + * (service keys, ports, volumes, restart, network keys). It never reads + * `environment`, `command`, `entrypoint`, `labels`, `secrets`, or `configs`, so a + * resolved secret VALUE in the rendered model can never reach this payload. + */ +import { ComposeService } from './ComposeService'; +import { parseMissingRequiredVars } from './ComposeDoctorService'; +import { getErrorMessage } from '../utils/errors'; +import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog'; + +const MAX_RENDER_ERROR = 600; + +export interface EffectiveAnatomyPort { + host: string; + container: string; + proto: string; + published: boolean; +} + +export interface EffectiveAnatomyVolume { + host: string; + container: string; +} + +export interface EffectiveAnatomy { + services: string[]; + ports: Record; + volumes: Record; + restart: string | null; + networks: string[]; +} + +export interface EffectiveAnatomyResult extends EffectiveAnatomy { + /** True when the merged model rendered; false leaves every fact list empty. */ + renderable: boolean; + /** A redacted, secret-safe reason when the render failed, else null. */ + renderError: string | null; +} + +const EMPTY: EffectiveAnatomy = { services: [], ports: {}, volumes: {}, restart: null, networks: [] }; + +function asString(v: unknown): string | undefined { + if (typeof v === 'string') return v; + if (typeof v === 'number') return String(v); + return undefined; +} + +/** Parse one rendered `ports:` entry (long object form, with a short-string fallback). */ +function parsePort(entry: unknown): EffectiveAnatomyPort | null { + if (entry && typeof entry === 'object') { + const o = entry as Record; + const host = asString(o.published) ?? ''; + const container = asString(o.target) ?? ''; + const proto = asString(o.protocol) ?? 'tcp'; + if (host === '' && container === '') return null; + return { host, container, proto, published: host !== '' }; + } + const s = asString(entry); + if (s === undefined) return null; + const protoMatch = s.match(/\/(tcp|udp)$/i); + const proto = protoMatch ? protoMatch[1].toLowerCase() : 'tcp'; + const body = s.replace(/\/(tcp|udp)$/i, ''); + const parts = body.split(':'); + if (parts.length === 2) return { host: parts[0], container: parts[1], proto, published: true }; + if (parts.length === 3) return { host: parts[1], container: parts[2], proto, published: true }; + return { host: '', container: body, proto, published: false }; +} + +/** Parse one rendered `volumes:` entry (long object form, with a short-string fallback). */ +function parseVolume(entry: unknown): EffectiveAnatomyVolume | null { + if (entry && typeof entry === 'object') { + const o = entry as Record; + const host = asString(o.source); + const container = asString(o.target); + if (host && container) return { host, container }; + return null; + } + const s = asString(entry); + if (s === undefined) return null; + const parts = s.split(':'); + if (parts.length >= 2) return { host: parts[0], container: parts[1] }; + return null; +} + +function networkKeys(value: unknown): string[] { + if (Array.isArray(value)) { + return value.map(asString).filter((s): s is string => s !== undefined); + } + if (value && typeof value === 'object') return Object.keys(value as Record); + return []; +} + +/** + * Map parsed `docker compose config --format json` to the {@link EffectiveAnatomy} + * shape. Tolerant of missing fields; empty / garbage input yields an empty model + * rather than throwing. Mirrors the frontend `parseAnatomy` field handling so the + * dossier reads the same facts whether they came from a single file or the merge. + */ +export function parseEffectiveAnatomy(parsed: unknown): EffectiveAnatomy { + if (!parsed || typeof parsed !== 'object') return { ...EMPTY }; + const root = parsed as Record; + const servicesObj = (root.services && typeof root.services === 'object' && !Array.isArray(root.services)) + ? root.services as Record + : {}; + const serviceNames = Object.keys(servicesObj); + + const ports: Record = {}; + const volumes: Record = {}; + let restart: string | null = null; + const networks = new Set(); + + for (const name of serviceNames) { + const svc = servicesObj[name]; + if (!svc || typeof svc !== 'object') continue; + const o = svc as Record; + + const p = Array.isArray(o.ports) + ? o.ports.map(parsePort).filter((r): r is EffectiveAnatomyPort => r !== null) + : []; + const v = Array.isArray(o.volumes) + ? o.volumes.map(parseVolume).filter((r): r is EffectiveAnatomyVolume => r !== null) + : []; + if (p.length) ports[name] = p; + if (v.length) volumes[name] = v; + if (restart === null && typeof o.restart === 'string') restart = o.restart; + for (const n of networkKeys(o.networks)) networks.add(n); + } + + if (root.networks && typeof root.networks === 'object' && !Array.isArray(root.networks)) { + for (const n of Object.keys(root.networks as Record)) networks.add(n); + } + + return { services: serviceNames, ports, volumes, restart, networks: Array.from(networks) }; +} + +/** + * Render the merged effective model for a stack and extract its anatomy facts. + * Mirrors the Network Inspector's render-error handling: a missing required + * variable, an unparseable model, or an unavailable docker binary becomes a + * redacted `renderError` with empty facts, never raw stderr or an exception, so + * the dossier can fall back to its root-only view. + */ +export async function buildEffectiveAnatomy(nodeId: number, stackName: string): Promise { + let renderError: string; + try { + const result = await ComposeService.getInstance(nodeId).renderConfig(stackName); + if (result.rendered !== null) { + try { + return { renderable: true, renderError: null, ...parseEffectiveAnatomy(JSON.parse(result.rendered)) }; + } catch (parseErr) { + // JSON.parse errors carry no file content, so the message is safe to log. + console.warn('[EffectiveAnatomy] Effective model parse failed for %s:', + sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(parseErr, 'unknown'))); + renderError = 'Sencho could not parse the rendered Compose model.'; + } + } else { + // Raw stderr can echo file content/secrets and is never surfaced; only the + // names of any missing required variables, otherwise a generic nudge. + const missing = parseMissingRequiredVars(result.stderr); + renderError = missing.length + ? `Required variable${missing.length > 1 ? 's' : ''} ${missing.join(', ')} ${missing.length > 1 ? 'have' : 'has'} no value, so the effective model cannot be rendered.` + : 'Sencho could not render the effective Compose model. Check the compose and env files for a YAML syntax error, an unresolved include or merge, or a required variable with no value.'; + } + } catch (err) { + // Spawn failure (docker unavailable), or an unexpected throw before/inside the + // render. Leave a sanitized breadcrumb so a non-spawn bug is not invisible, then + // redact the surfaced message defensively. + console.warn('[EffectiveAnatomy] Render failed for %s:', + sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown'))); + renderError = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.')).slice(0, MAX_RENDER_ERROR).trim() + || 'Sencho could not run docker compose on this node.'; + } + return { renderable: false, renderError, ...EMPTY }; +} diff --git a/backend/src/utils/authoredComposeArgs.ts b/backend/src/utils/authoredComposeArgs.ts index 5256ec0a..3476e147 100644 --- a/backend/src/utils/authoredComposeArgs.ts +++ b/backend/src/utils/authoredComposeArgs.ts @@ -1,4 +1,5 @@ import path from 'path'; +import { promises as fsPromises } from 'fs'; import { DatabaseService } from '../services/DatabaseService'; import { NodeRegistry } from '../services/NodeRegistry'; import { isPathWithinBase, isValidRelativeStackPath } from './validation'; @@ -55,3 +56,47 @@ export function authoredComposeFileArgs(stackName: string, nodeId?: number): str return args; } + +/** + * Build the `--env-file /.env` flag a multi-file Git deploy needs when + * the applied spec sets a context dir, or `[]` otherwise. + * + * When `authoredComposeFileArgs` emits `--project-directory `, Docker + * Compose treats the context dir as the project directory and looks for `.env` + * there, not at the stack root where Sencho writes it. `validateCompose` passes + * the root `.env` explicitly with `--env-file` whenever the stack has env content, + * so without the same flag at deploy/render/scan time a Git source could validate + * with one effective config and deploy or render another. This flag makes every + * compose invocation resolve env from the same root `.env` the validator used. + * + * Scoped to the context-dir case on purpose: with no `--project-directory`, the + * project directory stays the stack dir (the compose command's cwd) and Compose + * auto-discovers the root `.env`, so single-file / no-context stacks need no flag + * and keep their existing behavior. An explicit `--env-file` to a missing file + * errors, so the flag is only added when a root `.env` actually exists. + */ +export async function authoredComposeEnvFileArgs(stackName: string, nodeId?: number): Promise { + const resolvedNodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId(); + const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec; + if (!spec || spec.files.length === 0 || !spec.contextDir) return []; + + // Inline js/path-injection barrier at the fs sink: resolve against a known-safe + // base and assert containment with startsWith right here. CodeQL does not credit + // the wrapped isPathWithinBase helper or a check separated from the sink, matching + // the inline guards in renderConfig and validateCompose. `.env` is a fixed name. + const baseResolved = path.resolve(NodeRegistry.getInstance().getComposeDir(resolvedNodeId)); + const stackDir = path.resolve(baseResolved, stackName); + if (!stackDir.startsWith(baseResolved + path.sep)) return []; + const envPath = path.resolve(stackDir, '.env'); + try { + await fsPromises.access(envPath); + } catch (err) { + // A missing `.env` is the normal "nothing to pass" case. Any other error + // (e.g. EACCES on an existing but unreadable `.env`) is a real fault: surface + // it rather than silently dropping the flag and deploying a different effective + // config than the one validated. + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + return ['--env-file', envPath]; +} diff --git a/e2e/dossier-doc-drift.spec.ts b/e2e/dossier-doc-drift.spec.ts index 3d8b83d7..5e246d60 100644 --- a/e2e/dossier-doc-drift.spec.ts +++ b/e2e/dossier-doc-drift.spec.ts @@ -86,7 +86,16 @@ test.describe('Dossier documentation drift', () => { test('warns for an unpublished access-URL port and clears when it matches', async ({ page }) => { await createStackViaApi(page, STACK, COMPOSE); await openStack(page, STACK); - await page.getByRole('tab', { name: 'Dossier' }).click(); + // Wait for the dossier GET to land before typing: it resolves by setting + // `fields` from the server (empty access_urls) and only then flips the + // doc-drift gate on, so a fill that races ahead of it would be overwritten. + await Promise.all([ + page.waitForResponse( + (r) => r.url().includes(`/api/stacks/${STACK}/dossier`) && r.request().method() === 'GET' && r.ok(), + { timeout: 10_000 }, + ), + page.getByRole('tab', { name: 'Dossier' }).click(), + ]); await expect(page.getByTestId('dossier-panel')).toBeVisible(); const field = page.getByTestId('dossier-field-access_urls'); diff --git a/frontend/src/components/StackAnatomyPanel.test.tsx b/frontend/src/components/StackAnatomyPanel.test.tsx index abb72869..de5c4ea9 100644 --- a/frontend/src/components/StackAnatomyPanel.test.tsx +++ b/frontend/src/components/StackAnatomyPanel.test.tsx @@ -6,6 +6,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); vi.mock('./stack/StackActivityTimeline', () => ({ @@ -338,6 +339,83 @@ describe('StackAnatomyPanel exposed footer', () => { }); }); +describe('StackAnatomyPanel effective dossier (multi-file Git)', () => { + const ROOT_NO_PORTS = 'services:\n web:\n image: nginx:1.25\n'; + + function renderPanel(content = ROOT_NO_PORTS) { + return render( + , + ); + } + + it('reads override-published ports from the effective model, so the dossier shows them and doc-drift does not false-warn', async () => { + vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/update-preview')) return jsonRes(previewBody(false)); + if (url.includes('/scan-status')) return jsonRes({ status: 'ok' }); + // Multi-file source: two configured compose paths. + if (url.includes('/git-source')) return jsonRes({ + repo_url: 'https://github.com/org/repo.git', branch: 'main', + compose_path: 'compose.yaml', compose_paths: ['compose.yaml', 'infra/override.yaml'], + }); + // An override publishes :9000, absent from the root file above. + if (url.includes('/effective-anatomy')) return jsonRes({ + renderable: true, services: ['web'], + ports: { web: [{ host: '9000', container: '9000', proto: 'tcp', published: true }] }, + volumes: {}, restart: null, networks: ['default'], + }); + // The operator documented the override's port. + if (url.includes('/dossier')) return jsonRes({ access_urls: 'http://192.168.1.5:9000' }); + return jsonRes(null, false); + }); + + renderPanel(); + await userEvent.click(await screen.findByRole('tab', { name: 'Dossier' })); + await screen.findByTestId('dossier-panel'); + + // The generated-facts ports row counts the override-published port, proving the + // dossier read the merged effective model rather than the port-less root file. + // (Scoped to the SPAN so it does not also match the access_urls value below.) + await screen.findByText((content, el) => el?.tagName === 'SPAN' && content.startsWith('1 published')); + // And doc-drift stays silent: the documented :9000 is published in the effective + // model, so a root-only parse would false-warn here but the effective view must not. + await waitFor(() => expect(screen.queryByTestId('dossier-doc-drift')).not.toBeInTheDocument()); + expect(vi.mocked(apiFetch).mock.calls.some(([u]) => String(u).includes('/effective-anatomy'))).toBe(true); + }); + + it('does not fetch the effective model for a single-file Git stack', async () => { + vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/update-preview')) return jsonRes(previewBody(false)); + if (url.includes('/scan-status')) return jsonRes({ status: 'ok' }); + if (url.includes('/git-source')) return jsonRes({ + repo_url: 'https://github.com/org/repo.git', branch: 'main', + compose_path: 'compose.yaml', compose_paths: ['compose.yaml'], + }); + if (url.includes('/dossier')) return jsonRes({}); + return jsonRes(null, false); + }); + + renderPanel(); + await userEvent.click(await screen.findByRole('tab', { name: 'Dossier' })); + await screen.findByText(/github\.com\/org\/repo#main/); + // Give any (incorrect) effective fetch a chance to fire before asserting absence. + await waitFor(() => expect(vi.mocked(apiFetch).mock.calls.some(([u]) => String(u).includes('/git-source'))).toBe(true)); + expect(vi.mocked(apiFetch).mock.calls.some(([u]) => String(u).includes('/effective-anatomy'))).toBe(false); + }); +}); + describe('StackAnatomyPanel capability gating (capability off)', () => { it('hides the Networking and Doctor tabs when the capabilities are absent', async () => { render(panel(false)); diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index e32b2d5d..ac48d312 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -5,7 +5,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { ScrollableTabRow } from './ui/ScrollableTabRow'; import { apiFetch } from '@/lib/api'; import { cn } from '@/lib/utils'; -import { type AnatomyMarkdownInput } from '@/lib/anatomyMarkdown'; +import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown'; import { parseAnatomy, parseEnvKeys, formatGitSource, primaryPublishedHostPort, type GitSourceInfo } from '@/lib/anatomy'; import { buildServiceUrl } from '@/lib/serviceUrl'; import { StackActivityTimeline } from './stack/StackActivityTimeline'; @@ -48,6 +48,15 @@ interface UpdatePreview { changelog: string | null; } +/** Secret-safe effective facts from GET /stacks/:name/effective-anatomy. */ +interface EffectiveAnatomyFacts { + services: string[]; + ports: Record; + volumes: Record; + restart: string | null; + networks: string[]; +} + function Row({ label, children }: { label: string; children: React.ReactNode }) { return (
@@ -84,7 +93,12 @@ export default function StackAnatomyPanel({ const doctorEnabled = hasCapability('compose-doctor'); const networkingEnabled = hasCapability('compose-networking'); - const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo } | null>(null); + const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo; multiFile: boolean } | null>(null); + // Merged effective facts (services/ports/volumes/networks/restart) for a + // multi-file Git stack, fetched from the backend's rendered model so the Dossier + // and its doc-drift reflect every override file. Null for single-file / non-git + // stacks and whenever the render is unavailable, where the root-only parse stands. + const [effectiveAnatomy, setEffectiveAnatomy] = useState<({ stack: string } & EffectiveAnatomyFacts) | null>(null); const [updatePreview, setUpdatePreview] = useState(null); // Last preflight severity, used only to dot the Doctor tab. Radix mounts the // active tab content lazily, so the badge cannot come from PreflightPanel; the @@ -129,9 +143,13 @@ export default function StackAnatomyPanel({ if (data && data.linked === false) { setGitSource(null); } else { + // More than one configured compose path means override files merge into + // the deployed model, so the dossier must read the effective render. + const multiFile = Array.isArray(data.compose_paths) && data.compose_paths.length > 1; setGitSource({ stack: stackName, info: { repo_url: data.repo_url, branch: data.branch, compose_path: data.compose_path }, + multiFile, }); } } else { @@ -145,6 +163,41 @@ export default function StackAnatomyPanel({ return () => { cancelled = true; }; }, [stackName]); + // Multi-file Git stacks deploy a merged model, so the dossier reads the backend's + // rendered effective facts instead of the root compose alone. + useEffect(() => { + // Single-file / non-git stacks keep the root-only parse and skip the fetch. + // Any tagged result left from a previous stack is ignored downstream by the + // stack-name guard, so there is no need to clear state synchronously here. + if (!(gitSource?.stack === stackName && gitSource.multiFile)) return; + let cancelled = false; + const run = async () => { + try { + const res = await apiFetch(`/stacks/${stackName}/effective-anatomy`); + if (cancelled) return; + if (res.ok) { + const data = await res.json(); + // Adopt the merged facts only when the model actually rendered; on a render + // error keep the root-only parse so the dossier never shows an empty summary. + setEffectiveAnatomy(data && data.renderable ? { + stack: stackName, + services: Array.isArray(data.services) ? data.services : [], + ports: data.ports ?? {}, + volumes: data.volumes ?? {}, + restart: data.restart ?? null, + networks: Array.isArray(data.networks) ? data.networks : [], + } : null); + } else { + setEffectiveAnatomy(null); + } + } catch { + if (!cancelled) setEffectiveAnatomy(null); + } + }; + void run(); + return () => { cancelled = true; }; + }, [stackName, activeNode?.id, gitSource]); + useEffect(() => { let cancelled = false; const run = async () => { @@ -235,20 +288,29 @@ export default function StackAnatomyPanel({ // Assembled facts for this stack, passed to the Dossier tab for its read-only // summary and Markdown export. Null until compose parses. const anatomyInput = useMemo(() => { - if (!anatomy) return null; + // Prefer the merged effective facts for multi-file Git stacks so the dossier and + // its doc-drift reflect every override file; fall back to the root-only parse. + // Env-derived fields (count, missing vars, env file) always come from the raw + // parse, which reads the unresolved `${VAR}` references the render has substituted. + // `anatomy` already carries the same structural fields (plus env-only extras we + // read separately below), so the raw parse stands in directly when there are no + // effective facts for this stack. + const activeEffective = effectiveAnatomy?.stack === stackName ? effectiveAnatomy : null; + const structural = activeEffective ?? anatomy; + if (!structural) return null; return { stackName, - services: anatomy.services, - ports: anatomy.ports, - volumes: anatomy.volumes, - restart: anatomy.restart, + services: structural.services, + ports: structural.ports, + volumes: structural.volumes, + restart: structural.restart, envFile: firstEnvFile, envVarCount, missingVars, - networkName, + networkName: structural.networks.length > 0 ? structural.networks[0] : `${stackName}_default`, gitSource: activeGitSource ? formatGitSource(activeGitSource) : null, }; - }, [anatomy, stackName, firstEnvFile, envVarCount, missingVars, networkName, activeGitSource]); + }, [effectiveAnatomy, anatomy, stackName, firstEnvFile, envVarCount, missingVars, activeGitSource]); const bump = updatePreview?.summary.semver_bump ?? 'none'; const hasUpdate = Boolean(updatePreview?.summary.has_update);