mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 14:08:19 +00:00
feat(git): classify managed-file changes before apply (#1832)
* feat(git): classify managed-file changes before apply Pull now builds a fingerprint-bound plan of adds, modifies, deletes, and local conflicts. Apply refuses stale or blocked plans instead of overwriting live files, and promotion stays the only filesystem mutator. * fix(git): contain stack-dir probes before filesystem access The missing-stack and root-.env existence checks now resolve against the compose base and refuse paths that escape it before lstat or existsSync. * fix(git): address managed-file change plan audit blockers Wire build-context live inventory into the planner, reject special file nodes without readFile, fingerprint configured project env files, enrich plan metadata, and compute the create plan before promotion. Redact drift ledger service keys for managed-path conflicts and clear pending plan columns on revision reset. * fix(git): unblock change-plan CI sinks and fifo test Hash stack files through a contained open plus fstat on the same handle so CodeQL no longer flags the lstat/read race, and create fifo fixtures with mkfifo instead of mkfifoSync. * fix(git): preserve unowned context files and align candidate validation Inspect prior and candidate build contexts together, delete only owned paths, reject context-root symlinks before walking, and validate with the env-file model deploy will use after promotion. * fix(git): contain live context and candidate env path sinks Inline resolve and startsWith at the lstat and access calls so containment is checked at the filesystem sink. * fix(git): resolve live context walks from the compose root Rebuild readdir, lstat, and access paths from the compose directory at each sink so containment is checked against a known-safe base. * fix(git): validate synced env removal against post-promotion files A managed .env that the next revision omits must not be used for candidate validation or invocation, because promotion deletes it. Context walks now bound directory entries and skip descendants under nested symlinks. Plan fingerprints bind review metadata and secret-path matching covers .env.* names. * docs(git): capture classified change-plan review screenshots Replace the old Monaco pull-preview images with the classified operation list used by Apply. * fix(git): treat invocation drift as reviewable, not a file conflict A live Compose command-line change is not a managed-file conflict. Reviewed apply records the incoming invocation; webhook auto-apply still refuses.
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'), '');
|
||||
|
||||
|
||||
@@ -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<ComposeInputEntry> & { 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, unknown>): 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof import('../services/GitProjectManifestService')>(
|
||||
'../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<unknown>) => Promise<unknown> },
|
||||
'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<unknown>) => Promise<unknown>;
|
||||
validateCandidate: (...args: unknown[]) => Promise<{ ok: boolean }>;
|
||||
decodePendingCompose: (raw: string) => unknown;
|
||||
deriveAppliedSpec: (...args: unknown[]) => unknown;
|
||||
hashContent: (...args: unknown[]) => string;
|
||||
computeChangePlan: (...args: unknown[]) => Promise<typeof CLEAN_PLAN>;
|
||||
}) {
|
||||
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<unknown>) => Promise<unknown> },
|
||||
'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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof GitChangePlanService>, 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<typeof FileSystemService>, 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<StackGitSource, 'id' | 'created_at' | 'updated_at' | 'applied_deploy_spec' | 'manifest_version' | 'manifest_state' | 'manifest_generation'>): number {
|
||||
public upsertGitSource(source: Omit<StackGitSource, 'id' | 'created_at' | 'updated_at' | 'applied_deploy_spec' | 'manifest_version' | 'manifest_state' | 'manifest_generation' | 'pending_plan_fingerprint' | 'pending_plan_blocked' | 'pending_plan_summary' | 'last_plan_fingerprint' | 'last_plan_outcome'>): 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -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<ReturnType<FileSystemService['observeStackPath']>>;
|
||||
|
||||
function isSymlinkEscape(err: unknown): boolean {
|
||||
return (err as NodeJS.ErrnoException).code === 'SYMLINK_ESCAPE';
|
||||
}
|
||||
|
||||
async function observeKind(
|
||||
fsSvc: FileSystemService,
|
||||
stackName: string,
|
||||
pathKey: string,
|
||||
): Promise<LiveKind | 'escape'> {
|
||||
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<string, string | null>;
|
||||
/** 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<GitChangePlan> {
|
||||
const priorIndex = input.priorManifest
|
||||
? this.indexPaths(input.priorManifest.inputs, input.priorManifest.buildContexts)
|
||||
: new Map<string, PathMeta>();
|
||||
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<string, PathMeta> {
|
||||
const index = new Map<string, PathMeta>();
|
||||
const contextSensitivity = new Map<string, InputSensitivity>();
|
||||
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<string, string>();
|
||||
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<string>;
|
||||
reviewedLiveHash?: string | null;
|
||||
hasReviewedLive: boolean;
|
||||
isContextExtra: boolean;
|
||||
isProjectEnv: boolean;
|
||||
sourceRevision: string;
|
||||
fsSvc: FileSystemService;
|
||||
manifestSvc: GitProjectManifestService;
|
||||
}): Promise<GitChangePlanOperation> {
|
||||
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<string>();
|
||||
const usedAdds = new Set<string>();
|
||||
const renames: GitChangePlanOperation[] = [];
|
||||
|
||||
const deletesByHash = new Map<string, GitChangePlanOperation[]>();
|
||||
for (const d of deletes) {
|
||||
const list = deletesByHash.get(d.priorHash!) ?? [];
|
||||
list.push(d);
|
||||
deletesByHash.set(d.priorHash!, list);
|
||||
}
|
||||
const addsByHash = new Map<string, GitChangePlanOperation[]>();
|
||||
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<string[]> {
|
||||
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<string, BuildContextPlan>();
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
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<string>): Promise<string[]> {
|
||||
const abs = await this.stackFileAbs(stackName, context.repoPath);
|
||||
async verifyContextOnDisk(
|
||||
stackName: string,
|
||||
context: BuildContextPlan,
|
||||
managedInputPaths?: Set<string>,
|
||||
boundsOverride?: ManifestBounds,
|
||||
): Promise<string[]> {
|
||||
const bounds = boundsOverride ?? this.boundsConfig();
|
||||
const fsSvc = FileSystemService.getInstance();
|
||||
let rootKind: Awaited<ReturnType<FileSystemService['observeStackPath']>>;
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<ReturnType<FileSystemService['observeStackPath']>>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
// 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<string, Set<string>>();
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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. */
|
||||
|
||||
@@ -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<GitChangePlanOp> = 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[];
|
||||
}
|
||||
@@ -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<string[]> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, unknown> = { 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);
|
||||
|
||||
Reference in New Issue
Block a user