diff --git a/backend/src/__tests__/authored-compose-args.test.ts b/backend/src/__tests__/authored-compose-args.test.ts index 60f8be83..fb1dd75c 100644 --- a/backend/src/__tests__/authored-compose-args.test.ts +++ b/backend/src/__tests__/authored-compose-args.test.ts @@ -228,3 +228,136 @@ describe('authoredComposeEnvFileArgs', () => { spy.mockRestore(); }); }); + +describe('candidateValidationEnvFileArgs', () => { + let candidateValidationEnvFileArgs: typeof import('../utils/authoredComposeArgs').candidateValidationEnvFileArgs; + + beforeAll(async () => { + ({ candidateValidationEnvFileArgs } = await import('../utils/authoredComposeArgs')); + }); + + 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=live\n', 'utf-8'); + else fs.rmSync(path.join(stackDir, '.env'), { force: true }); + return stackDir; + } + + it('uses candidate .env for context-dir sync-env, not the live stack .env', async () => { + const stackName = 'val-sync-env'; + seedSource(stackName, ['compose.yaml']); + DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { + files: ['compose.yaml'], + contextDir: 'app', + }); + const liveDir = makeStackDir(stackName, true); + const candidateAbs = path.join(tmpDir, 'candidate-sync-env'); + fs.mkdirSync(candidateAbs, { recursive: true }); + fs.writeFileSync(path.join(candidateAbs, '.env'), 'TAG=candidate\n', 'utf-8'); + const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + const args = await candidateValidationEnvFileArgs({ + stackName, + nodeId, + candidateAbs, + contextDir: 'app', + syncEnv: true, + }); + expect(args).toEqual(['--env-file', path.resolve(candidateAbs, '.env')]); + expect(args).not.toContain(path.resolve(liveDir, '.env')); + }); + + it('does not fall back to a live .env when sync-env omits the candidate file', async () => { + const stackName = 'val-sync-env-removed'; + seedSource(stackName, ['compose.yaml']); + DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { + files: ['compose.yaml'], + contextDir: 'app', + }); + const liveDir = makeStackDir(stackName, true); + const candidateAbs = path.join(tmpDir, 'candidate-sync-env-removed'); + fs.mkdirSync(candidateAbs, { recursive: true }); + const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + const args = await candidateValidationEnvFileArgs({ + stackName, + nodeId, + candidateAbs, + contextDir: 'app', + syncEnv: true, + }); + expect(args).toEqual([]); + expect(args).not.toContain(path.resolve(liveDir, '.env')); + const deploy = await authoredComposeEnvFileArgs(stackName, nodeId); + expect(deploy).toEqual(['--env-file', path.resolve(liveDir, '.env')]); + }); + + it('falls back to the live .env when an unmanaged candidate file is absent', async () => { + const stackName = 'val-unmanaged-env-fallback'; + seedSource(stackName, ['compose.yaml']); + DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { + files: ['compose.yaml'], + contextDir: 'app', + }); + const liveDir = makeStackDir(stackName, true); + const candidateAbs = path.join(tmpDir, 'candidate-unmanaged-env'); + fs.mkdirSync(candidateAbs, { recursive: true }); + const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + const args = await candidateValidationEnvFileArgs({ + stackName, + nodeId, + candidateAbs, + contextDir: 'app', + syncEnv: false, + }); + expect(args).toEqual(['--env-file', path.resolve(liveDir, '.env')]); + }); + + it('uses only configured project env files even when candidate .env exists', async () => { + const stackName = 'val-project-env'; + seedSource(stackName, ['compose.yaml']); + const liveDir = makeStackDir(stackName, true); + fs.writeFileSync(path.join(liveDir, 'prod.env'), 'FOO=1\n', 'utf-8'); + DatabaseService.getInstance().setStackProjectEnvFiles( + NodeRegistry.getInstance().getDefaultNodeId(), + stackName, + ['prod.env'], + ); + const candidateAbs = path.join(tmpDir, 'candidate-project-env'); + fs.mkdirSync(candidateAbs, { recursive: true }); + fs.writeFileSync(path.join(candidateAbs, '.env'), 'TAG=candidate\n', 'utf-8'); + const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + const validation = await candidateValidationEnvFileArgs({ + stackName, + nodeId, + candidateAbs, + contextDir: 'app', + syncEnv: true, + }); + const deploy = await authoredComposeEnvFileArgs(stackName, nodeId); + expect(validation).toEqual(deploy); + expect(validation).toEqual(['--env-file', path.resolve(liveDir, 'prod.env')]); + expect(validation.join(' ')).not.toContain(path.join(candidateAbs, '.env')); + }); + + it('throws when a configured project env file is missing', async () => { + const stackName = 'val-missing-env'; + seedSource(stackName, ['compose.yaml']); + makeStackDir(stackName, false); + DatabaseService.getInstance().setStackProjectEnvFiles( + NodeRegistry.getInstance().getDefaultNodeId(), + stackName, + ['missing.env'], + ); + const candidateAbs = path.join(tmpDir, 'candidate-missing-env'); + fs.mkdirSync(candidateAbs, { recursive: true }); + const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + await expect(candidateValidationEnvFileArgs({ + stackName, + nodeId, + candidateAbs, + contextDir: null, + syncEnv: false, + })).rejects.toThrow(/missing/); + }); +}); diff --git a/backend/src/__tests__/drift-ledger.test.ts b/backend/src/__tests__/drift-ledger.test.ts index 1bb4b7f6..9d942823 100644 --- a/backend/src/__tests__/drift-ledger.test.ts +++ b/backend/src/__tests__/drift-ledger.test.ts @@ -429,3 +429,79 @@ describe('drift route (GET read-only, POST recheck persists)', () => { expect(db().getOpenDriftFindings(nodeId, STACK)).toHaveLength(1); }); }); + +describe('DriftLedgerService managed-path conflicts', () => { + const STACK = 'gitpath'; + beforeEach(() => clearLedger(STACK)); + + it('upserts without resolving and keeps the original detected_at', () => { + const ledger = DriftLedgerService.getInstance(); + ledger.upsertManagedPathConflicts(nodeId, STACK, [ + { path: 'compose.yaml', op: 'local-modified', role: 'compose-primary', sensitivity: 'medium' }, + ]); + const first = db().getOpenDriftFindings(nodeId, STACK); + expect(first).toHaveLength(1); + expect(first[0].finding_type).toBe('managed-path-conflict'); + expect(first[0].message).toBe('compose-primary local-modified'); + const detectedAt = first[0].detected_at; + + ledger.upsertManagedPathConflicts(nodeId, STACK, [ + { path: 'compose.yaml', op: 'local-modified', role: 'compose-primary', sensitivity: 'medium' }, + ]); + const second = db().getOpenDriftFindings(nodeId, STACK); + expect(second).toHaveLength(1); + expect(second[0].detected_at).toBe(detectedAt); + expect(second[0].id).toBe(first[0].id); + }); + + it('redacts high-sensitivity paths in the stored message', () => { + DriftLedgerService.getInstance().upsertManagedPathConflicts(nodeId, STACK, [ + { path: '.env', op: 'local-modified', role: 'env', sensitivity: 'high' }, + ]); + const open = db().getOpenDriftFindings(nodeId, STACK); + expect(open[0].message).toBe('secret-bearing managed path (local-modified)'); + expect(open[0].service).not.toContain('.env'); + }); + + it('does not resolve a git finding during spatial reconcile', () => { + const ledger = DriftLedgerService.getInstance(); + ledger.upsertManagedPathConflicts(nodeId, STACK, [ + { path: 'compose.yaml', op: 'local-modified', role: 'compose-primary', sensitivity: 'medium' }, + ]); + const res = ledger.reconcile(nodeId, STACK, reportWith([], { stack: STACK, status: 'in-sync' })); + expect(res.resolved).toBe(0); + expect(db().getOpenDriftFindings(nodeId, STACK)).toHaveLength(1); + expect(db().getOpenDriftFindings(nodeId, STACK)[0].finding_type).toBe('managed-path-conflict'); + }); + + it('resolves git findings only through resolveManagedPathConflicts', () => { + const ledger = DriftLedgerService.getInstance(); + ledger.upsertManagedPathConflicts(nodeId, STACK, [ + { path: 'compose.yaml', op: 'local-modified', role: 'compose-primary', sensitivity: 'medium' }, + ]); + ledger.resolveManagedPathConflicts(nodeId, STACK); + expect(db().getOpenDriftFindings(nodeId, STACK)).toHaveLength(0); + }); + + it('GET drift redacts the opaque service key for managed-path conflicts', async () => { + const 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:1.27\n'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }), + } as unknown as DockerController); + DriftLedgerService.getInstance().upsertManagedPathConflicts(nodeId, STACK, [ + { path: 'compose.yaml', op: 'local-modified', role: 'compose-primary', sensitivity: 'medium' }, + ]); + const stored = db().getOpenDriftFindings(nodeId, STACK)[0]; + expect(stored.service.length).toBeGreaterThan(0); + const res = await request(app).get(`/api/stacks/${STACK}/drift`).set('Authorization', authHeader); + expect(res.status).toBe(200); + const gitRow = res.body.ledger.find((r: { kind: string }) => r.kind === 'managed-path-conflict'); + expect(gitRow).toBeDefined(); + expect(gitRow.service).toBe(''); + expect(JSON.stringify(res.body)).not.toContain(stored.service); + fs.rmSync(stackDir, { recursive: true, force: true }); + }); +}); diff --git a/backend/src/__tests__/filesystem-stack-paths.test.ts b/backend/src/__tests__/filesystem-stack-paths.test.ts index e09e21d1..f96a54e6 100644 --- a/backend/src/__tests__/filesystem-stack-paths.test.ts +++ b/backend/src/__tests__/filesystem-stack-paths.test.ts @@ -13,9 +13,8 @@ import path from 'path'; import os from 'os'; import { isValidRelativeStackPath } from '../utils/validation'; -// On Windows, fs.unlink on a directory returns EPERM rather than EISDIR. -// The deleteStackPath empty-dir and NOT_EMPTY paths rely on EISDIR (Linux/macOS). -// Skip those specific cases on Windows. +// deleteStackPath rmdirs directories directly, so empty-dir and NOT_EMPTY +// cases run on every platform. const isWindows = process.platform === 'win32'; // Mutable state the mocked NodeRegistry reads. Each beforeEach updates it @@ -458,7 +457,7 @@ describe('FileSystemService stack methods', () => { await expect(fs.access(path.join(stackDir, 'todelete.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); }); - it.skipIf(isWindows)('deletes an empty directory (Linux/macOS only: Windows unlink returns EPERM)', async () => { + it('deletes an empty directory', async () => { await fs.mkdir(path.join(stackDir, 'emptydir')); const service = FileSystemService.getInstance(); @@ -467,7 +466,7 @@ describe('FileSystemService stack methods', () => { await expect(fs.access(path.join(stackDir, 'emptydir'))).rejects.toMatchObject({ code: 'ENOENT' }); }); - it.skipIf(isWindows)('throws NOT_EMPTY for non-empty directory without recursive flag (Linux/macOS only)', async () => { + it('throws NOT_EMPTY for a non-empty directory without the recursive flag', async () => { await fs.mkdir(path.join(stackDir, 'nonempty')); await fs.writeFile(path.join(stackDir, 'nonempty', 'child.txt'), ''); diff --git a/backend/src/__tests__/git-change-plan.test.ts b/backend/src/__tests__/git-change-plan.test.ts new file mode 100644 index 00000000..c2130420 --- /dev/null +++ b/backend/src/__tests__/git-change-plan.test.ts @@ -0,0 +1,993 @@ +/** + * Unit tests for GitChangePlanService: path-kind matrix, candidate invocation + * in the plan, live applied_deploy_spec not used as candidate invocation, and + * high-sensitivity paths absent from the public projection. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { spawnSync } from 'child_process'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import type { BuildContextPlan, ComposeInputEntry, GitProjectManifest } from '../types/gitProjectManifest'; +import { GIT_CHANGE_PLAN_SCHEMA_VERSION } from '../types/gitChangePlan'; + +let tmpDir: string; +let GitChangePlanService: typeof import('../services/GitChangePlanService').GitChangePlanService; +let GitProjectManifestService: typeof import('../services/GitProjectManifestService').GitProjectManifestService; +let buildCandidateComposeInvocation: typeof import('../utils/candidateComposeInvocation').buildCandidateComposeInvocation; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ GitChangePlanService } = await import('../services/GitChangePlanService')); + ({ GitProjectManifestService } = await import('../services/GitProjectManifestService')); + ({ buildCandidateComposeInvocation } = await import('../utils/candidateComposeInvocation')); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + // Each test uses a unique stack name; no extra cleanup required. +}); + +function sha(content: string): string { + return crypto.createHash('sha256').update(content, 'utf8').digest('hex'); +} + +function stackDir(stackName: string): string { + const dir = path.join(process.env.COMPOSE_DIR!, stackName); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeStackFile(stackName: string, rel: string, content: string): void { + const abs = path.join(stackDir(stackName), rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); +} + +function managedEntry(partial: Partial & { materializedPath: string; content: string }): ComposeInputEntry { + return { + sourcePath: partial.sourcePath ?? partial.materializedPath, + materializedPath: partial.materializedPath, + role: partial.role ?? 'compose-primary', + dependencyKind: partial.dependencyKind ?? 'explicit', + ownership: partial.ownership ?? 'managed', + provenance: partial.provenance ?? 'fetch', + sensitivity: partial.sensitivity ?? 'medium', + contentSha256: sha(partial.content), + sizeBytes: Buffer.byteLength(partial.content, 'utf8'), + state: partial.state ?? 'present', + deletionAuthority: partial.deletionAuthority ?? 'sencho', + note: partial.note ?? null, + }; +} + +function buildManifest( + stackName: string, + inputs: ComposeInputEntry[], + invocation: string[] = ['-f', 'compose.yaml', '-p', stackName], + contexts: BuildContextPlan[] = [], +): GitProjectManifest { + return GitProjectManifestService.getInstance().buildManifest({ + stackName, + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + commitSha: 'abc123def456', + projectRoot: null, + composeFiles: ['compose.yaml'], + projectName: stackName, + invocation, + inputs, + refusals: [], + buildContexts: contexts, + bounds: { + maxFiles: 10_000, + maxBytes: 512 * 1024 * 1024, + maxContextBytes: 256 * 1024 * 1024, + maxPathDepth: 64, + maxFileBytes: 10 * 1024 * 1024, + }, + priorManifest: null, + state: 'active', + }); +} + +describe('buildCandidateComposeInvocation', () => { + it('returns [] for a single-file selection with no context dir (auto-discovery)', () => { + expect(buildCandidateComposeInvocation({ + stackName: 'web', + composePaths: ['compose.yaml'], + contextDir: null, + stackDir: '/app/compose/web', + syncEnv: false, + envContentPresent: false, + })).toEqual([]); + }); + + it('emits ordered -f / -p / --project-directory from the candidate selection, not a live spec', () => { + const stackDirAbs = path.resolve('/tmp/compose/web'); + const args = buildCandidateComposeInvocation({ + stackName: 'web', + composePaths: ['infra/base.yml', 'infra/prod.yml'], + contextDir: 'infra', + stackDir: stackDirAbs, + syncEnv: false, + envContentPresent: false, + }); + expect(args).toEqual([ + '-f', 'compose.yaml', + '-f', 'infra/prod.yml', + '-p', 'web', + '--project-directory', path.resolve(stackDirAbs, 'infra'), + ]); + }); + + it('adds --env-file for sync-env when a context dir is set', () => { + const stackDirAbs = path.resolve('/tmp/compose/web'); + const args = buildCandidateComposeInvocation({ + stackName: 'web', + composePaths: ['infra/compose.yaml'], + contextDir: 'infra', + stackDir: stackDirAbs, + syncEnv: true, + envContentPresent: true, + }); + expect(args).toEqual([ + '-f', 'compose.yaml', + '-p', 'web', + '--project-directory', path.resolve(stackDirAbs, 'infra'), + '--env-file', path.resolve(stackDirAbs, '.env'), + ]); + }); + + it('adds --env-file for a context-dir stack when root .env is already present', () => { + const stackDirAbs = path.resolve('/tmp/compose/web'); + const args = buildCandidateComposeInvocation({ + stackName: 'web', + composePaths: ['infra/compose.yaml'], + contextDir: 'infra', + stackDir: stackDirAbs, + syncEnv: false, + envContentPresent: false, + rootEnvFilePresent: true, + }); + expect(args).toContain('--env-file'); + expect(args).toContain(path.resolve(stackDirAbs, '.env')); + }); + + it('does not keep --env-file when sync-env omits the candidate .env', () => { + const stackDirAbs = path.resolve('/tmp/compose/web'); + const args = buildCandidateComposeInvocation({ + stackName: 'web', + composePaths: ['infra/compose.yaml'], + contextDir: 'infra', + stackDir: stackDirAbs, + syncEnv: true, + envContentPresent: false, + rootEnvFilePresent: true, + }); + expect(args).not.toContain('--env-file'); + expect(args).not.toContain(path.resolve(stackDirAbs, '.env')); + }); + + it('does not add --env-file for a single-file selection (Compose auto-loads .env)', () => { + const stackDirAbs = path.resolve('/tmp/compose/web'); + expect(buildCandidateComposeInvocation({ + stackName: 'web', + composePaths: ['compose.yaml'], + contextDir: null, + stackDir: stackDirAbs, + syncEnv: true, + envContentPresent: true, + })).toEqual([]); + }); +}); + +describe('GitChangePlanService.build', () => { + it('classifies unmodified matching files as unchanged and does not block', async () => { + const stack = 'plan-unchanged'; + const content = 'services:\n web:\n image: nginx\n'; + writeStackFile(stack, 'compose.yaml', content); + const entry = managedEntry({ materializedPath: 'compose.yaml', content }); + const prior = buildManifest(stack, [entry]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [entry], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + expect(plan.blocked).toBe(false); + expect(plan.operations.find((o) => o.pathKey === 'compose.yaml')?.op).toBe('unchanged'); + expect(plan.counts.unchanged).toBe(1); + }); + + it('classifies a live hash mismatch as local-modified and blocks', async () => { + const stack = 'plan-local-mod'; + writeStackFile(stack, 'compose.yaml', 'services:\n web:\n image: nginx:local\n'); + const priorContent = 'services:\n web:\n image: nginx\n'; + const candidateContent = 'services:\n web:\n image: nginx:git\n'; + const priorEntry = managedEntry({ materializedPath: 'compose.yaml', content: priorContent }); + const candEntry = managedEntry({ materializedPath: 'compose.yaml', content: candidateContent }); + const prior = buildManifest(stack, [priorEntry]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [candEntry], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + expect(plan.blocked).toBe(true); + expect(plan.operations.find((o) => o.pathKey === 'compose.yaml')?.op).toBe('local-modified'); + }); + + it('still blocks when live bytes match the candidate but not the last-applied hash', async () => { + const stack = 'plan-local-match-cand'; + const priorContent = 'services:\n web:\n image: nginx\n'; + const candidateContent = 'services:\n web:\n image: nginx:git\n'; + writeStackFile(stack, 'compose.yaml', candidateContent); + const priorEntry = managedEntry({ materializedPath: 'compose.yaml', content: priorContent }); + const candEntry = managedEntry({ materializedPath: 'compose.yaml', content: candidateContent }); + const prior = buildManifest(stack, [priorEntry]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [candEntry], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + expect(plan.blocked).toBe(true); + const localOp = plan.operations.find((o) => o.pathKey === 'compose.yaml'); + expect(localOp?.op).toBe('local-modified'); + expect(localOp?.liveHash).toBe(localOp?.candidateHash); + expect(localOp?.liveHash).not.toBe(localOp?.priorHash); + }); + + it('classifies a missing live file as local-missing and blocks', async () => { + const stack = 'plan-missing'; + stackDir(stack); + const content = 'services:\n web:\n image: nginx\n'; + const entry = managedEntry({ materializedPath: 'compose.yaml', content }); + const prior = buildManifest(stack, [entry]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [entry], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + expect(plan.blocked).toBe(true); + expect(plan.operations.find((o) => o.pathKey === 'compose.yaml')?.op).toBe('local-missing'); + }); + + it('classifies a new candidate path over an unmanaged live file as unmanaged-collision', async () => { + const stack = 'plan-collision'; + writeStackFile(stack, 'extra.yaml', 'services: {}\n'); + const compose = 'services:\n web:\n image: nginx\n'; + writeStackFile(stack, 'compose.yaml', compose); + const priorEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const extra = managedEntry({ + materializedPath: 'extra.yaml', + content: 'services:\n db:\n image: postgres\n', + role: 'compose-additional', + }); + const prior = buildManifest(stack, [priorEntry]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [priorEntry, extra], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + expect(plan.blocked).toBe(true); + expect(plan.operations.find((o) => o.pathKey === 'extra.yaml')?.op).toBe('unmanaged-collision'); + }); + + it('classifies a removed sencho-authority file as delete when live still matches', async () => { + const stack = 'plan-delete'; + const compose = 'services:\n web:\n image: nginx\n'; + const extra = 'services:\n db:\n image: postgres\n'; + writeStackFile(stack, 'compose.yaml', compose); + writeStackFile(stack, 'extra.yaml', extra); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const extraEntry = managedEntry({ + materializedPath: 'extra.yaml', + content: extra, + role: 'compose-additional', + }); + const prior = buildManifest(stack, [composeEntry, extraEntry]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + expect(plan.blocked).toBe(false); + expect(plan.operations.find((o) => o.pathKey === 'extra.yaml')?.op).toBe('delete'); + }); + + it('pairs a same-hash delete+add as rename (presentation only)', async () => { + const stack = 'plan-rename'; + const compose = 'services:\n web:\n image: nginx\n'; + const shared = 'FOO=bar\n'; + writeStackFile(stack, 'compose.yaml', compose); + writeStackFile(stack, 'old.env', shared); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const oldEnv = managedEntry({ + materializedPath: 'old.env', + content: shared, + role: 'env', + dependencyKind: 'env_file', + }); + const newEnv = managedEntry({ + materializedPath: 'new.env', + content: shared, + role: 'env', + dependencyKind: 'env_file', + }); + const prior = buildManifest(stack, [composeEntry, oldEnv]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry, newEnv], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + const rename = plan.operations.find((o) => o.op === 'rename'); + expect(rename).toBeDefined(); + expect(rename?.fromPath).toBe('old.env'); + expect(rename?.pathKey).toBe('new.env'); + expect(plan.operations.some((o) => o.op === 'delete' && o.pathKey === 'old.env')).toBe(false); + expect(plan.operations.some((o) => o.op === 'add' && o.pathKey === 'new.env')).toBe(false); + }); + + it('classifies a live directory at a file path as type-changed', async () => { + const stack = 'plan-type'; + const compose = 'services:\n web:\n image: nginx\n'; + writeStackFile(stack, 'compose.yaml', compose); + fs.mkdirSync(path.join(stackDir(stack), 'config.yaml')); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const configEntry = managedEntry({ + materializedPath: 'config.yaml', + content: 'x: 1\n', + role: 'config', + dependencyKind: 'config', + }); + const prior = buildManifest(stack, [composeEntry, configEntry]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry, configEntry], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + expect(plan.blocked).toBe(true); + expect(plan.operations.find((o) => o.pathKey === 'config.yaml')?.op).toBe('type-changed'); + }); + + it('treats create mode as add even when live files already exist', async () => { + const stack = 'plan-create'; + const compose = 'services:\n web:\n image: nginx\n'; + writeStackFile(stack, 'compose.yaml', compose); + const entry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'create', + priorManifest: null, + candidateInputs: [entry], + candidateBuildContexts: [], + candidateInvocation: [], + liveInvocation: [], + }); + expect(plan.blocked).toBe(false); + expect(plan.operations.find((o) => o.pathKey === 'compose.yaml')?.op).toBe('add'); + }); + + it('classifies a live hash that drifted since review as local-modified', async () => { + const stack = 'plan-reviewed-drift'; + const reviewed = 'services:\n web:\n image: nginx\n'; + const drifted = 'services:\n web:\n image: nginx:local\n'; + writeStackFile(stack, 'compose.yaml', drifted); + const entry = managedEntry({ materializedPath: 'compose.yaml', content: reviewed }); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: null, + candidateInputs: [entry], + candidateBuildContexts: [], + candidateInvocation: [], + liveInvocation: [], + legacyOwnedPaths: ['compose.yaml'], + reviewedLiveHashes: new Map([['compose.yaml', sha(reviewed)]]), + }); + expect(plan.blocked).toBe(true); + expect(plan.operations.find((o) => o.pathKey === 'compose.yaml')?.op).toBe('local-modified'); + }); + + it('records candidate invocation change as informational when live still matches prior', async () => { + const stack = 'plan-inv-info'; + const compose = 'services:\n web:\n image: nginx\n'; + writeStackFile(stack, 'compose.yaml', compose); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack]); + const prod = managedEntry({ + materializedPath: 'prod.yaml', + content: 'services:\n web:\n restart: always\n', + role: 'compose-additional', + }); + const candidateInv = ['-f', 'compose.yaml', '-f', 'prod.yaml', '-p', stack]; + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry, prod], + candidateBuildContexts: [], + candidateInvocation: candidateInv, + liveInvocation: prior.project.invocation, + }); + expect(plan.blocked).toBe(false); + expect(plan.invocationBlocked).toBe(false); + expect(plan.operations.some((o) => o.op === 'invocation')).toBe(true); + expect(plan.candidateInvocation).toEqual(candidateInv); + }); + + it('records live invocation divergence without a file-conflict block', async () => { + const stack = 'plan-inv-block'; + const compose = 'services:\n web:\n image: nginx\n'; + writeStackFile(stack, 'compose.yaml', compose); + const entry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const priorInv = ['-f', 'compose.yaml', '-p', stack]; + const prior = buildManifest(stack, [entry], priorInv); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [entry], + candidateBuildContexts: [], + candidateInvocation: priorInv, + liveInvocation: ['-f', 'compose.yaml', '-f', 'override.yaml', '-p', stack], + }); + expect(plan.blocked).toBe(false); + expect(plan.invocationBlocked).toBe(true); + expect(plan.operations.some((o) => o.op === 'invocation')).toBe(true); + const pub = GitChangePlanService.getInstance().toPublic(plan); + expect(pub.blocked).toBe(false); + expect(pub.invocation.liveDiverged).toBe(true); + expect(pub.operations.some((o) => o.op === 'invocation')).toBe(true); + expect(pub.operations.find((o) => o.op === 'invocation')?.path).toBeNull(); + }); + + it('redacts high-sensitivity paths from the public projection and omits hashes', async () => { + const stack = 'plan-secret'; + const compose = 'services:\n web:\n image: nginx\n'; + const secret = 'SUPERSECRET=1\n'; + writeStackFile(stack, 'compose.yaml', compose); + writeStackFile(stack, '.env', secret); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const secretEntry = managedEntry({ + materializedPath: '.env', + content: secret, + role: 'env', + dependencyKind: 'sync-env', + sensitivity: 'high', + }); + const prior = buildManifest(stack, [composeEntry, secretEntry]); + const nextSecret = managedEntry({ + materializedPath: '.env', + content: 'SUPERSECRET=2\n', + role: 'env', + dependencyKind: 'sync-env', + sensitivity: 'high', + }); + // Live still matches prior, candidate changes the secret: modify, not blocked. + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry, nextSecret], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + const pub = GitChangePlanService.getInstance().toPublic(plan); + const serialized = JSON.stringify(pub); + expect(serialized).not.toContain('.env'); + expect(serialized).not.toContain('SUPERSECRET'); + expect(serialized).not.toContain(sha(secret)); + expect(pub.operations.find((o) => o.op === 'modify')?.path).toBeNull(); + expect(plan.fingerprint).toHaveLength(64); + expect(plan.schemaVersion).toBe(GIT_CHANGE_PLAN_SCHEMA_VERSION); + }); + + it('includes build-context files in the path universe', async () => { + const stack = 'plan-ctx'; + const compose = 'services:\n web:\n image: nginx\n build: ./app\n'; + writeStackFile(stack, 'compose.yaml', compose); + writeStackFile(stack, 'app/Dockerfile', 'FROM alpine\n'); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const ctx: BuildContextPlan = { + repoPath: 'app', + dockerfile: 'Dockerfile', + contextBytes: 12, + ignoredCount: 0, + dockerignoreApplied: false, + excludedFromCopy: false, + note: null, + files: [{ path: 'Dockerfile', sha256: sha('FROM alpine\n'), sizeBytes: 12 }], + }; + const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack], [ctx]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry], + candidateBuildContexts: [ctx], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + expect(plan.operations.find((o) => o.pathKey === 'app/Dockerfile')?.op).toBe('unchanged'); + }); + + it('blocks locally added files inside a retained build context', async () => { + const stack = 'plan-ctx-local-add'; + const compose = 'services:\n web:\n image: nginx\n build: ./app\n'; + writeStackFile(stack, 'compose.yaml', compose); + writeStackFile(stack, 'app/Dockerfile', 'FROM alpine\n'); + writeStackFile(stack, 'app/extra.txt', 'local-only\n'); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const ctx: BuildContextPlan = { + repoPath: 'app', + dockerfile: 'Dockerfile', + contextBytes: 12, + ignoredCount: 0, + dockerignoreApplied: false, + excludedFromCopy: false, + note: null, + files: [{ path: 'Dockerfile', sha256: sha('FROM alpine\n'), sizeBytes: 12 }], + }; + const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack], [ctx]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry], + candidateBuildContexts: [ctx], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + expect(plan.blocked).toBe(true); + expect(plan.operations.find((o) => o.pathKey === 'app/extra.txt')?.op).toBe('unmanaged-collision'); + }); + + it('classifies a prior-only upstream delete with an already-missing live file as local-missing', async () => { + const stack = 'plan-prior-missing'; + const compose = 'services:\n web:\n image: nginx\n'; + const extra = 'services:\n db:\n image: postgres\n'; + writeStackFile(stack, 'compose.yaml', compose); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const extraEntry = managedEntry({ + materializedPath: 'extra.yaml', + content: extra, + role: 'compose-additional', + }); + const prior = buildManifest(stack, [composeEntry, extraEntry]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + expect(plan.blocked).toBe(true); + expect(plan.operations.find((o) => o.pathKey === 'extra.yaml')?.op).toBe('local-missing'); + }); + + it('binds configured project env files into the fingerprint and blocks reviewed drift', async () => { + const stack = 'plan-project-env'; + const compose = 'services:\n web:\n image: nginx\n'; + writeStackFile(stack, 'compose.yaml', compose); + writeStackFile(stack, 'prod.env', 'FOO=1\n'); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const prior = buildManifest(stack, [composeEntry]); + const { DatabaseService } = await import('../services/DatabaseService'); + const { NodeRegistry } = await import('../services/NodeRegistry'); + const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + DatabaseService.getInstance().setStackProjectEnvFiles(nodeId, stack, ['prod.env']); + + const stable = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + projectEnvFiles: ['prod.env'], + }); + expect(stable.operations.find((o) => o.pathKey === 'prod.env')?.op).toBe('unchanged'); + + writeStackFile(stack, 'prod.env', 'FOO=2\n'); + const drifted = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + projectEnvFiles: ['prod.env'], + reviewedLiveHashes: new Map([['prod.env', sha('FOO=1\n')]]), + }); + expect(drifted.blocked).toBe(true); + expect(drifted.operations.find((o) => o.pathKey === 'prod.env')?.op).toBe('local-modified'); + }); + + it('records ownership, provenance, and source revision on managed operations', async () => { + const stack = 'plan-metadata'; + const compose = 'services:\n web:\n image: nginx\n'; + writeStackFile(stack, 'compose.yaml', compose); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const prior = buildManifest(stack, [composeEntry]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'deadbeef', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + const row = plan.operations.find((o) => o.pathKey === 'compose.yaml'); + expect(row?.ownership).toBe('managed'); + expect(row?.provenance).toBe('fetch'); + expect(row?.sourceRevision).toBe('deadbeef'); + expect(row?.reason).toBeTruthy(); + expect(plan.operations.every((o) => o.ownership && o.provenance && o.sourceRevision && o.reason)).toBe(true); + }); + + it.runIf(process.platform !== 'win32')('classifies fifo nodes as type-changed without reading them', async () => { + const stack = 'plan-fifo'; + const compose = 'services:\n web:\n image: nginx\n'; + writeStackFile(stack, 'compose.yaml', compose); + const fifoPath = path.join(stackDir(stack), 'pipe.fifo'); + const created = spawnSync('mkfifo', [fifoPath], { stdio: 'ignore' }); + expect(created.status).toBe(0); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const fifoEntry = managedEntry({ + materializedPath: 'pipe.fifo', + content: 'ignored', + role: 'config', + dependencyKind: 'config', + }); + const prior = buildManifest(stack, [composeEntry, fifoEntry]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry, fifoEntry], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + expect(plan.blocked).toBe(true); + expect(plan.operations.find((o) => o.pathKey === 'pipe.fifo')?.op).toBe('type-changed'); + }); + + it('blocks a locally added file inside a removed build context', async () => { + const stack = 'plan-removed-ctx-extra'; + const compose = 'services:\n web:\n image: nginx\n'; + writeStackFile(stack, 'compose.yaml', compose); + writeStackFile(stack, 'app/Dockerfile', 'FROM alpine\n'); + writeStackFile(stack, 'app/notes.txt', 'keep-me\n'); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const ctx: BuildContextPlan = { + repoPath: 'app', + dockerfile: 'Dockerfile', + contextBytes: 12, + ignoredCount: 0, + dockerignoreApplied: false, + excludedFromCopy: false, + note: null, + files: [{ path: 'Dockerfile', sha256: sha('FROM alpine\n'), sizeBytes: 12 }], + }; + const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack], [ctx]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + expect(plan.blocked).toBe(true); + expect(plan.operations.find((o) => o.pathKey === 'app/notes.txt')?.op).toBe('unmanaged-collision'); + expect(fs.readFileSync(path.join(stackDir(stack), 'app', 'notes.txt'), 'utf8')).toBe('keep-me\n'); + }); + + it('classifies a clean removed context as delete of owned files only', async () => { + const stack = 'plan-removed-ctx-clean'; + const compose = 'services:\n web:\n image: nginx\n'; + writeStackFile(stack, 'compose.yaml', compose); + writeStackFile(stack, 'app/Dockerfile', 'FROM alpine\n'); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const ctx: BuildContextPlan = { + repoPath: 'app', + dockerfile: 'Dockerfile', + contextBytes: 12, + ignoredCount: 0, + dockerignoreApplied: false, + excludedFromCopy: false, + note: null, + files: [{ path: 'Dockerfile', sha256: sha('FROM alpine\n'), sizeBytes: 12 }], + }; + const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack], [ctx]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry], + candidateBuildContexts: [], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + expect(plan.blocked).toBe(false); + expect(plan.operations.find((o) => o.pathKey === 'app/Dockerfile')?.op).toBe('delete'); + }); + + it('redacts a secret-bearing locally added context file from the public plan', async () => { + const stack = 'plan-ctx-secret-extra'; + const compose = 'services:\n web:\n image: nginx\n'; + writeStackFile(stack, 'compose.yaml', compose); + writeStackFile(stack, 'app/Dockerfile', 'FROM alpine\n'); + writeStackFile(stack, 'app/.env', 'TOKEN=supersecret\n'); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const ctx: BuildContextPlan = { + repoPath: 'app', + dockerfile: 'Dockerfile', + contextBytes: 12, + ignoredCount: 0, + dockerignoreApplied: false, + excludedFromCopy: false, + note: null, + files: [{ path: 'Dockerfile', sha256: sha('FROM alpine\n'), sizeBytes: 12 }], + }; + const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack], [ctx]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry], + candidateBuildContexts: [ctx], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + const extra = plan.operations.find((o) => o.pathKey === 'app/.env'); + expect(extra?.op).toBe('unmanaged-collision'); + expect(extra?.sensitivity).toBe('high'); + const pub = GitChangePlanService.getInstance().toPublic(plan); + expect(JSON.stringify(pub)).not.toContain('.env'); + expect(JSON.stringify(pub)).not.toContain('TOKEN'); + }); + + it('redacts .env.local and .env.production context extras from the public plan', async () => { + const stack = 'plan-ctx-env-dot-names'; + const compose = 'services:\n web:\n image: nginx\n'; + writeStackFile(stack, 'compose.yaml', compose); + writeStackFile(stack, 'app/Dockerfile', 'FROM alpine\n'); + writeStackFile(stack, 'app/.env.local', 'TOKEN=local\n'); + writeStackFile(stack, 'app/.env.production', 'TOKEN=prod\n'); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const ctx: BuildContextPlan = { + repoPath: 'app', + dockerfile: 'Dockerfile', + contextBytes: 12, + ignoredCount: 0, + dockerignoreApplied: false, + excludedFromCopy: false, + note: null, + files: [{ path: 'Dockerfile', sha256: sha('FROM alpine\n'), sizeBytes: 12 }], + }; + const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack], [ctx]); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry], + candidateBuildContexts: [ctx], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + const local = plan.operations.find((o) => o.pathKey === 'app/.env.local'); + const prod = plan.operations.find((o) => o.pathKey === 'app/.env.production'); + expect(local?.op).toBe('unmanaged-collision'); + expect(local?.sensitivity).toBe('high'); + expect(prod?.op).toBe('unmanaged-collision'); + expect(prod?.sensitivity).toBe('high'); + const pub = GitChangePlanService.getInstance().toPublic(plan); + const collisions = pub.operations.filter((o) => o.op === 'unmanaged-collision'); + expect(collisions).toHaveLength(2); + expect(collisions.every((o) => o.path === null)).toBe(true); + expect(JSON.stringify(pub)).not.toContain('.env.local'); + expect(JSON.stringify(pub)).not.toContain('.env.production'); + expect(JSON.stringify(pub)).not.toContain('TOKEN'); + }); + + it('records an invocation change when a synced .env disappears from the candidate', async () => { + const stack = 'plan-sync-env-removed'; + const compose = 'services:\n web:\n image: nginx\n'; + const env = 'TAG=live\n'; + writeStackFile(stack, 'compose.yaml', compose); + writeStackFile(stack, '.env', env); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const envEntry = managedEntry({ + materializedPath: '.env', + content: env, + role: 'env', + dependencyKind: 'sync-env', + sensitivity: 'high', + }); + const stackDirAbs = path.resolve(stackDir(stack)); + const invOpts = { + stackName: stack, + composePaths: ['app/compose.yaml'], + contextDir: 'app', + stackDir: stackDirAbs, + syncEnv: true, + }; + const priorInv = buildCandidateComposeInvocation({ ...invOpts, envContentPresent: true }); + const candidateInv = buildCandidateComposeInvocation({ + ...invOpts, + envContentPresent: false, + rootEnvFilePresent: true, + }); + expect(priorInv).toContain('--env-file'); + expect(candidateInv).not.toContain('--env-file'); + const prior = buildManifest(stack, [composeEntry, envEntry], priorInv); + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry], + candidateBuildContexts: [], + candidateInvocation: candidateInv, + liveInvocation: priorInv, + }); + expect(plan.blocked).toBe(false); + expect(plan.operations.find((o) => o.pathKey === '.env')?.op).toBe('delete'); + expect(plan.operations.find((o) => o.op === 'invocation')).toBeTruthy(); + expect(plan.candidateInvocation).toEqual(candidateInv); + expect(plan.candidateInvocation).not.toContain('--env-file'); + }); + + it('changes the fingerprint when rename source, ownership, sensitivity, or reason changes', () => { + const fingerprintOf = (overrides: Record): string => { + const svc = GitChangePlanService.getInstance() as unknown as { + fingerprint: (input: { + commitSha: string; + priorManifestVersion: number | null; + priorAppliedDir: string | null; + operations: unknown[]; + }) => string; + }; + const base = { + pathKey: 'compose.yaml', + op: 'modify', + role: 'compose-primary', + deletionAuthority: 'sencho', + priorHash: 'aa', + candidateHash: 'bb', + liveHash: 'aa', + sensitivity: 'medium', + ownership: 'managed', + provenance: 'fetch', + sourceRevision: 'deadbeef', + reason: 'candidate content differs from prior', + }; + return svc.fingerprint({ + commitSha: 'deadbeef', + priorManifestVersion: 1, + priorAppliedDir: 'generations/applied', + operations: [{ ...base, ...overrides }], + }); + }; + const base = fingerprintOf({}); + expect(fingerprintOf({ fromPath: 'old.yaml' })).not.toBe(base); + expect(fingerprintOf({ ownership: 'unmanaged' })).not.toBe(base); + expect(fingerprintOf({ sensitivity: 'high' })).not.toBe(base); + expect(fingerprintOf({ reason: 'live hash differs from prior managed hash' })).not.toBe(base); + expect(fingerprintOf({ provenance: 'adopted' })).not.toBe(base); + }); + + it.runIf(process.platform !== 'win32')('blocks a context-root symlink without enumerating the target', async () => { + const stack = 'plan-ctx-root-symlink'; + const compose = 'services:\n web:\n image: nginx\n'; + writeStackFile(stack, 'compose.yaml', compose); + const outside = path.join(process.env.COMPOSE_DIR!, '..', 'outside-ctx-root'); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, 'secret.txt'), 'should-not-be-read\n'); + const appDir = path.join(stackDir(stack), 'app'); + fs.symlinkSync(outside, appDir, 'dir'); + const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose }); + const ctx: BuildContextPlan = { + repoPath: 'app', + dockerfile: 'Dockerfile', + contextBytes: 12, + ignoredCount: 0, + dockerignoreApplied: false, + excludedFromCopy: false, + note: null, + files: [{ path: 'Dockerfile', sha256: sha('FROM alpine\n'), sizeBytes: 12 }], + }; + const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack], [ctx]); + const hashSpy = vi.spyOn(GitProjectManifestService.getInstance(), 'hashStackFile'); + try { + const plan = await GitChangePlanService.getInstance().build({ + stackName: stack, + commitSha: 'cafebabe', + mode: 'update', + priorManifest: prior, + candidateInputs: [composeEntry], + candidateBuildContexts: [ctx], + candidateInvocation: prior.project.invocation, + liveInvocation: prior.project.invocation, + }); + expect(plan.blocked).toBe(true); + expect(plan.operations.find((o) => o.pathKey === 'app')?.op).toBe('type-changed'); + expect(plan.operations.some((o) => o.pathKey.includes('secret.txt'))).toBe(false); + expect(JSON.stringify(plan.operations)).not.toContain('outside-ctx-root'); + const hashedOutside = hashSpy.mock.calls.some((c) => String(c[1]).includes('secret.txt') || String(c[1]).includes('outside')); + expect(hashedOutside).toBe(false); + } finally { + hashSpy.mockRestore(); + fs.rmSync(outside, { recursive: true, force: true }); + } + }); +}); diff --git a/backend/src/__tests__/git-project-manifest.test.ts b/backend/src/__tests__/git-project-manifest.test.ts index 86f62609..87b4fbde 100644 --- a/backend/src/__tests__/git-project-manifest.test.ts +++ b/backend/src/__tests__/git-project-manifest.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import crypto from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; import { DatabaseService } from '../services/DatabaseService'; import { FileSystemService } from '../services/FileSystemService'; -import { GitProjectManifestService, PROMOTION_MARKER, CANDIDATE_COMPLETE_MARKER } from '../services/GitProjectManifestService'; +import { GitProjectManifestService, PROMOTION_MARKER, CANDIDATE_COMPLETE_MARKER, PromoteGenerationError } from '../services/GitProjectManifestService'; +import { NodeRegistry } from '../services/NodeRegistry'; +import { authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs'; import type { ComposeInputEntry, GitProjectManifest, ManifestBounds } from '../types/gitProjectManifest'; const BOUNDS: ManifestBounds = { @@ -434,7 +437,11 @@ describe('promoteGeneration', () => { candidateRelPath: candidateRel, manifest: incoming, priorManifest: prior, - })).rejects.toThrow(/Case-only managed path changes/); + })).rejects.toSatisfy((err: unknown) => + err instanceof PromoteGenerationError + && err.phase === 'pre_mutation' + && /Case-only managed path changes/.test(err.message), + ); expect(readStackFile(stackName, 'Config.yml')).toBe('PRIOR\n'); expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false); }); @@ -654,6 +661,71 @@ describe('promoteGeneration', () => { }); expect(readStackFile(stackName, '.env')).toBe('NEW=1\n'); }); + + it('deletes a previously managed synced .env when the next generation omits it', async () => { + const svc = GitProjectManifestService.getInstance(); + const stackName = 'promote-sync-env-removed'; + writeStackFile(stackName, 'compose.yaml', 'v1\n'); + writeStackFile(stackName, '.env', 'SYNC=1\n'); + seedGitSource(stackName); + DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { + files: ['compose.yaml'], + contextDir: 'app', + }); + const syncEnvEntry: ComposeInputEntry = { + sourcePath: null, + materializedPath: '.env', + role: 'env', + dependencyKind: 'sync-env', + ownership: 'managed', + provenance: 'fetch', + sensitivity: 'high', + contentSha256: crypto.createHash('sha256').update('SYNC=1\n').digest('hex'), + sizeBytes: Buffer.byteLength('SYNC=1\n'), + state: 'present', + deletionAuthority: 'sencho', + note: null, + }; + const prior = buildManifest(stackName, [ + managedEntry({ materializedPath: 'compose.yaml' }), + syncEnvEntry, + ]); + const priorRel = 'generations/applied-prior'; + const priorAbs = path.join(tmpDir, 'git-managed', '1', stackName, priorRel); + fs.mkdirSync(priorAbs, { recursive: true }); + fs.writeFileSync(path.join(priorAbs, 'compose.yaml'), 'v1\n'); + fs.writeFileSync(path.join(priorAbs, '.env'), 'SYNC=1\n'); + prior.generation.appliedDir = priorRel; + await svc.writeManifest(stackName, prior); + + const incoming = buildManifest(stackName, [ + managedEntry({ materializedPath: 'compose.yaml' }), + ], prior); + const clone = makeClone({ 'compose.yaml': 'v2\n' }); + const candidateRel = await svc.buildCandidate( + stackName, + 'sha-env-gone', + clone, + [{ srcRel: 'compose.yaml', destRel: 'compose.yaml' }], + [], + BOUNDS, + ); + + await svc.promoteGeneration(stackName, { + sha: 'sha-env-gone', + candidateRelPath: candidateRel, + manifest: incoming, + priorManifest: prior, + }); + expect(fs.existsSync(path.join(stackDir(stackName), '.env'))).toBe(false); + expect(readStackFile(stackName, 'compose.yaml')).toBe('v2\n'); + + const deployArgs = await authoredComposeEnvFileArgs( + stackName, + NodeRegistry.getInstance().getDefaultNodeId(), + ); + expect(deployArgs).toEqual([]); + }); }); describe('sweepManagedArea (crash recovery)', () => { @@ -1483,4 +1555,233 @@ describe('build-context file-level ownership (audit round 2 C-2)', () => { const divergedAfter = await svc.verifyContextOnDisk(stackName, manifest2.buildContexts[0]); expect(divergedAfter.some((p) => p.includes('keep.txt'))).toBe(true); }); + + it('preserves an unowned file when a non-root context is removed', async () => { + const svc = GitProjectManifestService.getInstance(); + const { ComposeInputDiscoveryService } = await import('../services/ComposeInputDiscoveryService'); + const discovery = ComposeInputDiscoveryService.getInstance(); + const stackName = 'context-removed-unowned'; + writeStackFile(stackName, 'compose.yaml', 'services: {}\n'); + const clone1 = makeClone({ + 'compose.yaml': 'services:\n web:\n build:\n context: web\n', + 'web/keep.txt': 'keep\n', + }); + const inv1 = await discovery.discoverFromClone({ cloneDir: clone1, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); + const managed1 = inv1.inputs.filter((i) => i.ownership === 'managed' && i.materializedPath !== null); + const manifest1 = buildManifest(stackName, managed1, null, inv1.buildContexts); + const fileList1 = managed1.filter((i) => i.dependencyKind !== 'build-context'); + const cand1 = await svc.buildCandidate(stackName, 'rev1', clone1, fileList1.map((i) => ({ srcRel: i.sourcePath!, destRel: i.materializedPath! })), inv1.contextCopyPlans, BOUNDS); + await svc.promoteGeneration(stackName, { sha: 'rev1', candidateRelPath: cand1, manifest: manifest1, priorManifest: null, adoptExistingMaterializedPaths: 'all' }); + fs.writeFileSync(path.join(stackDir(stackName), 'web', 'notes.txt'), 'local\n'); + + const clone2 = makeClone({ + 'compose.yaml': 'services:\n web:\n image: nginx\n', + }); + const inv2 = await discovery.discoverFromClone({ cloneDir: clone2, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); + const managed2 = inv2.inputs.filter((i) => i.ownership === 'managed' && i.materializedPath !== null); + const manifest2 = buildManifest(stackName, managed2, manifest1, inv2.buildContexts); + const fileList2 = managed2.filter((i) => i.dependencyKind !== 'build-context'); + const cand2 = await svc.buildCandidate(stackName, 'rev2', clone2, fileList2.map((i) => ({ srcRel: i.sourcePath!, destRel: i.materializedPath! })), inv2.contextCopyPlans, BOUNDS); + await svc.promoteGeneration(stackName, { sha: 'rev2', candidateRelPath: cand2, manifest: manifest2, priorManifest: manifest1 }); + expect(fs.existsSync(path.join(stackDir(stackName), 'web', 'keep.txt'))).toBe(false); + expect(fs.readFileSync(path.join(stackDir(stackName), 'web', 'notes.txt'), 'utf8')).toBe('local\n'); + }); + + it('removes a clean non-root context directory after owned files are gone', async () => { + const svc = GitProjectManifestService.getInstance(); + const { ComposeInputDiscoveryService } = await import('../services/ComposeInputDiscoveryService'); + const discovery = ComposeInputDiscoveryService.getInstance(); + const stackName = 'context-removed-clean'; + writeStackFile(stackName, 'compose.yaml', 'services: {}\n'); + const clone1 = makeClone({ + 'compose.yaml': 'services:\n web:\n build:\n context: web\n', + 'web/keep.txt': 'keep\n', + }); + const inv1 = await discovery.discoverFromClone({ cloneDir: clone1, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); + const managed1 = inv1.inputs.filter((i) => i.ownership === 'managed' && i.materializedPath !== null); + const manifest1 = buildManifest(stackName, managed1, null, inv1.buildContexts); + const fileList1 = managed1.filter((i) => i.dependencyKind !== 'build-context'); + const cand1 = await svc.buildCandidate(stackName, 'rev1', clone1, fileList1.map((i) => ({ srcRel: i.sourcePath!, destRel: i.materializedPath! })), inv1.contextCopyPlans, BOUNDS); + await svc.promoteGeneration(stackName, { sha: 'rev1', candidateRelPath: cand1, manifest: manifest1, priorManifest: null, adoptExistingMaterializedPaths: 'all' }); + + const clone2 = makeClone({ + 'compose.yaml': 'services:\n web:\n image: nginx\n', + }); + const inv2 = await discovery.discoverFromClone({ cloneDir: clone2, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); + const managed2 = inv2.inputs.filter((i) => i.ownership === 'managed' && i.materializedPath !== null); + const manifest2 = buildManifest(stackName, managed2, manifest1, inv2.buildContexts); + const fileList2 = managed2.filter((i) => i.dependencyKind !== 'build-context'); + const cand2 = await svc.buildCandidate(stackName, 'rev2', clone2, fileList2.map((i) => ({ srcRel: i.sourcePath!, destRel: i.materializedPath! })), inv2.contextCopyPlans, BOUNDS); + await svc.promoteGeneration(stackName, { sha: 'rev2', candidateRelPath: cand2, manifest: manifest2, priorManifest: manifest1 }); + expect(fs.existsSync(path.join(stackDir(stackName), 'web'))).toBe(false); + }); + + it('removes root-context managed files individually and leaves unowned stack files', async () => { + const svc = GitProjectManifestService.getInstance(); + const stackName = 'context-root-removed'; + writeStackFile(stackName, 'compose.yaml', 'services: {}\n'); + writeStackFile(stackName, 'Dockerfile', 'FROM alpine\n'); + writeStackFile(stackName, 'local-notes.txt', 'keep\n'); + const composeEntry = { + sourcePath: 'compose.yaml', + materializedPath: 'compose.yaml', + role: 'compose-primary' as const, + dependencyKind: 'explicit' as const, + ownership: 'managed' as const, + provenance: 'fetch' as const, + sensitivity: 'medium' as const, + contentSha256: crypto.createHash('sha256').update('services: {}\n').digest('hex'), + sizeBytes: 12, + state: 'present' as const, + deletionAuthority: 'sencho' as const, + note: null, + }; + const priorCtx = { + repoPath: '', + dockerfile: 'Dockerfile', + contextBytes: 12, + ignoredCount: 0, + dockerignoreApplied: false, + excludedFromCopy: false, + note: null, + files: [{ path: 'Dockerfile', sha256: crypto.createHash('sha256').update('FROM alpine\n').digest('hex'), sizeBytes: 12 }], + }; + const prior = buildManifest(stackName, [composeEntry], null, [priorCtx]); + const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n' }); + const nextCompose = { + ...composeEntry, + contentSha256: crypto.createHash('sha256').update('services:\n web:\n image: nginx\n').digest('hex'), + sizeBytes: Buffer.byteLength('services:\n web:\n image: nginx\n'), + }; + const next = buildManifest(stackName, [nextCompose], prior, []); + const cand = await svc.buildCandidate(stackName, 'root-rm', clone, [{ srcRel: 'compose.yaml', destRel: 'compose.yaml' }], [], BOUNDS); + await svc.promoteGeneration(stackName, { sha: 'root-rm', candidateRelPath: cand, manifest: next, priorManifest: prior }); + expect(fs.existsSync(path.join(stackDir(stackName), 'Dockerfile'))).toBe(false); + expect(fs.readFileSync(path.join(stackDir(stackName), 'local-notes.txt'), 'utf8')).toBe('keep\n'); + expect(fs.existsSync(path.join(stackDir(stackName), 'compose.yaml'))).toBe(true); + }); + + it('fails closed when live context scanning exceeds the file bound', async () => { + const svc = GitProjectManifestService.getInstance(); + const stackName = 'context-scan-bound'; + writeStackFile(stackName, 'compose.yaml', 'services: {}\n'); + writeStackFile(stackName, 'web/a.txt', 'a\n'); + writeStackFile(stackName, 'web/b.txt', 'b\n'); + writeStackFile(stackName, 'web/c.txt', 'c\n'); + const ctx = { + repoPath: 'web', + dockerfile: null, + contextBytes: 0, + ignoredCount: 0, + dockerignoreApplied: false, + excludedFromCopy: false, + note: null, + files: [{ path: 'a.txt', sha256: 'x', sizeBytes: 1 }], + }; + const diverged = await svc.verifyContextOnDisk(stackName, ctx, undefined, { ...BOUNDS, maxFiles: 1 }); + expect(diverged.some((p) => p.includes('scan limit exceeded'))).toBe(true); + }); + + it('fails closed when live context scanning exceeds the path-depth bound', async () => { + const svc = GitProjectManifestService.getInstance(); + const stackName = 'context-scan-depth'; + writeStackFile(stackName, 'compose.yaml', 'services: {}\n'); + writeStackFile(stackName, 'web/a/b/c.txt', 'deep\n'); + const ctx = { + repoPath: 'web', + dockerfile: null, + contextBytes: 0, + ignoredCount: 0, + dockerignoreApplied: false, + excludedFromCopy: false, + note: null, + files: [{ path: 'a/b/c.txt', sha256: 'x', sizeBytes: 1 }], + }; + const diverged = await svc.verifyContextOnDisk(stackName, ctx, undefined, { ...BOUNDS, maxPathDepth: 1 }); + expect(diverged.some((p) => p.includes('scan limit exceeded'))).toBe(true); + }); + + it('fails closed when a live context file exceeds maxFileBytes before hashing', async () => { + const svc = GitProjectManifestService.getInstance(); + const stackName = 'context-scan-file-bytes'; + writeStackFile(stackName, 'compose.yaml', 'services: {}\n'); + writeStackFile(stackName, 'web/big.txt', 'abcdefghij\n'); + const ctx = { + repoPath: 'web', + dockerfile: null, + contextBytes: 0, + ignoredCount: 0, + dockerignoreApplied: false, + excludedFromCopy: false, + note: null, + files: [{ path: 'big.txt', sha256: 'x', sizeBytes: 1 }], + }; + const hashSpy = vi.spyOn(svc, 'hashStackFile'); + try { + const diverged = await svc.verifyContextOnDisk(stackName, ctx, undefined, { ...BOUNDS, maxFileBytes: 4 }); + expect(diverged.some((p) => p.includes('scan limit exceeded'))).toBe(true); + expect(hashSpy).not.toHaveBeenCalled(); + } finally { + hashSpy.mockRestore(); + } + }); + + it('fails closed when live context scanning exceeds the directory-entry bound', async () => { + const svc = GitProjectManifestService.getInstance(); + const stackName = 'context-scan-empty-dirs'; + writeStackFile(stackName, 'compose.yaml', 'services: {}\n'); + for (let i = 0; i < 8; i++) { + fs.mkdirSync(path.join(stackDir(stackName), 'web', `d${i}`), { recursive: true }); + } + const ctx = { + repoPath: 'web', + dockerfile: null, + contextBytes: 0, + ignoredCount: 0, + dockerignoreApplied: false, + excludedFromCopy: false, + note: null, + files: [{ path: 'keep.txt', sha256: 'x', sizeBytes: 1 }], + }; + const diverged = await svc.verifyContextOnDisk(stackName, ctx, undefined, { ...BOUNDS, maxFiles: 3 }); + expect(diverged.some((p) => p.includes('scan limit exceeded'))).toBe(true); + }); + + it.runIf(process.platform !== 'win32')('does not follow a nested context symlink to inspect owned descendants', async () => { + const svc = GitProjectManifestService.getInstance(); + const stackName = 'context-nested-symlink'; + writeStackFile(stackName, 'compose.yaml', 'services: {}\n'); + const outside = path.join(process.env.COMPOSE_DIR!, '..', 'outside-nested-symlink'); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, 'Dockerfile'), 'FROM alpine\n'); + fs.writeFileSync(path.join(outside, 'secret.txt'), 'should-not-be-read\n'); + const webDir = path.join(stackDir(stackName), 'web'); + fs.mkdirSync(webDir, { recursive: true }); + fs.symlinkSync(outside, path.join(webDir, 'nested'), 'dir'); + const ctx = { + repoPath: 'web', + dockerfile: 'Dockerfile', + contextBytes: 12, + ignoredCount: 0, + dockerignoreApplied: false, + excludedFromCopy: false, + note: null, + files: [{ path: 'nested/Dockerfile', sha256: 'x', sizeBytes: 12 }], + }; + const hashSpy = vi.spyOn(svc, 'hashStackFile'); + const observeSpy = vi.spyOn(FileSystemService.getInstance(), 'observeStackPath'); + try { + const diverged = await svc.verifyContextOnDisk(stackName, ctx); + expect(diverged.some((p) => p.includes('nested') && p.includes('symbolic link'))).toBe(true); + expect(hashSpy.mock.calls.some((c) => String(c[1]).includes('secret') || String(c[1]).includes('outside'))).toBe(false); + expect(observeSpy.mock.calls.some((c) => { + const rel = String(c[1]); + return rel.includes('nested/Dockerfile') || rel.includes('secret.txt'); + })).toBe(false); + } finally { + hashSpy.mockRestore(); + observeSpy.mockRestore(); + fs.rmSync(outside, { recursive: true, force: true }); + } + }); }); diff --git a/backend/src/__tests__/git-source-apply-recovery.test.ts b/backend/src/__tests__/git-source-apply-recovery.test.ts index 452cc714..13fbd1c5 100644 --- a/backend/src/__tests__/git-source-apply-recovery.test.ts +++ b/backend/src/__tests__/git-source-apply-recovery.test.ts @@ -3,6 +3,7 @@ * compensateWithCandidate is not called. */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GIT_CHANGE_PLAN_SCHEMA_VERSION } from '../types/gitChangePlan'; const mockCaptureCandidate = vi.fn(); const mockAbandon = vi.fn(); @@ -76,36 +77,46 @@ vi.mock('../services/NodeRegistry', () => ({ })); const mockPromoteGeneration = vi.fn().mockResolvedValue(undefined); -vi.mock('../services/GitProjectManifestService', () => ({ - GitProjectManifestService: { - getInstance: () => ({ - readManifest: vi.fn().mockResolvedValue(null), - buildManifest: vi.fn().mockReturnValue({ - manifestVersion: 1, - state: 'active', - inputs: [], - refusals: [], - generation: { candidateDir: 'c', appliedDir: 'a', previousDir: null }, +vi.mock('../services/GitProjectManifestService', async () => { + const actual = await vi.importActual( + '../services/GitProjectManifestService', + ); + return { + ...actual, + GitProjectManifestService: { + getInstance: () => ({ + readManifest: vi.fn().mockResolvedValue(null), + buildManifest: vi.fn().mockReturnValue({ + manifestVersion: 1, + state: 'active', + inputs: [], + refusals: [], + generation: { candidateDir: 'c', appliedDir: 'a', previousDir: null }, + }), + promoteGeneration: mockPromoteGeneration, + boundsConfig: vi.fn().mockReturnValue({}), + hashStackFile: vi.fn(), + verifyContextOnDisk: vi.fn().mockResolvedValue([]), + writeManifest: vi.fn(), + buildMigratedManifest: vi.fn(), }), - promoteGeneration: mockPromoteGeneration, - boundsConfig: vi.fn().mockReturnValue({}), - hashStackFile: vi.fn(), - verifyContextOnDisk: vi.fn().mockResolvedValue([]), - writeManifest: vi.fn(), - buildMigratedManifest: vi.fn(), - }), - }, -})); + }, + }; +}); vi.mock('../utils/authoredComposeArgs', () => ({ authoredComposeFileArgs: vi.fn().mockResolvedValue(['-f', 'compose.yaml']), authoredComposeEnvFileArgs: vi.fn().mockResolvedValue([]), + candidateValidationEnvFileArgs: vi.fn().mockResolvedValue([]), })); const mockGetGitSource = vi.fn(); const mockMarkGitSourceApplied = vi.fn(); const mockSetGitSourceAppliedSpec = vi.fn(); const mockSetGitSourceManifestState = vi.fn(); +const mockUpdateGitSourcePendingPlan = vi.fn(); +const mockSetGitSourceLastPlan = vi.fn(); +const mockAddNotificationHistory = vi.fn(); vi.mock('../services/DatabaseService', () => ({ DatabaseService: { @@ -114,6 +125,19 @@ vi.mock('../services/DatabaseService', () => ({ markGitSourceApplied: mockMarkGitSourceApplied, setGitSourceAppliedSpec: mockSetGitSourceAppliedSpec, setGitSourceManifestState: mockSetGitSourceManifestState, + updateGitSourcePendingPlan: mockUpdateGitSourcePendingPlan, + setGitSourceLastPlan: mockSetGitSourceLastPlan, + addNotificationHistory: mockAddNotificationHistory, + getStackProjectEnvFiles: vi.fn().mockReturnValue([]), + }), + }, +})); + +vi.mock('../services/DriftLedgerService', () => ({ + DriftLedgerService: { + getInstance: () => ({ + upsertManagedPathConflicts: vi.fn(), + resolveManagedPathConflicts: vi.fn(), }), }, })); @@ -162,7 +186,7 @@ describe('git-source apply recovery (R1)', () => { branch: 'main', pending_commit_sha: 'abc1234deadbeef', pending_compose_content: JSON.stringify({ - v: 3, + v: 4, files: { 'compose.yaml': 'services:\n web:\n image: nginx\n' }, contextDir: null, candidateRelPath: 'generations/cand', @@ -171,8 +195,12 @@ describe('git-source apply recovery (R1)', () => { refusals: [], buildContexts: [], }, + planFingerprint: 'fp-test', + planSchemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION, + operationId: 'op-aaaaaaaa', }), pending_env_content: null, + pending_plan_blocked: false, sync_env: false, compose_paths: ['compose.yaml'], context_dir: null, @@ -181,52 +209,57 @@ describe('git-source apply recovery (R1)', () => { }); }); - it('keeps applied=true and generation current without compensate when deploy fails', async () => { - const { GitSourceService } = await import('../services/GitSourceService'); - // Avoid withStackLock contention by calling applyLocked through apply - // after stubbing the lock if present. - const svc = GitSourceService.getInstance(); - const withLock = vi.spyOn( - svc as unknown as { withStackLock: (name: string, fn: () => Promise) => Promise }, - 'withStackLock', - ); - withLock.mockImplementation(async (_name, fn) => fn()); + const CLEAN_PLAN = { + schemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION, + fingerprint: 'fp-test', + blocked: false, + invocationBlocked: false, + candidateInvocation: ['-f', 'compose.yaml', '-p', 'app'], + liveInvocation: ['-f', 'compose.yaml', '-p', 'app'], + priorInvocation: ['-f', 'compose.yaml', '-p', 'app'], + operations: [], + counts: { + add: 0, modify: 0, delete: 0, rename: 0, unchanged: 0, + localModified: 0, localMissing: 0, typeChanged: 0, unmanagedCollision: 0, invocation: 0, + }, + }; - // validateCandidate is used on the v3 path; stub it open. - vi.spyOn( - svc as unknown as { - validateCandidate: (...args: unknown[]) => Promise<{ ok: boolean }>; - }, - 'validateCandidate', - ).mockResolvedValue({ ok: true }); - - vi.spyOn( - svc as unknown as { - decodePendingCompose: (raw: string) => unknown; - }, - 'decodePendingCompose', - ).mockReturnValue({ - files: { 'compose.yaml': 'services:\n web:\n image: nginx\n' }, + function stubApplyPath(svc: { + withStackLock: (name: string, fn: () => Promise) => Promise; + validateCandidate: (...args: unknown[]) => Promise<{ ok: boolean }>; + decodePendingCompose: (raw: string) => unknown; + deriveAppliedSpec: (...args: unknown[]) => unknown; + hashContent: (...args: unknown[]) => string; + computeChangePlan: (...args: unknown[]) => Promise; + }) { + vi.spyOn(svc, 'withStackLock').mockImplementation(async (_name, fn) => fn()); + vi.spyOn(svc, 'validateCandidate').mockResolvedValue({ ok: true }); + vi.spyOn(svc, 'decodePendingCompose').mockReturnValue({ + version: 4, + files: [{ path: 'compose.yaml', content: 'services:\n web:\n image: nginx\n' }], contextDir: null, candidateRelPath: 'generations/cand', inventory: { inputs: [], refusals: [], buildContexts: [] }, + planFingerprint: 'fp-test', + planSchemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION, + operationId: 'op-aaaaaaaa', + reviewedLive: [], }); + vi.spyOn(svc, 'computeChangePlan').mockResolvedValue(CLEAN_PLAN); + vi.spyOn(svc, 'deriveAppliedSpec').mockReturnValue({ files: ['compose.yaml'], contextDir: null }); + vi.spyOn(svc, 'hashContent').mockReturnValue('hash'); + } - vi.spyOn( - svc as unknown as { - deriveAppliedSpec: (...args: unknown[]) => unknown; - }, - 'deriveAppliedSpec', - ).mockReturnValue({ files: ['compose.yaml'], contextDir: null }); + it('keeps applied=true and generation current without compensate when deploy fails', async () => { + const { GitSourceService } = await import('../services/GitSourceService'); + const svc = GitSourceService.getInstance(); + stubApplyPath(svc as never); - vi.spyOn( - svc as unknown as { - hashContent: (...args: unknown[]) => string; - }, - 'hashContent', - ).mockReturnValue('hash'); - - const result = await svc.apply('app', 'abc1234deadbeef', { deploy: true, actor: 'tester' }); + const result = await svc.apply('app', 'abc1234deadbeef', { + deploy: true, + actor: 'tester', + requirePlanFingerprint: false, + }); expect(result.applied).toBe(true); expect(result.deployed).toBe(false); @@ -239,6 +272,7 @@ describe('git-source apply recovery (R1)', () => { expect(mockHandoff).toHaveBeenCalled(); expect(mockCompensate).not.toHaveBeenCalled(); expect(mockAbandon).not.toHaveBeenCalled(); + expect(mockSetGitSourceLastPlan).toHaveBeenCalledWith('app', 'fp-test', 'applied'); }); it('refuses to promote when recovery capture fails', async () => { @@ -247,37 +281,62 @@ describe('git-source apply recovery (R1)', () => { const { GitSourceService, GitSourceError } = await import('../services/GitSourceService'); const svc = GitSourceService.getInstance(); - vi.spyOn( - svc as unknown as { withStackLock: (name: string, fn: () => Promise) => Promise }, - 'withStackLock', - ).mockImplementation(async (_name, fn) => fn()); - vi.spyOn( - svc as unknown as { validateCandidate: (...args: unknown[]) => Promise<{ ok: boolean }> }, - 'validateCandidate', - ).mockResolvedValue({ ok: true }); - vi.spyOn( - svc as unknown as { decodePendingCompose: (raw: string) => unknown }, - 'decodePendingCompose', - ).mockReturnValue({ - files: { 'compose.yaml': 'services:\n web:\n image: nginx\n' }, - contextDir: null, - candidateRelPath: 'generations/cand', - inventory: { inputs: [], refusals: [], buildContexts: [] }, - }); - vi.spyOn( - svc as unknown as { deriveAppliedSpec: (...args: unknown[]) => unknown }, - 'deriveAppliedSpec', - ).mockReturnValue({ files: ['compose.yaml'], contextDir: null }); - vi.spyOn( - svc as unknown as { hashContent: (...args: unknown[]) => string }, - 'hashContent', - ).mockReturnValue('hash'); + stubApplyPath(svc as never); - await expect(svc.apply('app', 'abc1234deadbeef', { deploy: true, actor: 'tester' })).rejects.toBeInstanceOf(GitSourceError); + await expect(svc.apply('app', 'abc1234deadbeef', { + deploy: true, + actor: 'tester', + requirePlanFingerprint: false, + })).rejects.toBeInstanceOf(GitSourceError); expect(mockPromoteGeneration).not.toHaveBeenCalled(); expect(mockCaptureCandidate).toHaveBeenCalled(); expect(mockMarkGitSourceApplied).not.toHaveBeenCalled(); expect(mockHandoff).not.toHaveBeenCalled(); expect(mockAbandon).not.toHaveBeenCalled(); }); + + it('records git_apply_failed when promote fails before mutation', async () => { + const { PromoteGenerationError } = await import('../services/GitProjectManifestService'); + mockPromoteGeneration.mockRejectedValueOnce(new PromoteGenerationError('pre_mutation', new Error('refused'))); + const { GitSourceService, GitSourceError } = await import('../services/GitSourceService'); + const svc = GitSourceService.getInstance(); + stubApplyPath(svc as never); + + await expect(svc.apply('app', 'abc1234deadbeef', { requirePlanFingerprint: false })).rejects.toBeInstanceOf(GitSourceError); + expect(mockSetGitSourceLastPlan).toHaveBeenCalledWith('app', 'fp-test', 'failed'); + expect(mockAddNotificationHistory).toHaveBeenCalledWith( + 1, + expect.objectContaining({ category: 'git_apply_failed' }), + ); + }); + + it('records git_apply_rolled_back when promote restore succeeds', async () => { + const { PromoteGenerationError } = await import('../services/GitProjectManifestService'); + mockPromoteGeneration.mockRejectedValueOnce(new PromoteGenerationError('restored', new Error('write failed'))); + const { GitSourceService, GitSourceError } = await import('../services/GitSourceService'); + const svc = GitSourceService.getInstance(); + stubApplyPath(svc as never); + + await expect(svc.apply('app', 'abc1234deadbeef', { requirePlanFingerprint: false })).rejects.toBeInstanceOf(GitSourceError); + expect(mockSetGitSourceLastPlan).toHaveBeenCalledWith('app', 'fp-test', 'rolled_back'); + expect(mockAddNotificationHistory).toHaveBeenCalledWith( + 1, + expect.objectContaining({ category: 'git_apply_rolled_back' }), + ); + }); + + it('records git_apply_failed when restore itself fails', async () => { + const { PromoteGenerationError } = await import('../services/GitProjectManifestService'); + mockPromoteGeneration.mockRejectedValueOnce(new PromoteGenerationError('recovery_required', new Error('restore failed'))); + const { GitSourceService, GitSourceError } = await import('../services/GitSourceService'); + const svc = GitSourceService.getInstance(); + stubApplyPath(svc as never); + + await expect(svc.apply('app', 'abc1234deadbeef', { requirePlanFingerprint: false })).rejects.toBeInstanceOf(GitSourceError); + expect(mockSetGitSourceLastPlan).toHaveBeenCalledWith('app', 'fp-test', 'failed'); + expect(mockAddNotificationHistory).toHaveBeenCalledWith( + 1, + expect.objectContaining({ category: 'git_apply_failed' }), + ); + }); }); diff --git a/backend/src/__tests__/git-source-http.test.ts b/backend/src/__tests__/git-source-http.test.ts index a711a8b4..d587c9eb 100644 --- a/backend/src/__tests__/git-source-http.test.ts +++ b/backend/src/__tests__/git-source-http.test.ts @@ -27,6 +27,17 @@ describe('gitSourceStatus', () => { expect(gitSourceStatus('NETWORK_TIMEOUT')).toBe(504); }); + it('maps PLAN_FINGERPRINT_REQUIRED to 400', () => { + expect(gitSourceStatus('PLAN_FINGERPRINT_REQUIRED')).toBe(400); + }); + + it('maps stale, blocked, legacy, and unavailable plans to 409', () => { + expect(gitSourceStatus('STALE_PLAN')).toBe(409); + expect(gitSourceStatus('PLAN_BLOCKED')).toBe(409); + expect(gitSourceStatus('LEGACY_PENDING')).toBe(409); + expect(gitSourceStatus('PLAN_UNAVAILABLE')).toBe(409); + }); + it('maps unknown codes to 400', () => { expect(gitSourceStatus('GIT_ERROR')).toBe(400); }); @@ -70,4 +81,39 @@ describe('sendGitSourceError', () => { expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ error: 'Git source operation failed' }); }); + + it('attaches plan extras on STALE_PLAN and PLAN_BLOCKED', () => { + const plan = { blocked: true, counts: {}, operations: [], invocation: { candidateChanged: false, liveDiverged: false } }; + const stale = mockRes(); + sendGitSourceError(stale, new GitSourceError('STALE_PLAN', 'stale', { plan: plan as never, planFingerprint: 'fp-new' })); + expect(stale.status).toHaveBeenCalledWith(409); + expect(stale.json).toHaveBeenCalledWith({ + error: 'stale', + code: 'STALE_PLAN', + plan, + planFingerprint: 'fp-new', + }); + + const blocked = mockRes(); + sendGitSourceError(blocked, new GitSourceError('PLAN_BLOCKED', 'blocked', { plan: plan as never, planFingerprint: 'fp-b' })); + expect(blocked.status).toHaveBeenCalledWith(409); + expect(blocked.json).toHaveBeenCalledWith({ + error: 'blocked', + code: 'PLAN_BLOCKED', + plan, + planFingerprint: 'fp-b', + }); + }); + + it('maps LEGACY_PENDING and PLAN_UNAVAILABLE to 409 without extras', () => { + const legacy = mockRes(); + sendGitSourceError(legacy, new GitSourceError('LEGACY_PENDING', 'legacy')); + expect(legacy.status).toHaveBeenCalledWith(409); + expect(legacy.json).toHaveBeenCalledWith({ error: 'legacy', code: 'LEGACY_PENDING' }); + + const missing = mockRes(); + sendGitSourceError(missing, new GitSourceError('PLAN_UNAVAILABLE', 'unavailable')); + expect(missing.status).toHaveBeenCalledWith(409); + expect(missing.json).toHaveBeenCalledWith({ error: 'unavailable', code: 'PLAN_UNAVAILABLE' }); + }); }); diff --git a/backend/src/__tests__/git-source-routes.test.ts b/backend/src/__tests__/git-source-routes.test.ts index 90088b2b..ccbf4363 100644 --- a/backend/src/__tests__/git-source-routes.test.ts +++ b/backend/src/__tests__/git-source-routes.test.ts @@ -19,7 +19,7 @@ import path from 'path'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; import { DatabaseService } from '../services/DatabaseService'; import { ComposeService } from '../services/ComposeService'; -import { GitSourceService } from '../services/GitSourceService'; +import { GitSourceService, GitSourceError } from '../services/GitSourceService'; // ── Hoisted mocks (must come before importing the app) ───────────────── @@ -1033,6 +1033,16 @@ describe('stack_git_sources manifest cache columns', () => { expect(row.manifest_generation).toBe('generations/applied-x'); }); + it('migrateGitSourceChangePlan is idempotent', () => { + const db = DatabaseService.getInstance() as unknown as { migrateGitSourceChangePlan: () => void }; + expect(() => { + db.migrateGitSourceChangePlan(); + db.migrateGitSourceChangePlan(); + }).not.toThrow(); + const row = DatabaseService.getInstance().getGitSource('existing-stack'); + expect(row === undefined || row.pending_plan_fingerprint === null || typeof row.pending_plan_fingerprint === 'string').toBe(true); + }); + it('GET keeps flat manifest_state aligned with the healed summary', async () => { const composeDir = process.env.COMPOSE_DIR!; fs.mkdirSync(path.join(composeDir, 'stale-manifest-get'), { recursive: true }); @@ -1120,3 +1130,86 @@ describe('git-source routes: statuses-cache invalidation', () => { expect(mockInvalidateNodeCaches).not.toHaveBeenCalled(); }); }); + +describe('POST /api/stacks/:stackName/git-source/apply fingerprint', () => { + it('returns 400 PLAN_FINGERPRINT_REQUIRED when the body omits planFingerprint', async () => { + seedGitSource('existing-stack'); + const res = await request(app) + .post('/api/stacks/existing-stack/git-source/apply') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ commitSha: 'abc123', deploy: false }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('PLAN_FINGERPRINT_REQUIRED'); + }); + + it('returns 409 STALE_PLAN with the replacement plan attached', async () => { + seedGitSource('existing-stack'); + const plan = { + blocked: false, + counts: { + add: 0, modify: 1, delete: 0, rename: 0, unchanged: 0, + localModified: 0, localMissing: 0, typeChanged: 0, unmanagedCollision: 0, invocation: 0, + }, + operations: [{ path: 'compose.yaml', op: 'modify' as const, role: 'compose-primary' as const }], + invocation: { candidateChanged: false, liveDiverged: false }, + }; + const applySpy = vi.spyOn(GitSourceService.getInstance(), 'apply') + .mockRejectedValue(new GitSourceError('STALE_PLAN', 'stale', { plan, planFingerprint: 'fp-new' })); + try { + const res = await request(app) + .post('/api/stacks/existing-stack/git-source/apply') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ commitSha: 'abc123', planFingerprint: 'fp-old', deploy: false }); + expect(res.status).toBe(409); + expect(res.body.code).toBe('STALE_PLAN'); + expect(res.body.planFingerprint).toBe('fp-new'); + expect(res.body.plan).toEqual(plan); + expect(JSON.stringify(res.body)).not.toContain('SUPER-SECRET'); + } finally { + applySpy.mockRestore(); + } + }); +}); + +describe('POST /api/stacks/:stackName/git-source/pull permissions and actor', () => { + it('denies pull without stack:edit', async () => { + seedGitSource('existing-stack'); + const res = await request(app) + .post('/api/stacks/existing-stack/git-source/pull') + .set('Authorization', `Bearer ${jwt.sign({ username: 'viewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`); + expect([401, 403]).toContain(res.status); + }); + + it('passes the authenticated username as the pull actor', async () => { + seedGitSource('existing-stack'); + const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'pull').mockResolvedValue({ + commitSha: 'abc', + validation: { ok: true }, + refusals: [], + manifestSummary: null, + candidateReady: true, + warnings: [], + plan: { + blocked: false, + counts: { + add: 0, modify: 0, delete: 0, rename: 0, unchanged: 1, + localModified: 0, localMissing: 0, typeChanged: 0, unmanagedCollision: 0, invocation: 0, + }, + operations: [], + invocation: { candidateChanged: false, liveDiverged: false }, + }, + planFingerprint: 'fp', + }); + try { + const res = await request(app) + .post('/api/stacks/existing-stack/git-source/pull') + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(200); + expect(pullSpy).toHaveBeenCalledWith('existing-stack', { actor: TEST_USERNAME }); + expect(JSON.stringify(res.body)).not.toContain('incomingCompose'); + expect(JSON.stringify(res.body)).not.toContain('hasLocalChanges'); + } finally { + pullSpy.mockRestore(); + } + }); +}); diff --git a/backend/src/__tests__/git-source-service.test.ts b/backend/src/__tests__/git-source-service.test.ts index d2a8745b..d3cf162c 100644 --- a/backend/src/__tests__/git-source-service.test.ts +++ b/backend/src/__tests__/git-source-service.test.ts @@ -176,6 +176,8 @@ async function cleanupStackDir(name: string) { // ── Tests ────────────────────────────────────────────────────────────── +const SKIP_PLAN_FINGERPRINT = { requirePlanFingerprint: false as const }; + describe('GitSourceService.hashContent', () => { it('produces stable hashes for identical inputs', () => { const svc = GitSourceService.getInstance(); @@ -566,7 +568,7 @@ describe('GitSourceService.upsert (encryption + reachability)', () => { expect(row?.compose_paths).toEqual(['compose.yaml', 'override.yaml']); }); - it('apply refuses a stale-identity manifest with a detach-first instruction', async () => { + it('refuses a stale-identity manifest with a detach-first instruction', async () => { const sha = 'abc1234567890abc1234567890abc1234567890a'; await seedSource('id-change-apply'); // Manifest stamped for a different repository than the source row. @@ -574,14 +576,8 @@ describe('GitSourceService.upsert (encryption + reachability)', () => { const svc = GitSourceService.getInstance(); mockSuccessfulClone({ sha }); - await svc.pull('id-change-apply'); - const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); - try { - await expect(svc.apply('id-change-apply', sha)) - .rejects.toMatchObject({ code: 'GIT_ERROR', message: expect.stringMatching(/Detach the Git source/) }); - } finally { - validateSpy.mockRestore(); - } + await expect(svc.pull('id-change-apply')) + .rejects.toMatchObject({ code: 'GIT_ERROR', message: expect.stringMatching(/Detach the Git source/) }); }); }); }); @@ -852,6 +848,43 @@ describe('GitSourceService pending lifecycle', () => { svc.dismissPending('pending-stack'); expect(db.getGitSource('pending-stack')?.pending_commit_sha).toBeNull(); }); + + it('clearGitSourceAppliedRevision clears pending plan columns', async () => { + mockSuccessfulClone(); + const svc = GitSourceService.getInstance(); + await svc.upsert({ + stackName: 'clear-pending-plan', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const db = DatabaseService.getInstance(); + db.setGitSourcePending('clear-pending-plan', 'sha-pend', 'blob', null, { + fingerprint: 'fp-clear', + blocked: true, + summary: '{"fingerprint":"fp-clear"}', + }); + const before = db.getGitSource('clear-pending-plan'); + expect(before?.pending_plan_fingerprint).toBe('fp-clear'); + expect(before?.pending_plan_blocked).toBe(true); + expect(before?.pending_plan_summary).toBeTruthy(); + db.clearGitSourceAppliedRevision('clear-pending-plan'); + const after = db.getGitSource('clear-pending-plan'); + expect(after?.last_applied_commit_sha).toBeNull(); + expect(after?.pending_commit_sha).toBeNull(); + expect(after?.pending_compose_content).toBeNull(); + expect(after?.pending_env_content).toBeNull(); + expect(after?.pending_fetched_at).toBeNull(); + expect(after?.pending_plan_fingerprint).toBeNull(); + expect(after?.pending_plan_blocked).toBeNull(); + expect(after?.pending_plan_summary).toBeNull(); + }); }); describe('GitSourceService.handleWebhookPull debounce', () => { @@ -1204,6 +1237,8 @@ describe('GitSourceService.createStackFromGit', () => { expect(result.envWritten).toBe(false); expect(result.source.last_applied_commit_sha).toBe(sha); expect(result.source.pending_commit_sha).toBeNull(); + expect(result.source.last_plan_outcome).toBe('applied'); + expect(result.source.last_plan_fingerprint).toBeTruthy(); // The manifest cache is persisted after the row insert (audit S-2): // the immediate response and the DB row report the real state, not @@ -1227,6 +1262,49 @@ describe('GitSourceService.createStackFromGit', () => { } }); + it('builds the change plan before creating the active stack directory', async () => { + const sha = 'planbefore11112222333344445555666677778888'; + mockSuccessfulClone({ + compose: 'services:\n web:\n image: nginx\n', + sha, + }); + const svc = GitSourceService.getInstance(); + const { GitChangePlanService } = await import('../services/GitChangePlanService'); + const { FileSystemService } = await import('../services/FileSystemService'); + let stackExistedDuringPlan = true; + const origBuild = GitChangePlanService.prototype.build; + const buildSpy = vi.spyOn(GitChangePlanService.prototype, 'build').mockImplementation(async function (this: InstanceType, input) { + stackExistedDuringPlan = fs.existsSync(path.join(process.env.COMPOSE_DIR!, input.stackName)); + return origBuild.call(this, input); + }); + const origCreate = FileSystemService.prototype.createStack; + const createSpy = vi.spyOn(FileSystemService.prototype, 'createStack').mockImplementation(async function (this: InstanceType, name: string) { + expect(buildSpy).toHaveBeenCalled(); + return origCreate.call(this, name); + }); + try { + await svc.createStackFromGit({ + stackName: 'create-plan-first', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + token: null, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + expect(stackExistedDuringPlan).toBe(false); + expect(buildSpy.mock.invocationCallOrder[0]).toBeLessThan(createSpy.mock.invocationCallOrder[0]); + await cleanupStackDir('create-plan-first'); + } finally { + buildSpy.mockRestore(); + createSpy.mockRestore(); + } + }); + it('multi-file create then pull reports no local changes (hash is path-independent)', async () => { const sha = 'aaaa1111bbbb2222cccc3333dddd4444eeee5555'; mockSuccessfulClone({ @@ -1258,7 +1336,11 @@ describe('GitSourceService.createStackFromGit', () => { // stored hash was computed from repo paths while the disk read uses the // materialized paths (primary -> compose.yaml). This was the regression. const pull = await svc.pull('mf-clean-pull'); - expect(pull.hasLocalChanges).toBe(false); + expect(pull.plan).toBeTruthy(); + expect(pull.plan?.blocked).toBe(false); + expect(pull.plan?.counts.localModified).toBe(0); + expect(pull).not.toHaveProperty('hasLocalChanges'); + expect(pull).not.toHaveProperty('incomingCompose'); await cleanupStackDir('mf-clean-pull'); }); @@ -1413,6 +1495,8 @@ describe('GitSourceService.createStackFromGit', () => { }); describe('GitSourceService.apply', () => { + const skipFingerprint = SKIP_PLAN_FINGERPRINT; + async function seedPending(stackName: string, composeContent: string, commitSha: string) { mockSuccessfulClone({ compose: composeContent, sha: commitSha }); const svc = GitSourceService.getInstance(); @@ -1458,7 +1542,7 @@ describe('GitSourceService.apply', () => { const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!; try { - const result = await svc.apply('apply-deploy-gate', sha, { deploy: true }); + const result = await svc.apply('apply-deploy-gate', sha, { deploy: true, ...skipFingerprint }); expect(result.deployed).toBe(true); expect(deploySpy).toHaveBeenCalledWith('apply-deploy-gate', undefined, undefined, { source: 'git_apply', @@ -1492,7 +1576,7 @@ describe('GitSourceService.apply', () => { try { // Assert the return SHAPE: apply must not throw, deployError must // carry the failure detail so the UI can surface "applied but not deployed". - const result = await svc.apply('apply-deploy-fail', sha, { deploy: true }); + const result = await svc.apply('apply-deploy-fail', sha, { deploy: true, ...skipFingerprint }); expect(result.applied).toBe(true); expect(result.deployed).toBe(false); expect(result.deployError).toBeTruthy(); @@ -1542,9 +1626,8 @@ describe('GitSourceService.apply', () => { await svc.pull(stackName); const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); try { - await expect(svc.apply(stackName, sha)).rejects.toMatchObject({ - code: 'GIT_ERROR', - message: expect.stringMatching(/does not manage/), + await expect(svc.apply(stackName, sha, skipFingerprint)).rejects.toMatchObject({ + code: 'PLAN_BLOCKED', }); // The local file is preserved byte-for-byte. const onDisk = await fsSvc.readStackFile(stackName, 'configs/app.json'); @@ -1607,7 +1690,7 @@ describe('GitSourceService.apply', () => { }); try { - const result = await svc.apply('apply-policy-block', sha, { deploy: true }); + const result = await svc.apply('apply-policy-block', sha, { deploy: true, ...skipFingerprint }); expect(result.applied).toBe(true); expect(result.deployed).toBe(false); @@ -1838,7 +1921,7 @@ describe('GitSourceService multi-file create + apply flow', () => { const row = DatabaseService.getInstance().getGitSource('multi-pull'); expect(row?.pending_commit_sha).toBe(sha); - const applied = await svc.apply('multi-pull', pull.commitSha); + const applied = await svc.apply('multi-pull', pull.commitSha, SKIP_PLAN_FINGERPRINT); expect(applied.applied).toBe(true); const after = DatabaseService.getInstance().getGitSource('multi-pull'); @@ -1908,7 +1991,13 @@ describe('GitSourceService pending blob decode branches', () => { it('round-trips the v3 blob with candidate path and inventory', () => { const s = svc() as unknown as DecodeApi; - const encoded = s.encodePendingCompose([{ path: 'compose.yaml', content: 'x' }], null, 'generations/candidate-abc', { inputs: [], refusals: [], buildContexts: [] }); + const encoded = s.crypto.encrypt(JSON.stringify({ + v: 3, + files: [{ path: 'compose.yaml', content: 'x' }], + contextDir: null, + candidateRelPath: 'generations/candidate-abc', + inventory: { inputs: [], refusals: [], buildContexts: [] }, + })); const decoded = s.decodePendingCompose(encoded); expect(decoded.candidateRelPath).toBe('generations/candidate-abc'); expect(decoded.files[0].content).toBe('x'); @@ -1933,7 +2022,7 @@ describe('GitSourceService pending blob decode branches', () => { it('rejects a corrupt v3 blob as corrupt state instead of falling back to legacy', () => { const s = svc() as unknown as DecodeApi; const encoded = s.crypto.encrypt('{"v":3 not json'); - expect(() => s.decodePendingCompose(encoded)).toThrow(/corrupt/); + expect(() => s.decodePendingCompose(encoded)).toThrow(/cannot be reviewed/); }); }); @@ -2229,7 +2318,7 @@ describe('GitSourceService managed-area lifecycle', () => { }); describe('GitSourceService legacy pending apply (migration path)', () => { - it('applies a v2 pending blob via the historical path and builds a migrated manifest', async () => { + it('refuses a v2 pending blob and returns LEGACY_PENDING', async () => { const sha = '9999aaa9999aaa9999aaa9999aaa9999aaa9999a'; const svc = GitSourceService.getInstance(); const db = DatabaseService.getInstance(); @@ -2263,11 +2352,12 @@ describe('GitSourceService legacy pending apply (migration path)', () => { const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); try { - const applied = await svc.apply('legacy-apply', sha, { deploy: false }); - expect(applied.applied).toBe(true); - expect(await fsSvc.getStackContent('legacy-apply')).toContain('image: nginx'); - const row = db.getGitSource('legacy-apply'); - expect(row?.manifest_state).toBe('migrated'); + await expect(svc.apply('legacy-apply', sha, { deploy: false })).rejects.toMatchObject({ + code: 'LEGACY_PENDING', + }); + const disk = await fsSvc.getStackContent('legacy-apply').catch(() => ''); + expect(disk).toContain('nginx:latest'); + expect(disk).not.toContain('services:\n web:'); } finally { validateSpy.mockRestore(); await cleanupStackDir('legacy-apply'); @@ -2352,7 +2442,7 @@ describe('GitSourceService sync-env stacks with a repo .env (audit C-2)', () => autoDeployOnApply: false, }); const pull1 = await svc.pull('sync-env-double'); - const apply1 = await svc.apply('sync-env-double', pull1.commitSha, { deploy: false }); + const apply1 = await svc.apply('sync-env-double', pull1.commitSha, { deploy: false, ...SKIP_PLAN_FINGERPRINT }); expect(apply1.applied).toBe(true); // The manifest has exactly one .env entry. const manifest = await svc.getManifest('sync-env-double'); @@ -2362,7 +2452,7 @@ describe('GitSourceService sync-env stacks with a repo .env (audit C-2)', () => // Second cycle must not raise the divergence refusal. const pull2 = await svc.pull('sync-env-double'); - const apply2 = await svc.apply('sync-env-double', pull2.commitSha, { deploy: false }); + const apply2 = await svc.apply('sync-env-double', pull2.commitSha, { deploy: false, ...SKIP_PLAN_FINGERPRINT }); expect(apply2.applied).toBe(true); void db; } finally { @@ -2371,3 +2461,188 @@ describe('GitSourceService sync-env stacks with a repo .env (audit C-2)', () => } }); }); + +describe('GitSourceService classified plan fingerprint', () => { + it('refuses public apply without a fingerprint and binds the pulled fingerprint', async () => { + const sha = 'ffff0000ffff0000ffff0000ffff0000ffff0000'; + mockSuccessfulClone({ compose: 'services:\n web:\n image: nginx\n', sha }); + const svc = GitSourceService.getInstance(); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + await FileSystemService.getInstance().createStack('fp-bind'); + await svc.upsert({ + stackName: 'fp-bind', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + try { + const pull = await svc.pull('fp-bind', { actor: 'alice' }); + expect(pull.planFingerprint).toMatch(/^[a-f0-9]{64}$/); + expect(pull.plan?.blocked).toBe(false); + + const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!; + const acts = DatabaseService.getInstance().getStackActivity(nodeId, 'fp-bind', { limit: 20 }); + expect(acts.some((a: { category?: string; actor_username?: string | null }) => + a.category === 'git_pull_ready' && a.actor_username === 'alice', + )).toBe(true); + + await expect(svc.apply('fp-bind', sha)).rejects.toMatchObject({ code: 'PLAN_FINGERPRINT_REQUIRED' }); + await expect(svc.apply('fp-bind', sha, { planFingerprint: 'deadbeef' })).rejects.toMatchObject({ + code: 'STALE_PLAN', + }); + + const applied = await svc.apply('fp-bind', sha, { planFingerprint: pull.planFingerprint! }); + expect(applied.applied).toBe(true); + } finally { + validateSpy.mockRestore(); + await cleanupStackDir('fp-bind'); + } + }); + + it('lets a reviewed apply record invocation drift and refuses unattended apply', async () => { + const sha = 'aa11bb22cc33dd44ee55ff6677889900aabbccdd'; + mockSuccessfulClone({ compose: 'services:\n web:\n image: nginx\n', sha }); + const svc = GitSourceService.getInstance(); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const fsSvc = FileSystemService.getInstance(); + try { + await svc.createStackFromGit({ + stackName: 'inv-drift', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: 'app', + syncEnv: false, + envPath: null, + authType: 'none', + token: null, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + await fsSvc.writeStackFile('inv-drift', '.env', 'FOO=1\n'); + + const pull = await svc.pull('inv-drift'); + expect(pull.plan?.blocked).toBe(false); + expect(pull.plan?.invocation.liveDiverged).toBe(true); + + await expect(svc.apply('inv-drift', sha, SKIP_PLAN_FINGERPRINT)).rejects.toMatchObject({ + code: 'PLAN_BLOCKED', + message: expect.stringMatching(/invocation/i), + }); + expect((await fsSvc.readStackFile('inv-drift', '.env')).content).toBe('FOO=1\n'); + expect(DatabaseService.getInstance().getGitSource('inv-drift')?.pending_commit_sha).toBe(sha); + + const applied = await svc.apply('inv-drift', sha, { planFingerprint: pull.planFingerprint! }); + expect(applied.applied).toBe(true); + expect((await fsSvc.readStackFile('inv-drift', '.env')).content).toBe('FOO=1\n'); + } finally { + validateSpy.mockRestore(); + await cleanupStackDir('inv-drift'); + } + }); + + it('keeps operationId across a live-file recompute and flips GET pending to blocked', async () => { + const sha = 'eeee1111eeee1111eeee1111eeee1111eeee1111'; + mockSuccessfulClone({ compose: 'services:\n web:\n image: nginx\n', sha }); + const svc = GitSourceService.getInstance(); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const fsSvc = FileSystemService.getInstance(); + await fsSvc.createStack('fp-stale-live'); + await svc.upsert({ + stackName: 'fp-stale-live', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'none', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + try { + const pull = await svc.pull('fp-stale-live'); + const row = DatabaseService.getInstance().getGitSource('fp-stale-live'); + const decoded = (svc as unknown as { + decodePendingCompose: (raw: string) => { operationId: string | null }; + }).decodePendingCompose(row!.pending_compose_content!); + expect(decoded.operationId).toBeTruthy(); + + await fsSvc.saveStackContent('fp-stale-live', 'services:\n web:\n image: nginx:local\n'); + + await expect(svc.apply('fp-stale-live', sha, { planFingerprint: pull.planFingerprint! })) + .rejects.toMatchObject({ code: 'STALE_PLAN' }); + + const after = DatabaseService.getInstance().getGitSource('fp-stale-live'); + const decodedAfter = (svc as unknown as { + decodePendingCompose: (raw: string) => { operationId: string | null }; + }).decodePendingCompose(after!.pending_compose_content!); + expect(decodedAfter.operationId).toBe(decoded.operationId); + + const publicSrc = svc.get('fp-stale-live'); + expect(publicSrc?.pending_plan?.blocked).toBe(true); + expect(publicSrc?.pending_plan?.fingerprint).not.toBe(pull.planFingerprint); + } finally { + validateSpy.mockRestore(); + await cleanupStackDir('fp-stale-live'); + } + }); + + it('refuses an incomplete v4 pending blob as PLAN_UNAVAILABLE', async () => { + const sha = 'bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222'; + const svc = GitSourceService.getInstance(); + const db = DatabaseService.getInstance(); + const { FileSystemService } = await import('../services/FileSystemService'); + await FileSystemService.getInstance().createStack('plan-unavail'); + db.upsertGitSource({ + stack_name: 'plan-unavail', + repo_url: 'https://github.com/example/repo.git', + branch: 'main', + compose_path: 'compose.yaml', + compose_paths: ['compose.yaml'], + context_dir: null, + sync_env: false, + env_path: null, + auth_type: 'none', + encrypted_token: null, + auto_apply_on_webhook: false, + auto_deploy_on_apply: false, + last_applied_commit_sha: null, + last_applied_content_hash: null, + pending_commit_sha: sha, + pending_compose_content: null, + pending_env_content: null, + pending_fetched_at: null, + last_debounce_at: null, + }); + const svcPriv = svc as unknown as { crypto: { encrypt(s: string): string } }; + db.setGitSourcePending( + 'plan-unavail', + sha, + svcPriv.crypto.encrypt(JSON.stringify({ + v: 4, + files: [{ path: 'compose.yaml', content: 'services:\n web:\n image: nginx\n' }], + contextDir: null, + candidateRelPath: 'generations/cand', + inventory: { inputs: [], refusals: [], buildContexts: [] }, + })), + null, + ); + try { + await expect(svc.apply('plan-unavail', sha, SKIP_PLAN_FINGERPRINT)).rejects.toMatchObject({ + code: 'PLAN_UNAVAILABLE', + }); + } finally { + await cleanupStackDir('plan-unavail'); + } + }); +}); diff --git a/backend/src/__tests__/stack-activity.test.ts b/backend/src/__tests__/stack-activity.test.ts index 47967dbb..47510346 100644 --- a/backend/src/__tests__/stack-activity.test.ts +++ b/backend/src/__tests__/stack-activity.test.ts @@ -238,3 +238,23 @@ describe('DatabaseService.addNotificationHistory (no per-insert prune)', () => { expect(aActivity.map((e: any) => e.message)).toEqual(['first']); }); }); + +describe('DatabaseService.getStackActivity git categories', () => { + it('returns git change-plan history categories', () => { + const ts = Date.now(); + for (const category of ['git_pull_ready', 'git_plan_blocked', 'git_apply', 'git_create'] as const) { + db.addNotificationHistory(0, { + level: 'info', + category, + message: category, + timestamp: ts, + stack_name: 'git-act', + actor_username: 'alice', + }); + } + const out = db.getStackActivity(0, 'git-act', { limit: 50 }); + expect(out.map((e: { category?: string }) => e.category).sort()).toEqual( + ['git_apply', 'git_create', 'git_plan_blocked', 'git_pull_ready'].sort(), + ); + }); +}); diff --git a/backend/src/helpers/envFileResolution.ts b/backend/src/helpers/envFileResolution.ts index 5a4b259a..4440f138 100644 --- a/backend/src/helpers/envFileResolution.ts +++ b/backend/src/helpers/envFileResolution.ts @@ -21,6 +21,12 @@ import { parseInterpolationRefs, type InterpolationRef } from './envVarParse'; const MAX_COMPOSE_PARSE_BYTES = 1_048_576; // 1 MiB, matches the routes/stacks.ts bound const ROOT_COMPOSE_CANDIDATES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; +/** Basename is `.env`, `*.env` (e.g. `stack.env`), or `.env.*` (e.g. `.env.local`). */ +export function isEnvLikeFileName(name: string): boolean { + const base = name.replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? ''; + return base === '.env' || base.endsWith('.env') || base.startsWith('.env.'); +} + export type EnvFileExistence = 'present' | 'missing' | 'unverifiable'; /** @@ -310,14 +316,11 @@ export async function discoverStackLocalEnvFiles(nodeId: number, stackName: stri const candidates: string[] = []; for (const entry of entries) { const name = entry.name; - if (name === '.env' || name.endsWith('.env') || name.startsWith('.env.')) { - // Must be a regular file, not a directory. - if (entry.type !== 'file') continue; - // Validate containment (defense in depth). - const absPath = path.resolve(stackDir, name); - if (!isPathWithinBase(absPath, stackDir)) continue; - candidates.push(name); - } + if (!isEnvLikeFileName(name) || entry.type !== 'file') continue; + // Validate containment (defense in depth). + const absPath = path.resolve(stackDir, name); + if (!isPathWithinBase(absPath, stackDir)) continue; + candidates.push(name); } candidates.sort(); diff --git a/backend/src/routes/gitSources.ts b/backend/src/routes/gitSources.ts index 2bc45660..07092a31 100644 --- a/backend/src/routes/gitSources.ts +++ b/backend/src/routes/gitSources.ts @@ -312,7 +312,9 @@ stackGitSourceRouter.post('/:stackName/git-source/pull', async (req: Request, re } if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; try { - const result = await GitSourceService.getInstance().pull(stackName); + const result = await GitSourceService.getInstance().pull(stackName, { + actor: req.user?.username ?? 'unknown', + }); res.json(result); } catch (error) { sendGitSourceError(res, error); @@ -327,11 +329,15 @@ stackGitSourceRouter.post('/:stackName/git-source/apply', async (req: Request, r } if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; try { - const { commitSha, deploy } = req.body ?? {}; + const { commitSha, deploy, planFingerprint } = req.body ?? {}; if (typeof commitSha !== 'string' || !commitSha.trim()) { res.status(400).json({ error: 'commitSha is required' }); return; } + if (typeof planFingerprint !== 'string' || !planFingerprint.trim()) { + res.status(400).json({ error: 'planFingerprint is required', code: 'PLAN_FINGERPRINT_REQUIRED' }); + return; + } const source = DatabaseService.getInstance().getGitSource(stackName); const willDeploy = typeof deploy === 'boolean' ? deploy : source?.auto_deploy_on_apply === true; if (willDeploy && !requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; @@ -342,6 +348,8 @@ stackGitSourceRouter.post('/:stackName/git-source/apply', async (req: Request, r deploy: typeof deploy === 'boolean' ? deploy : undefined, actor: req.user?.username ?? 'unknown', bypassPolicy: req.query.ignorePolicy === 'true' && req.user?.role === 'admin', + planFingerprint: planFingerprint.trim(), + requirePlanFingerprint: true, }, ); invalidateNodeCaches(req.nodeId); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 0c0d0df9..197558cd 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -1360,7 +1360,13 @@ async function buildDriftPayload( // finding_type is a free-text column, but reconcile only ever writes a DriftFindingKind. const ledger: DriftLedgerEntry[] = DatabaseService.getInstance() .getRecentDriftFindings(nodeId, stackName, 20) - .map(r => ({ service: r.service, kind: r.finding_type as DriftFindingKind, message: r.message, detectedAt: r.detected_at, resolvedAt: r.resolved_at })); + .map(r => ({ + service: r.finding_type === 'managed-path-conflict' ? '' : r.service, + kind: r.finding_type as DriftFindingKind, + message: r.message, + detectedAt: r.detected_at, + resolvedAt: r.resolved_at, + })); // The ledger reflects the last reconcile (re-check, deploy, or background scan), // not this passive read, so surface when that was: the Drift tab labels the history // "checked {time ago}" and a stale finding reads as history, not current truth. diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index e1f33524..e02705dc 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -460,6 +460,11 @@ export interface StackGitSource { manifest_version: number | null; // cache of the managed-project manifest's manifestVersion (file is the source of truth) manifest_state: GitSourceManifestState | null; // DB-only enum, wider than the file state; see types/gitProjectManifest.ts manifest_generation: string | null; // stack-relative path of the applied generation dir + pending_plan_fingerprint: string | null; + pending_plan_blocked: boolean | null; + pending_plan_summary: string | null; + last_plan_fingerprint: string | null; + last_plan_outcome: string | null; created_at: number; updated_at: number; } @@ -1155,6 +1160,7 @@ export class DatabaseService { this.migrateStackDossierHashes(); this.migrateGitSourceMultiFile(); this.migrateGitSourceManifest(); + this.migrateGitSourceChangePlan(); this.migrateNodeUpdateSkips(); this.migrateStackAlertServiceScope(); @@ -2536,6 +2542,14 @@ export class DatabaseService { this.tryAddColumn('stack_git_sources', 'manifest_generation', 'TEXT'); } + private migrateGitSourceChangePlan(): void { + this.tryAddColumn('stack_git_sources', 'pending_plan_fingerprint', 'TEXT'); + this.tryAddColumn('stack_git_sources', 'pending_plan_blocked', 'INTEGER'); + this.tryAddColumn('stack_git_sources', 'pending_plan_summary', 'TEXT'); + this.tryAddColumn('stack_git_sources', 'last_plan_fingerprint', 'TEXT'); + this.tryAddColumn('stack_git_sources', 'last_plan_outcome', 'TEXT'); + } + private migrateGitSourceMultiFile(): void { this.tryAddColumn('stack_git_sources', 'compose_paths', 'TEXT'); this.tryAddColumn('stack_git_sources', 'context_dir', 'TEXT'); @@ -3854,6 +3868,10 @@ export class DatabaseService { this.db.prepare('UPDATE stack_drift_findings SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL').run(resolvedAt, id); } + public updateDriftFindingMessage(id: number, message: string): void { + this.db.prepare('UPDATE stack_drift_findings SET message = ? WHERE id = ? AND resolved_at IS NULL').run(message, id); + } + /** Open (unresolved) findings for a stack, oldest first. */ public getOpenDriftFindings(nodeId: number, stackName: string): StackDriftFindingRow[] { return this.db.prepare( @@ -6204,6 +6222,13 @@ export class DatabaseService { pending_env_content: (row.pending_env_content as string | null) ?? null, pending_fetched_at: (row.pending_fetched_at as number | null) ?? null, last_debounce_at: (row.last_debounce_at as number | null) ?? null, + pending_plan_fingerprint: (row.pending_plan_fingerprint as string | null) ?? null, + pending_plan_blocked: row.pending_plan_blocked === undefined || row.pending_plan_blocked === null + ? null + : Number(row.pending_plan_blocked) === 1, + pending_plan_summary: (row.pending_plan_summary as string | null) ?? null, + last_plan_fingerprint: (row.last_plan_fingerprint as string | null) ?? null, + last_plan_outcome: (row.last_plan_outcome as string | null) ?? null, created_at: row.created_at as number, updated_at: row.updated_at as number, }; @@ -6219,7 +6244,7 @@ export class DatabaseService { return rows.map(r => this.parseGitSource(r)!); } - public upsertGitSource(source: Omit): number { + public upsertGitSource(source: Omit): number { const now = Date.now(); const existing = this.getGitSource(source.stack_name); const composePathsJson = JSON.stringify(source.compose_paths ?? [source.compose_path]); @@ -6285,16 +6310,57 @@ export class DatabaseService { ).run(version, state, generation, Date.now(), stackName); } - public setGitSourcePending(stackName: string, commitSha: string, composeContent: string, envContent: string | null): void { + public setGitSourcePending( + stackName: string, + commitSha: string, + composeContent: string, + envContent: string | null, + plan?: { fingerprint: string; blocked: boolean; summary: string }, + ): void { this.db.prepare( `UPDATE stack_git_sources SET pending_commit_sha = ?, pending_compose_content = ?, pending_env_content = ?, pending_fetched_at = ?, + pending_plan_fingerprint = ?, + pending_plan_blocked = ?, + pending_plan_summary = ?, updated_at = ? WHERE stack_name = ?` - ).run(commitSha, composeContent, envContent, Date.now(), Date.now(), stackName); + ).run( + commitSha, + composeContent, + envContent, + Date.now(), + plan?.fingerprint ?? null, + plan ? (plan.blocked ? 1 : 0) : null, + plan?.summary ?? null, + Date.now(), + stackName, + ); + } + + public updateGitSourcePendingPlan( + stackName: string, + composeContent: string, + plan: { fingerprint: string; blocked: boolean; summary: string }, + ): void { + this.db.prepare( + `UPDATE stack_git_sources SET + pending_compose_content = ?, + pending_plan_fingerprint = ?, + pending_plan_blocked = ?, + pending_plan_summary = ?, + updated_at = ? + WHERE stack_name = ?` + ).run(composeContent, plan.fingerprint, plan.blocked ? 1 : 0, plan.summary, Date.now(), stackName); + } + + public setGitSourceLastPlan(stackName: string, fingerprint: string | null, outcome: string | null): void { + this.db.prepare( + `UPDATE stack_git_sources SET last_plan_fingerprint = ?, last_plan_outcome = ?, updated_at = ? WHERE stack_name = ?` + ).run(fingerprint, outcome, Date.now(), stackName); } public clearGitSourcePending(stackName: string): void { @@ -6304,6 +6370,9 @@ export class DatabaseService { pending_compose_content = NULL, pending_env_content = NULL, pending_fetched_at = NULL, + pending_plan_fingerprint = NULL, + pending_plan_blocked = NULL, + pending_plan_summary = NULL, updated_at = ? WHERE stack_name = ?` ).run(Date.now(), stackName); @@ -6318,6 +6387,9 @@ export class DatabaseService { pending_compose_content = NULL, pending_env_content = NULL, pending_fetched_at = NULL, + pending_plan_fingerprint = NULL, + pending_plan_blocked = NULL, + pending_plan_summary = NULL, updated_at = ? WHERE stack_name = ?` ).run(commitSha, contentHash, Date.now(), stackName); @@ -6337,6 +6409,9 @@ export class DatabaseService { pending_compose_content = NULL, pending_env_content = NULL, pending_fetched_at = NULL, + pending_plan_fingerprint = NULL, + pending_plan_blocked = NULL, + pending_plan_summary = NULL, updated_at = ? WHERE stack_name = ?` ).run(Date.now(), stackName); diff --git a/backend/src/services/DriftLedgerService.ts b/backend/src/services/DriftLedgerService.ts index 73f489a8..a19373ae 100644 --- a/backend/src/services/DriftLedgerService.ts +++ b/backend/src/services/DriftLedgerService.ts @@ -38,6 +38,17 @@ function findingKey(service: string, kind: string): string { return JSON.stringify([service, kind]); } +const SPATIAL_FINDING_KINDS = new Set([ + 'service-missing', + 'service-undeclared', + 'image-mismatch', + 'ports-mismatch', + 'network-undeclared', + 'network-missing', +]); + +const GIT_MANAGED_PATH_KIND = 'managed-path-conflict'; + /** * Order-independent serialization of the parsed model so two compose files that * differ only in comments, whitespace, or key order hash equal, while a real @@ -141,6 +152,7 @@ export class DriftLedgerService { } const toResolve: StackDriftFindingRow[] = []; for (const [key, row] of openByKey) { + if (!SPATIAL_FINDING_KINDS.has(row.finding_type)) continue; if (!currentByKey.has(key)) toResolve.push(row); } // Stamp the check time and apply any transitions in one transaction, so the @@ -205,6 +217,7 @@ export class DriftLedgerService { } const toResolve: StackDriftFindingRow[] = []; for (const [key, row] of openByKey) { + if (!SPATIAL_FINDING_KINDS.has(row.finding_type)) continue; if (!currentByKey.has(key)) toResolve.push(row); } db.getDb().transaction(() => { @@ -271,6 +284,64 @@ export class DriftLedgerService { } } + /** + * Insert or refresh Git managed-path findings. Never resolves. An existing + * open row keeps its original detected_at; only the redacted message updates. + */ + upsertManagedPathConflicts( + nodeId: number, + stackName: string, + conflicts: Array<{ path: string; op: string; role: string; sensitivity: 'high' | 'medium' | 'low' }>, + ): void { + const db = DatabaseService.getInstance(); + const now = Date.now(); + const open = db.getOpenDriftFindings(nodeId, stackName) + .filter((r) => r.finding_type === GIT_MANAGED_PATH_KIND); + const openByKey = new Map(open.map((r) => [r.service, r])); + db.getDb().transaction(() => { + for (const conflict of conflicts) { + const key = sha256Hex(`${stackName}\0${conflict.path}`); + const message = conflict.sensitivity === 'high' + ? `secret-bearing managed path (${conflict.op})` + : `${conflict.role} ${conflict.op}`; + const existing = openByKey.get(key); + if (existing) { + db.updateDriftFindingMessage(existing.id, message); + openByKey.delete(key); + continue; + } + db.insertDriftFinding({ + node_id: nodeId, + stack_name: stackName, + service: key, + finding_type: GIT_MANAGED_PATH_KIND, + severity: 'warning', + message, + expected_json: null, + actual_json: null, + detected_at: now, + }); + } + })(); + } + + /** + * Resolve every open managed-path-conflict for this stack. Call only after + * a clean promotion; a clean pull must not close an existing Git finding. + */ + resolveManagedPathConflicts(nodeId: number, stackName: string): void { + const db = DatabaseService.getInstance(); + const now = Date.now(); + const open = db.getOpenDriftFindings(nodeId, stackName) + .filter((r) => r.finding_type === GIT_MANAGED_PATH_KIND); + if (open.length === 0) return; + db.getDb().transaction(() => { + for (const row of open) { + db.resolveDriftFinding(row.id, now); + } + })(); + } + /** * Write a drift transition to the stack activity timeline. History-only (no * external channel dispatch): a drift signal belongs in the activity feed, not diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index c16cb3c9..8a69300a 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -1867,6 +1867,40 @@ export class FileSystemService { } } + /** + * Like pathKind, but distinguishes a symlink leaf from a regular file. + * Used by the Git change planner so a swapped symlink is type-changed, + * not hashed as if it were the target's content. + */ + async observeStackPath( + stackName: string, + relPath: string, + scope?: FileRootScope, + ): Promise<'file' | 'directory' | 'symlink' | 'special' | null> { + try { + if (scope?.rootAbsDir === undefined) { + // Canonical js/path-injection barrier inline with the lstat sink. A missing + // stack dir must return null (leaf resolve would throw path-escape). CodeQL + // only credits containment when it sits at the sink. + const baseResolved = path.resolve(this.baseDir); + const stackDir = path.resolve(baseResolved, stackName); + if (stackDir.startsWith(baseResolved + path.sep)) { + await fsPromises.lstat(stackDir); + } + } + const safePath = await this.resolveScopedLeafPath(stackName, relPath, scope); + const stat = await fsPromises.lstat(safePath); + if (stat.isSymbolicLink()) return 'symlink'; + if (stat.isDirectory()) return 'directory'; + if (stat.isFile()) return 'file'; + return 'special'; + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e.code === 'ENOENT') return null; + throw err; + } + } + /** * Optimistic-concurrency write for arbitrary stack files (file-explorer * editor save path). If `expectedMtimeMs` is provided, opens the target, @@ -1972,24 +2006,19 @@ export class FileSystemService { await fsPromises.rm(leafPath, { recursive: true, force: true }); return; } - try { - await fsPromises.unlink(leafPath); - } catch (err: unknown) { - const e = err as NodeJS.ErrnoException; - if (e.code === 'EISDIR') { - try { - await fsPromises.rmdir(leafPath); - } catch (inner: unknown) { - const ie = inner as NodeJS.ErrnoException; - if (ie.code === 'ENOTEMPTY' || ie.code === 'EEXIST') { - throw Object.assign(new Error('Directory is not empty'), { code: 'NOT_EMPTY' }); - } - throw inner; + if (leafStat.isDirectory()) { + try { + await fsPromises.rmdir(leafPath); + } catch (inner: unknown) { + const ie = inner as NodeJS.ErrnoException; + if (ie.code === 'ENOTEMPTY' || ie.code === 'EEXIST') { + throw Object.assign(new Error('Directory is not empty'), { code: 'NOT_EMPTY' }); } - } else { - throw err; + throw inner; } + return; } + await fsPromises.unlink(leafPath); } async mkdirStackPath(stackName: string, relPath: string, scope?: FileRootScope): Promise { diff --git a/backend/src/services/GitChangePlanService.ts b/backend/src/services/GitChangePlanService.ts new file mode 100644 index 00000000..c76b2552 --- /dev/null +++ b/backend/src/services/GitChangePlanService.ts @@ -0,0 +1,672 @@ +/** + * Classified compare of prior-manifest managed paths, the candidate Git + * inventory, and live disk. Pure policy: it never writes the stack directory. + * Promotion stays in GitProjectManifestService. + */ +import { createHash } from 'crypto'; +import { FileSystemService } from './FileSystemService'; +import { GitProjectManifestService } from './GitProjectManifestService'; +import { collectManifestFilePaths } from '../helpers/manifestFilePaths'; +import { isEnvLikeFileName } from '../helpers/envFileResolution'; +import { sha256Hex } from '../utils/hashing'; +import type { + BuildContextPlan, + ComposeInputEntry, + DeletionAuthority, + GitProjectManifest, + InputOwnership, + InputRole, + InputSensitivity, + ManifestProvenance, +} from '../types/gitProjectManifest'; +import { + BLOCKING_CHANGE_PLAN_OPS, + GIT_CHANGE_PLAN_SCHEMA_VERSION, + type GitChangePlan, + type GitChangePlanCounts, + type GitChangePlanMode, + type GitChangePlanOp, + type GitChangePlanOperation, + type PublicGitChangePlan, + type PublicGitChangePlanOperation, + type PublicPendingPlan, +} from '../types/gitChangePlan'; + +const INVOCATION_PATH_KEY = '__invocation__'; + +interface PathMeta { + hash: string | null; + role: InputRole | 'build-context-file'; + deletionAuthority: DeletionAuthority | null; + sensitivity: InputSensitivity; + ownership: InputOwnership; + provenance: ManifestProvenance; +} + +function isSecretBearingRelPath(rel: string): boolean { + const base = rel.split('/').pop()?.toLowerCase() ?? ''; + return isEnvLikeFileName(rel) + || base.includes('secret') + || base.includes('credential') + || base.endsWith('.pem') + || base === 'id_rsa'; +} + +type LiveKind = Awaited>; + +function isSymlinkEscape(err: unknown): boolean { + return (err as NodeJS.ErrnoException).code === 'SYMLINK_ESCAPE'; +} + +async function observeKind( + fsSvc: FileSystemService, + stackName: string, + pathKey: string, +): Promise { + try { + return await fsSvc.observeStackPath(stackName, pathKey); + } catch (err) { + if (isSymlinkEscape(err)) return 'escape'; + throw err; + } +} + +export interface BuildGitChangePlanInput { + stackName: string; + commitSha: string; + mode: GitChangePlanMode; + priorManifest: GitProjectManifest | null; + candidateInputs: ComposeInputEntry[]; + candidateBuildContexts: BuildContextPlan[]; + candidateInvocation: string[]; + liveInvocation: string[]; + /** Pre-manifest stacks: compose files + synced .env that Sencho already wrote. */ + legacyOwnedPaths?: string[]; + /** Live hashes captured when the pending plan was reviewed. A later mismatch is local-modified. */ + reviewedLiveHashes?: ReadonlyMap; + /** Stack-root project env files configured for deploy (live disk, not Git inventory). */ + projectEnvFiles?: string[]; +} + +export class GitChangePlanService { + private static instance: GitChangePlanService; + + static getInstance(): GitChangePlanService { + if (!GitChangePlanService.instance) { + GitChangePlanService.instance = new GitChangePlanService(); + } + return GitChangePlanService.instance; + } + + async build(input: BuildGitChangePlanInput): Promise { + const priorIndex = input.priorManifest + ? this.indexPaths(input.priorManifest.inputs, input.priorManifest.buildContexts) + : new Map(); + const candidateIndex = this.indexPaths(input.candidateInputs, input.candidateBuildContexts); + const priorPaths = input.priorManifest + ? collectManifestFilePaths(input.priorManifest) + : []; + const candidatePaths = collectManifestFilePaths({ + inputs: input.candidateInputs, + buildContexts: input.candidateBuildContexts, + }); + const contextExtras = await this.collectContextUniverseExtras({ + stackName: input.stackName, + candidateInputs: input.candidateInputs, + candidateBuildContexts: input.candidateBuildContexts, + priorBuildContexts: input.priorManifest?.buildContexts ?? [], + priorInputs: input.priorManifest?.inputs ?? [], + manifestSvc: GitProjectManifestService.getInstance(), + }); + const projectEnvFiles = input.projectEnvFiles ?? []; + const universe = this.mergePaths( + this.mergePaths(priorPaths, candidatePaths), + this.mergePaths(contextExtras, projectEnvFiles), + ); + const contextExtraSet = new Set(contextExtras.map((p) => p.toLowerCase())); + const projectEnvSet = new Set(projectEnvFiles.map((p) => p.toLowerCase())); + const legacyOwned = new Set(input.legacyOwnedPaths ?? []); + const fsSvc = FileSystemService.getInstance(); + const manifestSvc = GitProjectManifestService.getInstance(); + + const classified: GitChangePlanOperation[] = []; + for (const pathKey of universe) { + const pathFold = pathKey.toLowerCase(); + const prior = priorIndex.get(pathFold); + const candidate = candidateIndex.get(pathFold); + classified.push(await this.classifyPath({ + stackName: input.stackName, + pathKey, + prior, + candidate, + mode: input.mode, + legacyOwned, + reviewedLiveHash: input.reviewedLiveHashes?.get(pathFold), + hasReviewedLive: input.reviewedLiveHashes?.has(pathFold) === true, + isContextExtra: contextExtraSet.has(pathFold) + && prior === undefined + && candidate === undefined, + isProjectEnv: projectEnvSet.has(pathFold), + sourceRevision: input.commitSha, + fsSvc, + manifestSvc, + })); + } + + const operations = this.pairRenames(classified); + const { op: invocationOp, liveDiverged: invocationBlocked } = this.classifyInvocation( + input.priorManifest, + input.candidateInvocation, + input.liveInvocation, + input.commitSha, + ); + if (invocationOp) operations.push(invocationOp); + + const counts = this.countOps(operations); + const blocked = operations.some((op) => BLOCKING_CHANGE_PLAN_OPS.has(op.op)); + const fingerprint = this.fingerprint({ + commitSha: input.commitSha, + priorManifestVersion: input.priorManifest?.manifestVersion ?? null, + priorAppliedDir: input.priorManifest?.generation.appliedDir ?? null, + operations, + }); + + return { + schemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION, + fingerprint, + blocked, + invocationBlocked, + candidateInvocation: input.candidateInvocation, + liveInvocation: input.liveInvocation, + priorInvocation: input.priorManifest?.project.invocation ?? [], + operations, + counts, + }; + } + + toPublic(plan: GitChangePlan): PublicGitChangePlan { + return { + blocked: plan.blocked, + counts: plan.counts, + operations: plan.operations + .filter((op) => op.op !== 'unchanged') + .map((op) => this.toPublicOp(op)), + invocation: { + candidateChanged: this.invocationsDiffer(plan.candidateInvocation, plan.priorInvocation), + liveDiverged: plan.invocationBlocked, + }, + }; + } + + toPendingSummary(plan: GitChangePlan): PublicPendingPlan { + const publicPlan = this.toPublic(plan); + return { + fingerprint: plan.fingerprint, + blocked: publicPlan.blocked, + counts: publicPlan.counts, + operations: publicPlan.operations, + }; + } + + private toPublicOp(op: GitChangePlanOperation): PublicGitChangePlanOperation { + const redact = op.sensitivity === 'high'; + const publicOp: PublicGitChangePlanOperation = { + path: redact || op.op === 'invocation' ? null : op.pathKey, + op: op.op, + role: op.role, + }; + if (op.fromPath !== undefined) { + publicOp.fromPath = redact ? null : op.fromPath; + } + return publicOp; + } + + private fingerprint(input: { + commitSha: string; + priorManifestVersion: number | null; + priorAppliedDir: string | null; + operations: GitChangePlanOperation[]; + }): string { + const ops = [...input.operations] + .sort((a, b) => a.pathKey.localeCompare(b.pathKey)) + .map((op) => ({ + pathKey: op.pathKey, + op: op.op, + priorHash: op.priorHash, + candidateHash: op.candidateHash, + liveHash: op.liveHash, + role: op.role, + deletionAuthority: op.deletionAuthority, + fromPath: op.fromPath ?? null, + ownership: op.ownership, + provenance: op.provenance, + sensitivity: op.sensitivity, + reason: op.reason, + })); + const canonical = { + schemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION, + commitSha: input.commitSha, + priorManifestVersion: input.priorManifestVersion, + priorAppliedDir: input.priorAppliedDir, + operations: ops, + }; + return createHash('sha256').update(JSON.stringify(canonical), 'utf8').digest('hex'); + } + + private indexPaths(inputs: ComposeInputEntry[], buildContexts: BuildContextPlan[]): Map { + const index = new Map(); + const contextSensitivity = new Map(); + for (const entry of inputs) { + if (entry.materializedPath === null) continue; + if (entry.dependencyKind === 'build-context' || entry.dependencyKind === 'build-additional-context') { + contextSensitivity.set(entry.materializedPath.toLowerCase(), entry.sensitivity); + } + if (entry.ownership !== 'managed' || entry.state !== 'present' || entry.materializedPath === null) continue; + if (entry.dependencyKind === 'build-context' || entry.dependencyKind === 'build-additional-context') continue; + index.set(entry.materializedPath.toLowerCase(), { + hash: entry.contentSha256, + role: entry.role, + deletionAuthority: entry.deletionAuthority, + sensitivity: entry.sensitivity, + ownership: entry.ownership, + provenance: entry.provenance, + }); + } + for (const context of buildContexts) { + const parentSensitivity = contextSensitivity.get(context.repoPath.toLowerCase()) ?? 'medium'; + for (const file of context.files) { + const rel = context.repoPath ? `${context.repoPath}/${file.path}` : file.path; + index.set(rel.toLowerCase(), { + hash: file.sha256, + role: 'build-context-file', + deletionAuthority: 'sencho', + sensitivity: parentSensitivity, + ownership: 'managed', + provenance: 'fetch', + }); + } + } + return index; + } + + private mergePaths(prior: string[], candidate: string[]): string[] { + const byFold = new Map(); + for (const rel of [...prior, ...candidate]) { + const key = rel.toLowerCase(); + if (!byFold.has(key)) byFold.set(key, rel); + } + return [...byFold.values()].sort((a, b) => a.localeCompare(b)); + } + + private async classifyPath(args: { + stackName: string; + pathKey: string; + prior: PathMeta | undefined; + candidate: PathMeta | undefined; + mode: GitChangePlanMode; + legacyOwned: Set; + reviewedLiveHash?: string | null; + hasReviewedLive: boolean; + isContextExtra: boolean; + isProjectEnv: boolean; + sourceRevision: string; + fsSvc: FileSystemService; + manifestSvc: GitProjectManifestService; + }): Promise { + const { pathKey, prior, candidate, mode, legacyOwned, sourceRevision } = args; + const role = candidate?.role ?? prior?.role ?? (args.isProjectEnv ? 'env' : 'other'); + const deletionAuthority = candidate?.deletionAuthority ?? prior?.deletionAuthority ?? null; + const secretExtra = args.isContextExtra && isSecretBearingRelPath(pathKey); + const sensitivity = secretExtra + ? 'high' + : (candidate?.sensitivity ?? prior?.sensitivity ?? (args.isProjectEnv ? 'high' : 'medium')); + const ownership = candidate?.ownership + ?? prior?.ownership + ?? (args.isProjectEnv || args.isContextExtra ? 'unmanaged' : 'managed'); + const provenance = candidate?.provenance + ?? prior?.provenance + ?? (args.isProjectEnv || args.isContextExtra ? 'adopted' : 'fetch'); + const meta = { ownership, provenance, sourceRevision }; + const priorHash = prior?.hash ?? null; + const candidateHash = candidate?.hash ?? null; + const typeChanged = (reason: string): GitChangePlanOperation => this.op( + pathKey, 'type-changed', role, deletionAuthority, priorHash, candidateHash, null, sensitivity, { ...meta, reason }, + ); + + const liveKind = await observeKind(args.fsSvc, args.stackName, pathKey); + if (liveKind === 'escape') { + return typeChanged('live path escapes the stack through a symlink'); + } + + let liveHash: string | null = null; + if (liveKind === 'file') { + try { + liveHash = await args.manifestSvc.hashStackFile(args.stackName, pathKey); + } catch (err) { + if (isSymlinkEscape(err)) return typeChanged('live path is not a regular file'); + const kindAfter = await observeKind(args.fsSvc, args.stackName, pathKey); + if (kindAfter !== 'file' && kindAfter !== null) { + return typeChanged('live path is not a regular file'); + } + throw err; + } + } + + const priorPresent = prior !== undefined && priorHash !== null; + const candidatePresent = candidate !== undefined && candidateHash !== null; + + if (liveKind !== 'file' && liveKind !== null) { + return typeChanged('live path is not a regular file'); + } + + if (args.hasReviewedLive && args.reviewedLiveHash !== liveHash) { + return this.op(pathKey, 'local-modified', role, deletionAuthority, priorHash, candidateHash, liveHash, sensitivity, { + ...meta, + reason: 'live hash changed since review', + }); + } + + if (priorPresent && candidatePresent) { + if (liveKind === null) { + return this.op(pathKey, 'local-missing', role, deletionAuthority, priorHash, candidateHash, null, sensitivity, { + ...meta, + reason: 'managed path absent on disk', + }); + } + if (liveHash !== priorHash) { + // Live vs last-applied, not vs candidate. Matching incoming + // bytes by coincidence is still a local edit. + return this.op(pathKey, 'local-modified', role, deletionAuthority, priorHash, candidateHash, liveHash, sensitivity, { + ...meta, + reason: 'live hash differs from prior managed hash', + }); + } + if (candidateHash === priorHash) { + return this.op(pathKey, 'unchanged', role, deletionAuthority, priorHash, candidateHash, liveHash, sensitivity, { + ...meta, + reason: 'matches prior managed hash', + }); + } + return this.op(pathKey, 'modify', role, deletionAuthority, priorHash, candidateHash, liveHash, sensitivity, { + ...meta, + reason: 'candidate content differs from prior', + }); + } + + if (priorPresent && !candidatePresent) { + if (liveKind === null) { + return this.op(pathKey, 'local-missing', role, deletionAuthority, priorHash, null, null, sensitivity, { + ...meta, + reason: 'managed path absent on disk', + }); + } + if (prior?.deletionAuthority !== 'sencho') { + return this.op(pathKey, 'local-modified', role, deletionAuthority, priorHash, null, liveHash, sensitivity, { + ...meta, + reason: 'live path is not sencho-deletable', + }); + } + if (liveKind === 'file' && liveHash !== priorHash) { + return this.op(pathKey, 'local-modified', role, deletionAuthority, priorHash, null, liveHash, sensitivity, { + ...meta, + reason: 'live hash differs from prior managed hash', + }); + } + return this.op(pathKey, 'delete', role, deletionAuthority, priorHash, null, liveHash, sensitivity, { + ...meta, + reason: 'removed from candidate', + }); + } + + if (!priorPresent && !candidatePresent) { + if (args.isProjectEnv) { + if (liveKind === null) { + return this.op(pathKey, 'local-missing', role, deletionAuthority, null, null, null, sensitivity, { + ...meta, + reason: 'configured project env file missing on disk', + }); + } + return this.op(pathKey, 'unchanged', role, deletionAuthority, null, null, liveHash, sensitivity, { + ...meta, + reason: 'configured project env file', + }); + } + if (args.isContextExtra) { + return this.op(pathKey, 'unmanaged-collision', role, deletionAuthority, null, null, liveHash, sensitivity, { + ...meta, + reason: 'locally added in build context', + }); + } + } + + // Candidate-only path (add or collision). + if (mode === 'create' || liveKind === null || legacyOwned.has(pathKey)) { + return this.op(pathKey, 'add', role, deletionAuthority, null, candidateHash, liveHash, sensitivity, { + ...meta, + reason: 'new managed path', + }); + } + return this.op(pathKey, 'unmanaged-collision', role, deletionAuthority, null, candidateHash, liveHash, sensitivity, { + ...meta, + reason: 'unmanaged live file at a candidate path', + }); + } + + private pairRenames(ops: GitChangePlanOperation[]): GitChangePlanOperation[] { + const deletes = ops.filter((o) => o.op === 'delete' && o.priorHash); + const adds = ops.filter((o) => o.op === 'add' && o.candidateHash); + const usedDeletes = new Set(); + const usedAdds = new Set(); + const renames: GitChangePlanOperation[] = []; + + const deletesByHash = new Map(); + for (const d of deletes) { + const list = deletesByHash.get(d.priorHash!) ?? []; + list.push(d); + deletesByHash.set(d.priorHash!, list); + } + const addsByHash = new Map(); + for (const a of adds) { + const list = addsByHash.get(a.candidateHash!) ?? []; + list.push(a); + addsByHash.set(a.candidateHash!, list); + } + + for (const [hash, delList] of deletesByHash) { + const addList = addsByHash.get(hash); + if (!addList) continue; + const leftoverDel = delList + .filter((d) => !usedDeletes.has(d.pathKey)) + .sort((a, b) => a.pathKey.localeCompare(b.pathKey)); + const leftoverAdd = addList + .filter((a) => !usedAdds.has(a.pathKey)) + .sort((a, b) => a.pathKey.localeCompare(b.pathKey)); + const pairs = Math.min(leftoverDel.length, leftoverAdd.length); + for (let i = 0; i < pairs; i++) { + const del = leftoverDel[i]; + const add = leftoverAdd[i]; + usedDeletes.add(del.pathKey); + usedAdds.add(add.pathKey); + const sensitivity = add.sensitivity === 'high' || del.sensitivity === 'high' ? 'high' : add.sensitivity; + renames.push(this.op( + add.pathKey, + 'rename', + add.role, + del.deletionAuthority, + del.priorHash, + add.candidateHash, + add.liveHash, + sensitivity, + { + fromPath: del.pathKey, + ownership: add.ownership, + provenance: add.provenance, + sourceRevision: add.sourceRevision, + reason: 'same content, new path', + }, + )); + } + } + + const kept = ops.filter((o) => + !(o.op === 'delete' && usedDeletes.has(o.pathKey)) + && !(o.op === 'add' && usedAdds.has(o.pathKey)), + ); + return [...kept, ...renames].sort((a, b) => a.pathKey.localeCompare(b.pathKey)); + } + + private classifyInvocation( + prior: GitProjectManifest | null, + candidateInvocation: string[], + liveInvocation: string[], + sourceRevision: string, + ): { op: GitChangePlanOperation | null; liveDiverged: boolean } { + if (prior === null) return { op: null, liveDiverged: false }; + const priorInv = prior.project.invocation; + const liveDiverged = this.invocationsDiffer(liveInvocation, priorInv); + const candidateChanged = this.invocationsDiffer(candidateInvocation, priorInv); + if (!liveDiverged && !candidateChanged) return { op: null, liveDiverged: false }; + return { + liveDiverged, + op: this.op( + INVOCATION_PATH_KEY, + 'invocation', + 'invocation', + null, + sha256Hex(JSON.stringify(priorInv)), + sha256Hex(JSON.stringify(candidateInvocation)), + sha256Hex(JSON.stringify(liveInvocation)), + 'low', + { + ownership: 'managed', + provenance: 'fetch', + sourceRevision, + reason: liveDiverged + ? 'live compose invocation diverged from prior' + : 'candidate compose invocation changed', + }, + ), + }; + } + + private invocationsEqual(a: string[], b: string[]): boolean { + return JSON.stringify(a) === JSON.stringify(b); + } + + private invocationsDiffer(a: string[], b: string[]): boolean { + return !this.invocationsEqual(a, b); + } + + private countOps(operations: GitChangePlanOperation[]): GitChangePlanCounts { + const counts: GitChangePlanCounts = { + add: 0, + modify: 0, + delete: 0, + rename: 0, + unchanged: 0, + localModified: 0, + localMissing: 0, + typeChanged: 0, + unmanagedCollision: 0, + invocation: 0, + }; + for (const op of operations) { + switch (op.op) { + case 'add': counts.add += 1; break; + case 'modify': counts.modify += 1; break; + case 'delete': counts.delete += 1; break; + case 'rename': counts.rename += 1; break; + case 'unchanged': counts.unchanged += 1; break; + case 'local-modified': counts.localModified += 1; break; + case 'local-missing': counts.localMissing += 1; break; + case 'type-changed': counts.typeChanged += 1; break; + case 'unmanaged-collision': counts.unmanagedCollision += 1; break; + case 'invocation': counts.invocation += 1; break; + } + } + return counts; + } + + private async collectContextUniverseExtras(args: { + stackName: string; + candidateInputs: ComposeInputEntry[]; + candidateBuildContexts: BuildContextPlan[]; + priorBuildContexts: BuildContextPlan[]; + priorInputs: ComposeInputEntry[]; + manifestSvc: GitProjectManifestService; + }): Promise { + const managedInputPaths = new Set( + [...args.priorInputs, ...args.candidateInputs] + .filter((i) => i.ownership === 'managed' && i.state === 'present' && i.materializedPath !== null) + .map((i) => i.materializedPath!), + ); + const contextsByFold = new Map(); + for (const context of [...args.priorBuildContexts, ...args.candidateBuildContexts]) { + contextsByFold.set(context.repoPath.toLowerCase(), context); + } + const extras: string[] = []; + for (const context of contextsByFold.values()) { + const diverged = await args.manifestSvc.verifyContextOnDisk( + args.stackName, + context, + managedInputPaths, + ); + for (const entry of diverged) { + const stackRel = this.stackPathFromContextDivergence(context.repoPath, entry); + if (stackRel) extras.push(stackRel); + } + } + return extras; + } + + private stackPathFromContextDivergence(contextRepoPath: string, diverged: string): string | null { + if ( + diverged === '. (symbolic link)' + || diverged === '. (special file node)' + || diverged === '. (scan limit exceeded)' + ) { + return contextRepoPath || '.'; + } + const join = (rel: string): string => (contextRepoPath ? `${contextRepoPath}/${rel}` : rel); + const annotated = diverged.match( + /^(.+) \((?:locally added, not in the managed context|symbolic link|special file node|missing)\)$/, + ); + if (annotated) return join(annotated[1]); + if (!diverged.includes('(')) return join(diverged); + return null; + } + + private op( + pathKey: string, + op: GitChangePlanOp, + role: GitChangePlanOperation['role'], + deletionAuthority: DeletionAuthority | null, + priorHash: string | null, + candidateHash: string | null, + liveHash: string | null, + sensitivity: InputSensitivity, + meta: { + fromPath?: string; + ownership: InputOwnership; + provenance: ManifestProvenance; + sourceRevision: string; + reason: string; + }, + ): GitChangePlanOperation { + return { + pathKey, + op, + role, + deletionAuthority, + priorHash, + candidateHash, + liveHash, + sensitivity, + ownership: meta.ownership, + provenance: meta.provenance, + sourceRevision: meta.sourceRevision, + reason: meta.reason, + ...(meta.fromPath !== undefined ? { fromPath: meta.fromPath } : {}), + }; + } +} diff --git a/backend/src/services/GitProjectManifestService.ts b/backend/src/services/GitProjectManifestService.ts index ba50ac94..5bf78d5b 100644 --- a/backend/src/services/GitProjectManifestService.ts +++ b/backend/src/services/GitProjectManifestService.ts @@ -97,6 +97,22 @@ type RecoveryIncoming = | { inputs: ComposeInputEntry[]; buildContexts: BuildContextPlan[] } | { introducedPaths: string[] }; +export type PromoteFailurePhase = 'pre_mutation' | 'restored' | 'recovery_required'; + +/** Typed promotion failure so apply can record restore vs pre-mutation vs recovery-required. */ +export class PromoteGenerationError extends Error { + readonly phase: PromoteFailurePhase; + readonly cause: unknown; + + constructor(phase: PromoteFailurePhase, cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); + super(message); + this.name = 'PromoteGenerationError'; + this.phase = phase; + this.cause = cause; + } +} + const MANIFEST_STATES: readonly ManifestState[] = ['none', 'migrated', 'active', 'partial', 'unsupported']; const DEPENDENCY_KINDS: readonly InputDependencyKind[] = [ 'explicit', 'implicit-override', 'include', 'include-env', 'extends', 'env_file', @@ -649,72 +665,191 @@ export class GitProjectManifestService { /** Hash of the stack-dir file at a materialized path, or null when absent. */ async hashStackFile(stackName: string, relPath: string): Promise { - const abs = await this.stackFileAbs(stackName, relPath); + const composeDir = NodeRegistry.getInstance().getComposeDir(NodeRegistry.getInstance().getDefaultNodeId()); + const baseResolved = path.resolve(composeDir); + if (!isValidStackName(stackName) || !isSafeRelPath(relPath)) throw new Error('Invalid stack file path'); + const stackRoot = path.resolve(baseResolved, stackName); + const abs = path.resolve(stackRoot, relPath); + // Canonical js/path-injection barrier inline with the open sink. CodeQL + // only credits containment when it sits at the sink; helpers are ignored. + if (!stackRoot.startsWith(baseResolved + path.sep)) throw new Error('Invalid stack file path'); + if (abs !== stackRoot && !abs.startsWith(stackRoot + path.sep)) { + throw new Error('Stack file path escapes the stack root'); + } + if (!abs.startsWith(baseResolved + path.sep)) { + throw new Error('Stack file path escapes the compose directory'); + } + let flags = fs.constants.O_RDONLY; + if (typeof fs.constants.O_NOFOLLOW === 'number') flags |= fs.constants.O_NOFOLLOW; + if (typeof fs.constants.O_NONBLOCK === 'number') flags |= fs.constants.O_NONBLOCK; try { - return sha256Of(await fs.promises.readFile(abs)); + const handle = await fs.promises.open(abs, flags); + try { + const stat = await handle.stat(); + if (!stat.isFile()) return null; + return sha256Of(await handle.readFile()); + } finally { + await handle.close(); + } } catch (e) { - if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; + const err = e as NodeJS.ErrnoException; + if (err.code === 'ENOENT' || err.code === 'ELOOP' || err.code === 'ENXIO' || err.code === 'EAGAIN') { + return null; + } throw e; } } /** - * Verify a build-context subtree on disk against the manifest's file-level - * inventory. Returns the context-relative paths that diverge: files whose - * hash differs, files missing from the stack, and files present in the - * stack that the manifest does not own (locally added). This gives contexts - * the same local-modification protection as plain managed files. + * Compare a build-context subtree on disk to the manifest inventory. + * Observes the context root with no-follow semantics before walking. + * A symlink, special node, or file at the root returns a sentinel and + * does not enumerate the target. Nested symlinks are classified without + * following, and owned descendants beneath them are not inspected. + * Scan limits count every visited entry (files and directories), plus + * depth and on-disk bytes, and fail closed with `. (scan limit exceeded)`. + * `boundsOverride` is for tests. */ - async verifyContextOnDisk(stackName: string, context: BuildContextPlan, managedInputPaths?: Set): Promise { - const abs = await this.stackFileAbs(stackName, context.repoPath); + async verifyContextOnDisk( + stackName: string, + context: BuildContextPlan, + managedInputPaths?: Set, + boundsOverride?: ManifestBounds, + ): Promise { + const bounds = boundsOverride ?? this.boundsConfig(); + const fsSvc = FileSystemService.getInstance(); + let rootKind: Awaited>; + try { + rootKind = await fsSvc.observeStackPath(stackName, context.repoPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'SYMLINK_ESCAPE') throw err; + rootKind = 'symlink'; + } + if (rootKind === 'symlink') return ['. (symbolic link)']; + if (rootKind === 'special' || rootKind === 'file') return ['. (special file node)']; + if (rootKind !== 'directory') return []; + + if (!isValidStackName(stackName) || !isSafeRelPath(context.repoPath)) { + throw new Error('Invalid stack file path'); + } + const composeDir = NodeRegistry.getInstance().getComposeDir(NodeRegistry.getInstance().getDefaultNodeId()); const diverged: string[] = []; - const owned = new Set(context.files.map((f) => f.path)); - const walk = async (dir: string, rel: string): Promise => { + const expectedByPath = new Map(context.files.map((f) => [f.path, f.sha256])); + const symlinkPrefixes: string[] = []; + let filesSeen = 0; + let bytesSeen = 0; + let limitExceeded = false; + const exceedLimit = (): void => { + diverged.push('. (scan limit exceeded)'); + limitExceeded = true; + }; + const walk = async (rel: string): Promise => { + if (limitExceeded) return; + if (!isSafeRelPath(rel)) return; + const depth = rel === '' ? 0 : rel.split('/').filter(Boolean).length; + if (depth > bounds.maxPathDepth) { + exceedLimit(); + return; + } let entriesList: fs.Dirent[]; try { - entriesList = await fs.promises.readdir(dir, { withFileTypes: true }); + const baseResolved = path.resolve(composeDir); + const dirParts = [stackName, context.repoPath, rel].filter((p) => p !== ''); + const dirAbs = path.resolve(baseResolved, ...dirParts); + if (!dirAbs.startsWith(baseResolved + path.sep)) return; + entriesList = await fs.promises.readdir(dirAbs, { withFileTypes: true }); } catch { - return; // missing context dir reported by the owned-file loop below + return; } for (const entry of entriesList) { + if (limitExceeded) return; const childRel = rel ? `${rel}/${entry.name}` : entry.name; - if (entry.isDirectory()) { - await walk(path.join(dir, entry.name), childRel); - continue; + if (!isSafeRelPath(childRel)) continue; + filesSeen += 1; + if (filesSeen > bounds.maxFiles) { + exceedLimit(); + return; } if (entry.isSymbolicLink()) { diverged.push(`${childRel} (symbolic link)`); + symlinkPrefixes.push(childRel); continue; } - // Files not in the context inventory: if they have a - // managed-input owner (stack-relative path), they are owned - // by another manifest entry. The managed set uses stack- - // relative paths; the walk uses context-relative paths. - if (!owned.has(childRel)) { - const stackRel = context.repoPath ? `${context.repoPath}/${childRel}` : childRel; - if (managedInputPaths && managedInputPaths.has(stackRel)) continue; + if (entry.isDirectory()) { + await walk(childRel); + continue; + } + if (entry.isFIFO() || entry.isSocket() || entry.isBlockDevice() || entry.isCharacterDevice()) { + diverged.push(`${childRel} (special file node)`); + continue; + } + const stackRel = context.repoPath ? `${context.repoPath}/${childRel}` : childRel; + if (!expectedByPath.has(childRel)) { + if (managedInputPaths?.has(stackRel)) continue; diverged.push(`${childRel} (locally added, not in the managed context)`); continue; } - const expected = context.files.find((f) => f.path === childRel)?.sha256; - const actual = await this.hashStackFile(stackName, context.repoPath ? `${context.repoPath}/${childRel}` : childRel); + let onDiskBytes = 0; + try { + const baseResolved = path.resolve(composeDir); + const childAbs = path.resolve(baseResolved, stackName, stackRel); + if (!childAbs.startsWith(baseResolved + path.sep)) continue; + onDiskBytes = (await fs.promises.lstat(childAbs)).size; + } catch { + diverged.push(`${childRel} (missing)`); + continue; + } + if (onDiskBytes > bounds.maxFileBytes || bytesSeen + onDiskBytes > bounds.maxContextBytes) { + exceedLimit(); + return; + } + bytesSeen += onDiskBytes; + const expected = expectedByPath.get(childRel); + const actual = await this.hashStackFile(stackName, stackRel); if (expected === undefined || actual !== expected) { diverged.push(childRel); } } }; - await walk(abs, ''); + await walk(''); for (const ownedFile of context.files) { - if (!owned.has(ownedFile.path)) continue; - const present = await fs.promises - .access(path.join(abs, ownedFile.path)) - .then(() => true) - .catch(() => false); - if (!present) diverged.push(`${ownedFile.path} (missing)`); + if (!isSafeRelPath(ownedFile.path)) continue; + if (symlinkPrefixes.some((p) => ownedFile.path === p || ownedFile.path.startsWith(`${p}/`))) { + continue; + } + const stackRel = context.repoPath ? `${context.repoPath}/${ownedFile.path}` : ownedFile.path; + let kind: Awaited>; + try { + kind = await fsSvc.observeStackPath(stackName, stackRel); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'SYMLINK_ESCAPE') throw err; + kind = 'symlink'; + } + if (kind === null) diverged.push(`${ownedFile.path} (missing)`); + else if (kind === 'symlink') diverged.push(`${ownedFile.path} (symbolic link)`); + else if (kind !== 'file') diverged.push(`${ownedFile.path} (special file node)`); } return diverged; } + private async tryRemoveEmptyDir(stackName: string, relPath: string, fsSvc: FileSystemService): Promise { + if (!relPath) return; + try { + await fsSvc.deleteStackPath(stackName, relPath, false, { protectedEnabled: false }); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTEMPTY' || code === 'EEXIST' || code === 'NOT_EMPTY') return; + throw err; + } + } + + private async tryRemoveEmptyParents(stackName: string, fileRel: string, fsSvc: FileSystemService): Promise { + const parts = fileRel.replace(/\\/g, '/').split('/').filter(Boolean); + for (let i = parts.length - 1; i >= 1; i--) { + await this.tryRemoveEmptyDir(stackName, parts.slice(0, i).join('/'), fsSvc); + } + } + private async stackFileAbs(stackName: string, relPath: string): Promise { // Same resolution chain as FileSystemService: node.compose_dir -> // COMPOSE_DIR -> /app/compose. The stack name was validated upstream @@ -811,7 +946,10 @@ export class GitProjectManifestService { return priorRel !== undefined && priorRel !== rel; }); if (caseOnlyChange !== undefined) { - throw new Error(`Case-only managed path changes are not supported: ${priorByCaseFold.get(caseOnlyChange.toLowerCase())} -> ${caseOnlyChange}`); + throw new PromoteGenerationError( + 'pre_mutation', + new Error(`Case-only managed path changes are not supported: ${priorByCaseFold.get(caseOnlyChange.toLowerCase())} -> ${caseOnlyChange}`), + ); } const introduced = incomingFiles.filter((rel) => !priorKeys.has(rel.toLowerCase())); const affected = [...new Map([...priorFiles, ...incomingFiles].map((rel) => [rel.toLowerCase(), rel])).values()] @@ -882,18 +1020,16 @@ export class GitProjectManifestService { } // 2. Stale cleanup: prior-manifest paths Sencho owns (deletionAuthority - // sencho), absent from the new set. Only sencho-authority paths are - // ever unlinked; user/none authority stays untouched. A failed - // unlink FAILS the promotion (the transaction restores the prior - // generation) rather than recording a tombstone for a file that - // still exists and can silently change the deployed model. + // sencho), absent from the new set. Only sencho-authority files are + // unlinked. Build-context directory inventory entries are tombstoned + // without a recursive directory delete; their owned files are + // removed one path at a time. A failed unlink fails the promotion. const newPaths = new Set(managed.map((i) => i.materializedPath!)); const removed: ComposeInputEntry[] = []; const fsSvc = FileSystemService.getInstance(); - // Context files are reconciled FILE-LEVEL: a file removed from the - // repository inside a retained context must disappear from the - // stack context too, or the deployed/build context would keep - // deleted (possibly secret-bearing) content. + // Context files are reconciled file-level for both retained and + // removed contexts. After owned files are gone, an empty non-root + // context directory is removed; unowned leftovers keep the directory. const newContextFiles = new Map>(); for (const ctx of manifest.buildContexts) { newContextFiles.set(ctx.repoPath, new Set(ctx.files.map((f) => f.path))); @@ -901,26 +1037,25 @@ export class GitProjectManifestService { if (priorManifest) { for (const entry of priorManifest.inputs) { if (entry.ownership !== 'managed' || entry.state !== 'present' || entry.materializedPath === null) continue; - if (entry.deletionAuthority !== 'sencho') continue; // never touch user/none authority + if (entry.deletionAuthority !== 'sencho') continue; if (newPaths.has(entry.materializedPath)) continue; - // Directories (build contexts) need a recursive unlink; a - // non-recursive attempt would throw and fail the promotion - // even though the directory is legitimately removable. - const isDir = await fsSvc - .pathKind(stackName, entry.materializedPath) - .then((kind) => kind === 'directory') - .catch(() => false); - await fsSvc.deleteStackPath(stackName, entry.materializedPath, isDir, { protectedEnabled: false }); + if (entry.dependencyKind === 'build-context' || entry.dependencyKind === 'build-additional-context') { + removed.push({ ...entry, state: 'tombstoned', contentSha256: null, sizeBytes: null }); + continue; + } + await fsSvc.deleteStackPath(stackName, entry.materializedPath, false, { protectedEnabled: false }); removed.push({ ...entry, state: 'tombstoned', contentSha256: null, sizeBytes: null }); } - // Context-file reconciliation for contexts retained in both sets. for (const priorCtx of priorManifest.buildContexts) { - const newFiles = newContextFiles.get(priorCtx.repoPath); - if (!newFiles) continue; // context removed entirely; handled above + const newFiles = newContextFiles.get(priorCtx.repoPath) ?? new Set(); for (const priorFile of priorCtx.files) { if (newFiles.has(priorFile.path)) continue; const rel = priorCtx.repoPath ? `${priorCtx.repoPath}/${priorFile.path}` : priorFile.path; await fsSvc.deleteStackPath(stackName, rel, false, { protectedEnabled: false }); + await this.tryRemoveEmptyParents(stackName, rel, fsSvc); + } + if (!newContextFiles.has(priorCtx.repoPath) && priorCtx.repoPath) { + await this.tryRemoveEmptyDir(stackName, priorCtx.repoPath, fsSvc); } } } @@ -959,18 +1094,22 @@ export class GitProjectManifestService { console.warn('[GitManifest] committed promotion marker cleanup failed:', (e as Error).message); } } catch (error) { - if (!liveMutationStarted) throw error; + if (!liveMutationStarted) { + throw new PromoteGenerationError('pre_mutation', error); + } // Mid-write failure: restore the previous applied generation and - // rethrow so the caller reports the failure honestly. + // rethrow a typed outcome so the caller records restore vs recovery-required. + let restored = false; try { - await this.restorePreviousGeneration(stackName, { + restored = await this.restorePreviousGeneration(stackName, { priorManifest: opts.priorManifest, incoming: { inputs: opts.manifest.inputs, buildContexts: opts.manifest.buildContexts }, }); } catch (restoreError) { console.error('[GitManifest] promotion failed and recovery restore also failed:', (restoreError as Error).message); + restored = false; } - throw error; + throw new PromoteGenerationError(restored ? 'restored' : 'recovery_required', error); } } diff --git a/backend/src/services/GitSourceService.ts b/backend/src/services/GitSourceService.ts index 137817b8..6a64c5bd 100644 --- a/backend/src/services/GitSourceService.ts +++ b/backend/src/services/GitSourceService.ts @@ -1,4 +1,4 @@ -import { promises as fsPromises } from 'fs'; +import { promises as fsPromises, existsSync } from 'fs'; import { spawn } from 'child_process'; import crypto from 'crypto'; import os from 'os'; @@ -18,10 +18,16 @@ import { sanitizeForLog } from '../utils/safeLog'; import { isPathWithinBase, isValidRelativeStackPath } from '../utils/validation'; import { gitSourceLocalComposeFiles, PRIMARY_COMPOSE_FILENAME } from '../utils/gitComposeFiles'; import { ComposeInputDiscoveryService, type ContextCopyPlan } from './ComposeInputDiscoveryService'; -import { GitProjectManifestService } from './GitProjectManifestService'; +import { GitProjectManifestService, PromoteGenerationError } from './GitProjectManifestService'; +import { GitChangePlanService } from './GitChangePlanService'; +import { DriftLedgerService } from './DriftLedgerService'; import { StackUpdateRecoveryService } from './StackUpdateRecoveryService'; -import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs'; +import { authoredComposeFileArgs, authoredComposeEnvFileArgs, candidateValidationEnvFileArgs } from '../utils/authoredComposeArgs'; +import { buildCandidateComposeInvocation } from '../utils/candidateComposeInvocation'; import type { ComposeInputEntry, GitProjectManifest, GitSourceManifestState, InventoryResult, ManifestSummary, RefusalInfo } from '../types/gitProjectManifest'; +import type { GitChangePlan, PublicGitChangePlan, GitChangePlanCounts, PublicGitChangePlanOperation } from '../types/gitChangePlan'; +import { GIT_CHANGE_PLAN_SCHEMA_VERSION } from '../types/gitChangePlan'; +import type { NotificationCategory } from './NotificationService'; import type { GitHttpRequest, GitHttpResponse, HttpClient } from 'isomorphic-git/http/node'; // isomorphic-git is the heaviest dependency in the backend (~5 MB) and only @@ -169,10 +175,19 @@ export type GitSourceErrorCode = | 'BRANCH_NOT_FOUND' | 'FILE_NOT_FOUND' | 'NETWORK_TIMEOUT' - | 'GIT_ERROR'; + | 'GIT_ERROR' + | 'STALE_PLAN' + | 'PLAN_FINGERPRINT_REQUIRED' + | 'PLAN_BLOCKED' + | 'LEGACY_PENDING' + | 'PLAN_UNAVAILABLE'; export class GitSourceError extends Error { - constructor(public code: GitSourceErrorCode, message: string) { + constructor( + public code: GitSourceErrorCode, + message: string, + public extras?: { plan?: PublicGitChangePlan; planFingerprint?: string }, + ) { super(message); this.name = 'GitSourceError'; } @@ -270,12 +285,7 @@ export interface CreateStackFromGitResult { export interface PullResult { commitSha: string; - incomingCompose: string; - incomingEnv: string | null; - currentCompose: string; - currentEnv: string | null; validation: { ok: boolean; error?: string }; - hasLocalChanges: boolean; /** Tolerated (non-actionable) refusals from complete-project discovery. */ refusals: RefusalInfo[]; /** Projection of the current managed-project manifest, when one exists. */ @@ -284,6 +294,15 @@ export interface PullResult { candidateReady: boolean; /** Clone-time warnings (submodules present, for example). */ warnings: string[]; + plan: PublicGitChangePlan | null; + planFingerprint: string | null; +} + +export interface PublicPendingPlanView { + fingerprint: string; + blocked: boolean; + counts: GitChangePlanCounts; + operations: PublicGitChangePlanOperation[]; } export interface PublicGitSource { @@ -307,6 +326,18 @@ export interface PublicGitSource { updated_at: number; /** Managed-project manifest cache state (DB-only enum, see gitProjectManifest.ts). */ manifest_state: GitSourceManifestState | null; + pending_plan: PublicPendingPlanView | null; + last_plan_fingerprint: string | null; + last_plan_outcome: string | null; +} + +export interface GitApplyOpts { + deploy?: boolean; + actor?: string; + bypassPolicy?: boolean; + planFingerprint?: string; + /** Public apply requires a fingerprint. Webhook and internal callers pass false. */ + requirePlanFingerprint?: boolean; } // ─── Constants ─────────────────────────────────────────────────────────────── @@ -646,6 +677,9 @@ export class GitSourceService { created_at: src.created_at, updated_at: src.updated_at, manifest_state: src.manifest_state ?? 'absent', + pending_plan: this.parsePendingPlanSummary(src.pending_plan_summary), + last_plan_fingerprint: src.last_plan_fingerprint, + last_plan_outcome: src.last_plan_outcome, }; } @@ -1316,8 +1350,9 @@ export class GitSourceService { * Complete-project materialization inside the clone lifecycle: discover + * classify every declared input, abort on actionable refusals, build the * staged candidate (managed files + filtered build contexts), and validate - * the exact candidate with the exact deploy invocation (including -p). - * Runs only when the complete-project contract applies. + * it with candidateValidationEnvFileArgs (not a mix of staged and live + * --env-file inputs). Includes -p. Runs only when the complete-project + * contract applies. */ private async buildMaterialization( stackName: string, @@ -1376,17 +1411,23 @@ export class GitSourceService { await fsPromises.writeFile(path.join(candidateAbs, '.env'), envContent, 'utf8'); } - const validation = await this.validateCandidate(stackName, candidateRel, src.compose_paths, src.context_dir); + const validation = await this.validateCandidate(stackName, candidateRel, src.compose_paths, src.context_dir, src.sync_env); return { inventory, contextCopyPlans: inventory.contextCopyPlans, candidateRelPath: candidateRel, validation }; } /** - * Validate the staged candidate with the exact deploy invocation: the same - * relative -f order, -p project name, --project-directory, and --env-file - * the deploy uses, run inside the candidate dir. Candidate validation gets a - * larger budget than the pull preview (30s) and names the timeout. + * Validate the staged candidate with the same -f order, -p project name, + * and --project-directory deploy will use, run inside the candidate dir. + * --env-file comes from candidateValidationEnvFileArgs. Candidate + * validation gets a 30s budget. */ - private async validateCandidate(stackName: string, candidateRelPath: string, composePaths: string[], contextDir: string | null): Promise<{ ok: boolean; error?: string }> { + private async validateCandidate( + stackName: string, + candidateRelPath: string, + composePaths: string[], + contextDir: string | null, + syncEnv: boolean, + ): Promise<{ ok: boolean; error?: string }> { const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data'); const candidateAbs = path.join(dataDir, 'git-managed', String(nodeId), stackName, candidateRelPath); @@ -1413,14 +1454,16 @@ export class GitSourceService { } args.push('--project-directory', ctxAbs); } - // Mirror the deploy-time env resolution: only pass --env-file when the - // candidate actually carries the env (sync-env stacks stage it there). - const candidateEnv = path.join(candidateAbs, '.env'); try { - await fsPromises.access(candidateEnv); - args.push('--env-file', candidateEnv); - } catch { - // no staged env; compose falls back to environment interpolation + args.push(...(await candidateValidationEnvFileArgs({ + stackName, + nodeId, + candidateAbs, + contextDir, + syncEnv, + }))); + } catch (err) { + return { ok: false, error: (err as Error).message || 'Invalid project env file configuration.' }; } args.push('config', '--quiet'); const result = await this.runDockerCompose(args, candidateAbs, 30_000); @@ -1478,12 +1521,6 @@ export class GitSourceService { return h.digest('hex'); } - /** Combine an ordered file set into a single path-headed preview for the diff UI. */ - private combinedComposePreview(files: ComposeFile[]): string { - if (files.length <= 1) return files[0]?.content ?? ''; - return files.map(f => `# ── ${f.path} ──\n${f.content}`).join('\n\n'); - } - /** * The deploy-time spec for an ordered file set. Single-file stacks with no * context dir get `null`, so runtime stays plain `docker compose` auto-discovery. @@ -1493,26 +1530,90 @@ export class GitSourceService { return { files: gitSourceLocalComposeFiles(composePaths), contextDir: contextDir ?? null }; } - /** Encrypt the ordered compose file set as the v3 pending blob (carries contextDir + staged candidate + inventory). */ - private encodePendingCompose(files: ComposeFile[], contextDir: string | null, candidateRelPath: string | null, inventory: InventoryResult | null): string { - return this.crypto.encrypt(JSON.stringify({ v: 3, files, contextDir, candidateRelPath, inventory })); + /** Encrypt the candidate as a v4 pending blob (files + inventory + plan identity). */ + private encodePendingCompose( + files: ComposeFile[], + contextDir: string | null, + candidateRelPath: string | null, + inventory: InventoryResult | null, + plan: { + fingerprint: string; + schemaVersion: number; + operationId: string; + reviewedLive?: Array<{ pathKey: string; liveHash: string | null }>; + }, + ): string { + return this.crypto.encrypt(JSON.stringify({ + v: 4, + files, + contextDir, + candidateRelPath, + inventory, + planFingerprint: plan.fingerprint, + planSchemaVersion: plan.schemaVersion, + operationId: plan.operationId, + reviewedLive: plan.reviewedLive ?? [], + })); } /** - * Decrypt a stored pending compose blob into its ordered file set + contextDir. - * Detects the v3 marker first, then v2, then legacy single-file plaintext. - * The `{"v":2` / `{"v":3` prefixes are mutually exclusive, so ordering is for - * readability only; a parse failure under a version marker is corrupt state - * (logged, never silently treated as legacy). + * Decrypt a stored pending compose blob. v4 is the classified-plan format. + * v3 / v2 / plaintext are recognized so apply can refuse them as LEGACY_PENDING + * instead of treating them as a reviewable plan. */ - private decodePendingCompose(stored: string): { files: ComposeFile[]; contextDir: string | null; candidateRelPath: string | null; inventory: InventoryResult | null } { + private decodePendingCompose(stored: string): { + version: 2 | 3 | 4 | 'plaintext'; + files: ComposeFile[]; + contextDir: string | null; + candidateRelPath: string | null; + inventory: InventoryResult | null; + planFingerprint: string | null; + planSchemaVersion: number | null; + operationId: string | null; + reviewedLive: Array<{ pathKey: string; liveHash: string | null }>; + } { const raw = this.crypto.decrypt(stored); + if (raw.startsWith('{"v":4')) { + try { + const parsed = JSON.parse(raw) as { + v: number; + files?: ComposeFile[]; + contextDir?: string | null; + candidateRelPath?: string | null; + inventory?: InventoryResult | null; + planFingerprint?: string; + planSchemaVersion?: number; + operationId?: string; + reviewedLive?: Array<{ pathKey: string; liveHash: string | null }>; + }; + const inventoryValid = + parsed.inventory === null || + (parsed.inventory !== undefined && + Array.isArray(parsed.inventory.inputs) && + Array.isArray(parsed.inventory.refusals) && + Array.isArray(parsed.inventory.buildContexts)); + const reviewedLive = this.parseReviewedLive(parsed.reviewedLive); + if (Array.isArray(parsed.files) && parsed.files.length > 0 && inventoryValid && reviewedLive !== null) { + return { + version: 4, + files: parsed.files, + contextDir: parsed.contextDir ?? null, + candidateRelPath: typeof parsed.candidateRelPath === 'string' ? parsed.candidateRelPath : null, + inventory: parsed.inventory ?? null, + planFingerprint: typeof parsed.planFingerprint === 'string' ? parsed.planFingerprint : null, + planSchemaVersion: typeof parsed.planSchemaVersion === 'number' ? parsed.planSchemaVersion : null, + operationId: typeof parsed.operationId === 'string' ? parsed.operationId : null, + reviewedLive, + }; + } + } catch (e) { + console.error('[GitSource] pending compose blob carried the v4 marker but failed to parse:', (e as Error).message); + } + throw new GitSourceError('PLAN_UNAVAILABLE', 'Pending update cannot be reviewed; pull again.'); + } if (raw.startsWith('{"v":3')) { try { const parsed = JSON.parse(raw) as { v: number; files?: ComposeFile[]; contextDir?: string | null; candidateRelPath?: string | null; inventory?: InventoryResult | null }; - // Shape gate: the inventory drives the manifest build, so a - // structurally wrong inventory must be corrupt, never silently - // filtered into a half-populated manifest. const inventoryValid = parsed.inventory === null || (parsed.inventory !== undefined && @@ -1521,63 +1622,82 @@ export class GitSourceService { Array.isArray(parsed.inventory.buildContexts)); if (Array.isArray(parsed.files) && parsed.files.length > 0 && inventoryValid) { return { + version: 3, files: parsed.files, contextDir: parsed.contextDir ?? null, candidateRelPath: typeof parsed.candidateRelPath === 'string' ? parsed.candidateRelPath : null, inventory: parsed.inventory ?? null, + planFingerprint: null, + planSchemaVersion: null, + operationId: null, + reviewedLive: [], }; } } catch (e) { console.error('[GitSource] pending compose blob carried the v3 marker but failed to parse:', (e as Error).message); } - // A v3-marker blob that fails to parse is corrupt state, not a - // legacy row: applying it as plaintext compose would deploy garbage - // or a misleading validation error. - throw new GitSourceError('GIT_ERROR', 'Pending update is corrupt; pull again to rebuild it.'); + throw new GitSourceError('PLAN_UNAVAILABLE', 'Pending update cannot be reviewed; pull again.'); } if (raw.startsWith('{"v":2')) { try { const parsed = JSON.parse(raw) as { v: number; files?: ComposeFile[]; contextDir?: string | null }; if (Array.isArray(parsed.files) && parsed.files.length > 0) { - return { files: parsed.files, contextDir: parsed.contextDir ?? null, candidateRelPath: null, inventory: null }; + return { + version: 2, + files: parsed.files, + contextDir: parsed.contextDir ?? null, + candidateRelPath: null, + inventory: null, + planFingerprint: null, + planSchemaVersion: null, + operationId: null, + reviewedLive: [], + }; } } catch (e) { console.error('[GitSource] pending compose blob carried the v2 marker but failed to parse; treating as legacy:', (e as Error).message); } } - return { files: [{ path: PRIMARY_COMPOSE_FILENAME, content: raw }], contextDir: null, candidateRelPath: null, inventory: null }; + return { + version: 'plaintext', + files: [{ path: PRIMARY_COMPOSE_FILENAME, content: raw }], + contextDir: null, + candidateRelPath: null, + inventory: null, + planFingerprint: null, + planSchemaVersion: null, + operationId: null, + reviewedLive: [], + }; } - private async readDiskContent(stackName: string, syncEnv: boolean, relFiles: string[]): Promise<{ files: ComposeFile[]; env: string | null }> { - const fsSvc = FileSystemService.getInstance(); - const files: ComposeFile[] = []; - for (let i = 0; i < relFiles.length; i++) { - const rel = relFiles[i]; - try { - // The primary uses compose discovery (compose.yaml / docker-compose.yml); - // additional files are read at their materialized relative path. - const content = i === 0 - ? await fsSvc.getStackContent(stackName) - : (await fsSvc.readStackFile(stackName, rel)).content ?? ''; - files.push({ path: rel, content }); - } catch (e) { - // Empty-on-error is a defensible default (a prior-spec file may have - // been removed by a concurrent edit), but log it so an unexpected - // "local changes detected" can be traced to an unreadable file. - console.warn(`[GitSource] could not read ${sanitizeForLog(rel)} for ${sanitizeForLog(stackName)} diff:`, (e as Error).message); - files.push({ path: rel, content: '' }); - } + private parseReviewedLive( + raw: Array<{ pathKey: string; liveHash: string | null }> | undefined, + ): Array<{ pathKey: string; liveHash: string | null }> | null { + if (!Array.isArray(raw)) return null; + const parsed: Array<{ pathKey: string; liveHash: string | null }> = []; + for (const row of raw) { + if (!row || typeof row.pathKey !== 'string') return null; + if (row.liveHash !== null && typeof row.liveHash !== 'string') return null; + parsed.push({ pathKey: row.pathKey, liveHash: row.liveHash }); } - let env: string | null = null; - if (syncEnv) { - try { - env = await fsSvc.getEnvContent(stackName); - } catch (e) { - console.warn(`[GitSource] could not read .env for ${sanitizeForLog(stackName)} diff:`, (e as Error).message); - env = null; - } + return parsed; + } + + private reviewedLiveFromPlan(plan: GitChangePlan): Array<{ pathKey: string; liveHash: string | null }> { + return plan.operations + .filter((op) => op.op !== 'invocation') + .map((op) => ({ pathKey: op.pathKey, liveHash: op.liveHash })); + } + + private reviewedLiveMap( + rows: Array<{ pathKey: string; liveHash: string | null }> | null | undefined, + ): Map { + const map = new Map(); + for (const row of rows ?? []) { + map.set(row.pathKey.toLowerCase(), row.liveHash); } - return { files, env }; + return map; } /** @@ -1634,11 +1754,19 @@ export class GitSourceService { // ─── Pull / apply ──────────────────────────────────────────────────────── - public async pull(stackName: string): Promise { + public async pull(stackName: string, opts: { actor?: string } = {}): Promise { // Guarded by the per-stack mutex (see withStackLock). Without this, a // concurrent delete-source + pull can land a pending row on a stack // whose config row has just been removed. - return this.withStackLock(stackName, () => this.pullLocked(stackName)); + const actor = opts.actor ?? 'unknown'; + return this.withStackLock(stackName, async () => { + try { + return await this.pullLocked(stackName, actor); + } catch (e) { + this.recordGitActivity(stackName, 'git_pull_failed', `Git pull failed for ${stackName}`, actor, 'error'); + throw e; + } + }); } /** @@ -1649,7 +1777,7 @@ export class GitSourceService { * reads last_debounce_at while it is still unset on every request, slips * past the gate, and clones once per request. */ - private async pullLocked(stackName: string): Promise { + private async pullLocked(stackName: string, actor: string): Promise { const db = DatabaseService.getInstance(); const src = db.getGitSource(stackName); if (!src) throw new GitSourceError('GIT_ERROR', 'No Git source configured for this stack.'); @@ -1678,49 +1806,92 @@ export class GitSourceService { const validation = materialization.value ? materialization.value.validation : await this.validateCompose(fetched.composeFiles, fetched.envContent, src.context_dir); - const appliedFiles = src.applied_deploy_spec?.files ?? [PRIMARY_COMPOSE_FILENAME]; - const disk = await this.readDiskContent(stackName, src.sync_env, appliedFiles); - const currentHash = this.hashContent(disk.files, disk.env); - const hasLocalChanges = src.last_applied_content_hash !== null - && src.last_applied_content_hash !== currentHash; - // Store pending so a subsequent apply doesn't re-fetch. Compose files - // routinely contain secrets inlined as env interpolations or passwords, - // so the v3 blob (ordered files + contextDir + candidate + inventory) - // is encrypted at rest. + let manifestSummary: ManifestSummary | null = null; + const priorRead = await manifestSvc.readManifest(stackName, src.repo_url, src.branch); + if (priorRead !== null && 'corrupt' in priorRead) { + const identityMismatch = priorRead.corrupt.includes('identity'); + throw new GitSourceError( + 'GIT_ERROR', + identityMismatch + ? `The managed-project manifest for ${stackName} is stamped for a different repository or branch. Detach the Git source, then re-link it to the current repository and branch.` + : `The managed-project manifest for ${stackName} cannot be trusted (${priorRead.corrupt}). Detach the Git source and re-link it to rebuild the managed project.`, + ); + } + const prior = priorRead; + if (prior) manifestSummary = manifestSvc.summaryFrom(prior); + + const operationId = crypto.randomUUID(); + let plan: GitChangePlan | null = null; + if (materialization.value?.inventory) { + plan = await this.computeChangePlan({ + stackName, + commitSha: fetched.commitSha, + mode: 'update', + src, + inventory: materialization.value.inventory, + envContent: fetched.envContent, + prior, + }); + } + + const publicPlan = plan ? GitChangePlanService.getInstance().toPublic(plan) : null; + const summary = plan ? GitChangePlanService.getInstance().toPendingSummary(plan) : null; db.setGitSourcePending( stackName, fetched.commitSha, - this.encodePendingCompose(fetched.composeFiles, src.context_dir, materialization.value?.candidateRelPath ?? null, materialization.value?.inventory ?? null), + this.encodePendingCompose( + fetched.composeFiles, + src.context_dir, + materialization.value?.candidateRelPath ?? null, + materialization.value?.inventory ?? null, + { + fingerprint: plan?.fingerprint ?? '', + schemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION, + operationId, + reviewedLive: plan ? this.reviewedLiveFromPlan(plan) : [], + }, + ), fetched.envContent !== null ? this.crypto.encrypt(fetched.envContent) : null, + summary ? { fingerprint: summary.fingerprint, blocked: summary.blocked, summary: JSON.stringify(summary) } : undefined, ); - // Prior manifest summary for the pull response (managed/unmanaged/refused - // counts + pinned revision); a corrupt manifest is surfaced as such. - let manifestSummary: ManifestSummary | null = null; - const prior = await manifestSvc.readManifest(stackName, src.repo_url, src.branch); - if (prior !== null && !('corrupt' in prior)) { - manifestSummary = manifestSvc.summaryFrom(prior); + if (plan) { + this.upsertGitPlanDrift(stackName, plan); + const shortSha = fetched.commitSha.slice(0, 7); + const fpPrefix = plan.fingerprint.slice(0, 12); + if (plan.blocked) { + this.recordGitActivity( + stackName, + 'git_plan_blocked', + `Git plan blocked for ${stackName} (${shortSha}, op ${operationId.slice(0, 8)}, plan ${fpPrefix})`, + actor, + 'warning', + ); + } else { + this.recordGitActivity( + stackName, + 'git_pull_ready', + `Git pull ready for ${stackName} (${shortSha}, op ${operationId.slice(0, 8)}, plan ${fpPrefix})`, + actor, + ); + } } - console.log(`[GitSource] Pending update ready for ${stackName} at ${fetched.commitSha.slice(0, 7)} (validation=${validation.ok ? 'ok' : 'fail'}, localEdits=${hasLocalChanges}, candidate=${materialization.value?.candidateRelPath ?? "none"})`); + console.log(`[GitSource] Pending update ready for ${stackName} at ${fetched.commitSha.slice(0, 7)} (validation=${validation.ok ? 'ok' : 'fail'}, blocked=${plan?.blocked ?? 'n/a'}, candidate=${materialization.value?.candidateRelPath ?? "none"})`); if (diag) { - console.log(`[GitSource:diag] pull done stack=${stackName} sha=${fetched.commitSha.slice(0, 7)} validation=${validation.ok} localEdits=${hasLocalChanges} candidate=${materialization.value !== null}`); + console.log(`[GitSource:diag] pull done stack=${stackName} sha=${fetched.commitSha.slice(0, 7)} validation=${validation.ok} blocked=${plan?.blocked ?? 'n/a'} candidate=${materialization.value !== null}`); } return { commitSha: fetched.commitSha, - incomingCompose: this.combinedComposePreview(fetched.composeFiles), - incomingEnv: fetched.envContent, - currentCompose: this.combinedComposePreview(disk.files), - currentEnv: disk.env, validation, - hasLocalChanges, - // High-sensitivity refusals are redacted on every public surface. refusals: manifestSvc.toPublicRefusals(materialization.value?.inventory.refusals ?? []), manifestSummary, candidateReady: materialization.value !== null && materialization.value.validation.ok, warnings: fetched.warnings, + plan: publicPlan, + planFingerprint: plan?.fingerprint ?? null, }; } @@ -1739,9 +1910,12 @@ export class GitSourceService { public async apply( stackName: string, commitSha: string, - opts: { deploy?: boolean; actor?: string; bypassPolicy?: boolean } = {}, + opts: GitApplyOpts = {}, ): Promise<{ applied: boolean; deployed: boolean; deployError?: string; recoveryId?: string }> { - return this.withStackLock(stackName, () => this.applyWithSharedLock(stackName, commitSha, opts)); + return this.withStackLock(stackName, () => this.applyWithSharedLock(stackName, commitSha, { + ...opts, + requirePlanFingerprint: opts.requirePlanFingerprint !== false, + })); } /** @@ -1753,7 +1927,7 @@ export class GitSourceService { private async applyWithSharedLock( stackName: string, commitSha: string, - opts: { deploy?: boolean; actor?: string; bypassPolicy?: boolean }, + opts: GitApplyOpts, ): Promise<{ applied: boolean; deployed: boolean; deployError?: string; recoveryId?: string }> { const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); const lock = await StackOpLockService.getInstance().runExclusive( @@ -1776,7 +1950,7 @@ export class GitSourceService { private async applyLocked( stackName: string, commitSha: string, - opts: { deploy?: boolean; actor?: string; bypassPolicy?: boolean }, + opts: GitApplyOpts, ): Promise<{ applied: boolean; deployed: boolean; deployError?: string; recoveryId?: string }> { const diag = isDebugEnabled(); const db = DatabaseService.getInstance(); @@ -1793,20 +1967,36 @@ export class GitSourceService { // Materialize from the pending blob (its files + contextDir), never the // live config: a config edit between pull and apply must not change what - // gets written. The v3 blob is decoded here (legacy v2/plaintext blobs - // fall back to the historical file-set path below). + // gets written. const pending = this.decodePendingCompose(src.pending_compose_content); + if (pending.version === 2 || pending.version === 3 || pending.version === 'plaintext') { + throw new GitSourceError('LEGACY_PENDING', 'Pending update was stored before classified review. Pull again to rebuild it.'); + } + if ( + pending.version !== 4 + || pending.inventory === null + || !pending.planFingerprint + || pending.planSchemaVersion !== GIT_CHANGE_PLAN_SCHEMA_VERSION + || !pending.operationId + ) { + throw new GitSourceError('PLAN_UNAVAILABLE', 'Pending update cannot be reviewed; pull again.'); + } + const requireFingerprint = opts.requirePlanFingerprint !== false; + if (requireFingerprint && (!opts.planFingerprint || !opts.planFingerprint.trim())) { + throw new GitSourceError('PLAN_FINGERPRINT_REQUIRED', 'planFingerprint is required to apply this pull.'); + } const envContent = src.pending_env_content !== null ? this.crypto.decrypt(src.pending_env_content) : null; const manifestSvc = GitProjectManifestService.getInstance(); const recoverySvc = StackUpdateRecoveryService.getInstance(); const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + const actor = opts.actor ?? 'system:git-source'; let recoveryId: string | undefined; let appliedSpec: GitSourceAppliedSpec | null; if (pending.candidateRelPath !== null && pending.inventory !== null) { - // ── Complete-project path (v3 pending) ─────────────────────────── + // ── Complete-project path (v4 pending) ─────────────────────────── const prior = await manifestSvc.readManifest(stackName, src.repo_url, src.branch); if (prior !== null && 'corrupt' in prior) { // Any identity-stamp corruption (missing identity, node/stack/ @@ -1828,56 +2018,105 @@ export class GitSourceService { const candidateAbs = path.join(dataDir, 'git-managed', String(nodeId), stackName, pending.candidateRelPath); try { await fsPromises.access(candidateAbs); - } catch { + } catch (accessErr: unknown) { + const code = (accessErr as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') { + console.error( + `[GitSource] candidate access failed for ${sanitizeForLog(stackName)}:`, + accessErr instanceof Error ? accessErr.message : String(accessErr), + ); + throw new GitSourceError('GIT_ERROR', 'Cannot read the pending candidate; try again.'); + } throw new GitSourceError('GIT_ERROR', 'Pending update was invalidated; pull again.'); } // Re-validate the exact candidate before touching the live project. - const candValidation = await this.validateCandidate(stackName, pending.candidateRelPath, src.compose_paths, src.context_dir); + const candValidation = await this.validateCandidate( + stackName, + pending.candidateRelPath, + src.compose_paths, + src.context_dir, + src.sync_env, + ); if (!candValidation.ok) { if (diag) console.log(`[GitSource:diag] apply candidate validation fail stack=${stackName}`); throw new GitSourceError('GIT_ERROR', `Candidate validation failed: ${candValidation.error}`); } - // Local-modification refusal (convergence prelude): every managed, - // present input must still match the manifest hash, or the incoming - // commit would overwrite user edits. Abort before any write. - if (prior) { - const diverged: string[] = []; - const unreadable: string[] = []; - for (const entry of prior.inputs) { - if (entry.ownership !== 'managed' || entry.state !== 'present' || entry.materializedPath === null) continue; - if (entry.contentSha256 === null) continue; - const diskHash = await manifestSvc.hashStackFile(stackName, entry.materializedPath); - if (diskHash === null) unreadable.push(entry.materializedPath); - else if (diskHash !== entry.contentSha256) diverged.push(entry.materializedPath); - } - // Build contexts are file-granular: local edits, added files, - // and missing files inside a retained context are divergence. - const managedInputPaths = new Set( - prior.inputs - .filter((i) => i.ownership === 'managed' && i.materializedPath !== null) - .map((i) => i.materializedPath!), + const plan = await this.computeChangePlan({ + stackName, + commitSha, + mode: 'update', + src, + inventory: pending.inventory, + envContent, + prior: prior ?? null, + reviewedLiveHashes: this.reviewedLiveMap(pending.reviewedLive), + }); + const publicPlan = GitChangePlanService.getInstance().toPublic(plan); + if (plan.fingerprint !== pending.planFingerprint || plan.blocked !== (src.pending_plan_blocked === true)) { + db.updateGitSourcePendingPlan( + stackName, + this.encodePendingCompose( + pending.files, + pending.contextDir, + pending.candidateRelPath, + pending.inventory, + { + fingerprint: plan.fingerprint, + schemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION, + operationId: pending.operationId, + reviewedLive: pending.reviewedLive, + }, + ), + { + fingerprint: plan.fingerprint, + blocked: plan.blocked, + summary: JSON.stringify(GitChangePlanService.getInstance().toPendingSummary(plan)), + }, ); - for (const context of prior.buildContexts) { - if (context.files.length === 0) continue; - const contextDiverged = await manifestSvc.verifyContextOnDisk(stackName, context, managedInputPaths); - for (const rel of contextDiverged) { - diverged.push(`${context.repoPath}/${rel}`); - } - } - if (diverged.length > 0) { - throw new GitSourceError( - 'GIT_ERROR', - `Local modifications detected on ${diverged.join(', ')}. Detach the Git source or restore these files; the incoming commit will not overwrite local changes.`, - ); - } - if (unreadable.length > 0) { - throw new GitSourceError( - 'GIT_ERROR', - `Could not read ${unreadable.join(', ')} to verify it is unchanged; restore it or detach the Git source before applying.`, + } + if (requireFingerprint && opts.planFingerprint !== plan.fingerprint) { + throw new GitSourceError( + 'STALE_PLAN', + 'The change plan is stale. Review the updated plan before applying.', + { plan: publicPlan, planFingerprint: plan.fingerprint }, + ); + } + const blockedPlanActivity = `Git plan blocked for ${stackName} (${commitSha.slice(0, 7)}, op ${pending.operationId.slice(0, 8)}, plan ${plan.fingerprint.slice(0, 12)})`; + if (plan.blocked) { + this.upsertGitPlanDrift(stackName, plan); + db.setGitSourceLastPlan(stackName, plan.fingerprint, 'blocked'); + if (src.pending_plan_blocked !== true) { + this.recordGitActivity( + stackName, + 'git_plan_blocked', + blockedPlanActivity, + actor, + 'warning', ); } + throw new GitSourceError( + 'PLAN_BLOCKED', + 'The change plan is blocked by local conflicts. Resolve them before applying.', + { plan: publicPlan, planFingerprint: plan.fingerprint }, + ); + } + // Unattended apply (webhook auto-write) still refuses invocation drift. + if (!requireFingerprint && plan.invocationBlocked) { + db.setGitSourceLastPlan(stackName, plan.fingerprint, 'blocked'); + this.recordGitActivity( + stackName, + 'git_plan_blocked', + blockedPlanActivity, + actor, + 'warning', + ); + throw new GitSourceError( + 'PLAN_BLOCKED', + 'The live Compose invocation no longer matches the last applied generation. Review the change plan and apply it to record the incoming invocation.', + { plan: publicPlan, planFingerprint: plan.fingerprint }, + ); } // Assemble the new manifest from the pull-time inventory. @@ -1898,13 +2137,7 @@ export class GitSourceService { note: null, } : null; - const invocation: string[] = []; - try { - invocation.push(...(await authoredComposeFileArgs(stackName, nodeId))); - invocation.push(...(await authoredComposeEnvFileArgs(stackName, nodeId))); - } catch (e) { - console.warn(`[GitSource] invocation build failed for ${stackName}:`, (e as Error).message); - } + const invocation = plan.candidateInvocation; const manifest = manifestSvc.buildManifest({ stackName, repoUrl: src.repo_url, @@ -1959,11 +2192,10 @@ export class GitSourceService { adoptExistingMaterializedPaths: legacyOwnedPaths, }); } catch (e) { - // Pre-mutation refusals (collision guard, case-only changes) - // and promotion failures surface as clean GitSourceErrors. - // The original error is logged with its stack for diagnosis, - // and the message is scrubbed of credentials and of the - // incoming manifest's high-sensitivity paths. + // Pre-mutation refusals and promotion failures surface as + // GitSourceErrors. Typed PromoteGenerationError records whether + // restore confirmed so last_plan_outcome never claims a rollback + // that did not happen. if (recoveryId) { try { await recoverySvc.abandon(recoveryId); @@ -1984,70 +2216,39 @@ export class GitSourceService { for (const rel of sensitivePaths) { redacted = redacted.split(rel).join('[redacted]'); } + const phase = e instanceof PromoteGenerationError ? e.phase : 'pre_mutation'; + if (phase === 'restored') { + db.setGitSourceLastPlan(stackName, plan.fingerprint, 'rolled_back'); + this.recordGitActivity( + stackName, + 'git_apply_rolled_back', + `Git apply rolled back for ${stackName} (${commitSha.slice(0, 7)}, op ${pending.operationId.slice(0, 8)}, plan ${plan.fingerprint.slice(0, 12)})`, + actor, + 'warning', + ); + } else { + db.setGitSourceLastPlan(stackName, plan.fingerprint, 'failed'); + this.recordGitActivity( + stackName, + 'git_apply_failed', + `Git apply failed for ${stackName} (${commitSha.slice(0, 7)}, op ${pending.operationId.slice(0, 8)}, plan ${plan.fingerprint.slice(0, 12)})`, + actor, + 'error', + ); + } throw new GitSourceError('GIT_ERROR', scrubCredentials(redacted)); } appliedSpec = this.deriveAppliedSpec(src.compose_paths, src.context_dir); + db.setGitSourceLastPlan(stackName, plan.fingerprint, 'applied'); + DriftLedgerService.getInstance().resolveManagedPathConflicts(nodeId, stackName); + this.recordGitActivity( + stackName, + 'git_apply', + `Git apply succeeded for ${stackName} (${commitSha.slice(0, 7)}, op ${pending.operationId.slice(0, 8)}, plan ${plan.fingerprint.slice(0, 12)})`, + actor, + ); } else { - // ── Legacy path (v2/plaintext pending from before the upgrade) ── - const validation = await this.validateCompose(pending.files, envContent, pending.contextDir); - if (!validation.ok) { - if (diag) console.log(`[GitSource:diag] apply validation fail stack=${stackName}`); - throw new GitSourceError('GIT_ERROR', `Compose validation failed: ${validation.error}`); - } - // Capture the true pre-apply project BEFORE materialize writes new files. - try { - const captured = await recoverySvc.captureCandidate({ - nodeId, - stackName, - createdBy: opts.actor ?? 'git-source', - operationKind: 'git_apply', - }); - recoveryId = captured.id; - } catch (captureError) { - const detail = captureError instanceof Error ? captureError.message : String(captureError); - console.error( - `[GitSource] Recovery capture failed before legacy apply of ${sanitizeForLog(stackName)}:`, - detail, - ); - throw new GitSourceError( - 'GIT_ERROR', - `Rollback capture failed before apply; refusing to materialize without recovery coverage: ${scrubCredentials(detail)}`, - ); - } - appliedSpec = await this.materialize( - stackName, pending.files, pending.contextDir, src.sync_env, envContent, src.applied_deploy_spec, - ).catch(async (materializeError: unknown) => { - if (recoveryId) { - const reverted = await recoverySvc.revertToGenerationContent(recoveryId); - if (!reverted) { - throw new GitSourceError( - 'GIT_ERROR', - `Legacy materialize failed and pre-apply generation restore also failed: ${scrubCredentials( - materializeError instanceof Error ? materializeError.message : String(materializeError), - )}`, - ); - } - try { - await recoverySvc.abandon(recoveryId); - } catch (abandonError) { - console.warn( - `[GitSource] Failed to abandon recovery after legacy materialize failure for ${sanitizeForLog(stackName)}:`, - abandonError instanceof Error ? abandonError.message : String(abandonError), - ); - } - recoveryId = undefined; - } - throw materializeError; - }); - // Migration: build the conservative manifest from spec + disk. - const migrated = await manifestSvc.buildMigratedManifest(stackName, { - repo_url: src.repo_url, - branch: src.branch, - sync_env: src.sync_env, - applied_deploy_spec: appliedSpec, - }); - await manifestSvc.writeManifest(stackName, migrated); - db.setGitSourceManifestState(stackName, migrated.manifestVersion, migrated.state, migrated.generation.appliedDir); + throw new GitSourceError('PLAN_UNAVAILABLE', 'Pending update cannot be reviewed; pull again.'); } const hash = this.hashContent(pending.files, envContent); @@ -2208,10 +2409,9 @@ export class GitSourceService { throw new GitSourceError('GIT_ERROR', `Compose validation failed: ${validation.error}`); } - // 3. Create directory + boilerplate, then promote the staged - // candidate (or fall back to the historical file-set write). - // createStack() throws if the directory already exists, so a - // name collision is caught here. + // 3. Classify the candidate against live disk (the stack directory + // does not exist yet), then create the stack and promote. + // createStack() throws if the directory already exists. let stackCreated = false; let rowInserted = false; // Promotion persists the manifest cache columns BEFORE the row @@ -2219,9 +2419,8 @@ export class GitSourceService { // insert below so list and immediate projections report the real // state instead of 'absent'. let completeProjectManifest: GitProjectManifest | null = null; + let recordedCreatePlan: GitChangePlan | null = null; try { - await fsSvc.createStack(input.stackName); - stackCreated = true; let appliedSpec: GitSourceAppliedSpec | null; if (materialization.value) { const inputs = materialization.value.inventory.inputs.filter( @@ -2244,13 +2443,18 @@ export class GitSourceService { note: null, } : null; - const invocation: string[] = []; - try { - invocation.push(...(await authoredComposeFileArgs(input.stackName, NodeRegistry.getInstance().getDefaultNodeId()))); - invocation.push(...(await authoredComposeEnvFileArgs(input.stackName, NodeRegistry.getInstance().getDefaultNodeId()))); - } catch { - // best-effort; a fresh pull rebuilds the invocation - } + const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + const stackDir = path.resolve(NodeRegistry.getInstance().getComposeDir(nodeId), input.stackName); + const invocation = buildCandidateComposeInvocation({ + stackName: input.stackName, + composePaths: input.composePaths, + contextDir: input.contextDir, + stackDir, + syncEnv: input.syncEnv, + envContentPresent: fetched.envContent !== null, + projectEnvFiles: db.getStackProjectEnvFiles(nodeId, input.stackName), + rootEnvFilePresent: fetched.envContent !== null, + }); const manifest = manifestSvc.buildManifest({ stackName: input.stackName, repoUrl: input.repoUrl, @@ -2267,16 +2471,57 @@ export class GitSourceService { priorManifest: null, state: materialization.value.inventory.refusals.length > 0 ? 'partial' : 'active', }); + const createPlan = await this.computeChangePlan({ + stackName: input.stackName, + commitSha: fetched.commitSha, + mode: 'create', + src: { + stack_name: input.stackName, + repo_url: input.repoUrl, + branch: input.branch, + compose_path: input.composePaths[0], + compose_paths: input.composePaths, + context_dir: input.contextDir, + sync_env: input.syncEnv, + env_path: input.syncEnv ? input.envPath : null, + auth_type: input.authType, + encrypted_token: null, + auto_apply_on_webhook: false, + auto_deploy_on_apply: false, + last_applied_commit_sha: null, + last_applied_content_hash: null, + pending_commit_sha: null, + pending_compose_content: null, + pending_env_content: null, + pending_fetched_at: null, + last_debounce_at: null, + applied_deploy_spec: this.deriveAppliedSpec(input.composePaths, input.contextDir), + } as StackGitSource, + inventory: materialization.value.inventory, + envContent: fetched.envContent, + prior: null, + }); + if (createPlan.blocked) { + throw new GitSourceError( + 'GIT_ERROR', + `Git create blocked for ${input.stackName}: the managed-file change plan reported blocking operations.`, + ); + } + recordedCreatePlan = createPlan; + completeProjectManifest = manifest; + } + + await fsSvc.createStack(input.stackName); + stackCreated = true; + + if (completeProjectManifest && materialization.value) { await manifestSvc.promoteGeneration(input.stackName, { sha: fetched.commitSha, candidateRelPath: materialization.value.candidateRelPath, - manifest, + manifest: completeProjectManifest, priorManifest: null, - // The stack directory was just created by this flow; - // every existing file is adoption boilerplate. adoptExistingMaterializedPaths: 'all', }); - completeProjectManifest = manifest; appliedSpec = this.deriveAppliedSpec(input.composePaths, input.contextDir); } else { appliedSpec = await this.materialize( @@ -2327,6 +2572,24 @@ export class GitSourceService { } rowInserted = true; + const operationId = crypto.randomUUID(); + if (completeProjectManifest && materialization.value && recordedCreatePlan) { + db.setGitSourceLastPlan(input.stackName, recordedCreatePlan.fingerprint, 'applied'); + this.recordGitActivity( + input.stackName, + 'git_create', + `Git create succeeded for ${input.stackName} (${fetched.commitSha.slice(0, 7)}, op ${operationId.slice(0, 8)}, plan ${recordedCreatePlan.fingerprint.slice(0, 12)})`, + 'system:git-source', + ); + } else { + this.recordGitActivity( + input.stackName, + 'git_create', + `Git create succeeded for ${input.stackName} (${fetched.commitSha.slice(0, 7)}, op ${operationId.slice(0, 8)})`, + 'system:git-source', + ); + } + const source = this.get(input.stackName); if (!source) { throw new GitSourceError('GIT_ERROR', 'Failed to read back created git source.'); @@ -2505,8 +2768,17 @@ export class GitSourceService { return { status: 'skipped', message: 'Rate limited (debounced).' }; } + let pullResult: PullResult; + try { + pullResult = await this.pullLocked(stackName, 'system:webhook'); + } catch (e) { + const msg = e instanceof GitSourceError ? `${e.code}: ${e.message}` : (e as Error).message; + const scrubbed = scrubCredentials(msg); + this.recordGitActivity(stackName, 'git_pull_failed', `Git pull failed for ${stackName}`, 'system:webhook', 'error'); + console.error(`[GitSource] Webhook pull failed for ${sanitizeForLog(stackName)}: ${sanitizeForLog(scrubbed)}`); + return { status: 'error', message: scrubbed }; + } try { - const pullResult = await this.pullLocked(stackName); // Only burn the debounce window once the fetch actually produced // something. A transient network failure should be retriable // immediately rather than locked out for the debounce interval. @@ -2526,6 +2798,7 @@ export class GitSourceService { const applied = await this.applyWithSharedLock(stackName, pullResult.commitSha, { deploy: src.auto_deploy_on_apply, actor: 'system:webhook', + requirePlanFingerprint: false, }); if (applied.deployError) { // Apply wrote to disk but deploy failed. Surface it so the @@ -2547,6 +2820,151 @@ export class GitSourceService { }); } + // ─── Change plan helpers ───────────────────────────────────────────────── + + private parsePendingPlanSummary(raw: string | null): PublicPendingPlanView | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as PublicPendingPlanView; + if (typeof parsed.fingerprint !== 'string' || typeof parsed.blocked !== 'boolean') return null; + return parsed; + } catch { + return null; + } + } + + private async liveInvocationArgs(stackName: string, nodeId: number): Promise { + try { + return [ + ...authoredComposeFileArgs(stackName, nodeId), + ...(await authoredComposeEnvFileArgs(stackName, nodeId)), + ]; + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + console.warn(`[GitSource] live invocation read failed for ${stackName}:`, detail); + throw new GitSourceError('GIT_ERROR', `Cannot read the live compose invocation for ${stackName}.`); + } + } + + private candidateInvocationArgs(stackName: string, src: StackGitSource, envContentPresent: boolean): string[] { + const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + // Canonical js/path-injection barrier inline with the existsSync sink. + // CodeQL does not credit a wrapped helper or a check separated from the sink. + const baseResolved = path.resolve(NodeRegistry.getInstance().getComposeDir(nodeId)); + const stackDir = path.resolve(baseResolved, stackName); + let rootEnvFilePresent = false; + if (!src.sync_env) { + const envPath = path.resolve(stackDir, '.env'); + if (envPath.startsWith(baseResolved + path.sep) && existsSync(envPath)) { + rootEnvFilePresent = true; + } + } + return buildCandidateComposeInvocation({ + stackName, + composePaths: src.compose_paths, + contextDir: src.context_dir, + stackDir, + syncEnv: src.sync_env, + envContentPresent, + projectEnvFiles: DatabaseService.getInstance().getStackProjectEnvFiles(nodeId, stackName), + rootEnvFilePresent, + }); + } + + private async computeChangePlan(opts: { + stackName: string; + commitSha: string; + mode: 'update' | 'create'; + src: StackGitSource; + inventory: InventoryResult; + envContent: string | null; + prior: GitProjectManifest | null; + reviewedLiveHashes?: ReadonlyMap; + }): Promise { + const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + const syncEnvEntry: ComposeInputEntry | null = + opts.src.sync_env && opts.envContent !== null + ? { + sourcePath: null, + materializedPath: '.env', + role: 'env', + dependencyKind: 'sync-env', + ownership: 'managed', + provenance: 'fetch', + sensitivity: 'high', + contentSha256: crypto.createHash('sha256').update(opts.envContent).digest('hex'), + sizeBytes: Buffer.byteLength(opts.envContent, 'utf8'), + state: 'present', + deletionAuthority: 'sencho', + note: null, + } + : null; + const inputs = mergeSyncEnvEntry(opts.inventory.inputs, syncEnvEntry); + const liveInvocation = await this.liveInvocationArgs(opts.stackName, nodeId); + const candidateInvocation = this.candidateInvocationArgs(opts.stackName, opts.src, opts.envContent !== null); + const legacyOwnedPaths = opts.prior + ? undefined + : [ + ...(opts.src.applied_deploy_spec?.files ?? [PRIMARY_COMPOSE_FILENAME]), + ...(opts.src.sync_env ? ['.env'] : []), + ]; + return GitChangePlanService.getInstance().build({ + stackName: opts.stackName, + commitSha: opts.commitSha, + mode: opts.mode, + priorManifest: opts.prior, + candidateInputs: inputs, + candidateBuildContexts: opts.inventory.buildContexts, + candidateInvocation, + liveInvocation, + legacyOwnedPaths, + reviewedLiveHashes: opts.reviewedLiveHashes, + projectEnvFiles: DatabaseService.getInstance().getStackProjectEnvFiles(nodeId, opts.stackName), + }); + } + + private upsertGitPlanDrift(stackName: string, plan: GitChangePlan): void { + const blocking = plan.operations.filter((op) => + op.op === 'local-modified' || op.op === 'local-missing' || op.op === 'type-changed' || op.op === 'unmanaged-collision', + ); + if (blocking.length === 0) return; + const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + DriftLedgerService.getInstance().upsertManagedPathConflicts( + nodeId, + stackName, + blocking.map((op) => ({ + path: op.pathKey, + op: op.op, + role: String(op.role), + sensitivity: op.sensitivity, + })), + ); + } + + private recordGitActivity( + stackName: string, + category: NotificationCategory, + message: string, + actor: string, + level: 'info' | 'warning' | 'error' = 'info', + ): void { + try { + DatabaseService.getInstance().addNotificationHistory( + NodeRegistry.getInstance().getDefaultNodeId(), + { + level, + category, + message, + timestamp: Date.now(), + stack_name: stackName, + actor_username: actor, + }, + ); + } catch (error) { + console.error('[GitSource] Failed to record activity for %s:', sanitizeForLog(stackName), error); + } + } + // ─── Concurrency ───────────────────────────────────────────────────────── private async withStackLock(stackName: string, fn: () => Promise): Promise { diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index e4a09b99..535685b5 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -54,6 +54,14 @@ export type NotificationCategory = | 'rollback_generation_released' // Automatic external-network creation during deploy. History-only. | 'network_auto_created' + // Git source change-plan attempts. History-only (Activity timeline). + | 'git_pull_ready' + | 'git_plan_blocked' + | 'git_pull_failed' + | 'git_apply' + | 'git_apply_failed' + | 'git_apply_rolled_back' + | 'git_create' | 'node_update_available' | 'system'; @@ -72,6 +80,8 @@ export const ALL_SUPPRESSIBLE_CATEGORIES: readonly NotificationCategory[] = [ 'drift_detected', 'drift_resolved', 'update_started', 'health_gate_passed', 'health_gate_failed', 'network_auto_created', 'rollback_generation_released', + 'git_pull_ready', 'git_plan_blocked', 'git_pull_failed', + 'git_apply', 'git_apply_failed', 'git_apply_rolled_back', 'git_create', ]; /** Webhook timeout: 10 seconds per external dispatch call. */ diff --git a/backend/src/types/gitChangePlan.ts b/backend/src/types/gitChangePlan.ts new file mode 100644 index 00000000..5485bfac --- /dev/null +++ b/backend/src/types/gitChangePlan.ts @@ -0,0 +1,112 @@ +/** + * Canonical types for the Git managed-file change plan: a classified compare + * of prior-manifest paths, candidate inventory, and live disk. Internal hashes + * stay on the planner; public projections carry operations and counts only. + */ +import type { + DeletionAuthority, + InputOwnership, + InputRole, + InputSensitivity, + ManifestProvenance, +} from './gitProjectManifest'; + +export const GIT_CHANGE_PLAN_SCHEMA_VERSION = 2 as const; + +export type GitChangePlanOp = + | 'add' + | 'modify' + | 'delete' + | 'rename' + | 'unchanged' + | 'local-modified' + | 'local-missing' + | 'type-changed' + | 'unmanaged-collision' + | 'invocation'; + +export type GitChangePlanMode = 'update' | 'create'; + +export type GitPlanLastOutcome = 'applied' | 'blocked' | 'rolled_back' | 'failed'; + +export const BLOCKING_CHANGE_PLAN_OPS: ReadonlySet = new Set([ + 'local-modified', + 'local-missing', + 'type-changed', + 'unmanaged-collision', +]); + +/** One classified path (or the invocation row) before public redaction. */ +export interface GitChangePlanOperation { + pathKey: string; + op: GitChangePlanOp; + role: InputRole | 'build-context-file' | 'invocation'; + deletionAuthority: DeletionAuthority | null; + priorHash: string | null; + candidateHash: string | null; + liveHash: string | null; + sensitivity: InputSensitivity; + /** Present on rename: the prior (deleted) path. */ + fromPath?: string; + ownership: InputOwnership; + provenance: ManifestProvenance; + /** Commit SHA the candidate inventory was built from. */ + sourceRevision: string; + /** Human-readable classification note (internal plan only). */ + reason: string; +} + +export interface GitChangePlanCounts { + add: number; + modify: number; + delete: number; + rename: number; + unchanged: number; + localModified: number; + localMissing: number; + typeChanged: number; + unmanagedCollision: number; + invocation: number; +} + +export interface GitChangePlan { + schemaVersion: typeof GIT_CHANGE_PLAN_SCHEMA_VERSION; + fingerprint: string; + /** File conflicts only. Invocation drift is `invocationBlocked`. */ + blocked: boolean; + /** Live Compose invocation differs from the last applied generation. */ + invocationBlocked: boolean; + candidateInvocation: string[]; + liveInvocation: string[]; + priorInvocation: string[]; + operations: GitChangePlanOperation[]; + counts: GitChangePlanCounts; +} + +/** Public operation: no hashes, high-sensitivity paths redacted to null. */ +export interface PublicGitChangePlanOperation { + path: string | null; + op: GitChangePlanOp; + role: GitChangePlanOperation['role']; + fromPath?: string | null; +} + +export interface PublicGitChangePlan { + /** File conflicts only. Invocation drift is `invocation.liveDiverged`. */ + blocked: boolean; + counts: GitChangePlanCounts; + operations: PublicGitChangePlanOperation[]; + invocation: { + candidateChanged: boolean; + liveDiverged: boolean; + }; +} + +/** GET /git-source pending summary stored in `pending_plan_summary`. */ +export interface PublicPendingPlan { + fingerprint: string; + /** File conflicts only. Invocation drift is not this field. */ + blocked: boolean; + counts: GitChangePlanCounts; + operations: PublicGitChangePlanOperation[]; +} diff --git a/backend/src/utils/authoredComposeArgs.ts b/backend/src/utils/authoredComposeArgs.ts index 1a6b2803..08838c09 100644 --- a/backend/src/utils/authoredComposeArgs.ts +++ b/backend/src/utils/authoredComposeArgs.ts @@ -150,3 +150,39 @@ export async function authoredComposeEnvFileArgs(stackName: string, nodeId?: num } return ['--env-file', envPath]; } + +/** + * `--env-file` arguments for candidate `docker compose config` validation. + * Configured project env files stay live-stack paths (same as deploy). + * Otherwise a context-dir stack uses the candidate `.env` when that file + * exists on the candidate. If it does not, fall back to the live legacy `.env` + * only when that file will survive promotion (`syncEnv` is false). A managed + * synced `.env` that this generation omits must not be used for validation. + */ +export async function candidateValidationEnvFileArgs(opts: { + stackName: string; + nodeId: number; + candidateAbs: string; + contextDir: string | null; + syncEnv: boolean; +}): Promise { + const configured = DatabaseService.getInstance().getStackProjectEnvFiles(opts.nodeId, opts.stackName); + if (configured.length > 0) { + return authoredComposeEnvFileArgs(opts.stackName, opts.nodeId); + } + if (!opts.contextDir) return []; + // Canonical js/path-injection barrier inline with the access sink. CodeQL + // does not credit a wrapped helper or a check separated from the sink. + const baseResolved = path.resolve(opts.candidateAbs); + const candidateEnv = path.resolve(baseResolved, '.env'); + try { + if (candidateEnv.startsWith(baseResolved + path.sep)) { + await fsPromises.access(candidateEnv); + return ['--env-file', candidateEnv]; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + if (opts.syncEnv) return []; + return authoredComposeEnvFileArgs(opts.stackName, opts.nodeId); +} diff --git a/backend/src/utils/candidateComposeInvocation.ts b/backend/src/utils/candidateComposeInvocation.ts new file mode 100644 index 00000000..6ca2134f --- /dev/null +++ b/backend/src/utils/candidateComposeInvocation.ts @@ -0,0 +1,98 @@ +/** + * Pure candidate compose invocation builder. + * + * Derives the ordered docker-compose argv (`-f`, `-p`, optional + * `--project-directory`, `--env-file`) from the *candidate* Git selection, + * never from the currently applied deploy spec. Using the live spec here would + * stamp the previous generation's file list onto a new one. + * + * Project-env-file flags are current stack configuration (not prior spec), so + * the caller may pass them to keep deploy-time env files on the new generation. + */ +import path from 'path'; +import { gitSourceLocalComposeFiles } from './gitComposeFiles'; +import { isPathWithinBase, isValidRelativeStackPath } from './validation'; + +export interface CandidateComposeInvocationInput { + stackName: string; + composePaths: string[]; + contextDir: string | null; + /** Stack directory (absolute). Used only to resolve `--project-directory` and `--env-file`. */ + stackDir: string; + syncEnv: boolean; + envContentPresent: boolean; + /** Stack-root project env files currently configured for this stack. */ + projectEnvFiles?: string[]; + /** + * True when an unmanaged stack-root `.env` will survive promotion. + * Ignored when `syncEnv` is true; that path uses `envContentPresent` only. + */ + rootEnvFilePresent?: boolean; +} + +export function buildCandidateComposeInvocation(input: CandidateComposeInvocationInput): string[] { + const { stackName, composePaths, contextDir, stackDir, syncEnv, envContentPresent } = input; + const stackRoot = path.resolve(stackDir); + const args: string[] = []; + const rootEnvFilePresent = input.rootEnvFilePresent === true; + + const emitFileArgs = composePaths.length > 1 || !!contextDir; + if (emitFileArgs) { + const localFiles = gitSourceLocalComposeFiles(composePaths); + for (const file of localFiles) { + if (!file || !isValidRelativeStackPath(file)) { + throw new Error(`Invalid compose file path in candidate selection for stack "${stackName}"`); + } + if (!isPathWithinBase(path.resolve(stackRoot, file), stackRoot)) { + throw new Error(`Compose file path escapes the stack directory for stack "${stackName}"`); + } + args.push('-f', file); + } + args.push('-p', stackName); + if (contextDir) { + if (!isValidRelativeStackPath(contextDir)) { + throw new Error(`Invalid context directory in candidate selection for stack "${stackName}"`); + } + const ctxAbs = path.resolve(stackRoot, contextDir); + if (!isPathWithinBase(ctxAbs, stackRoot)) { + throw new Error(`Context directory escapes the stack directory for stack "${stackName}"`); + } + args.push('--project-directory', ctxAbs); + } + } + + const projectEnvFiles = input.projectEnvFiles ?? []; + if (projectEnvFiles.length > 0) { + for (const file of projectEnvFiles) { + if (!file || !isValidRelativeStackPath(file)) { + throw new Error(`Invalid project env file path for stack "${stackName}": "${file}"`); + } + if (file.includes('/') || file.includes('\\')) { + throw new Error( + `Project env file "${file}" for stack "${stackName}" must be at the stack root.`, + ); + } + const envPath = path.resolve(stackRoot, file); + if (!isPathWithinBase(envPath, stackRoot)) { + throw new Error(`Project env file path escapes stack directory for stack "${stackName}": "${file}"`); + } + args.push('--env-file', envPath); + } + return args; + } + + // Compose auto-loads stack-root .env for single-file selections. For a + // context dir, emit --env-file only when this generation will own `.env` + // (sync-env content) or an unmanaged live file will survive promotion. + // A managed `.env` scheduled for deletion must not appear here. + const includeRootEnvFile = syncEnv ? envContentPresent : rootEnvFilePresent; + if (contextDir && includeRootEnvFile) { + const envPath = path.resolve(stackRoot, '.env'); + if (!isPathWithinBase(envPath, stackRoot)) { + throw new Error(`Env file path escapes the stack directory for stack "${stackName}"`); + } + args.push('--env-file', envPath); + } + + return args; +} diff --git a/backend/src/utils/gitSourceHttp.ts b/backend/src/utils/gitSourceHttp.ts index 836a9584..ad8d755e 100644 --- a/backend/src/utils/gitSourceHttp.ts +++ b/backend/src/utils/gitSourceHttp.ts @@ -17,13 +17,22 @@ import { GitSourceError } from '../services/GitSourceService'; export function gitSourceStatus(code: GitSourceErrorCode): number { switch (code) { - case 'AUTH_FAILED': return 400; + case 'AUTH_FAILED': + case 'PLAN_FINGERPRINT_REQUIRED': + return 400; case 'REPO_NOT_FOUND': case 'BRANCH_NOT_FOUND': case 'FILE_NOT_FOUND': return 404; - case 'NETWORK_TIMEOUT': return 504; - default: return 400; + case 'STALE_PLAN': + case 'PLAN_BLOCKED': + case 'LEGACY_PENDING': + case 'PLAN_UNAVAILABLE': + return 409; + case 'NETWORK_TIMEOUT': + return 504; + default: + return 400; } } @@ -54,7 +63,10 @@ export function webhookPullStatus(status: 'success' | 'skipped' | 'error'): numb export function sendGitSourceError(res: Response, err: unknown): void { if (err instanceof GitSourceError) { - res.status(gitSourceStatus(err.code)).json({ error: err.message, code: err.code }); + const body: Record = { error: err.message, code: err.code }; + if (err.extras?.plan) body.plan = err.extras.plan; + if (err.extras?.planFingerprint) body.planFingerprint = err.extras.planFingerprint; + res.status(gitSourceStatus(err.code)).json(body); return; } console.error('[GitSource] Unexpected error:', err); diff --git a/docs/features/git-sources.mdx b/docs/features/git-sources.mdx index d443aa26..b9eda3b4 100644 --- a/docs/features/git-sources.mdx +++ b/docs/features/git-sources.mdx @@ -3,7 +3,7 @@ title: Git Sources description: Link a stack to a Git repository and keep one or more compose files in sync via manual pulls or webhook triggers. --- -Git Sources turn any stack into a GitOps target. Point Sencho at a repository and branch, choose one or more compose files to merge in order, pull updates on demand or from CI, and review a diff before applying changes to disk. Optional sibling `.env` sync keeps configuration consistent too. +Git Sources turn any stack into a GitOps target. Point Sencho at a repository and branch, choose one or more compose files to merge in order, pull updates on demand or from CI, and review a classified change plan before applying files to disk. Optional sibling `.env` sync keeps configuration consistent too. Git Sources are available on every tier, including Community. @@ -13,8 +13,8 @@ Git Sources turn any stack into a GitOps target. Point Sencho at a repository an 1. Open a stack and click the **Git Source** button in the editor toolbar. 2. Fill in the repository URL and branch, then choose the compose files. Use **Browse** to pick them from the repository tree, or type a path and press Enter. Add a token if the repo is private. -3. Click **Pull now** to fetch the latest commit. Sencho opens a side-by-side diff between the on-disk files and the incoming version. -4. Click **Apply** to write the incoming content to disk. Tick **Deploy after apply** in the same dialog to redeploy in one step. +3. Click **Pull now** to fetch the latest commit. Sencho opens a classified change plan: adds, modifications, removals, and any local conflicts. +4. Click **Apply** to write the incoming files to disk. Apply stays disabled while local file conflicts are present. A Compose invocation change (for example a `.env` file added or removed outside Git) is shown in the plan and does not disable Apply. Applying records the incoming invocation as the new baseline and leaves unmanaged files on disk. Tick **Deploy after apply** in the same dialog to redeploy in one step. Writes land in the stack's existing directory using the same storage Sencho uses for the in-browser editor. @@ -26,7 +26,7 @@ Writes land in the stack's existing directory using the same storage Sencho uses The panel groups four regions: -- **Pending update banner.** Appears at the top when a webhook in **Review only** mode has fetched a new commit. Click **Review** to re-fetch the incoming commit and open the diff dialog. +- **Pending update banner.** Appears at the top when a webhook in **Review only** mode has fetched a new commit. Click **Review** to re-fetch the incoming commit and open the change plan. If local files conflict with the incoming commit, the banner says the update is blocked. - **Form fields.** Repository URL, branch, the ordered compose-file picker, an optional project directory, optional sibling `.env` sync, authentication toggle, and the apply behavior radio group. - **Last applied stat strip.** Shows the short SHA of the last commit Sencho applied to disk, plus the timestamp of the most recent successful save or pull. - **Footer actions.** **Remove** disconnects the source by exporting the effective compose model into a single `compose.yaml` and removing auto-discovered override files; the remaining materialized files are kept. **Pull now** fetches the configured branch's HEAD; **Save** or **Update** persists form changes after a reachability check passes. @@ -39,7 +39,7 @@ Skip the "empty stack then link later" detour and point at a repo from the start New stack dialog with the From Git tab selected, showing stack name, repository URL, branch, compose path, sibling .env toggle, authentication toggle, apply behavior radio group, a Deploy after create checkbox, and an HTTPS REPOS ONLY footer hint -Sencho fetches the compose files, validates the merged result with `docker compose config`, writes them to a fresh stack directory, and links the Git source in one step. The last-applied commit SHA is seeded from the fetch so the first manual pull produces a clean diff rather than a "local edits detected" warning. +Sencho fetches the compose files, validates the merged result with `docker compose config`, writes them to a fresh stack directory, and links the Git source in one step. The last-applied commit SHA is seeded from the fetch so the first manual pull starts from a clean classified plan. Tick **Deploy after create** to run `docker compose up -d` immediately after the files land. If the deploy fails, the stack and Git source are kept on disk so you can fix the underlying issue (missing image, port conflict, host resources) and retry the deploy from the editor. @@ -76,33 +76,35 @@ On disk, the primary file lands as the stack's `compose.yaml` and each additiona | Mode | What happens when a webhook fires | |------|-----------------------------------| -| **Review only** | Sencho fetches and validates the incoming commit and marks the stack as having a pending update. You review the diff and apply manually. | +| **Review only** | Sencho fetches and validates the incoming commit and marks the stack as having a pending update. You review the change plan and apply manually. | | **Auto-write files** | Sencho writes the new compose and env to disk automatically but does not redeploy. Use this when another process handles rollout. | | **Auto-deploy** | Sencho writes the files and immediately runs `docker compose up -d` so the stack picks up the new configuration. | Auto-deploy implies Auto-write: you cannot deploy automatically without also writing the new files first. -You can always override on the spot: when you click **Apply** in the diff dialog, a **Deploy after apply** checkbox lets you deploy regardless of the configured mode. +You can always override on the spot: when you click **Apply** in the change plan, a **Deploy after apply** checkbox lets you deploy regardless of the configured mode. ## Pulling and reviewing changes Click **Pull now** on the Git Source panel to fetch the latest commit on the configured branch. - GIT · PULL PREVIEW dialog for the demo-app stack, showing a Local edits detected on disk warning above a Monaco side-by-side diff between the on-disk compose.yaml and the incoming commit, with a Deploy after apply checkbox and an Apply button in the footer + GIT · CHANGE PLAN dialog for the demo-app stack, listing classified file operations (add, modify, remove) with a Deploy after apply checkbox and an Apply button in the footer -The diff dialog shows: +The change plan shows: -- The `GIT · PULL PREVIEW` kicker and the short SHA of the incoming commit at the top. -- A side-by-side compare of the on-disk compose file and the incoming version. -- A `.env` tab when the source is configured to sync `.env`. +- The `GIT · CHANGE PLAN` kicker and the short SHA of the incoming commit at the top. +- One row per classified operation: add, modify, remove, rename, a Compose invocation change, or a local conflict. Unchanged files collapse to a count. - An **Incoming compose failed validation** banner when the incoming compose fails `docker compose config`. The Apply button stays disabled until validation passes. -- A **Local edits detected on disk** banner when the on-disk content differs from the last applied commit. Applying in this state opens an **Overwrite local edits?** confirmation modal whose primary button is **Overwrite and apply**. +- A **Local conflicts block apply** banner when a managed file was edited or removed on disk, or an unmanaged file sits in the way. Apply stays disabled until those conflicts are resolved. Sencho does not overwrite them. +- A **Live Compose invocation changed** banner when the Compose command line on disk no longer matches the last applied generation. Apply stays enabled. Applying records the incoming invocation as the new baseline and leaves unmanaged files on disk. + +High-sensitivity paths (secret-bearing files) appear as "secret-bearing managed path" rather than as a filename. ### Pending updates -When a webhook fires in **Review only** mode, the stack gets a pending GitBranch icon next to its row in the sidebar and a pulsing dot on the **Git Source** button in the editor. Clicking either re-fetches the commit and opens the diff dialog; the panel also shows a **Pending update** banner with a **Review** button. +When a webhook fires in **Review only** mode, the stack gets a pending GitBranch icon next to its row in the sidebar and a pulsing dot on the **Git Source** button in the editor. Clicking either re-fetches the commit and opens the change plan; the panel also shows a **Pending update** banner with a **Review** button. If the same stack also has an image update available, the image-update dot in the sidebar takes priority over the Git source icon, so only the update dot renders. The pending Git source is still surfaced inside the editor on the **Git Source** button. @@ -110,7 +112,7 @@ If the same stack also has an image update available, the image-update dot in th Sidebar stack list with a small GitBranch icon next to the demo-app entry indicating a pending Git source update -Click **Dismiss** in the diff dialog to discard a pending update without applying. +Click **Dismiss** in the change plan to discard a pending update without applying. ## Trigger from CI with a webhook @@ -153,16 +155,18 @@ For private repositories, use a Personal Access Token scoped to read access on t Paste the token into the **Token** field and save. Sencho stores it encrypted at rest and never returns it in API responses or UI. When editing the source later, the token field shows a masked placeholder; leave it blank to keep the stored value, or type a new token to replace it. Switching the auth type back to **Public (no auth)** clears the stored token. -The encryption boundary covers the pending update payload too: every pull caches the fetched compose and env content in the database so the diff dialog can reopen without a refetch, and that cached content is encrypted at rest in the same way as the token, since compose files routinely embed secrets via env interpolation. +The encryption boundary covers the pending update payload too: every pull caches the fetched compose and env content in the database so the change plan can reopen without a refetch, and that cached content is encrypted at rest in the same way as the token, since compose files routinely embed secrets via env interpolation. ## Local edits vs Git -Sencho tracks a hash of the compose and env contents at the moment of the last apply. When you pull, it compares that hash against the current on-disk content. +Sencho classifies every managed path against the last applied generation and the live disk. -- Matching hash: applying overwrites content that Sencho itself last wrote. -- Differing hash: someone edited the files outside Git. The diff dialog shows the **Local edits detected on disk** banner, and Apply requires the **Overwrite local edits?** confirmation. +- Matching the last applied content: applying writes files Sencho itself last wrote. +- Locally modified, missing, type-changed, or colliding unmanaged files: the plan is blocked. Resolve those files on disk (or commit them back to the repository), then pull again. Sencho will not overwrite them. +- A local edit still blocks even when the live bytes already match the incoming commit. Classification compares disk to the last applied generation, not to the incoming files, so an uncommitted local edit is never treated as a clean Git apply. +- Compose invocation drift (for example adding or removing a root `.env` outside Git): the plan shows the invocation change and stays applicable. Applying records the incoming invocation as the new baseline. Unmanaged files stay on disk. Webhook auto-apply still refuses until you review that plan. -The in-browser editor and the Git Source panel both write to the same files, so you can always fall back to editing locally. The next pull will just flag the divergence rather than silently clobbering your edits. +The in-browser editor and the Git Source panel both write to the same files, so you can always fall back to editing locally. The next pull will flag the divergence rather than silently clobbering your edits. Pulls, applies, and create-from-git operations on the same stack are serialized by a per-stack lock, so a webhook that fires in the middle of a manual apply waits for the apply to finish rather than racing it. @@ -192,8 +196,12 @@ Pulls, applies, and create-from-git operations on the same stack are serialized Sencho runs `docker compose config` against the incoming content before letting you apply. The error banner shows the exact message. Common causes: unresolved `${VAR}` interpolation (commit a `.env` file next to the compose file and enable sibling `.env` sync), invalid `include:` paths, or schema issues introduced by a recent compose change. Validation has a 10-second budget; an unusually large compose with many services may need to be split. - - The on-disk files diverge from the last applied Git commit. Either confirm **Overwrite and apply** to take the incoming content, or discard local work with a redeploy from the stack editor, or commit your local changes back to the repo so the diff becomes clean. + + A managed file was edited or removed on disk, or an unmanaged file sits on a path the incoming commit wants to add. Sencho will not overwrite those files. Restore or relocate the local copy, or commit the local change back to the repository so the next pull is clean, then pull again. + + + + The Compose command line on disk no longer matches the last applied generation, most often because a root `.env` file was added or removed outside Git. This is not a file conflict: Apply stays enabled. Applying records the incoming invocation as the new baseline and leaves unmanaged files on disk. Webhook auto-apply will not write until you review that plan in the dashboard. @@ -209,7 +217,7 @@ Pulls, applies, and create-from-git operations on the same stack are serialized - You opened a diff dialog, then a webhook fired and replaced the pending commit before you clicked **Apply**. Close the dialog and reopen the panel to load the latest pending commit; the **Review** button will fetch the newer one. + You opened a change plan, then a webhook fired and replaced the pending commit before you clicked **Apply**. Close the dialog and reopen the panel to load the latest pending commit; the **Review** button will fetch the newer one. diff --git a/docs/images/git-sources/diff-dialog.png b/docs/images/git-sources/diff-dialog.png index d19b263f..aec15a3a 100644 Binary files a/docs/images/git-sources/diff-dialog.png and b/docs/images/git-sources/diff-dialog.png differ diff --git a/docs/images/tutorials/connect-a-git-source/pull-preview-diff.png b/docs/images/tutorials/connect-a-git-source/pull-preview-diff.png index ec620b76..c29b1835 100644 Binary files a/docs/images/tutorials/connect-a-git-source/pull-preview-diff.png and b/docs/images/tutorials/connect-a-git-source/pull-preview-diff.png differ diff --git a/docs/tutorials/connect-a-git-source.mdx b/docs/tutorials/connect-a-git-source.mdx index 554a74ac..6d38aa48 100644 --- a/docs/tutorials/connect-a-git-source.mdx +++ b/docs/tutorials/connect-a-git-source.mdx @@ -1,10 +1,10 @@ --- title: Deploy Compose Changes by Pulling From Git Instead of Editing by Hand sidebarTitle: Deploy compose changes from Git -description: Link a running stack to a Git repository, pull a real commit, review the diff, and deploy it, instead of hand-editing the compose file in the browser. +description: Link a running stack to a Git repository, pull a real commit, review the classified change plan, and deploy it, instead of hand-editing the compose file in the browser. --- -Say `marketing-site` is a small nginx stack you created directly in Sencho, and your team has decided the compose file should live in a Git repository instead, so changes go through a commit and a pull request before they reach the stack. This walks through connecting that already-running stack to a repository, pulling a real commit a teammate pushed, reviewing the diff Sencho builds against what is on disk, and applying it, which both writes the new file and redeploys the container. +Say `marketing-site` is a small nginx stack you created directly in Sencho, and your team has decided the compose file should live in a Git repository instead, so changes go through a commit and a pull request before they reach the stack. This walks through connecting that already-running stack to a repository, pulling a real commit a teammate pushed, reviewing the classified change plan Sencho builds against what is on disk, and applying it, which both writes the new files and redeploys the container. This tutorial covers linking an existing stack to a Git source and running one manual pull-review-apply cycle. It doesn't cover creating a brand-new stack directly from a repository, the three webhook-driven apply modes, or multi-file compose sources; see the [Git Sources](/features/git-sources) feature page for all of that. @@ -43,18 +43,18 @@ This tutorial covers linking an existing stack to a Git source and running one m Git source panel with a Repository URL filled in and the Browse file picker open below the Compose files list, showing a checked compose.yaml and an unchecked README.md fetched live from the repository. - Leave **Authentication** on **Public (no auth)** for a public repository, and **Apply behavior** on **Review only**, the safest default: a pull only stages a diff for you to review, it never writes or deploys on its own. Select **Save**. Sencho runs a reachability check against the repository before persisting anything; if that check fails, nothing is saved and the panel reports why. + Leave **Authentication** on **Public (no auth)** for a public repository, and **Apply behavior** on **Review only**, the safest default: a pull only stages a change plan for you to review, it never writes or deploys on its own. Select **Save**. Sencho runs a reachability check against the repository before persisting anything; if that check fails, nothing is saved and the panel reports why. Now make a change the way your team actually would: edit the compose file in your repository (not in Sencho) and push a commit. For this tutorial, bump the pinned tag from `nginx:1.27-alpine` to `nginx:1.28-alpine` and push it to the branch you configured. - Back in the Git Source panel, select **Pull now**. Sencho fetches the branch's current commit and opens a side-by-side diff against what's on disk. + Back in the Git Source panel, select **Pull now**. Sencho fetches the branch's current commit and opens a classified change plan against what's on disk. - GIT · PULL PREVIEW dialog for marketing-site, showing a Monaco side-by-side diff with the on-disk compose.yaml on the left and the incoming commit on the right, the only difference highlighted on the image line: nginx:1.27-alpine changing to nginx:1.28-alpine. + GIT · CHANGE PLAN dialog for marketing-site, listing a Modify row for compose.yaml (image tag change) with Deploy after apply and Apply in the footer. - The diff shows only the line that actually changed. If the incoming compose file failed `docker compose config` validation, an error banner would appear here and the **Apply** button would stay disabled; since this pull is clean, Apply is enabled. + The plan lists only the files that actually change. If the incoming compose file failed `docker compose config` validation, an error banner would appear here and the **Apply** button would stay disabled; since this pull is clean, Apply is enabled. Tick **Deploy after apply** at the bottom of the dialog, then select **Apply**. This both writes the incoming file to disk and runs `docker compose up -d` against it, so the running container picks up the new tag immediately instead of just staging the file for a later manual deploy. diff --git a/e2e/git-sources.spec.ts b/e2e/git-sources.spec.ts index 2e733e87..605242e0 100644 --- a/e2e/git-sources.spec.ts +++ b/e2e/git-sources.spec.ts @@ -453,16 +453,18 @@ test.describe('Git Sources complete-project materialization (local git server)', }, stackName); expect(pull.status, JSON.stringify(pull.body)).toBe(200); expect(pull.body.candidateReady).toBe(true); + expect(pull.body.plan).toBeTruthy(); + expect(JSON.stringify(pull.body)).not.toContain('incomingCompose'); - const applied = await page.evaluate(async ({ name, sha }) => { + const applied = await page.evaluate(async ({ name, sha, fp }) => { const res = await fetch(`/api/stacks/${name}/git-source/apply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ commitSha: sha, deploy: false }), + body: JSON.stringify({ commitSha: sha, planFingerprint: fp, deploy: false }), }); return { status: res.status, body: await res.json() }; - }, { name: stackName, sha: pull.body.commitSha }); + }, { name: stackName, sha: pull.body.commitSha, fp: pull.body.planFingerprint }); expect(applied.status).toBe(200); expect(applied.body.applied).toBe(true); @@ -495,15 +497,15 @@ test.describe('Git Sources complete-project materialization (local git server)', const res = await fetch(`/api/stacks/${name}/git-source/pull`, { method: 'POST', credentials: 'include' }); return await res.json(); }, stackName); - const applied = await page.evaluate(async ({ name, sha }) => { + const applied = await page.evaluate(async ({ name, sha, fp }) => { const res = await fetch(`/api/stacks/${name}/git-source/apply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ commitSha: sha, deploy: false }), + body: JSON.stringify({ commitSha: sha, planFingerprint: fp, deploy: false }), }); return res.status; - }, { name: stackName, sha: pull.commitSha }); + }, { name: stackName, sha: pull.commitSha, fp: pull.planFingerprint }); expect(applied).toBe(200); // Locally modify a managed input through the file editor API. @@ -522,18 +524,22 @@ test.describe('Git Sources complete-project materialization (local git server)', const res = await fetch(`/api/stacks/${name}/git-source/pull`, { method: 'POST', credentials: 'include' }); return await res.json(); }, stackName); - const refused = await page.evaluate(async ({ name, sha }) => { + const refused = await page.evaluate(async ({ name, sha, fp }) => { const res = await fetch(`/api/stacks/${name}/git-source/apply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ commitSha: sha, deploy: false }), + body: JSON.stringify({ commitSha: sha, planFingerprint: fp, deploy: false }), }); return { status: res.status, body: await res.json() }; - }, { name: stackName, sha: secondPull.commitSha }); - expect(refused.status).toBe(400); - expect(JSON.stringify(refused.body)).toContain('Local modifications'); - expect(JSON.stringify(refused.body)).toContain('web.env'); + }, { name: stackName, sha: secondPull.commitSha, fp: secondPull.planFingerprint }); + expect(refused.status).toBe(409); + expect(refused.body.code).toBe('PLAN_BLOCKED'); + const stillLocal = await page.evaluate(async (name) => { + const res = await fetch(`/api/stacks/${name}/files/content?path=web.env`, { credentials: 'include' }); + return res.ok ? await res.text() : ''; + }, stackName); + expect(stillLocal).toContain('locally-edited'); }); test('detaches a multi-file stack with the export contract', async ({ page }) => { @@ -552,15 +558,15 @@ test.describe('Git Sources complete-project materialization (local git server)', const res = await fetch(`/api/stacks/${name}/git-source/pull`, { method: 'POST', credentials: 'include' }); return await res.json(); }, stackName); - const applied = await page.evaluate(async ({ name, sha }) => { + const applied = await page.evaluate(async ({ name, sha, fp }) => { const res = await fetch(`/api/stacks/${name}/git-source/apply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ commitSha: sha, deploy: false }), + body: JSON.stringify({ commitSha: sha, planFingerprint: fp, deploy: false }), }); return res.status; - }, { name: stackName, sha: pull.commitSha }); + }, { name: stackName, sha: pull.commitSha, fp: pull.planFingerprint }); expect(applied).toBe(200); const detached = await page.evaluate(async (name) => { @@ -605,4 +611,29 @@ test.describe('Git Sources complete-project materialization (local git server)', expect(pull.status).toBe(400); expect(JSON.stringify(pull.body)).toMatch(/outside the repository|Cannot materialize/); }); + + test('shows a classified plan in the review dialog and keeps Apply reachable on a phone', async ({ page }) => { + await loginAs(page); + await page.evaluate(async (name) => { + await fetch('/api/stacks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ stackName: name }), + }); + }, stackName); + expect(await saveSource(page, `${server.url}/app.git`, ['compose.yaml'])).toBe(200); + + await page.getByRole('button', { name: 'Create Stack' }).waitFor({ timeout: 15_000 }); + await page.getByText(stackName).first().click(); + await page.getByRole('button', { name: /Git Source/i }).click(); + await expect(page.getByRole('dialog').getByRole('heading', { name: /git source/i })).toBeVisible(); + await page.getByRole('button', { name: /Pull now/i }).click(); + await expect(page.getByTestId('git-plan-op').first()).toBeVisible({ timeout: 20_000 }); + const applyBtn = page.getByRole('button', { name: /^Apply$/ }); + await expect(applyBtn).toBeEnabled(); + + await page.setViewportSize({ width: 375, height: 812 }); + await expect(applyBtn).toBeVisible(); + }); }); diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts index 5e3490db..c57bcf99 100644 --- a/e2e/screenshots.spec.ts +++ b/e2e/screenshots.spec.ts @@ -10,7 +10,7 @@ */ import * as fs from 'fs'; import * as path from 'path'; -import { test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; import { loginAs } from './helpers'; const DOCS_IMAGES = path.resolve(__dirname, '../docs/images'); @@ -51,3 +51,144 @@ test('resources', async ({ page }) => { await page.waitForTimeout(800); await page.screenshot({ path: path.join(DOCS_IMAGES, 'resources.png'), fullPage: true }); }); + +function emptyCounts() { + return { + add: 0, modify: 0, delete: 0, rename: 0, unchanged: 0, + localModified: 0, localMissing: 0, typeChanged: 0, unmanagedCollision: 0, invocation: 0, + }; +} + +function linkedSource(stackName: string) { + return { + id: 1, + stack_name: stackName, + repo_url: 'https://github.com/example/compose.git', + branch: 'main', + compose_path: 'compose.yaml', + compose_paths: ['compose.yaml'], + sync_env: false, + env_path: null, + auth_type: 'none', + has_token: false, + auto_apply_on_webhook: false, + auto_deploy_on_apply: false, + last_applied_commit_sha: '1111111111111111111111111111111111111111', + pending_commit_sha: null, + pending_fetched_at: null, + created_at: 0, + updated_at: 0, + manifest_state: 'active', + manifest: null, + }; +} + +async function createStack(page: Page, stackName: string) { + await page.evaluate(async (name) => { + await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {}); + await fetch('/api/stacks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ stackName: name }), + }); + }, stackName); +} + +async function stubGitSourceAndPull(page: Page, stackName: string, pullBody: unknown) { + await page.route('**/git-source/pull', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(pullBody), + }); + }); + await page.route(new RegExp(`/api/stacks/${stackName}/git-source$`), async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(linkedSource(stackName)), + }); + return; + } + await route.continue(); + }); +} + +async function openStubbedChangePlan(page: Page, stackName: string) { + await page.getByRole('button', { name: 'Create Stack' }).waitFor({ timeout: 15_000 }); + await page.getByText(stackName).first().click(); + await page.getByRole('button', { name: /Git Source/i }).click(); + await expect(page.getByRole('dialog').getByRole('heading', { name: /git source/i })).toBeVisible(); + await page.getByRole('button', { name: /Pull now/i }).click(); + await expect(page.getByTestId('git-plan-op').first()).toBeVisible({ timeout: 10_000 }); +} + +test.describe('classified change-plan docs screenshots', () => { + test.use({ viewport: { width: 1920, height: 1080 } }); + + test('git-sources change plan dialog', async ({ page }) => { + await loginAs(page); + const stackName = 'demo-app'; + await createStack(page, stackName); + await stubGitSourceAndPull(page, stackName, { + commitSha: 'c0ffee12c0ffee12c0ffee12c0ffee12c0ffee12', + validation: { ok: true }, + refusals: [], + warnings: [], + plan: { + blocked: false, + counts: { ...emptyCounts(), add: 1, modify: 1, delete: 1, unchanged: 2 }, + operations: [ + { path: 'added.conf', op: 'add', role: 'config' }, + { path: 'compose.yaml', op: 'modify', role: 'compose-primary' }, + { path: 'extra.conf', op: 'delete', role: 'config' }, + ], + invocation: { candidateChanged: false, liveDiverged: false }, + }, + planFingerprint: 'fp-demo-docs', + }); + await page.goto('/'); + await openStubbedChangePlan(page, stackName); + const planDialog = page.getByRole('dialog').filter({ hasText: 'GIT · CHANGE PLAN' }); + await expect(planDialog).toBeVisible(); + await planDialog.screenshot({ + path: path.join(DOCS_IMAGES, 'git-sources', 'diff-dialog.png'), + }); + await page.evaluate(async (name) => { + await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {}); + }, stackName); + }); + + test('tutorial pull change plan dialog', async ({ page }) => { + await loginAs(page); + const stackName = 'marketing-site'; + await createStack(page, stackName); + await stubGitSourceAndPull(page, stackName, { + commitSha: 'a1b2c3da1b2c3da1b2c3da1b2c3da1b2c3da1b2c', + validation: { ok: true }, + refusals: [], + warnings: [], + plan: { + blocked: false, + counts: { ...emptyCounts(), modify: 1, unchanged: 0 }, + operations: [ + { path: 'compose.yaml', op: 'modify', role: 'compose-primary' }, + ], + invocation: { candidateChanged: false, liveDiverged: false }, + }, + planFingerprint: 'fp-marketing-docs', + }); + await page.goto('/'); + await openStubbedChangePlan(page, stackName); + const planDialog = page.getByRole('dialog').filter({ hasText: 'GIT · CHANGE PLAN' }); + await expect(planDialog).toBeVisible(); + await planDialog.screenshot({ + path: path.join(DOCS_IMAGES, 'tutorials', 'connect-a-git-source', 'pull-preview-diff.png'), + }); + await page.evaluate(async (name) => { + await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {}); + }, stackName); + }); +}); diff --git a/frontend/src/components/stack/DriftPanel.test.tsx b/frontend/src/components/stack/DriftPanel.test.tsx index 13fe3e6e..fe7ab0f4 100644 --- a/frontend/src/components/stack/DriftPanel.test.tsx +++ b/frontend/src/components/stack/DriftPanel.test.tsx @@ -228,4 +228,17 @@ describe('DriftPanel', () => { await screen.findByTestId('drift-status'); expect(screen.queryByText(/checked/i)).not.toBeInTheDocument(); }); + + it('labels a managed-path conflict without rendering the opaque service key', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ + status: 'in-sync', + ledger: [ + { service: 'deadbeefcafebabe', kind: 'managed-path-conflict', message: 'compose-primary local-modified', detectedAt: Date.now(), resolvedAt: null }, + ], + }))); + render(); + await screen.findByText('managed path'); + expect(screen.queryByText('deadbeefcafebabe')).not.toBeInTheDocument(); + expect(screen.getByText('compose-primary local-modified')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/stack/DriftPanel.tsx b/frontend/src/components/stack/DriftPanel.tsx index e1b932a5..61aa38b3 100644 --- a/frontend/src/components/stack/DriftPanel.tsx +++ b/frontend/src/components/stack/DriftPanel.tsx @@ -13,7 +13,7 @@ import { useNodes } from '@/context/NodeContext'; type StackDriftStatus = 'in-sync' | 'drifted' | 'missing-runtime' | 'unreachable'; type DriftFindingKind = | 'service-missing' | 'service-undeclared' | 'image-mismatch' | 'ports-mismatch' - | 'network-undeclared' | 'network-missing'; + | 'network-undeclared' | 'network-missing' | 'managed-path-conflict'; interface StackDriftFinding { kind: DriftFindingKind; @@ -91,6 +91,7 @@ const FINDING_LABEL: Record = { 'ports-mismatch': 'ports', 'network-undeclared': 'network', 'network-missing': 'network missing', + 'managed-path-conflict': 'managed path', }; /** The temporal overlay: how the on-disk compose compares to the last deploy baseline. */ @@ -125,10 +126,13 @@ function temporalMeta(temporal: DriftTemporal): { label: string; icon: LucideIco } function Finding({ finding }: { finding: StackDriftFinding }) { + const gitPath = finding.kind === 'managed-path-conflict'; return (
- {finding.service} + {!gitPath && ( + {finding.service} + )} {FINDING_LABEL[finding.kind]}
{finding.detail}
@@ -146,10 +150,13 @@ function Finding({ finding }: { finding: StackDriftFinding }) { function LedgerRow({ entry }: { entry: DriftLedgerEntry }) { const resolved = entry.resolvedAt != null; + const gitPath = entry.kind === 'managed-path-conflict'; return (
- {entry.service} + {!gitPath && ( + {entry.service} + )} {FINDING_LABEL[entry.kind] ?? entry.kind} {resolved ? 'resolved' : 'open'} diff --git a/frontend/src/components/stack/GitSourceDiffDialog.test.tsx b/frontend/src/components/stack/GitSourceDiffDialog.test.tsx new file mode 100644 index 00000000..1b12aa9b --- /dev/null +++ b/frontend/src/components/stack/GitSourceDiffDialog.test.tsx @@ -0,0 +1,125 @@ +/** + * Classified Git change-plan review: operations render, Apply stays disabled + * when blocked or the plan is missing, and Apply never posts source bytes. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { GitSourceDiffDialog, type PullResult } from './GitSourceDiffDialog'; + +function emptyCounts() { + return { + add: 0, modify: 0, delete: 0, rename: 0, unchanged: 0, + localModified: 0, localMissing: 0, typeChanged: 0, unmanagedCollision: 0, invocation: 0, + }; +} + +function pull(over: Partial = {}): PullResult { + return { + commitSha: 'abcdef1234567890abcdef1234567890abcdef12', + validation: { ok: true }, + plan: { + blocked: false, + counts: { ...emptyCounts(), modify: 1, unchanged: 3 }, + operations: [ + { path: 'compose.yaml', op: 'modify', role: 'compose-primary' }, + ], + invocation: { candidateChanged: false, liveDiverged: false }, + }, + planFingerprint: 'fp-clean', + ...over, + }; +} + +function renderDialog(over: Partial = {}, onApply = vi.fn()) { + render( + , + ); + return onApply; +} + +describe('GitSourceDiffDialog', () => { + it('lists classified operations and collapses unchanged files', () => { + renderDialog(); + expect(screen.getByText('Modify')).toBeInTheDocument(); + expect(screen.getByText('compose.yaml')).toBeInTheDocument(); + expect(screen.getByText('3 unchanged files')).toBeInTheDocument(); + expect(screen.queryByText(/Monaco|Overwrite local edits/i)).not.toBeInTheDocument(); + }); + + it('disables Apply when the plan is blocked', () => { + renderDialog({ + plan: { + blocked: true, + counts: { ...emptyCounts(), localModified: 1 }, + operations: [{ path: 'compose.yaml', op: 'local-modified', role: 'compose-primary' }], + invocation: { candidateChanged: false, liveDiverged: false }, + }, + }); + expect(screen.getByRole('button', { name: /^Apply$/ })).toBeDisabled(); + expect(screen.getAllByText(/Local conflicts block apply/i).length).toBeGreaterThan(0); + }); + + it('keeps Apply enabled for invocation drift and does not claim file conflicts', () => { + const onApply = renderDialog({ + plan: { + blocked: false, + counts: { ...emptyCounts(), invocation: 1, modify: 1 }, + operations: [ + { path: 'compose.yaml', op: 'modify', role: 'compose-primary' }, + { path: null, op: 'invocation', role: 'invocation' }, + ], + invocation: { candidateChanged: false, liveDiverged: true }, + }, + planFingerprint: 'fp-inv', + }); + expect(screen.queryByText(/Local conflicts block apply/i)).not.toBeInTheDocument(); + expect(screen.getByText(/Live Compose invocation changed/i)).toBeInTheDocument(); + expect(screen.getByText('Compose command line')).toBeInTheDocument(); + expect(screen.queryByText(/secret-bearing managed path/i)).not.toBeInTheDocument(); + const apply = screen.getByRole('button', { name: /^Apply$/ }); + expect(apply).toBeEnabled(); + fireEvent.click(apply); + expect(onApply).toHaveBeenCalledWith( + 'abcdef1234567890abcdef1234567890abcdef12', + false, + 'fp-inv', + ); + }); + + it('disables Apply when the plan is missing', () => { + renderDialog({ plan: null, planFingerprint: null }); + expect(screen.getByRole('button', { name: /^Apply$/ })).toBeDisabled(); + expect(screen.getByText(/Change plan unavailable/i)).toBeInTheDocument(); + }); + + it('calls onApply with commitSha, deploy, and planFingerprint', () => { + const onApply = renderDialog(); + fireEvent.click(screen.getByRole('button', { name: /^Apply$/ })); + expect(onApply).toHaveBeenCalledWith( + 'abcdef1234567890abcdef1234567890abcdef12', + false, + 'fp-clean', + ); + }); + + it('redacts a missing path as a secret-bearing managed path', () => { + renderDialog({ + plan: { + blocked: false, + counts: { ...emptyCounts(), modify: 1 }, + operations: [{ path: null, op: 'modify', role: 'env' }], + invocation: { candidateChanged: false, liveDiverged: false }, + }, + }); + expect(screen.getByText('secret-bearing managed path')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/stack/GitSourceDiffDialog.tsx b/frontend/src/components/stack/GitSourceDiffDialog.tsx index 2f9d8551..bb41a29e 100644 --- a/frontend/src/components/stack/GitSourceDiffDialog.tsx +++ b/frontend/src/components/stack/GitSourceDiffDialog.tsx @@ -1,25 +1,67 @@ -import { useState, Suspense } from 'react'; -import { SafeDiffEditor } from '@/lib/SafeDiffEditor'; -import { AlertTriangle, Loader2 } from 'lucide-react'; -import { Modal, ModalHeader, ModalFooter, ConfirmModal } from '@/components/ui/modal'; -import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs'; +import { useState } from 'react'; +import { AlertTriangle, GitBranch, Loader2 } from 'lucide-react'; +import { Modal, ModalHeader, ModalFooter } from '@/components/ui/modal'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; -import { springs } from '@/lib/motion'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +export type GitChangePlanOp = + | 'add' + | 'modify' + | 'delete' + | 'rename' + | 'unchanged' + | 'local-modified' + | 'local-missing' + | 'type-changed' + | 'unmanaged-collision' + | 'invocation'; + +export interface PublicGitChangePlanOperation { + path: string | null; + op: GitChangePlanOp; + role: string; + fromPath?: string | null; +} + +export interface GitChangePlanCounts { + add: number; + modify: number; + delete: number; + rename: number; + unchanged: number; + localModified: number; + localMissing: number; + typeChanged: number; + unmanagedCollision: number; + invocation: number; +} + +export interface PublicGitChangePlan { + blocked: boolean; + counts: GitChangePlanCounts; + operations: PublicGitChangePlanOperation[]; + invocation: { + candidateChanged: boolean; + liveDiverged: boolean; + }; +} + +export interface PublicPendingPlan { + fingerprint: string; + blocked: boolean; + counts: GitChangePlanCounts; + operations: PublicGitChangePlanOperation[]; +} export interface PullResult { commitSha: string; - incomingCompose: string; - incomingEnv: string | null; - currentCompose: string; - currentEnv: string | null; validation: { ok: boolean; error?: string }; - hasLocalChanges: boolean; - /** Tolerated refusals from complete-project discovery; intentionally not surfaced in this dialog (actionable refusals abort the pull, so this is always empty). */ refusals?: Array<{ sourcePath: string | null; kind: string; reason: string; actionable: boolean }>; - /** Clone-time warnings (submodules present, for example). */ warnings?: string[]; + plan: PublicGitChangePlan | null; + planFingerprint: string | null; } interface GitSourceDiffDialogProps { @@ -27,178 +69,191 @@ interface GitSourceDiffDialogProps { onOpenChange: (open: boolean) => void; stackName: string; pull: PullResult | null; - syncEnv: boolean; autoDeployDefault: boolean; - isDarkMode: boolean; applying: boolean; - onApply: (commitSha: string, deploy: boolean) => Promise; + onApply: (commitSha: string, deploy: boolean, planFingerprint: string) => Promise; onDismiss: () => Promise; } +const OP_LABEL: Record = { + add: 'Add', + modify: 'Modify', + delete: 'Remove', + rename: 'Rename', + unchanged: 'Unchanged', + 'local-modified': 'Locally modified', + 'local-missing': 'Missing on disk', + 'type-changed': 'Type changed', + 'unmanaged-collision': 'Unmanaged file in the way', + invocation: 'Compose invocation', +}; + +const BLOCKING_OPS = new Set([ + 'local-modified', + 'local-missing', + 'type-changed', + 'unmanaged-collision', +]); + +function opPathLabel(op: PublicGitChangePlanOperation): string { + if (op.op === 'invocation') return 'Compose command line'; + if (op.op === 'rename' && op.fromPath) { + return `${op.fromPath} → ${op.path ?? 'secret-bearing path'}`; + } + return op.path ?? 'secret-bearing managed path'; +} + export function GitSourceDiffDialog({ open, onOpenChange, stackName, pull, - syncEnv, autoDeployDefault, - isDarkMode, applying, onApply, onDismiss, }: GitSourceDiffDialogProps) { - const [diffTab, setDiffTab] = useState<'compose' | 'env'>('compose'); const [deployAfter, setDeployAfter] = useState(autoDeployDefault); - const [confirmOpen, setConfirmOpen] = useState(false); - - const envAvailable = syncEnv && pull?.incomingEnv !== null; - const effectiveTab = envAvailable ? diffTab : 'compose'; if (!pull) return null; const shortSha = pull.commitSha.slice(0, 7); + const missingPlan = !pull.plan || !pull.planFingerprint; + // plan.blocked is file conflicts only; invocation.liveDiverged does not disable Apply. + const blocked = missingPlan || pull.plan?.blocked === true || !pull.validation.ok; + const ops = pull.plan?.operations ?? []; + const unchanged = pull.plan?.counts.unchanged ?? 0; const apply = async () => { - await onApply(pull.commitSha, deployAfter); + if (!pull.planFingerprint || blocked) return; + await onApply(pull.commitSha, deployAfter, pull.planFingerprint); }; - const handleApplyClick = () => { - if (pull.hasLocalChanges) { - setConfirmOpen(true); - return; - } - apply(); - }; - - const currentValue = effectiveTab === 'compose' ? pull.currentCompose : (pull.currentEnv ?? ''); - const incomingValue = effectiveTab === 'compose' ? pull.incomingCompose : (pull.incomingEnv ?? ''); - return ( - <> - - - -
- {!pull.validation.ok && ( -
- -
-

Incoming compose failed validation

-
{pull.validation.error}
-
-
- )} - {pull.hasLocalChanges && ( -
- -
-

Local edits detected on disk

-

Applying will overwrite changes that differ from the last applied commit.

-
-
- )} - {envAvailable && ( - setDiffTab(v as 'compose' | 'env')}> - - - - Compose - - - .env - - - - - )} -
- -
-
- }> - - -
-
- - - setDeployAfter(checked === true)} - disabled={applying || !pull.validation.ok} - /> - -
- } - secondary={ - - } - primary={ - - } - /> - - - { - setConfirmOpen(false); - await apply(); - }} + + - + +
+ {!pull.validation.ok && ( +
+ +
+

Incoming compose failed validation

+
{pull.validation.error}
+
+
+ )} + {missingPlan && ( +
+ +
+

Change plan unavailable

+

This node did not return a classified plan. Pull again after updating the remote instance.

+
+
+ )} + {pull.plan?.blocked && ( +
+ +
+

Local conflicts block apply

+

Resolve locally modified, missing, or colliding files, then pull again. Sencho will not overwrite them.

+
+
+ )} + {pull.plan?.invocation.liveDiverged && ( +
+ +
+

Live Compose invocation changed

+

The Compose command line on disk no longer matches the last applied generation, for example a .env file was added or removed outside Git. Apply records the incoming invocation as the new baseline. Unmanaged files stay on disk.

+
+
+ )} +
+ +
+ +
    + {ops.map((op, i) => ( +
  • + +
    +

    + {OP_LABEL[op.op]} + {BLOCKING_OPS.has(op.op) ? ' (blocks apply)' : ''} +

    +

    + {opPathLabel(op)} +

    +
    +
  • + ))} + {unchanged > 0 && ( +
  • + {unchanged} unchanged file{unchanged === 1 ? '' : 's'} +
  • + )} + {ops.length === 0 && unchanged === 0 && !missingPlan && ( +
  • No file operations in this plan.
  • + )} +
+
+
+ + + setDeployAfter(checked === true)} + disabled={applying || blocked} + /> + +
+ } + secondary={ + + } + primary={ + + } + /> + ); } diff --git a/frontend/src/components/stack/GitSourcePanel.test.tsx b/frontend/src/components/stack/GitSourcePanel.test.tsx index 0433322f..e3d30542 100644 --- a/frontend/src/components/stack/GitSourcePanel.test.tsx +++ b/frontend/src/components/stack/GitSourcePanel.test.tsx @@ -32,8 +32,28 @@ vi.mock('@/context/NodeContext', () => ({ // Drive applyPull(commitSha, deploy=true) directly without standing up the real // diff UI; the panel passes applyPull as onApply. vi.mock('./GitSourceDiffDialog', () => ({ - GitSourceDiffDialog: ({ onApply }: { onApply: (sha: string, deploy: boolean) => void }) => ( - + GitSourceDiffDialog: ({ + onApply, + pull, + }: { + onApply: (sha: string, deploy: boolean, fp: string) => void; + pull: PullResult | null; + }) => ( +
+ {pull?.planFingerprint ?? ''} + + +
), })); vi.mock('@/components/ui/toast-store', () => ({ @@ -48,6 +68,8 @@ vi.mock('@/components/ui/toast-store', () => ({ import { apiFetch } from '@/lib/api'; import { GitSourcePanel } from './GitSourcePanel'; +import { toast } from '@/components/ui/toast-store'; +import type { PullResult } from './GitSourceDiffDialog'; function jsonRes(body: unknown, ok = true, status = 200) { return { ok, status, json: async () => body, text: async () => '' } as unknown as Response; @@ -90,6 +112,9 @@ beforeEach(() => { vi.mocked(apiFetch).mockReset(); nodeCtl.activeNode = null; dfCtl.params = null; + vi.mocked(toast.success).mockClear(); + vi.mocked(toast.warning).mockClear(); + vi.mocked(toast.error).mockClear(); }); describe('GitSourcePanel load', () => { @@ -129,17 +154,87 @@ describe('GitSourcePanel deploy-mode apply node binding', () => { }); it('binds both runWithLog and the apply POST to the captured node when deploying', async () => { + vi.mocked(apiFetch).mockImplementation(async (url: string) => { + if (String(url).includes('/git-source/apply')) { + return jsonRes({ applied: true, deployed: true }); + } + return jsonRes(LINKED_SOURCE); + }); render(panel()); fireEvent.click(await screen.findByTestId('apply-deploy')); await waitFor(() => { const applyCall = vi.mocked(apiFetch).mock.calls.find(c => String(c[0]).includes('/git-source/apply')); expect(applyCall?.[1]).toEqual(expect.objectContaining({ nodeId: 4 })); + expect(JSON.parse(String((applyCall?.[1] as { body?: string })?.body))).toEqual({ + commitSha: 'sha-123', + planFingerprint: 'fp-test', + deploy: true, + }); }); expect(dfCtl.params).toEqual(expect.objectContaining({ action: 'deploy', nodeId: 4 })); }); }); +describe('GitSourcePanel stale plan handling', () => { + const PULL_RESULT: PullResult = { + commitSha: 'sha-old', + validation: { ok: true }, + refusals: [], + warnings: [], + plan: { + blocked: false, + counts: { + add: 0, + modify: 0, + delete: 0, + rename: 0, + unchanged: 1, + localModified: 0, + localMissing: 0, + typeChanged: 0, + unmanagedCollision: 0, + invocation: 0, + }, + operations: [], + invocation: { candidateChanged: false, liveDiverged: false }, + }, + planFingerprint: 'fp-old', + }; + + beforeEach(() => { + vi.mocked(apiFetch).mockImplementation(async (url: string) => { + if (String(url).includes('/git-source/pull')) { + return jsonRes(PULL_RESULT); + } + if (String(url).includes('/git-source/apply')) { + return jsonRes({ + error: 'The change plan is stale.', + code: 'STALE_PLAN', + planFingerprint: 'fp-new', + plan: { ...PULL_RESULT.plan, blocked: true }, + }, false, 409); + } + return jsonRes(LINKED_SOURCE); + }); + }); + + it('keeps the diff open and replaces the pending plan on STALE_PLAN', async () => { + render(panel()); + fireEvent.click(await screen.findByRole('button', { name: /pull now/i })); + await screen.findByTestId('plan-fingerprint'); + expect(screen.getByTestId('plan-fingerprint')).toHaveTextContent('fp-old'); + + fireEvent.click(screen.getByTestId('apply-only')); + + await waitFor(() => { + expect(toast.warning).toHaveBeenCalledWith(expect.stringMatching(/stale/i)); + expect(screen.getByTestId('plan-fingerprint')).toHaveTextContent('fp-new'); + }); + expect(toast.success).not.toHaveBeenCalled(); + }); +}); + describe('GitSourcePanel manifest summary', () => { it('renders the managed-project section when the source carries a manifest', async () => { const summary = { diff --git a/frontend/src/components/stack/GitSourcePanel.tsx b/frontend/src/components/stack/GitSourcePanel.tsx index 55ce7e54..0a2c7a9d 100644 --- a/frontend/src/components/stack/GitSourcePanel.tsx +++ b/frontend/src/components/stack/GitSourcePanel.tsx @@ -8,7 +8,7 @@ import { apiFetch } from '@/lib/api'; import { useDeployFeedback } from '@/context/DeployFeedbackContext'; import { useNodes } from '@/context/NodeContext'; import { toast } from '@/components/ui/toast-store'; -import { GitSourceDiffDialog, type PullResult } from './GitSourceDiffDialog'; +import { GitSourceDiffDialog, type PullResult, type PublicPendingPlan } from './GitSourceDiffDialog'; import { GitSourceFields, type ApplyMode } from './GitSourceFields'; import { GitManifestSummary, type ManifestSummary } from './GitManifestSummary'; import type { GitBrowseResult } from './GitComposeFilePicker'; @@ -30,6 +30,9 @@ export interface GitSource { last_applied_commit_sha: string | null; pending_commit_sha: string | null; pending_fetched_at: number | null; + pending_plan: PublicPendingPlan | null; + last_plan_fingerprint: string | null; + last_plan_outcome: string | null; created_at: number; updated_at: number; manifest_state: ManifestSummary['state'] | null; @@ -58,7 +61,6 @@ export function GitSourcePanel({ onOpenChange, stackName, canEdit, - isDarkMode, onSourceChanged, }: GitSourcePanelProps) { const [loading, setLoading] = useState(true); @@ -269,7 +271,7 @@ export function GitSourcePanel({ } }; - const applyPull = async (commitSha: string, deploy: boolean) => { + const applyPull = async (commitSha: string, deploy: boolean, planFingerprint: string) => { setApplying(true); const loadingId = toast.loading(deploy ? 'Applying and deploying...' : 'Applying changes...'); // Snapshot the node once so the apply (and any deploy it triggers) stays @@ -281,7 +283,7 @@ export function GitSourcePanel({ const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/apply`, { method: 'POST', nodeId: opNodeId, - body: JSON.stringify({ commitSha, deploy }), + body: JSON.stringify({ commitSha, planFingerprint, deploy }), }); if (res.ok) { const data: { applied: boolean; deployed: boolean; deployError?: string } = await res.json(); @@ -298,8 +300,20 @@ export function GitSourcePanel({ onSourceChanged?.(); return { ok: true }; } else { - const err = await res.json().catch(() => ({})); - const msg = (err as { error?: string }).error || 'Failed to apply changes.'; + const err = await res.json().catch(() => ({})) as { + error?: string; + code?: string; + plan?: PullResult['plan']; + planFingerprint?: string; + }; + if (res.status === 409 && err.code === 'STALE_PLAN' && err.plan && err.planFingerprint) { + setPull((prev) => prev + ? { ...prev, plan: err.plan ?? null, planFingerprint: err.planFingerprint ?? null } + : prev); + toast.warning(err.error || 'The change plan is stale. Review the updated plan before applying.'); + return { ok: false, errorMessage: err.error }; + } + const msg = err.error || 'Failed to apply changes.'; toast.error(msg); return { ok: false, errorMessage: msg }; } @@ -360,12 +374,21 @@ export function GitSourcePanel({ ) : ( <> {source?.pending_commit_sha && ( -
- +
+
-

Pending update

+

+ {source.pending_plan?.blocked ? 'Pending update blocked' : 'Pending update'} +

- Commit {source.pending_commit_sha.slice(0, 7)} is ready to review. + Commit {source.pending_commit_sha.slice(0, 7)} + {source.pending_plan?.blocked + ? ' has local conflicts. Review the plan; apply stays disabled until they are resolved.' + : ' is ready to review.'}