feat: ordered multi-file Compose for Git sources (#1380)

* feat: ordered multi-file Compose for Git sources

Extend Git sources to deploy an ordered list of compose files merged with
docker compose -f base.yaml -f override.yaml ..., plus an optional project
directory.

- Pick and reorder compose files from the repository tree (drag to reorder on
  desktop, up/down arrows on phones); manual path entry is also supported.
- The ordered set drives every stack-scoped compose command (deploy, update,
  start/stop/restart/down, image scans, Compose Doctor) and the container
  lookup, so a service or image declared only in an override is handled too.
- Runtime keys off the materialized set, not the saved configuration: saving a
  source does not change deploy args until the pull is applied, and apply
  materializes from the pending snapshot rather than live config.
- The project directory is passed as --project-directory, with -p <stack>
  pinning the Compose project so container labels stay stable.
- The Mesh override is layered last; single-file sources are byte-identical to
  before, and existing rows keep working via the single-path fallback.

Docs cover the picker, ordering, project directory, and the new troubleshooting
and limitations (referenced files are not materialized; the dependency graph,
drift, and networking views read the primary file).

* fix: harden multi-file Git source (hash, unlink, collisions, node id)

- hashContent folds ordered file CONTENTS (not paths) so a clean multi-file
  stack is not flagged as locally edited: create/apply hash the fetched files
  (repo paths) while pull hashes the on-disk files (materialized paths), which
  previously disagreed and showed a false "local edits detected".
- Block unlinking a multi-file or project-directory Git source (409): the deploy
  spec lives on the source row, so removing it would silently revert deploys to
  root compose.yaml. Single-file sources still unlink.
- Reject materialized-path collisions in the selection validator: an additional
  file equal to or nested under compose.yaml, an ancestor/descendant overlap
  between selected files, and a project directory nested under a compose file
  (previously a 500 at materialization).
- DockerController.getContainersByStack uses the controller's node compose dir
  and passes its node id to the authored prefix, instead of the process default.

* fix: CI failures on multi-file Git source (test crash, aria query, path barrier)

- GitSourceFields no longer crashes when repoUrl/branch are falsy: the canBrowse
  trim() is optional-chained, so a reusable field component tolerates partial
  props. Fixes the apply-binding panel test, which feeds a minimal source object.
- GitSourcePanel tests query the footer Remove button by its exact name, so the
  picker's per-file "Remove <path>" buttons no longer collide with the broad
  /remove/i match (the test intent, footer Remove present/absent, is unchanged).
- validateCompose uses an inline resolve + startsWith barrier at the context-dir
  mkdir sink (CodeQL does not credit the wrapped isPathWithinBase helper),
  clearing the js/path-injection alert. The containment check is equivalent and
  contextDir is also validated upstream.

* test: update Git source E2E spec for the multi-file compose picker

The compose-file picker replaced the single #git-source-path input and added
per-file Remove buttons, so the E2E spec drove selectors that no longer exist:

- Drop the redundant compose.yaml fills (the picker defaults to compose.yaml).
- Select the footer Remove button by exact name so the picker's per-file
  "Remove <path>" buttons no longer make the locator ambiguous.
- Set a custom compose path through the picker (add via the manual input, press
  Enter, then remove the default compose.yaml).

* test: match the footer Remove button with an exact Playwright name

Playwright's getByRole name option is a substring match by default, so
{ name: 'Remove' } also matched the picker's "Remove <path>" buttons. Require an
exact match so only the footer Remove button is selected.
This commit is contained in:
Anso
2026-06-17 13:24:55 -04:00
committed by GitHub
parent 7ce045accb
commit f23b7e1bac
34 changed files with 2299 additions and 385 deletions
@@ -0,0 +1,138 @@
/**
* Unit tests for authoredComposeFileArgs: the docker compose global-flag prefix
* (`-f` per file, `-p <project>`, optional `--project-directory`) derived from a
* stack's applied deploy spec.
*
* Contract:
* - no applied spec (single-file / non-git) -> [] so runtime stays plain auto-discovery
* - a multi-file applied spec -> ordered -f flags, then -p <stack>, then
* --project-directory <abs> when a context dir is set
* - any spec file path or context dir that is absolute / contains ".." throws
* before any args are returned (it is spliced straight into child-process argv)
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import path from 'path';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let authoredComposeFileArgs: typeof import('../utils/authoredComposeArgs').authoredComposeFileArgs;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ authoredComposeFileArgs } = await import('../utils/authoredComposeArgs'));
({ DatabaseService } = await import('../services/DatabaseService'));
({ NodeRegistry } = await import('../services/NodeRegistry'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
const db = DatabaseService.getInstance();
for (const s of db.getGitSources()) db.deleteGitSource(s.stack_name);
});
/** Seed a git-source row (no applied spec yet) so setGitSourceAppliedSpec can target it. */
function seedSource(stackName: string, composePaths: string[]): void {
DatabaseService.getInstance().upsertGitSource({
stack_name: stackName,
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_path: composePaths[0],
compose_paths: composePaths,
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: null,
pending_compose_content: null,
pending_env_content: null,
pending_fetched_at: null,
last_debounce_at: null,
});
}
describe('authoredComposeFileArgs', () => {
it('returns [] for a stack with no git source at all', () => {
expect(authoredComposeFileArgs('no-such-stack')).toEqual([]);
});
it('returns [] for a git-source stack with no applied spec (single-file)', () => {
seedSource('single-stack', ['compose.yaml']);
// No setGitSourceAppliedSpec call -> applied_deploy_spec stays null.
expect(authoredComposeFileArgs('single-stack')).toEqual([]);
});
it('returns ordered -f / -p / --project-directory for a multi-file spec', () => {
const stackName = 'multi-stack';
seedSource(stackName, ['infra/base.yml', 'infra/prod.yml']);
DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
files: ['compose.yaml', 'infra/prod.yml'],
contextDir: 'app',
});
const args = authoredComposeFileArgs(stackName);
const baseDir = NodeRegistry.getInstance().getComposeDir(NodeRegistry.getInstance().getDefaultNodeId());
const expectedCtx = path.resolve(baseDir, stackName, 'app');
expect(args).toEqual([
'-f', 'compose.yaml',
'-f', 'infra/prod.yml',
'-p', stackName,
'--project-directory', expectedCtx,
]);
});
it('omits --project-directory when the spec has no context dir', () => {
const stackName = 'multi-no-ctx';
seedSource(stackName, ['infra/base.yml', 'infra/prod.yml']);
DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
files: ['compose.yaml', 'infra/prod.yml'],
contextDir: null,
});
expect(authoredComposeFileArgs(stackName)).toEqual([
'-f', 'compose.yaml',
'-f', 'infra/prod.yml',
'-p', stackName,
]);
});
it('throws when a spec file path is absolute', () => {
const stackName = 'abs-file';
seedSource(stackName, ['compose.yaml', 'infra/prod.yml']);
DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
files: ['compose.yaml', '/etc/passwd'],
contextDir: null,
});
expect(() => authoredComposeFileArgs(stackName)).toThrow();
});
it('throws when a spec file path contains a ".." traversal', () => {
const stackName = 'dotdot-file';
seedSource(stackName, ['compose.yaml', 'infra/prod.yml']);
DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
files: ['compose.yaml', '../escape.yml'],
contextDir: null,
});
expect(() => authoredComposeFileArgs(stackName)).toThrow();
});
it('throws when the context dir contains a ".." traversal', () => {
const stackName = 'dotdot-ctx';
seedSource(stackName, ['compose.yaml', 'infra/prod.yml']);
DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
files: ['compose.yaml', 'infra/prod.yml'],
contextDir: '../escape',
});
expect(() => authoredComposeFileArgs(stackName)).toThrow();
});
});
+1 -1
View File
@@ -38,7 +38,7 @@ vi.mock('../services/DockerController', () => ({
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: { getInstance: () => ({ getRegistries: () => [] }) },
DatabaseService: { getInstance: () => ({ getRegistries: () => [], getGitSource: () => undefined }) },
}));
vi.mock('../services/RegistryService', () => ({
@@ -82,6 +82,7 @@ vi.mock('../services/DatabaseService', () => ({
getInstance: () => ({
getRegistries: mockGetRegistries,
getGlobalSettings: mockGetGlobalSettings,
getGitSource: () => undefined,
}),
},
}));
@@ -107,6 +108,15 @@ vi.mock('../services/LogFormatter', () => ({
LogFormatter: { formatLine: (line: string) => line },
}));
// runCommand and the deploy/update paths route through authoredComposeArgs, which
// resolves the (optional) mesh override. Stub it to "no override" so a single-file
// stack yields plain `docker compose <action>` args deterministically.
vi.mock('../services/MeshService', () => ({
MeshService: {
getInstance: () => ({ ensureStackOverride: vi.fn().mockResolvedValue(null) }),
},
}));
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
const originalComposeTimeout = process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS;
@@ -130,6 +140,16 @@ function createMockProcess() {
return proc;
}
/**
* runCommand now awaits the authored compose args (mesh override resolution) before
* spawning, so the child is created one microtask after the call. Tests that drive
* the mock child's events must wait for the spawn first, or the event fires before
* any listener is attached.
*/
async function waitForSpawn() {
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
}
/** Sets up mockSpawn to auto-close with exit code 0 on next tick */
function setupAutoCloseSpawn(exitCode = 0) {
mockSpawn.mockImplementation(() => {
@@ -186,6 +206,7 @@ describe('ComposeService - runCommand', () => {
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart');
await waitForSpawn();
proc.emit('close', 0);
await promise;
@@ -202,6 +223,7 @@ describe('ComposeService - runCommand', () => {
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'start');
await waitForSpawn();
proc.emit('close', 0);
await expect(promise).resolves.toBeUndefined();
@@ -213,6 +235,7 @@ describe('ComposeService - runCommand', () => {
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'stop');
await waitForSpawn();
proc.stderr.emit('data', Buffer.from('service not found'));
proc.emit('close', 1);
@@ -225,6 +248,7 @@ describe('ComposeService - runCommand', () => {
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'stop');
await waitForSpawn();
proc.stderr.emit('data', Buffer.from('token=abc123SECRET password=hunter2 Authorization: Bearer abc.def.ghi'));
proc.emit('close', 1);
@@ -240,6 +264,7 @@ describe('ComposeService - runCommand', () => {
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart', ws);
await waitForSpawn();
proc.stdout.emit('data', Buffer.from('Restarting...'));
proc.emit('close', 0);
await promise;
@@ -272,6 +297,7 @@ describe('ComposeService - runCommand', () => {
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart', ws);
await waitForSpawn();
// The deploy is owned by its HTTP request; closing the progress socket
// (panel minimized, navigated away, connection blip) must not abort it.
ws.emit('close');
@@ -292,6 +318,7 @@ describe('ComposeService - runCommand', () => {
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart', ws);
await waitForSpawn();
const err = Object.assign(new Error('spawn docker ENOMEM'), { code: 'ENOMEM' });
proc.emit('error', err);
@@ -314,6 +341,7 @@ describe('ComposeService - runCommand', () => {
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart', ws);
await waitForSpawn();
const err = Object.assign(new Error('spawn docker ENOENT'), { code: 'ENOENT' });
proc.emit('error', err);
@@ -335,6 +363,7 @@ describe('ComposeService - runCommand', () => {
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart');
await waitForSpawn();
const err = Object.assign(new Error('spawn docker ENOENT'), { code: 'ENOENT' });
proc.emit('error', err);
@@ -38,6 +38,7 @@ vi.mock('../services/NodeRegistry', () => ({
// Prevent COMPOSE_DIR related issues
vi.mock('child_process', () => ({
exec: vi.fn(),
execFile: vi.fn(),
}));
vi.mock('util', () => ({
promisify: () => vi.fn(),
@@ -0,0 +1,37 @@
/**
* Unit tests for gitSourceLocalComposeFiles: the mapping from ordered repo
* compose paths to the local relative filenames Sencho materializes under the
* stack directory.
*
* Contract:
* - index 0 (primary) always maps to compose.yaml at the stack root
* - every additional file keeps its repo-relative path, with a leading "./" stripped
*/
import { describe, it, expect } from 'vitest';
import { gitSourceLocalComposeFiles, PRIMARY_COMPOSE_FILENAME } from '../utils/gitComposeFiles';
describe('gitSourceLocalComposeFiles', () => {
it('maps the primary file to compose.yaml regardless of its repo name', () => {
expect(gitSourceLocalComposeFiles(['infra/base.yml'])).toEqual([PRIMARY_COMPOSE_FILENAME]);
expect(gitSourceLocalComposeFiles(['deploy/docker-compose.prod.yaml'])).toEqual(['compose.yaml']);
});
it('keeps additional files at their repo-relative paths', () => {
expect(gitSourceLocalComposeFiles(['infra/base.yml', 'infra/prod.yml']))
.toEqual(['compose.yaml', 'infra/prod.yml']);
});
it('strips a leading "./" only from additional files', () => {
expect(gitSourceLocalComposeFiles(['./base.yml', './override/prod.yml']))
.toEqual(['compose.yaml', 'override/prod.yml']);
});
it('preserves order across a larger set', () => {
expect(gitSourceLocalComposeFiles(['a/base.yml', 'b/x.yml', 'c/y.yml', 'd/z.yml']))
.toEqual(['compose.yaml', 'b/x.yml', 'c/y.yml', 'd/z.yml']);
});
it('returns just compose.yaml for a single-file selection', () => {
expect(gitSourceLocalComposeFiles(['compose.yaml'])).toEqual(['compose.yaml']);
});
});
+127 -2
View File
@@ -26,6 +26,8 @@ function seedGitSource(stackName: string): void {
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',
@@ -132,7 +134,7 @@ describe('PUT /api/stacks/:stackName/git-source — max-length caps', () => {
compose_path: 'c'.repeat(1100),
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/compose_path/i);
expect(res.body.error).toMatch(/compose path/i);
});
it('rejects oversized env_path', async () => {
@@ -191,7 +193,7 @@ describe('git-source routes — repository path validation', () => {
auth_type: 'none',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/compose_path/i);
expect(res.body.error).toMatch(/compose path/i);
});
it('rejects absolute env_path on create-from-git', async () => {
@@ -288,6 +290,97 @@ describe('GET /api/stacks/:stackName/git-source', () => {
});
});
describe('PUT /api/stacks/:stackName/git-source: multi-file selection', () => {
// The success-path tests forward a parsed selection to upsert(), whose dry-run
// fetch would clone a real repo. Stub upsert so the assertion stays at the
// route layer (parse + forward) without network. Rejection-path tests hit the
// parseComposeSelection 400 before upsert is ever reached, so they need no stub.
it('persists a compose_paths array and forwards it to the service', async () => {
const upsertSpy = vi.spyOn(GitSourceService.getInstance(), 'upsert')
.mockResolvedValue({} as Awaited<ReturnType<typeof GitSourceService.prototype.upsert>>);
const res = await request(app)
.put('/api/stacks/existing-stack/git-source')
.set('Authorization', `Bearer ${adminToken()}`)
.send({
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_paths: ['infra/base.yml', 'infra/prod.yml'],
context_dir: 'app',
auth_type: 'none',
});
expect(res.status).toBe(200);
expect(upsertSpy).toHaveBeenCalledWith(expect.objectContaining({
composePaths: ['infra/base.yml', 'infra/prod.yml'],
contextDir: 'app',
}));
upsertSpy.mockRestore();
});
it('still accepts the legacy compose_path string and maps it to a one-element array', async () => {
const upsertSpy = vi.spyOn(GitSourceService.getInstance(), 'upsert')
.mockResolvedValue({} as Awaited<ReturnType<typeof GitSourceService.prototype.upsert>>);
const res = await request(app)
.put('/api/stacks/existing-stack/git-source')
.set('Authorization', `Bearer ${adminToken()}`)
.send({
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_path: 'stacks/web/compose.yaml',
auth_type: 'none',
});
expect(res.status).toBe(200);
expect(upsertSpy).toHaveBeenCalledWith(expect.objectContaining({
composePaths: ['stacks/web/compose.yaml'],
contextDir: null,
}));
upsertSpy.mockRestore();
});
it('rejects a compose_paths array with more than 10 files (400)', async () => {
const tooMany = Array.from({ length: 11 }, (_, i) => `f${i}.yml`);
const res = await request(app)
.put('/api/stacks/existing-stack/git-source')
.set('Authorization', `Bearer ${adminToken()}`)
.send({
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_paths: tooMany,
auth_type: 'none',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/exceed/i);
});
it('rejects duplicate entries in compose_paths (400)', async () => {
const res = await request(app)
.put('/api/stacks/existing-stack/git-source')
.set('Authorization', `Bearer ${adminToken()}`)
.send({
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_paths: ['compose.yaml', 'infra/prod.yml', 'infra/prod.yml'],
auth_type: 'none',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/duplicate/i);
});
it('rejects a context_dir that collides with the primary compose.yaml (400)', async () => {
const res = await request(app)
.put('/api/stacks/existing-stack/git-source')
.set('Authorization', `Bearer ${adminToken()}`)
.send({
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_paths: ['compose.yaml'],
context_dir: 'compose.yaml',
auth_type: 'none',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/context_dir/i);
});
});
describe('POST /api/stacks/from-git', () => {
const validBody = {
stack_name: 'route-from-git',
@@ -399,6 +492,38 @@ describe('POST /api/stacks/:stackName/git-source/webhook-pull status codes', ()
});
});
describe('DELETE /api/stacks/:stackName/git-source — multi-file unlink guard', () => {
it('blocks unlinking a multi-file source with 409 and keeps the row', async () => {
seedGitSource('mf-unlink');
DatabaseService.getInstance().setGitSourceAppliedSpec('mf-unlink', { files: ['compose.yaml', 'infra/prod.yml'], contextDir: null });
const res = await request(app)
.delete('/api/stacks/mf-unlink/git-source')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/multiple compose files/i);
expect(DatabaseService.getInstance().getGitSource('mf-unlink')).toBeTruthy();
});
it('blocks unlinking a context-dir source with 409', async () => {
seedGitSource('ctx-unlink');
DatabaseService.getInstance().setGitSourceAppliedSpec('ctx-unlink', { files: ['compose.yaml'], contextDir: 'app' });
const res = await request(app)
.delete('/api/stacks/ctx-unlink/git-source')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(409);
expect(DatabaseService.getInstance().getGitSource('ctx-unlink')).toBeTruthy();
});
it('allows unlinking a single-file source', async () => {
seedGitSource('sf-unlink');
const res = await request(app)
.delete('/api/stacks/sf-unlink/git-source')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
expect(DatabaseService.getInstance().getGitSource('sf-unlink')).toBeUndefined();
});
});
describe('GET /api/git-sources', () => {
it('returns 200 and a JSON array for an authenticated admin', async () => {
const res = await request(app)
@@ -0,0 +1,168 @@
/**
* Unit tests for the compose-selection parser used by the git-source routes.
*
* parseComposeSelection normalizes a request body into an ordered compose-path
* list plus an optional context dir, enforcing the file-count cap, duplicate
* rejection, the reserved-primary-name rule, and context-dir validation. Pure
* function, no DB or filesystem.
*/
import { describe, it, expect } from 'vitest';
import { parseComposeSelection, defaultEnvPath, MAX_COMPOSE_FILES } from '../helpers/gitSourceSelection';
/** Narrow the result union to its ok branch (throws if the parse failed). */
function expectOk(result: ReturnType<typeof parseComposeSelection>) {
if (!result.ok) throw new Error(`expected ok, got error: ${result.error}`);
return result.value;
}
describe('parseComposeSelection', () => {
it('maps a legacy single compose_path string to a one-element array', () => {
const value = expectOk(parseComposeSelection({ compose_path: 'stacks/web/compose.yaml' }));
expect(value.composePaths).toEqual(['stacks/web/compose.yaml']);
expect(value.contextDir).toBeNull();
});
it('accepts an ordered compose_paths array', () => {
const value = expectOk(parseComposeSelection({ compose_paths: ['infra/base.yml', 'infra/prod.yml'] }));
expect(value.composePaths).toEqual(['infra/base.yml', 'infra/prod.yml']);
});
it('prefers compose_paths over a legacy compose_path when both are present', () => {
const value = expectOk(parseComposeSelection({
compose_paths: ['a.yml', 'b.yml'],
compose_path: 'ignored.yml',
}));
expect(value.composePaths).toEqual(['a.yml', 'b.yml']);
});
it('rejects an empty selection', () => {
expect(parseComposeSelection({ compose_paths: [] }).ok).toBe(false);
expect(parseComposeSelection({}).ok).toBe(false);
});
it('rejects more than the file-count cap', () => {
const tooMany = Array.from({ length: MAX_COMPOSE_FILES + 1 }, (_, i) => `f${i}.yml`);
const result = parseComposeSelection({ compose_paths: tooMany });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/exceed/i);
});
it('accepts exactly the file-count cap', () => {
const exact = Array.from({ length: MAX_COMPOSE_FILES }, (_, i) => `f${i}.yml`);
expect(parseComposeSelection({ compose_paths: exact }).ok).toBe(true);
});
it('rejects duplicate compose paths', () => {
const result = parseComposeSelection({ compose_paths: ['compose.yaml', 'infra/prod.yml', 'infra/prod.yml'] });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/duplicate/i);
});
it('rejects a non-first entry named compose.yaml (reserved for the primary)', () => {
const result = parseComposeSelection({ compose_paths: ['infra/base.yml', 'compose.yaml'] });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/compose\.yaml/i);
});
it('rejects a non-first entry that normalizes to compose.yaml via "./"', () => {
const result = parseComposeSelection({ compose_paths: ['infra/base.yml', './compose.yaml'] });
expect(result.ok).toBe(false);
});
it('rejects an additional file nested under the primary compose.yaml', () => {
// 'compose.yaml/prod.yml' would try to write under the root compose.yaml file.
const result = parseComposeSelection({ compose_paths: ['infra/base.yml', 'compose.yaml/prod.yml'] });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/collides/i);
});
it('rejects two selected files where one is a directory ancestor of another', () => {
const result = parseComposeSelection({ compose_paths: ['base.yml', 'sub.yml', 'sub.yml/deep.yml'] });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/collides/i);
});
it('allows compose.yaml as the primary (index 0)', () => {
const value = expectOk(parseComposeSelection({ compose_paths: ['compose.yaml', 'infra/prod.yml'] }));
expect(value.composePaths).toEqual(['compose.yaml', 'infra/prod.yml']);
});
it('rejects a path that escapes the repo with traversal', () => {
expect(parseComposeSelection({ compose_paths: ['../escape.yml'] }).ok).toBe(false);
expect(parseComposeSelection({ compose_path: '/etc/passwd' }).ok).toBe(false);
});
it('rejects a path targeting the .git directory', () => {
expect(parseComposeSelection({ compose_paths: ['.git/config'] }).ok).toBe(false);
});
it('accepts a valid multi-file selection with a context_dir', () => {
const value = expectOk(parseComposeSelection({
compose_paths: ['infra/base.yml', 'infra/prod.yml'],
context_dir: 'app',
}));
expect(value.contextDir).toBe('app');
});
it('normalizes an empty context_dir to null', () => {
expect(expectOk(parseComposeSelection({ compose_paths: ['compose.yaml'], context_dir: '' })).contextDir).toBeNull();
expect(expectOk(parseComposeSelection({ compose_paths: ['compose.yaml'], context_dir: ' ' })).contextDir).toBeNull();
});
it('rejects a context_dir with traversal', () => {
const result = parseComposeSelection({ compose_paths: ['compose.yaml'], context_dir: '../escape' });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/context_dir/i);
});
it('rejects a context_dir that targets the .git directory', () => {
const result = parseComposeSelection({ compose_paths: ['compose.yaml'], context_dir: '.git' });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/\.git/i);
});
it('rejects a context_dir equal to compose.yaml', () => {
const result = parseComposeSelection({ compose_paths: ['compose.yaml'], context_dir: 'compose.yaml' });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/context_dir/i);
});
it('rejects a context_dir equal to a selected additional compose path', () => {
const result = parseComposeSelection({
compose_paths: ['compose.yaml', 'infra/prod.yml'],
context_dir: 'infra/prod.yml',
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/context_dir/i);
});
it('rejects a context_dir nested under the primary compose.yaml', () => {
const result = parseComposeSelection({ compose_paths: ['infra/base.yml'], context_dir: 'compose.yaml/app' });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/context_dir/i);
});
it('rejects a context_dir nested under a selected additional compose file', () => {
const result = parseComposeSelection({
compose_paths: ['compose.yaml', 'infra/prod.yml'],
context_dir: 'infra/prod.yml/sub',
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/context_dir/i);
});
});
describe('defaultEnvPath', () => {
it('returns the explicit env path when provided', () => {
expect(defaultEnvPath('infra/base.yml', 'custom/.env')).toBe('custom/.env');
});
it('defaults to a sibling .env of the primary compose file', () => {
expect(defaultEnvPath('infra/base.yml', undefined)).toBe('infra/.env');
expect(defaultEnvPath('infra/base.yml', '')).toBe('infra/.env');
});
it('defaults to .env at the repo root when the primary is at the root', () => {
expect(defaultEnvPath('compose.yaml', undefined)).toBe('.env');
});
});
+428 -53
View File
@@ -12,6 +12,7 @@
* - Webhook debounce enforcement
* - Per-stack mutex serialization ordering
*/
import crypto from 'crypto';
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
@@ -67,6 +68,11 @@ function mockSuccessfulClone(options: {
composePath?: string;
envPath?: string | null;
sha?: string;
/**
* Additional repo-relative files to write into the clone temp dir on top of
* the primary compose file. Lets multi-file tests stage base+override layouts.
*/
extraFiles?: Record<string, string>;
} = {}) {
const {
compose = 'services:\n web:\n image: nginx\n',
@@ -74,6 +80,7 @@ function mockSuccessfulClone(options: {
composePath = 'compose.yaml',
envPath = null,
sha = 'abc1234567890abc1234567890abc1234567890a',
extraFiles = {},
} = options;
mockGitClone.mockImplementation(async (args: { dir: string }) => {
@@ -82,6 +89,11 @@ function mockSuccessfulClone(options: {
const composeAbs = path.join(args.dir, composePath);
await fsp.mkdir(path.dirname(composeAbs), { recursive: true });
await fsp.writeFile(composeAbs, compose, 'utf-8');
for (const [rel, content] of Object.entries(extraFiles)) {
const abs = path.join(args.dir, rel);
await fsp.mkdir(path.dirname(abs), { recursive: true });
await fsp.writeFile(abs, content, 'utf-8');
}
if (env !== null && envPath) {
const envAbs = path.join(args.dir, envPath);
await fsp.mkdir(path.dirname(envAbs), { recursive: true });
@@ -92,71 +104,116 @@ function mockSuccessfulClone(options: {
return sha;
}
/** Wrap a single compose string in the ComposeFile[] shape the new APIs take. */
function asFiles(content: string): import('../services/GitSourceService').ComposeFile[] {
return [{ path: 'compose.yaml', content }];
}
/** Best-effort teardown for tests that materialize a stack on disk. */
async function cleanupStackDir(name: string) {
const { FileSystemService } = await import('../services/FileSystemService');
try {
await FileSystemService.getInstance().deleteStack(name);
} catch {
// directory may not exist; ignore
}
}
// ── Tests ──────────────────────────────────────────────────────────────
describe('GitSourceService.hashContent', () => {
it('produces stable hashes for identical inputs', () => {
const svc = GitSourceService.getInstance();
const a = svc.hashContent('services:\n web: nginx\n', 'FOO=bar');
const b = svc.hashContent('services:\n web: nginx\n', 'FOO=bar');
const a = svc.hashContent(asFiles('services:\n web: nginx\n'), 'FOO=bar');
const b = svc.hashContent(asFiles('services:\n web: nginx\n'), 'FOO=bar');
expect(a).toBe(b);
expect(a).toMatch(/^[a-f0-9]{64}$/);
});
it('distinguishes env=null from env=""', () => {
const svc = GitSourceService.getInstance();
const nullHash = svc.hashContent('x: 1', null);
const emptyHash = svc.hashContent('x: 1', '');
const nullHash = svc.hashContent(asFiles('x: 1'), null);
const emptyHash = svc.hashContent(asFiles('x: 1'), '');
// Both hash-empty-string after null-coalesce, so they should match by design.
expect(nullHash).toBe(emptyHash);
});
it('changes when compose content changes', () => {
const svc = GitSourceService.getInstance();
const a = svc.hashContent('x: 1', null);
const b = svc.hashContent('x: 2', null);
const a = svc.hashContent(asFiles('x: 1'), null);
const b = svc.hashContent(asFiles('x: 2'), null);
expect(a).not.toBe(b);
});
it('changes when env content changes', () => {
const svc = GitSourceService.getInstance();
const a = svc.hashContent('x: 1', 'A=1');
const b = svc.hashContent('x: 1', 'A=2');
const a = svc.hashContent(asFiles('x: 1'), 'A=1');
const b = svc.hashContent(asFiles('x: 1'), 'A=2');
expect(a).not.toBe(b);
});
it('does not confuse compose|env boundary (uses NUL separator)', () => {
const svc = GitSourceService.getInstance();
// If the separator were absent, "ab" + "cd" would equal "abc" + "d".
const a = svc.hashContent('ab', 'cd');
const b = svc.hashContent('abc', 'd');
const a = svc.hashContent(asFiles('ab'), 'cd');
const b = svc.hashContent(asFiles('abc'), 'd');
expect(a).not.toBe(b);
});
it('keeps the single-file hash stable vs the legacy content+env formula', () => {
const svc = GitSourceService.getInstance();
// Legacy single-string hash was sha256(content + '\x00' + (env ?? '')).
const legacy = crypto
.createHash('sha256')
.update('x: 1')
.update('\x00')
.update('FOO=bar')
.digest('hex');
expect(svc.hashContent(asFiles('x: 1'), 'FOO=bar')).toBe(legacy);
});
it('folds ordered contents (not paths) for a multi-file set', () => {
const svc = GitSourceService.getInstance();
const base = { path: 'compose.yaml', content: 'a' };
const override = { path: 'infra/prod.yml', content: 'b' };
const ab = svc.hashContent([base, override], null);
const ba = svc.hashContent([override, base], null);
// Order-sensitive: swapping the two files changes the hash (content order).
expect(ab).not.toBe(ba);
// Path-INsensitive by design: the same contents in the same order hash equal
// regardless of path, so create (repo paths) and pull (materialized paths,
// primary -> compose.yaml) agree and a clean stack is not flagged as edited.
const repoPaths = svc.hashContent([{ path: 'infra/base.yml', content: 'a' }, { path: 'infra/prod.yml', content: 'b' }], null);
const localPaths = svc.hashContent([{ path: 'compose.yaml', content: 'a' }, { path: 'infra/prod.yml', content: 'b' }], null);
expect(repoPaths).toBe(localPaths);
// Content-sensitive: changing a file's content changes the hash.
expect(ab).not.toBe(svc.hashContent([base, { path: 'infra/prod.yml', content: 'B' }], null));
});
});
describe('GitSourceService.validateCompose (YAML pre-check)', () => {
const svc = () => GitSourceService.getInstance();
it('rejects empty content', async () => {
const r = await svc().validateCompose('', null);
const r = await svc().validateCompose(asFiles(''), null, null);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/empty/i);
});
it('rejects a YAML array at the root', async () => {
const r = await svc().validateCompose('- one\n- two\n', null);
const r = await svc().validateCompose(asFiles('- one\n- two\n'), null, null);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/mapping/i);
});
it('rejects a YAML scalar at the root', async () => {
const r = await svc().validateCompose('42', null);
const r = await svc().validateCompose(asFiles('42'), null, null);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/mapping/i);
});
it('rejects malformed YAML syntax', async () => {
const r = await svc().validateCompose('services:\n web:\n image: "unterminated\n', null);
const r = await svc().validateCompose(asFiles('services:\n web:\n image: "unterminated\n'), null, null);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/YAML parse error/i);
});
@@ -170,7 +227,8 @@ describe('GitSourceService.upsert (encryption + reachability)', () => {
stackName: 'enc-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'token',
@@ -196,7 +254,8 @@ describe('GitSourceService.upsert (encryption + reachability)', () => {
stackName: 'keep-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'token',
@@ -210,7 +269,8 @@ describe('GitSourceService.upsert (encryption + reachability)', () => {
stackName: 'keep-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'token',
@@ -229,7 +289,8 @@ describe('GitSourceService.upsert (encryption + reachability)', () => {
stackName: 'clear-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'token',
@@ -242,7 +303,8 @@ describe('GitSourceService.upsert (encryption + reachability)', () => {
stackName: 'clear-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
@@ -260,7 +322,8 @@ describe('GitSourceService.upsert (encryption + reachability)', () => {
stackName: 'bad-matrix',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
@@ -279,7 +342,8 @@ describe('GitSourceService.upsert (encryption + reachability)', () => {
stackName: 'unreachable',
repoUrl: 'https://github.com/example/nope.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
@@ -296,7 +360,7 @@ describe('GitSourceService error mapping', () => {
const fetchParams = {
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
};
it('maps 401 with supplied token to AUTH_FAILED', async () => {
@@ -427,7 +491,7 @@ describe('GitSourceService error mapping', () => {
mockGitLog.mockResolvedValue([{ oid: 'deadbeef' }]);
await expect(svc().fetchFromGit({
...fetchParams,
composePath: 'missing/compose.yaml',
composePaths: ['missing/compose.yaml'],
})).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' });
});
@@ -485,7 +549,7 @@ describe('GitSourceService.fetchFromGit (size limits)', () => {
const fetchParams = {
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
};
it('rejects a compose file larger than the per-file read cap', async () => {
@@ -541,7 +605,8 @@ describe('GitSourceService pending lifecycle', () => {
stackName: 'pending-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
@@ -566,7 +631,8 @@ describe('GitSourceService.handleWebhookPull debounce', () => {
stackName: 'debounce-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
@@ -602,7 +668,8 @@ describe('GitSourceService.handleWebhookPull debounce', () => {
stackName: 'fanout-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
@@ -630,7 +697,8 @@ describe('GitSourceService.handleWebhookPull debounce', () => {
stackName: 'webhook-validate-fail',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
@@ -707,7 +775,7 @@ describe('GitSourceService.fetchFromGit (.git metadata guard)', () => {
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: '.git/config',
composePaths: ['.git/config'],
})).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' });
expect(mockGitClone).not.toHaveBeenCalled();
});
@@ -716,7 +784,7 @@ describe('GitSourceService.fetchFromGit (.git metadata guard)', () => {
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'subdir/.git/HEAD',
composePaths: ['subdir/.git/HEAD'],
})).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' });
});
@@ -724,7 +792,7 @@ describe('GitSourceService.fetchFromGit (.git metadata guard)', () => {
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
envPath: '.git/config',
})).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' });
});
@@ -734,7 +802,7 @@ describe('GitSourceService.fetchFromGit (.git metadata guard)', () => {
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'gitops.yaml',
composePaths: ['gitops.yaml'],
})).resolves.toBeDefined();
});
@@ -748,7 +816,7 @@ describe('GitSourceService.fetchFromGit (.git metadata guard)', () => {
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
})).rejects.toMatchObject({
code: 'FILE_NOT_FOUND',
message: expect.stringMatching(/symbolic link/i),
@@ -768,7 +836,7 @@ describe('GitSourceService.fetchFromGit (LFS + submodule detection)', () => {
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
})).rejects.toMatchObject({
code: 'GIT_ERROR',
message: expect.stringMatching(/LFS/i),
@@ -784,7 +852,7 @@ describe('GitSourceService.fetchFromGit (LFS + submodule detection)', () => {
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
envPath: '.env',
})).rejects.toMatchObject({
code: 'GIT_ERROR',
@@ -808,7 +876,7 @@ describe('GitSourceService.fetchFromGit (LFS + submodule detection)', () => {
const result = await svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
});
expect(result.warnings).toEqual(
expect.arrayContaining([expect.stringMatching(/submodules/i)]),
@@ -820,7 +888,7 @@ describe('GitSourceService.fetchFromGit (LFS + submodule detection)', () => {
const result = await svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
});
expect(result.warnings).toEqual([]);
});
@@ -834,15 +902,6 @@ describe('GitSourceService.pull', () => {
});
describe('GitSourceService.createStackFromGit', () => {
async function cleanupStackDir(name: string) {
const { FileSystemService } = await import('../services/FileSystemService');
try {
await FileSystemService.getInstance().deleteStack(name);
} catch {
// directory may not exist; ignore
}
}
it('creates a stack on disk, writes compose, and seeds last_applied columns', async () => {
const sha = 'fedcba9876543210fedcba9876543210fedcba98';
mockSuccessfulClone({
@@ -855,7 +914,8 @@ describe('GitSourceService.createStackFromGit', () => {
stackName: 'create-happy',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
@@ -876,6 +936,42 @@ describe('GitSourceService.createStackFromGit', () => {
await cleanupStackDir('create-happy');
});
it('multi-file create then pull reports no local changes (hash is path-independent)', async () => {
const sha = 'aaaa1111bbbb2222cccc3333dddd4444eeee5555';
mockSuccessfulClone({
composePath: 'infra/base.yml',
compose: 'services:\n web:\n image: nginx\n',
extraFiles: { 'infra/prod.yml': 'services:\n web:\n restart: always\n' },
sha,
});
const svc = GitSourceService.getInstance();
await svc.createStackFromGit({
stackName: 'mf-clean-pull',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['infra/base.yml', 'infra/prod.yml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
// The primary is materialized as compose.yaml, the override at its repo path.
const row = DatabaseService.getInstance().getGitSource('mf-clean-pull');
expect(row?.applied_deploy_spec?.files).toEqual(['compose.yaml', 'infra/prod.yml']);
// Pulling the identical commit must NOT flag local edits, even though the
// 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);
await cleanupStackDir('mf-clean-pull');
});
it('resolves a nested compose_path and nested env_path into the stack dir', async () => {
const sha = 'deadbeef1234567890deadbeef1234567890abcd';
mockSuccessfulClone({
@@ -891,7 +987,8 @@ describe('GitSourceService.createStackFromGit', () => {
stackName: 'create-nested',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'apps/web/compose.yaml',
composePaths: ['apps/web/compose.yaml'],
contextDir: null,
syncEnv: true,
envPath: 'apps/web/.env',
authType: 'none',
@@ -928,7 +1025,8 @@ describe('GitSourceService.createStackFromGit', () => {
stackName: 'create-env',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: true,
envPath: '.env',
authType: 'none',
@@ -951,7 +1049,8 @@ describe('GitSourceService.createStackFromGit', () => {
stackName: 'create-bad-matrix',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
@@ -975,7 +1074,8 @@ describe('GitSourceService.createStackFromGit', () => {
stackName: 'create-bad-yaml',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
@@ -1003,7 +1103,8 @@ describe('GitSourceService.createStackFromGit', () => {
stackName: 'create-rollback',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
@@ -1028,7 +1129,8 @@ describe('GitSourceService.apply', () => {
stackName,
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
@@ -1171,3 +1273,276 @@ describe('GitSourceService.apply', () => {
scanSpy.mockRestore();
});
});
describe('GitSourceService DB normalization (compose_paths back-compat)', () => {
it('reads back [compose_path] when a row stores compose_paths as null (legacy)', async () => {
mockSuccessfulClone({ composePath: 'stacks/web/compose.yaml' });
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'legacy-null',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['stacks/web/compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
// Simulate a row written before the multi-file column existed.
const db = DatabaseService.getInstance();
db.getDb().prepare('UPDATE stack_git_sources SET compose_paths = NULL WHERE stack_name = ?').run('legacy-null');
const row = db.getGitSource('legacy-null');
expect(row?.compose_paths).toEqual(['stacks/web/compose.yaml']);
expect(row?.compose_path).toBe('stacks/web/compose.yaml');
});
it('reads back [compose_path] when compose_paths holds an empty JSON array', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'legacy-empty',
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.getDb().prepare(`UPDATE stack_git_sources SET compose_paths = '[]' WHERE stack_name = ?`).run('legacy-empty');
expect(db.getGitSource('legacy-empty')?.compose_paths).toEqual(['compose.yaml']);
});
it('backfills compose_paths to json_array(compose_path) for a NULL-column row', async () => {
// The migration runs json_array(compose_path) for any row whose
// compose_paths is NULL. Insert a NULL-column row, run the backfill SQL,
// and confirm the stored JSON is a one-element array of the legacy path.
mockSuccessfulClone({ composePath: 'deploy/compose.yaml' });
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'backfill-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['deploy/compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const db = DatabaseService.getInstance();
db.getDb().prepare('UPDATE stack_git_sources SET compose_paths = NULL WHERE stack_name = ?').run('backfill-stack');
db.getDb().prepare(
`UPDATE stack_git_sources SET compose_paths = json_array(compose_path) WHERE compose_paths IS NULL`,
).run();
const stored = db.getDb()
.prepare('SELECT compose_paths FROM stack_git_sources WHERE stack_name = ?')
.get('backfill-stack') as { compose_paths: string };
expect(JSON.parse(stored.compose_paths)).toEqual(['deploy/compose.yaml']);
});
});
describe('GitSourceService multi-file create + apply flow', () => {
it('materializes both files and persists a non-null applied_deploy_spec for a two-file create', async () => {
const sha = '1111aaa1111aaa1111aaa1111aaa1111aaa1111a';
mockSuccessfulClone({
compose: 'services:\n web:\n image: nginx\n',
composePath: 'infra/base.yml',
extraFiles: { 'infra/prod.yml': 'services:\n web:\n environment:\n - X=1\n' },
sha,
});
const svc = GitSourceService.getInstance();
// Isolate materialize behavior from docker availability.
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
const result = await svc.createStackFromGit({
stackName: 'multi-create',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['infra/base.yml', 'infra/prod.yml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
expect(result.commitSha).toBe(sha);
const row = DatabaseService.getInstance().getGitSource('multi-create');
expect(row?.compose_paths).toEqual(['infra/base.yml', 'infra/prod.yml']);
expect(row?.applied_deploy_spec).not.toBeNull();
expect(row?.applied_deploy_spec?.files).toEqual(['compose.yaml', 'infra/prod.yml']);
const { FileSystemService } = await import('../services/FileSystemService');
const fsSvc = FileSystemService.getInstance();
// Primary lands at the root compose.yaml; the additional file at its repo path.
expect(await fsSvc.getStackContent('multi-create')).toContain('image: nginx');
const prod = await fsSvc.readStackFile('multi-create', 'infra/prod.yml');
expect(prod.content).toContain('X=1');
validateSpy.mockRestore();
await cleanupStackDir('multi-create');
});
it('leaves applied_deploy_spec null for a plain single-file create', async () => {
const sha = '2222bbb2222bbb2222bbb2222bbb2222bbb2222b';
mockSuccessfulClone({ compose: 'services:\n web:\n image: nginx\n', sha });
const svc = GitSourceService.getInstance();
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
await svc.createStackFromGit({
stackName: 'single-create',
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,
});
const row = DatabaseService.getInstance().getGitSource('single-create');
expect(row?.compose_paths).toEqual(['compose.yaml']);
expect(row?.applied_deploy_spec).toBeNull();
validateSpy.mockRestore();
await cleanupStackDir('single-create');
});
it('sets applied_deploy_spec for a single-file create that has a context_dir', async () => {
const sha = '3333ccc3333ccc3333ccc3333ccc3333ccc3333c';
mockSuccessfulClone({ compose: 'services:\n web:\n image: nginx\n', sha });
const svc = GitSourceService.getInstance();
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
await svc.createStackFromGit({
stackName: 'ctx-create',
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,
});
const row = DatabaseService.getInstance().getGitSource('ctx-create');
expect(row?.applied_deploy_spec).not.toBeNull();
expect(row?.applied_deploy_spec?.files).toEqual(['compose.yaml']);
expect(row?.applied_deploy_spec?.contextDir).toBe('app');
validateSpy.mockRestore();
await cleanupStackDir('ctx-create');
});
it('pulls a multi-file v2 pending blob and applies both files to disk', async () => {
const sha = '4444ddd4444ddd4444ddd4444ddd4444ddd4444d';
mockSuccessfulClone({
compose: 'services:\n web:\n image: nginx\n',
composePath: 'infra/base.yml',
extraFiles: { 'infra/prod.yml': 'services:\n web:\n environment:\n - Y=2\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('multi-pull');
await svc.upsert({
stackName: 'multi-pull',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['infra/base.yml', 'infra/prod.yml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const pull = await svc.pull('multi-pull');
// Pending compose blob is encrypted; assert it round-trips by applying it.
const row = DatabaseService.getInstance().getGitSource('multi-pull');
expect(row?.pending_commit_sha).toBe(sha);
const applied = await svc.apply('multi-pull', pull.commitSha);
expect(applied.applied).toBe(true);
const after = DatabaseService.getInstance().getGitSource('multi-pull');
expect(after?.applied_deploy_spec?.files).toEqual(['compose.yaml', 'infra/prod.yml']);
expect(await fsSvc.getStackContent('multi-pull')).toContain('image: nginx');
expect((await fsSvc.readStackFile('multi-pull', 'infra/prod.yml')).content).toContain('Y=2');
validateSpy.mockRestore();
await cleanupStackDir('multi-pull');
});
it('keeps pending across an upsert with the SAME config and clears it when compose_paths change', async () => {
const sha = '5555eee5555eee5555eee5555eee5555eee5555e';
mockSuccessfulClone({
compose: 'services:\n web:\n image: nginx\n',
composePath: 'infra/base.yml',
extraFiles: {
'infra/prod.yml': 'services:\n web:\n environment:\n - Z=3\n',
// The changed-config upsert re-fetches this path; the dry-run reads
// every configured file, so it must exist in the clone.
'infra/staging.yml': 'services:\n web:\n environment:\n - Z=4\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('pending-config');
const baseInput = {
stackName: 'pending-config',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['infra/base.yml', 'infra/prod.yml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none' as const,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
};
await svc.upsert(baseInput);
await svc.pull('pending-config');
const db = DatabaseService.getInstance();
expect(db.getGitSource('pending-config')?.pending_commit_sha).toBe(sha);
// Same config -> pending must survive.
await svc.upsert(baseInput);
expect(db.getGitSource('pending-config')?.pending_commit_sha).toBe(sha);
// Changed compose_paths -> the captured pending blob no longer matches, clear it.
await svc.upsert({ ...baseInput, composePaths: ['infra/base.yml', 'infra/staging.yml'] });
expect(db.getGitSource('pending-config')?.pending_commit_sha).toBeNull();
validateSpy.mockRestore();
await cleanupStackDir('pending-config');
});
});
@@ -43,6 +43,7 @@ vi.mock('../services/DatabaseService', () => ({
getInstance: () => ({
getGlobalSettings: mockGetGlobalSettings,
getNodes: () => [],
getGitSource: () => undefined,
upsertStackUpdateStatus: mockUpsertStackUpdateStatus,
getStackUpdateStatus: mockGetStackUpdateStatus,
clearStackUpdateStatus: mockClearStackUpdateStatus,
@@ -551,6 +552,7 @@ describe('ImageUpdateService - check() concurrency guard', () => {
dbModule.DatabaseService.getInstance = (() => ({
getGlobalSettings: () => ({ developer_mode: developerMode }),
getNodes: () => [{ type: 'local', id: 1, name: 'local', mode: 'proxy', compose_dir: '/tmp/compose', is_default: true, status: 'online', created_at: 1 }],
getGitSource: () => undefined,
upsertStackUpdateStatus: mockUpsertStackUpdateStatus,
getStackUpdateStatus: mockGetStackUpdateStatus,
clearStackUpdateStatus: mockClearStackUpdateStatus,
@@ -38,7 +38,7 @@ vi.mock('../services/NodeRegistry', () => ({
// Prevent COMPOSE_DIR filesystem reads (resolveProjectNameMap reads compose files).
// With these mocked, fs.readFile returns ENOENT and the map falls back to
// { stackName: stackName } for each stack passed in.
vi.mock('child_process', () => ({ exec: vi.fn() }));
vi.mock('child_process', () => ({ exec: vi.fn(), execFile: vi.fn() }));
vi.mock('util', () => ({ promisify: () => vi.fn() }));
import DockerController from '../services/DockerController';
@@ -29,7 +29,7 @@ vi.mock('../services/NodeRegistry', () => ({
},
}));
vi.mock('child_process', () => ({ exec: vi.fn() }));
vi.mock('child_process', () => ({ exec: vi.fn(), execFile: vi.fn() }));
vi.mock('util', () => ({ promisify: () => vi.fn() }));
import SelfIdentityService from '../services/SelfIdentityService';
@@ -13,6 +13,30 @@ function adminToken(): string {
return jwt.sign({ username: TEST_USERNAME, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' });
}
function seedGitSource(stackName: string): void {
DatabaseService.getInstance().upsertGitSource({
stack_name: stackName,
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: null,
pending_compose_content: null,
pending_env_content: null,
pending_fetched_at: null,
last_debounce_at: null,
});
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
@@ -35,25 +59,7 @@ describe('node-aware Git source webhooks', () => {
it('persists node_id when creating a webhook', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id;
db.upsertGitSource({
stack_name: 'webhook-local-git',
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_path: 'compose.yaml',
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: null,
pending_compose_content: null,
pending_env_content: null,
pending_fetched_at: null,
last_debounce_at: null,
});
seedGitSource('webhook-local-git');
const res = await request(app)
.post('/api/webhooks')
@@ -117,25 +123,7 @@ describe('node-aware Git source webhooks', () => {
it('rejects retargeting an existing git-pull webhook without a Git source', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id;
db.upsertGitSource({
stack_name: 'retarget-source-stack',
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_path: 'compose.yaml',
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: null,
pending_compose_content: null,
pending_env_content: null,
pending_fetched_at: null,
last_debounce_at: null,
});
seedGitSource('retarget-source-stack');
const webhookId = db.addWebhook({
node_id: nodeId,
name: 'retarget git webhook',
+114
View File
@@ -0,0 +1,114 @@
import path from 'path';
import { isValidGitSourcePath, isValidRelativeStackPath } from '../utils/validation';
import { PRIMARY_COMPOSE_FILENAME, gitSourceLocalComposeFiles } from '../utils/gitComposeFiles';
/** Upper bound on how many compose files one stack can order. Generous; real
* base+override layouts use a handful. */
export const MAX_COMPOSE_FILES = 10;
const MAX_COMPOSE_PATH_LENGTH = 1024;
const MAX_CONTEXT_DIR_LENGTH = 1024;
export interface ComposeSelection {
composePaths: string[];
contextDir: string | null;
}
type ParseResult =
| { ok: true; value: ComposeSelection }
| { ok: false; error: string };
/**
* Validate and normalize the multi-file compose selection from a request body.
* Accepts `compose_paths` (ordered array) or the legacy `compose_path` (single
* string, mapped to a one-element array). Enforces the file-count cap, rejects
* duplicates and a root `compose.yaml` collision (the primary is always
* materialized there), and validates an optional `context_dir`.
*/
export function parseComposeSelection(body: unknown): ParseResult {
const b = (body ?? {}) as Record<string, unknown>;
let rawPaths: unknown = b.compose_paths;
if (rawPaths === undefined && typeof b.compose_path === 'string') {
rawPaths = [b.compose_path];
}
if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
return { ok: false, error: 'compose_paths must be a non-empty array of repository file paths' };
}
if (rawPaths.length > MAX_COMPOSE_FILES) {
return { ok: false, error: `compose_paths cannot exceed ${MAX_COMPOSE_FILES} files` };
}
const composePaths: string[] = [];
for (const raw of rawPaths) {
if (typeof raw !== 'string' || !raw.trim()) {
return { ok: false, error: 'each compose path must be a non-empty string' };
}
const trimmed = raw.trim();
if (trimmed.length > MAX_COMPOSE_PATH_LENGTH) {
return { ok: false, error: 'a compose path is too long' };
}
if (!isValidGitSourcePath(trimmed)) {
return { ok: false, error: `compose path must be a relative repository file path: ${trimmed}` };
}
composePaths.push(trimmed);
}
if (new Set(composePaths).size !== composePaths.length) {
return { ok: false, error: 'compose_paths cannot contain duplicate entries' };
}
// Materialized local layout: the primary (index 0) is written to the root
// compose.yaml, each additional file at its repo-relative path. Reject the
// collisions that would make materialization fail at runtime (ENOTDIR / clobber):
// an additional file equal to or nested under compose.yaml, and any file path
// that is a directory ancestor of another selected file.
const materialized = gitSourceLocalComposeFiles(composePaths);
const label = (k: number) => (k === 0 ? PRIMARY_COMPOSE_FILENAME : composePaths[k]);
for (let i = 0; i < materialized.length; i++) {
for (let j = 0; j < materialized.length; j++) {
if (i === j) continue;
if (materialized[j] === materialized[i] || materialized[j].startsWith(materialized[i] + '/')) {
return { ok: false, error: `compose file "${label(j)}" collides with "${label(i)}" once materialized to disk` };
}
}
}
let contextDir: string | null = null;
const rawCtx = b.context_dir;
if (rawCtx !== undefined && rawCtx !== null && rawCtx !== '') {
if (typeof rawCtx !== 'string') {
return { ok: false, error: 'context_dir must be a string' };
}
if (rawCtx.length > MAX_CONTEXT_DIR_LENGTH) {
return { ok: false, error: 'context_dir is too long' };
}
const ctx = rawCtx.trim().replace(/^\.\//, '').replace(/\/+$/, '');
if (ctx !== '') {
if (!isValidRelativeStackPath(ctx)) {
return { ok: false, error: 'context_dir must be a relative path within the repository' };
}
if (ctx.split('/').some(seg => seg.toLowerCase() === '.git')) {
return { ok: false, error: 'context_dir cannot target the .git directory' };
}
// The project dir is mkdir'd, so it cannot equal a compose file path or sit
// beneath one (the primary compose.yaml and any override are files, not dirs).
if (materialized.some(f => ctx === f || ctx.startsWith(f + '/'))) {
return { ok: false, error: 'context_dir cannot match or be nested under a compose file path' };
}
contextDir = ctx;
}
}
return { ok: true, value: { composePaths, contextDir } };
}
/**
* Default the env path to a sibling `.env` of the primary compose file when env
* sync is on and no explicit path is provided. Mirrors the prior single-file
* behavior, keyed off the primary (compose_paths[0]).
*/
export function defaultEnvPath(primaryComposePath: string, explicit: unknown): string {
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
const dir = path.posix.dirname(primaryComposePath.replace(/\\/g, '/')) || '.';
return path.posix.join(dir, '.env');
}
+92 -17
View File
@@ -1,11 +1,12 @@
import { Router, type Request, type Response } from 'express';
import path from 'path';
import { GitSourceService } from '../services/GitSourceService';
import { FileSystemService } from '../services/FileSystemService';
import { DatabaseService } from '../services/DatabaseService';
import { CryptoService } from '../services/CryptoService';
import { checkPermission, requirePermission } from '../middleware/permissions';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
import { triggerPostDeployScan } from '../helpers/policyGate';
import { parseComposeSelection, defaultEnvPath } from '../helpers/gitSourceSelection';
import { isValidGitSourcePath, isValidStackName } from '../utils/validation';
import { sendGitSourceError, webhookPullStatus } from '../utils/gitSourceHttp';
import { sanitizeForLog } from '../utils/safeLog';
@@ -14,10 +15,59 @@ import { sanitizeForLog } from '../utils/safeLog';
// payloads. Generous compared to anything a real Git provider emits.
const MAX_REPO_URL_LENGTH = 2048;
const MAX_BRANCH_LENGTH = 256;
const MAX_COMPOSE_PATH_LENGTH = 1024;
const MAX_ENV_PATH_LENGTH = 1024;
const MAX_TOKEN_LENGTH = 8192;
/**
* Shared handler for the "browse repository" compose-file picker: validate the
* repo target, clone it, and list its files. `storedToken` (already decrypted)
* is reused when the request omits a token, so the edit-mode flow does not force
* re-entering a stored PAT.
*/
async function handleBrowse(req: Request, res: Response, storedToken: string | null): Promise<void> {
const { repo_url, branch, auth_type, token } = req.body ?? {};
if (typeof repo_url !== 'string' || !repo_url.trim()) {
res.status(400).json({ error: 'repo_url is required' });
return;
}
if (typeof branch !== 'string' || !branch.trim()) {
res.status(400).json({ error: 'branch is required' });
return;
}
if (!/^https:\/\//i.test(repo_url)) {
res.status(400).json({ error: 'Only HTTPS repository URLs are supported' });
return;
}
if (repo_url.length > MAX_REPO_URL_LENGTH) {
res.status(400).json({ error: 'repo_url is too long' });
return;
}
if (branch.length > MAX_BRANCH_LENGTH) {
res.status(400).json({ error: 'branch is too long' });
return;
}
if (auth_type !== undefined && auth_type !== 'none' && auth_type !== 'token') {
res.status(400).json({ error: 'auth_type must be "none" or "token"' });
return;
}
if (typeof token === 'string' && token.length > MAX_TOKEN_LENGTH) {
res.status(400).json({ error: 'token is too long' });
return;
}
const explicitToken = typeof token === 'string' && token.trim() ? token : null;
const effectiveToken = auth_type === 'none' ? null : (explicitToken ?? storedToken);
try {
const result = await GitSourceService.getInstance().listRepoTree({
repoUrl: repo_url.trim(),
branch: branch.trim(),
token: effectiveToken,
});
res.json(result);
} catch (error) {
sendGitSourceError(res, error);
}
}
/** Router for listing git-source configuration: `GET /api/git-sources`. */
export const gitSourcesRouter = Router();
@@ -33,6 +83,13 @@ gitSourcesRouter.get('/', async (req: Request, res: Response): Promise<void> =>
}
});
// Create-mode repo browse (no stack yet): gated by the same permission as
// creating a stack from Git.
gitSourcesRouter.post('/browse', async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'stack:create')) return;
await handleBrowse(req, res, null);
});
/**
* Router for per-stack git-source endpoints. Mount at `/api/stacks` so the
* `/:stackName/git-source*` paths work alongside other stack-scoped routes
@@ -80,7 +137,6 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res
const {
repo_url,
branch,
compose_path,
sync_env,
env_path,
auth_type,
@@ -97,8 +153,9 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res
res.status(400).json({ error: 'branch is required' });
return;
}
if (typeof compose_path !== 'string' || !compose_path.trim()) {
res.status(400).json({ error: 'compose_path is required' });
const selection = parseComposeSelection(req.body);
if (!selection.ok) {
res.status(400).json({ error: selection.error });
return;
}
if (auth_type !== 'none' && auth_type !== 'token') {
@@ -125,18 +182,10 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res
res.status(400).json({ error: 'branch is too long' });
return;
}
if (compose_path.length > MAX_COMPOSE_PATH_LENGTH) {
res.status(400).json({ error: 'compose_path is too long' });
return;
}
if (typeof env_path === 'string' && env_path.length > MAX_ENV_PATH_LENGTH) {
res.status(400).json({ error: 'env_path is too long' });
return;
}
if (!isValidGitSourcePath(compose_path.trim())) {
res.status(400).json({ error: 'compose_path must be a relative repository file path' });
return;
}
if (typeof env_path === 'string' && env_path.trim() && !isValidGitSourcePath(env_path.trim())) {
res.status(400).json({ error: 'env_path must be a relative repository file path' });
return;
@@ -160,16 +209,15 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res
const syncEnv = Boolean(sync_env);
const resolvedEnvPath = syncEnv
? (typeof env_path === 'string' && env_path.trim()
? env_path
: path.posix.join(path.posix.dirname(compose_path.replace(/\\/g, '/')) || '.', '.env'))
? defaultEnvPath(selection.value.composePaths[0], env_path)
: null;
const source = await GitSourceService.getInstance().upsert({
stackName,
repoUrl: repo_url.trim(),
branch: branch.trim(),
composePath: compose_path.trim(),
composePaths: selection.value.composePaths,
contextDir: selection.value.contextDir,
syncEnv,
envPath: resolvedEnvPath,
authType: auth_type,
@@ -193,6 +241,18 @@ stackGitSourceRouter.delete('/:stackName/git-source', async (req: Request, res:
}
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
try {
// The deploy spec lives on the Git-source row, so unlinking a multi-file (or
// project-directory) source would silently drop the spec and revert future
// deploys to root compose.yaml auto-discovery, ignoring the override files
// still on disk. Refuse rather than change deploy semantics out from under the
// user; deleting the stack removes it cleanly.
const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec;
if (spec && (spec.files.length > 1 || spec.contextDir)) {
res.status(409).json({
error: 'This stack deploys multiple compose files configured by its Git source. Unlinking would change it to deploy only compose.yaml. Delete the stack to remove it, or keep the Git source.',
});
return;
}
GitSourceService.getInstance().delete(stackName);
console.log(`[GitSource] Removed git source for ${stackName}`);
res.json({ success: true });
@@ -299,3 +359,18 @@ stackGitSourceRouter.post('/:stackName/git-source/dismiss-pending', async (req:
sendGitSourceError(res, error);
}
});
// Edit-mode repo browse for an existing stack: gated by stack:edit so a user who
// can edit (but not create) stacks can re-pick files, and reuses the stored token
// when the request omits one.
stackGitSourceRouter.post('/:stackName/git-source/browse', async (req: Request, res: Response): Promise<void> => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
res.status(400).json({ error: 'Invalid stack name' });
return;
}
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const src = DatabaseService.getInstance().getGitSource(stackName);
const storedToken = src?.encrypted_token ? CryptoService.getInstance().decrypt(src.encrypted_token) : null;
await handleBrowse(req, res, storedToken);
});
+8 -14
View File
@@ -34,6 +34,7 @@ import { sendGitSourceError } from '../utils/gitSourceHttp';
import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan } from '../helpers/policyGate';
import { parseComposePreview, type ComposePreview } from '../helpers/composePreview';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
import { parseComposeSelection, defaultEnvPath } from '../helpers/gitSourceSelection';
import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants';
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
@@ -751,7 +752,6 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => {
stack_name,
repo_url,
branch,
compose_path,
sync_env,
env_path,
auth_type,
@@ -775,8 +775,9 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => {
if (typeof branch !== 'string' || !branch.trim()) {
return res.status(400).json({ error: 'branch is required' });
}
if (typeof compose_path !== 'string' || !compose_path.trim()) {
return res.status(400).json({ error: 'compose_path is required' });
const selection = parseComposeSelection(req.body);
if (!selection.ok) {
return res.status(400).json({ error: selection.error });
}
if (auto_apply_on_webhook !== undefined && typeof auto_apply_on_webhook !== 'boolean') {
return res.status(400).json({ error: 'auto_apply_on_webhook must be a boolean' });
@@ -794,18 +795,12 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => {
if (branch.length > 256) {
return res.status(400).json({ error: 'branch is too long' });
}
if (compose_path.length > 1024) {
return res.status(400).json({ error: 'compose_path is too long' });
}
if (typeof env_path === 'string' && env_path.length > 1024) {
return res.status(400).json({ error: 'env_path is too long' });
}
if (typeof token === 'string' && token.length > 8192) {
return res.status(400).json({ error: 'token is too long' });
}
if (!isValidGitSourcePath(compose_path.trim())) {
return res.status(400).json({ error: 'compose_path must be a relative repository file path' });
}
if (typeof env_path === 'string' && env_path.trim() && !isValidGitSourcePath(env_path.trim())) {
return res.status(400).json({ error: 'env_path must be a relative repository file path' });
}
@@ -821,14 +816,12 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => {
const syncEnv = Boolean(sync_env);
const resolvedEnvPath = syncEnv
? (typeof env_path === 'string' && env_path.trim()
? env_path
: path.posix.join(path.posix.dirname(compose_path.replace(/\\/g, '/')) || '.', '.env'))
? defaultEnvPath(selection.value.composePaths[0], env_path)
: null;
if (fromGitDiag) {
dlog(
`[Stacks:diag] from-git start stack=${sanitizeForLog(stack_name)} nodeId=${req.nodeId ?? 'local'} host=${sanitizeForLog(gitRepoHost(repo_url))} branch=${sanitizeForLog(branch)} composePath=${sanitizeForLog(compose_path)} envPath=${sanitizeForLog(resolvedEnvPath ?? 'none')} authType=${sanitizeForLog(resolvedAuthType)} autoApplyOnWebhook=${autoApplyOnWebhook} autoDeployOnApply=${autoDeployOnApply} deployNow=${deploy_now === true}`
`[Stacks:diag] from-git start stack=${sanitizeForLog(stack_name)} nodeId=${req.nodeId ?? 'local'} host=${sanitizeForLog(gitRepoHost(repo_url))} branch=${sanitizeForLog(branch)} files=${selection.value.composePaths.length} envPath=${sanitizeForLog(resolvedEnvPath ?? 'none')} authType=${sanitizeForLog(resolvedAuthType)} autoApplyOnWebhook=${autoApplyOnWebhook} autoDeployOnApply=${autoDeployOnApply} deployNow=${deploy_now === true}`
);
}
@@ -836,7 +829,8 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => {
stackName: stack_name.trim(),
repoUrl: repo_url.trim(),
branch: branch.trim(),
composePath: compose_path.trim(),
composePaths: selection.value.composePaths,
contextDir: selection.value.contextDir,
syncEnv,
envPath: resolvedEnvPath,
authType: resolvedAuthType,
+43 -16
View File
@@ -16,6 +16,7 @@ import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { describeSpawnError } from '../utils/spawnErrors';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { authoredComposeFileArgs } from '../utils/authoredComposeArgs';
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
export class ComposeRollbackError extends Error {
@@ -86,14 +87,24 @@ export class ComposeService {
}
/**
* Build the `docker compose` argument prefix for a stack, splicing in the
* Sencho Mesh override file if the stack is opted into the mesh. When no
* override applies, returns args without `-f` so docker compose's built-in
* file discovery resolves the stack's actual compose filename. The user's
* source compose file is never mutated.
* Build the authored `docker compose` argument list for a stack: the validated
* multi-file deploy prefix (ordered `-f` files + `-p <stackName>` +
* `--project-directory`) for a Git source with an applied multi-file spec, then
* the Sencho Mesh override file last (highest `-f` precedence) when the stack is
* opted into the mesh, then the action. Single-file / non-git stacks get no file
* prefix, so docker compose's built-in discovery resolves the root compose.yaml,
* byte-identical to the pre-multi-file behavior. The user's source files are
* never mutated. Lifecycle commands (deploy, update, stop/start/restart/down)
* route through this method, so they share one file prefix plus the mesh override.
* Image scans (listStackImages) and the Compose Doctor (renderConfig) reuse the
* same `authoredComposeFileArgs` prefix directly but intentionally omit the mesh
* override, rendering the user's authored model without mesh injection.
*/
private async composeArgs(stackName: string, action: string[]): Promise<string[]> {
private async authoredComposeArgs(stackName: string, action: string[]): Promise<string[]> {
const args: string[] = ['compose'];
const filePrefix = authoredComposeFileArgs(stackName, this.nodeId);
args.push(...filePrefix);
let overridePath: string | null = null;
try {
overridePath = await MeshService.getInstance().ensureStackOverride(this.nodeId, stackName);
@@ -101,8 +112,13 @@ export class ComposeService {
console.warn('[ComposeService] mesh override skipped:', sanitizeForLog((err as Error).message));
}
if (overridePath) {
const baseFilename = await FileSystemService.getInstance(this.nodeId).getComposeFilename(stackName);
args.push('-f', baseFilename, '-f', overridePath);
if (filePrefix.length === 0) {
// Single-file stack: passing any -f disables auto-discovery, so name the
// base file explicitly before layering the override on top of it.
const baseFilename = await FileSystemService.getInstance(this.nodeId).getComposeFilename(stackName);
args.push('-f', baseFilename);
}
args.push('-f', overridePath);
}
args.push(...action);
return args;
@@ -323,7 +339,7 @@ export class ComposeService {
const fsSvc = FileSystemService.getInstance(this.nodeId);
await fsSvc.restoreStackFiles(stackName);
await this.withRegistryAuth(async (env) => {
await this.execute('docker', await this.composeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env);
await this.execute('docker', await this.authoredComposeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env);
}, sendOutput);
sendOutput('=== Rolled back successfully ===\n');
return true;
@@ -342,7 +358,7 @@ export class ComposeService {
async runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
await this.execute('docker', ['compose', action], stackDir, ws);
await this.execute('docker', await this.authoredComposeArgs(stackName, [action]), stackDir, ws);
}
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
@@ -371,7 +387,7 @@ export class ComposeService {
}
await this.withRegistryAuth(async (env) => {
await this.execute('docker', await this.composeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env, getComposeStallTimeoutMs());
await this.execute('docker', await this.authoredComposeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env, getComposeStallTimeoutMs());
}, sendOutput);
// Post-Deploy Health Probe
@@ -548,10 +564,10 @@ export class ComposeService {
await this.withRegistryAuth(async (env) => {
sendOutput('=== Pulling latest images ===\n');
await this.execute('docker', ['compose', 'pull'], stackDir, ws, true, env, getComposeStallTimeoutMs());
await this.execute('docker', await this.authoredComposeArgs(stackName, ['pull']), stackDir, ws, true, env, getComposeStallTimeoutMs());
sendOutput('=== Recreating containers ===\n');
await this.execute('docker', await this.composeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env, getComposeStallTimeoutMs());
await this.execute('docker', await this.authoredComposeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env, getComposeStallTimeoutMs());
}, sendOutput);
// Post-Update Health Probe
@@ -611,7 +627,7 @@ export class ComposeService {
public async downStack(stackName: string): Promise<void> {
const stackPath = path.join(this.baseDir, stackName);
try {
await this.execute('docker', ['compose', 'down', '--volumes', '--remove-orphans'], stackPath, undefined, false);
await this.execute('docker', await this.authoredComposeArgs(stackName, ['down', '--volumes', '--remove-orphans']), stackPath, undefined, false);
} catch (error) {
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${sanitizeForLog(stackName)}`);
}
@@ -634,7 +650,10 @@ export class ComposeService {
if (!isPathWithinBase(stackDir, this.baseDir) || path.resolve(this.baseDir) === stackDir) {
throw new Error('Invalid stack path');
}
const stdout = await this.captureCompose(['config', '--images'], stackDir);
// Use the authored multi-file model (no mesh override) so override-only image
// refs are scanned by the policy gate; single-file stacks get an empty prefix.
const filePrefix = authoredComposeFileArgs(stackName, this.nodeId);
const stdout = await this.captureCompose([...filePrefix, 'config', '--images'], stackDir);
const seen = new Set<string>();
const images: string[] = [];
for (const raw of stdout.split(/\r?\n/)) {
@@ -705,7 +724,15 @@ export class ComposeService {
if (!stackDir.startsWith(baseResolved + path.sep)) {
return Promise.reject(new Error('Invalid stack path'));
}
const child = spawn('docker', ['compose', 'config', '--format', 'json'], {
// Render the authored multi-file model (no mesh override) so the Compose Doctor
// sees every override file; single-file stacks get an empty prefix.
let filePrefix: string[];
try {
filePrefix = authoredComposeFileArgs(stackName, this.nodeId);
} catch (err) {
return Promise.reject(err instanceof Error ? err : new Error(String(err)));
}
const child = spawn('docker', ['compose', ...filePrefix, 'config', '--format', 'json'], {
cwd: stackDir,
env: {
...process.env,
+95 -8
View File
@@ -221,12 +221,28 @@ export interface Webhook {
export type GitSourceAuthType = 'none' | 'token';
/**
* The ordered set of local compose files actually materialized on disk for a
* stack, plus the optional project directory. This is the deploy-time source of
* truth: the deploy/lifecycle path, the mesh service, and the image-update path all
* read it (never the configured `compose_paths`), so a saved-but-not-yet-applied config change does
* not alter runtime until apply writes the files. `null` (the default) means a
* plain single-file stack that uses docker compose auto-discovery, byte-identical
* to pre-multi-file behavior.
*/
export interface GitSourceAppliedSpec {
files: string[]; // ordered local relative compose filenames (files[0] is always compose.yaml)
contextDir: string | null; // optional project dir within the stack, passed as --project-directory
}
export interface StackGitSource {
id?: number;
stack_name: string;
repo_url: string;
branch: string;
compose_path: string;
compose_path: string; // primary repo path; kept in sync with compose_paths[0] for back-compat readers
compose_paths: string[]; // ordered repo-relative compose paths; normalized to [compose_path] for legacy rows
context_dir: string | null; // configured project dir within the repo
sync_env: boolean;
env_path: string | null;
auth_type: GitSourceAuthType;
@@ -240,6 +256,7 @@ export interface StackGitSource {
pending_env_content: string | null;
pending_fetched_at: number | null;
last_debounce_at: number | null;
applied_deploy_spec: GitSourceAppliedSpec | null; // deploy-time materialized file set; null = single-file auto-discovery
created_at: number;
updated_at: number;
}
@@ -786,6 +803,7 @@ export class DatabaseService {
this.migrateAutoHealNodeId();
this.migrateFleetSyncStickyError();
this.migrateStackDossierHashes();
this.migrateGitSourceMultiFile();
// Reset the cache once at end of constructor in case any migration
// populated it via getGlobalSettings() and a subsequent migration
@@ -1195,6 +1213,8 @@ export class DatabaseService {
repo_url TEXT NOT NULL,
branch TEXT NOT NULL,
compose_path TEXT NOT NULL,
compose_paths TEXT,
context_dir TEXT,
sync_env INTEGER NOT NULL DEFAULT 0,
env_path TEXT,
auth_type TEXT NOT NULL DEFAULT 'none',
@@ -1208,6 +1228,7 @@ export class DatabaseService {
pending_env_content TEXT,
pending_fetched_at INTEGER,
last_debounce_at INTEGER,
applied_deploy_spec TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
@@ -1649,6 +1670,21 @@ export class DatabaseService {
this.tryAddColumn('stack_dossiers', 'rendered_hash', 'TEXT');
}
private migrateGitSourceMultiFile(): void {
this.tryAddColumn('stack_git_sources', 'compose_paths', 'TEXT');
this.tryAddColumn('stack_git_sources', 'context_dir', 'TEXT');
this.tryAddColumn('stack_git_sources', 'applied_deploy_spec', 'TEXT');
// Backfill legacy rows so compose_paths is always a JSON array. parseGitSource
// also normalizes on read, so this is belt-and-suspenders for any direct readers.
try {
this.db.prepare(
`UPDATE stack_git_sources SET compose_paths = json_array(compose_path) WHERE compose_paths IS NULL`
).run();
} catch (e) {
console.warn('[DatabaseService] git-source compose_paths backfill skipped:', (e as Error).message);
}
}
private migrateScanPolicyFleetColumns(): void {
this.tryAddColumn('scan_policies', 'node_identity', "TEXT NOT NULL DEFAULT ''");
this.tryAddColumn('scan_policies', 'replicated_from_control', 'INTEGER NOT NULL DEFAULT 0');
@@ -3736,14 +3772,51 @@ export class DatabaseService {
// --- Stack Git Sources ---
/**
* Normalize the stored compose_paths column to a non-empty ordered array.
* Legacy rows (column null), empty arrays, or malformed JSON all fall back
* to the single compose_path so every consumer can rely on a usable list.
*/
private normalizeComposePaths(raw: unknown, composePath: string): string[] {
if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
const paths = parsed.filter((p): p is string => typeof p === 'string' && p.trim().length > 0);
if (paths.length > 0) return paths;
}
} catch {
// fall through to the single-path fallback
}
}
return [composePath];
}
private parseAppliedDeploySpec(raw: unknown): GitSourceAppliedSpec | null {
if (typeof raw !== 'string' || !raw.trim()) return null;
try {
const parsed = JSON.parse(raw) as Partial<GitSourceAppliedSpec>;
if (!parsed || !Array.isArray(parsed.files)) return null;
const files = parsed.files.filter((f): f is string => typeof f === 'string' && f.trim().length > 0);
if (files.length === 0) return null;
return { files, contextDir: typeof parsed.contextDir === 'string' ? parsed.contextDir : null };
} catch {
return null;
}
}
private parseGitSource(row: Record<string, unknown> | undefined): StackGitSource | undefined {
if (!row) return undefined;
const composePath = row.compose_path as string;
return {
id: row.id as number,
stack_name: row.stack_name as string,
repo_url: row.repo_url as string,
branch: row.branch as string,
compose_path: row.compose_path as string,
compose_path: composePath,
compose_paths: this.normalizeComposePaths(row.compose_paths, composePath),
context_dir: (row.context_dir as string | null) ?? null,
applied_deploy_spec: this.parseAppliedDeploySpec(row.applied_deploy_spec),
sync_env: Number(row.sync_env) === 1,
env_path: (row.env_path as string | null) ?? null,
auth_type: row.auth_type as GitSourceAuthType,
@@ -3772,19 +3845,21 @@ export class DatabaseService {
return rows.map(r => this.parseGitSource(r)!);
}
public upsertGitSource(source: Omit<StackGitSource, 'id' | 'created_at' | 'updated_at'>): number {
public upsertGitSource(source: Omit<StackGitSource, 'id' | 'created_at' | 'updated_at' | 'applied_deploy_spec'>): number {
const now = Date.now();
const existing = this.getGitSource(source.stack_name);
const composePathsJson = JSON.stringify(source.compose_paths ?? [source.compose_path]);
if (existing) {
this.db.prepare(
`UPDATE stack_git_sources SET
repo_url = ?, branch = ?, compose_path = ?, sync_env = ?, env_path = ?,
repo_url = ?, branch = ?, compose_path = ?, compose_paths = ?, context_dir = ?,
sync_env = ?, env_path = ?,
auth_type = ?, encrypted_token = ?,
auto_apply_on_webhook = ?, auto_deploy_on_apply = ?,
updated_at = ?
WHERE stack_name = ?`
).run(
source.repo_url, source.branch, source.compose_path,
source.repo_url, source.branch, source.compose_path, composePathsJson, source.context_dir,
source.sync_env ? 1 : 0, source.env_path,
source.auth_type, source.encrypted_token,
source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
@@ -3794,12 +3869,12 @@ export class DatabaseService {
}
const result = this.db.prepare(
`INSERT INTO stack_git_sources
(stack_name, repo_url, branch, compose_path, sync_env, env_path,
(stack_name, repo_url, branch, compose_path, compose_paths, context_dir, sync_env, env_path,
auth_type, encrypted_token, auto_apply_on_webhook, auto_deploy_on_apply,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
source.stack_name, source.repo_url, source.branch, source.compose_path,
source.stack_name, source.repo_url, source.branch, source.compose_path, composePathsJson, source.context_dir,
source.sync_env ? 1 : 0, source.env_path,
source.auth_type, source.encrypted_token,
source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
@@ -3808,6 +3883,18 @@ export class DatabaseService {
return result.lastInsertRowid as number;
}
/**
* Persist (or clear) the deploy-time materialized compose file set. Called by
* the Git apply/create paths after files land on disk; the deploy/lifecycle
* path, the mesh service, and the image-update path read it back via
* getGitSource(). Passing null resets the stack to single-file auto-discovery.
*/
public setGitSourceAppliedSpec(stackName: string, spec: GitSourceAppliedSpec | null): void {
this.db.prepare(
`UPDATE stack_git_sources SET applied_deploy_spec = ?, updated_at = ? WHERE stack_name = ?`
).run(spec ? JSON.stringify(spec) : null, Date.now(), stackName);
}
public deleteGitSource(stackName: string): void {
this.db.prepare('DELETE FROM stack_git_sources WHERE stack_name = ?').run(stackName);
}
+22 -9
View File
@@ -1,6 +1,6 @@
import Docker from 'dockerode';
import WebSocket from 'ws';
import { exec } from 'child_process';
import { execFile } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
@@ -13,8 +13,9 @@ import { isPathWithinBase } from '../utils/validation';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import { describeSpawnError } from '../utils/spawnErrors';
import { authoredComposeFileArgs } from '../utils/authoredComposeArgs';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose';
/** Canonical compose file name variants, checked in priority order. */
@@ -1277,16 +1278,28 @@ class DockerController {
}
public async getContainersByStack(stackName: string) {
const stackDir = path.join(COMPOSE_DIR, stackName);
// Resolve the compose dir and the authored prefix for THIS controller's node,
// not the process default, so a non-default local node sees its own stack dir
// and deploy spec.
const stackDir = path.join(NodeRegistry.getInstance().getComposeDir(this.nodeId), stackName);
try {
const { stdout, stderr } = await execAsync('docker compose ps --format json -a', {
cwd: stackDir,
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
// Splice the authored multi-file prefix (-f files + -p + --project-directory)
// so a Git stack's override-only services are listed; single-file stacks get an
// empty prefix and behave exactly as before. execFile avoids shell quoting on
// the absolute --project-directory path.
const filePrefix = authoredComposeFileArgs(stackName, this.nodeId);
const { stdout, stderr } = await execFileAsync(
'docker',
['compose', ...filePrefix, 'ps', '--format', 'json', '-a'],
{
cwd: stackDir,
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
}
});
);
// Robust JSON parsing - handle both JSON array and newline-separated JSON objects
// Docker Compose v2 may return either format depending on version
+385 -134
View File
@@ -5,7 +5,7 @@ import os from 'os';
import path from 'path';
import YAML from 'yaml';
import { CryptoService } from './CryptoService';
import { DatabaseService, type StackGitSource, type GitSourceAuthType } from './DatabaseService';
import { DatabaseService, type StackGitSource, type GitSourceAuthType, type GitSourceAppliedSpec } from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { ComposeService } from './ComposeService';
import { HealthGateService } from './HealthGateService';
@@ -13,7 +13,8 @@ import { NodeRegistry } from './NodeRegistry';
import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import { isPathWithinBase } from '../utils/validation';
import { isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
import { gitSourceLocalComposeFiles, PRIMARY_COMPOSE_FILENAME } from '../utils/gitComposeFiles';
import type { GitHttpRequest, GitHttpResponse, HttpClient } from 'isomorphic-git/http/node';
// isomorphic-git is the heaviest dependency in the backend (~5 MB) and only
@@ -170,17 +171,23 @@ export class GitSourceError extends Error {
}
}
/** A single compose file fetched from a repo, keyed by its repo-relative path. */
export interface ComposeFile {
path: string;
content: string;
}
export interface FetchParams {
repoUrl: string;
branch: string;
composePath: string;
composePaths: string[];
envPath?: string | null;
token?: string | null;
timeoutMs?: number;
}
export interface FetchResult {
composeContent: string;
composeFiles: ComposeFile[];
envContent: string | null;
commitSha: string;
/**
@@ -195,7 +202,8 @@ export interface UpsertInput {
stackName: string;
repoUrl: string;
branch: string;
composePath: string;
composePaths: string[];
contextDir: string | null;
syncEnv: boolean;
envPath: string | null;
authType: GitSourceAuthType;
@@ -208,7 +216,8 @@ export interface CreateStackFromGitInput {
stackName: string;
repoUrl: string;
branch: string;
composePath: string;
composePaths: string[];
contextDir: string | null;
syncEnv: boolean;
envPath: string | null;
authType: GitSourceAuthType;
@@ -240,6 +249,8 @@ export interface PublicGitSource {
repo_url: string;
branch: string;
compose_path: string;
compose_paths: string[];
context_dir: string | null;
sync_env: boolean;
env_path: string | null;
auth_type: GitSourceAuthType;
@@ -536,6 +547,8 @@ export class GitSourceService {
repo_url: src.repo_url,
branch: src.branch,
compose_path: src.compose_path,
compose_paths: src.compose_paths,
context_dir: src.context_dir,
sync_env: src.sync_env,
env_path: src.env_path,
auth_type: src.auth_type,
@@ -583,23 +596,39 @@ export class GitSourceService {
throw new GitSourceError('GIT_ERROR', 'Auto-deploy requires auto-apply-on-webhook to be enabled.');
}
// Dry-run reachability check before persisting.
// Dry-run reachability check before persisting. Fetches every configured
// file so a bad path in the ordered list is caught at save time.
const token = encryptedToken ? this.crypto.decrypt(encryptedToken) : null;
await this.fetchFromGit({
repoUrl: input.repoUrl,
branch: input.branch,
composePath: input.composePath,
composePaths: input.composePaths,
envPath: input.syncEnv ? input.envPath : null,
token,
});
const resolvedEnvPath = input.syncEnv ? input.envPath : null;
// A pending pull captured the files/contextDir for the previous config. If
// any of those change, that pending blob would apply the wrong files, so
// clear it; the user re-pulls against the new config.
const configChanged = !!existing && (
existing.repo_url !== input.repoUrl ||
existing.branch !== input.branch ||
JSON.stringify(existing.compose_paths) !== JSON.stringify(input.composePaths) ||
existing.sync_env !== input.syncEnv ||
(existing.env_path ?? null) !== resolvedEnvPath ||
(existing.context_dir ?? null) !== input.contextDir
);
db.upsertGitSource({
stack_name: input.stackName,
repo_url: input.repoUrl,
branch: input.branch,
compose_path: input.composePath,
compose_path: input.composePaths[0],
compose_paths: input.composePaths,
context_dir: input.contextDir,
sync_env: input.syncEnv,
env_path: input.syncEnv ? input.envPath : null,
env_path: resolvedEnvPath,
auth_type: input.authType,
encrypted_token: encryptedToken,
auto_apply_on_webhook: input.autoApplyOnWebhook,
@@ -613,6 +642,10 @@ export class GitSourceService {
last_debounce_at: existing?.last_debounce_at ?? null,
});
if (configChanged) {
db.clearGitSourcePending(input.stackName);
}
return this.get(input.stackName)!;
}
@@ -622,29 +655,23 @@ export class GitSourceService {
// ─── Fetch ───────────────────────────────────────────────────────────────
public async fetchFromGit(params: FetchParams): Promise<FetchResult> {
const { repoUrl, branch, composePath, envPath, token } = params;
/**
* Clone a repo into a throwaway temp dir, run `fn` against the checkout, and
* always clean up. Centralizes the clone timeout, size cap, commit-sha read,
* and submodule warning so both fetchFromGit (reads compose/env files) and
* listRepoTree (lists the working tree) share one hardened clone path.
*/
private async withClonedRepo<T>(
params: { repoUrl: string; branch: string; token?: string | null; timeoutMs?: number },
fn: (dir: string, commitSha: string, warnings: string[]) => Promise<T>,
): Promise<T> {
const { repoUrl, branch, token } = params;
const timeoutMs = params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
// Reject any compose/env target that resolves inside the `.git`
// metadata directory BEFORE we spin up a clone. This blocks a
// caller from reading `.git/config` (which leaks the remote URL
// and any mis-configured inline credentials) via the fetch path.
assertNotGitMeta(composePath, 'compose_path');
if (envPath) assertNotGitMeta(envPath, 'env_path');
const dir = await createTempDir();
const startedAt = Date.now();
const diag = isDebugEnabled();
if (diag) {
console.log(
`[GitSource:diag] fetch start host=${sanitizeForLog(repoHost(repoUrl))} branch=${sanitizeForLog(branch)} compose=${sanitizeForLog(composePath)} envSync=${envPath ? 'true' : 'false'} timeoutMs=${timeoutMs}`
);
}
// isomorphic-git's onAuth callback hands credentials to the HTTP
// layer without them touching the URL string, which keeps tokens
// out of any error messages generated during the clone.
// isomorphic-git's onAuth callback hands credentials to the HTTP layer
// without them touching the URL string, keeping tokens out of any error
// messages generated during the clone.
const onAuth = token
? () => ({ username: 'x-access-token', password: token })
: undefined;
@@ -698,38 +725,6 @@ export class GitSourceService {
}
const commitSha = log[0].oid;
const composeContent = await readRepoFile(dir, composePath, 'Compose path');
if (isLfsPointer(composeContent)) {
console.error(`[GitSource] LFS pointer detected in ${sanitizeForLog(composePath)}`);
throw new GitSourceError(
'GIT_ERROR',
`Compose file at ${composePath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`,
);
}
let envContent: string | null = null;
if (envPath) {
try {
envContent = await readRepoFile(dir, envPath, 'Env path');
} catch (e) {
if (e instanceof GitSourceError && e.code === 'FILE_NOT_FOUND' && e.message.startsWith('File not found')) {
// A missing sibling .env is legitimate (repo may not carry one
// in the requested directory). Return null so the caller can
// decide whether to warn.
envContent = null;
} else {
throw e;
}
}
if (envContent !== null && isLfsPointer(envContent)) {
console.error(`[GitSource] LFS pointer detected in ${sanitizeForLog(envPath)}`);
throw new GitSourceError(
'GIT_ERROR',
`Env file at ${envPath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`,
);
}
}
// Submodule detection: non-fatal, surfaced as a warning. isomorphic-git
// does not recursively clone submodules, so any path that lives inside
// a submodule directory will be empty after apply. Users need to know.
@@ -739,12 +734,75 @@ export class GitSourceService {
warnings.push(SUBMODULE_WARNING);
}
if (diag) {
console.log(
`[GitSource:diag] fetch ok host=${sanitizeForLog(repoHost(repoUrl))} branch=${sanitizeForLog(branch)} sha=${commitSha.slice(0, 7)} env=${envContent !== null ? 'present' : 'absent'} warnings=${warnings.length} elapsedMs=${Date.now() - startedAt}`
);
}
return { composeContent, envContent, commitSha, warnings };
return await fn(dir, commitSha, warnings);
} finally {
await removeTempDir(dir);
}
}
public async fetchFromGit(params: FetchParams): Promise<FetchResult> {
const { repoUrl, branch, composePaths, envPath, token } = params;
// Reject any compose/env target that resolves inside the `.git`
// metadata directory BEFORE we spin up a clone. This blocks a
// caller from reading `.git/config` (which leaks the remote URL
// and any mis-configured inline credentials) via the fetch path.
for (const composePath of composePaths) assertNotGitMeta(composePath, 'compose_path');
if (envPath) assertNotGitMeta(envPath, 'env_path');
const startedAt = Date.now();
const diag = isDebugEnabled();
if (diag) {
console.log(
`[GitSource:diag] fetch start host=${sanitizeForLog(repoHost(repoUrl))} branch=${sanitizeForLog(branch)} files=${composePaths.length} envSync=${envPath ? 'true' : 'false'}`
);
}
try {
return await this.withClonedRepo({ repoUrl, branch, token, timeoutMs: params.timeoutMs }, async (dir, commitSha, warnings) => {
const composeFiles: ComposeFile[] = [];
for (const composePath of composePaths) {
const content = await readRepoFile(dir, composePath, 'Compose path');
if (isLfsPointer(content)) {
console.error(`[GitSource] LFS pointer detected in ${sanitizeForLog(composePath)}`);
throw new GitSourceError(
'GIT_ERROR',
`Compose file at ${composePath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`,
);
}
composeFiles.push({ path: composePath, content });
}
let envContent: string | null = null;
if (envPath) {
try {
envContent = await readRepoFile(dir, envPath, 'Env path');
} catch (e) {
if (e instanceof GitSourceError && e.code === 'FILE_NOT_FOUND' && e.message.startsWith('File not found')) {
// A missing sibling .env is legitimate (repo may not carry one
// in the requested directory). Return null so the caller can
// decide whether to warn.
envContent = null;
} else {
throw e;
}
}
if (envContent !== null && isLfsPointer(envContent)) {
console.error(`[GitSource] LFS pointer detected in ${sanitizeForLog(envPath)}`);
throw new GitSourceError(
'GIT_ERROR',
`Env file at ${envPath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`,
);
}
}
if (diag) {
console.log(
`[GitSource:diag] fetch ok host=${sanitizeForLog(repoHost(repoUrl))} branch=${sanitizeForLog(branch)} sha=${commitSha.slice(0, 7)} files=${composeFiles.length} env=${envContent !== null ? 'present' : 'absent'} warnings=${warnings.length} elapsedMs=${Date.now() - startedAt}`
);
}
return { composeFiles, envContent, commitSha, warnings };
});
} catch (err) {
if (diag) {
const msg = err instanceof GitSourceError ? `${err.code}: ${err.message}` : (err as Error).message;
@@ -753,11 +811,56 @@ export class GitSourceService {
);
}
throw err;
} finally {
await removeTempDir(dir);
}
}
/**
* Clone a repo and list its working-tree files (POSIX-relative, `.git`
* skipped) for the "browse repository" compose-file picker. Bounded by the
* same clone size/timeout guards as fetch, plus a file-count cap.
*/
public async listRepoTree(
params: { repoUrl: string; branch: string; token?: string | null; timeoutMs?: number },
): Promise<{ files: string[]; truncated: boolean; commitSha: string; warnings: string[] }> {
return this.withClonedRepo(params, async (dir, commitSha, warnings) => {
const { files, truncated } = await this.walkRepoFiles(dir);
return { files, truncated, commitSha, warnings };
});
}
private async walkRepoFiles(rootDir: string): Promise<{ files: string[]; truncated: boolean }> {
const MAX_FILES = 2000;
const files: string[] = [];
let truncated = false;
const walk = async (relDir: string): Promise<void> => {
if (truncated) return;
let entries: import('fs').Dirent[];
try {
entries = await fsPromises.readdir(path.join(rootDir, relDir), { withFileTypes: true });
} catch (e) {
// A readdir failure on a just-cloned subtree silently drops it from the
// picker; log so a partial listing is traceable (the user can still
// add paths manually).
console.warn(`[GitSource] repo walk skipped ${sanitizeForLog(relDir || '.')}:`, (e as Error).message);
return;
}
for (const entry of entries) {
if (truncated) return;
if (entry.name === '.git') continue;
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
await walk(rel);
} else if (entry.isFile()) {
if (files.length >= MAX_FILES) { truncated = true; return; }
files.push(rel);
}
}
};
await walk('');
files.sort();
return { files, truncated };
}
private mapGitError(err: Error, hasToken: boolean, host = 'unknown'): GitSourceError {
const raw = scrubCredentials(err.message || String(err));
const code = (err as Error & { code?: string }).code;
@@ -833,26 +936,55 @@ export class GitSourceService {
* errors, invalid `include:` references, etc., which a shallow schema
* check would miss.
*/
public async validateCompose(composeContent: string, envContent: string | null): Promise<{ ok: boolean; error?: string }> {
// Cheap syntax pre-check
try {
const parsed = YAML.parse(composeContent);
if (parsed === null || parsed === undefined) {
return { ok: false, error: 'Compose file is empty.' };
public async validateCompose(composeFiles: ComposeFile[], envContent: string | null, contextDir: string | null): Promise<{ ok: boolean; error?: string }> {
if (composeFiles.length === 0) return { ok: false, error: 'No compose files provided.' };
// Cheap syntax pre-check per file
for (const file of composeFiles) {
try {
const parsed = YAML.parse(file.content);
if (parsed === null || parsed === undefined) {
return { ok: false, error: `Compose file ${file.path} is empty.` };
}
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
return { ok: false, error: `Compose file ${file.path} must be a YAML mapping.` };
}
} catch (e) {
return { ok: false, error: `YAML parse error in ${file.path}: ${(e as Error).message}` };
}
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
return { ok: false, error: 'Compose file must be a YAML mapping.' };
}
} catch (e) {
return { ok: false, error: `YAML parse error: ${(e as Error).message}` };
}
// Semantic check via `docker compose config`
// Semantic check via `docker compose config` over the ordered set, written
// in the same local layout the deploy materializes (primary -> compose.yaml,
// additional files under their repo-relative paths), with each path segment
// re-sanitized for this throwaway dir. So the merge order, project directory,
// and relative cross-references resolve from the same base the real deploy uses.
const dir = await createTempDir();
try {
const composeFile = path.join(dir, 'compose.yaml');
await fsPromises.writeFile(composeFile, composeContent, 'utf-8');
const args = ['compose', '-f', composeFile];
const localFiles = gitSourceLocalComposeFiles(composeFiles.map(f => f.path));
const args = ['compose'];
for (let i = 0; i < composeFiles.length; i++) {
const safeRel = localFiles[i].replace(/\\/g, '/').split('/').map(s => path.basename(s)).join('/');
const abs = path.resolve(dir, safeRel);
if (!isPathWithinBase(abs, dir)) {
return { ok: false, error: `Compose path escapes the validation dir: ${composeFiles[i].path}` };
}
await fsPromises.mkdir(path.dirname(abs), { recursive: true });
await fsPromises.writeFile(abs, composeFiles[i].content, 'utf-8');
args.push('-f', safeRel);
}
if (contextDir) {
// Inline path-injection barrier at the mkdir sink. CodeQL does not
// credit the wrapped isPathWithinBase helper, so resolve against a
// known-safe base and check containment with startsWith right here.
const baseResolved = path.resolve(dir);
const ctxAbs = path.resolve(baseResolved, contextDir);
if (!ctxAbs.startsWith(baseResolved + path.sep)) {
return { ok: false, error: 'Context directory escapes the validation dir.' };
}
await fsPromises.mkdir(ctxAbs, { recursive: true });
args.push('--project-directory', ctxAbs);
}
if (envContent !== null) {
const envFile = path.join(dir, '.env');
await fsPromises.writeFile(envFile, envContent, 'utf-8');
@@ -891,21 +1023,90 @@ export class GitSourceService {
// ─── Hashing + diff ──────────────────────────────────────────────────────
public hashContent(compose: string, env: string | null): string {
return crypto.createHash('sha256')
.update(compose)
.update('\x00')
.update(env ?? '')
.digest('hex');
public hashContent(files: ComposeFile[], env: string | null): string {
// Hash the ordered file CONTENTS (NUL-separated) plus env. Paths are
// deliberately excluded: create/apply hash the fetched files (repo paths)
// while pull hashes the on-disk files (materialized paths, primary ->
// compose.yaml), so including paths would make the two disagree and flag a
// clean multi-file stack as locally edited. Content order already changes
// the hash on reorder, and a reorder is a config change that re-applies.
// Single-file keeps the legacy (content + env) shape, byte-stable on upgrade.
const h = crypto.createHash('sha256');
if (files.length === 1) {
h.update(files[0].content);
} else {
for (const f of files) {
h.update(f.content);
h.update('\x00');
}
}
h.update('\x00');
h.update(env ?? '');
return h.digest('hex');
}
private async readDiskContent(stackName: string, syncEnv: boolean): Promise<{ compose: string; env: string | null }> {
/** Combine an ordered file set into a single path-headed preview for the diff UI. */
private combinedComposePreview(files: ComposeFile[]): string {
if (files.length <= 1) return files[0]?.content ?? '';
return files.map(f => `# ── ${f.path} ──\n${f.content}`).join('\n\n');
}
/**
* The deploy-time spec for an ordered file set. Single-file stacks with no
* context dir get `null`, so runtime stays plain `docker compose` auto-discovery.
*/
private deriveAppliedSpec(composePaths: string[], contextDir: string | null): GitSourceAppliedSpec | null {
if (composePaths.length <= 1 && !contextDir) return null;
return { files: gitSourceLocalComposeFiles(composePaths), contextDir: contextDir ?? null };
}
/** Encrypt the ordered compose file set as the v2 pending blob (carries contextDir). */
private encodePendingCompose(files: ComposeFile[], contextDir: string | null): string {
return this.crypto.encrypt(JSON.stringify({ v: 2, files, contextDir }));
}
/**
* Decrypt a stored pending compose blob into its ordered file set + contextDir.
* Detects the v2 marker; anything else is a legacy single-file plaintext string.
*/
private decodePendingCompose(stored: string): { files: ComposeFile[]; contextDir: string | null } {
const raw = this.crypto.decrypt(stored);
if (raw.startsWith('{"v":2')) {
try {
const parsed = JSON.parse(raw) as { v: number; files?: ComposeFile[]; contextDir?: string | null };
if (Array.isArray(parsed.files) && parsed.files.length > 0) {
return { files: parsed.files, contextDir: parsed.contextDir ?? null };
}
} catch (e) {
// The v2 marker proves this was written as multi-file, so a parse
// failure signals a corrupt pending blob, not a legacy row. Log it
// so the misleading downstream validation error is traceable; the
// re-validate in apply still blocks deploying the garbled content.
console.error('[GitSource] pending compose blob carried the v2 marker but failed to parse; treating as legacy:', (e as Error).message);
}
}
return { files: [{ path: PRIMARY_COMPOSE_FILENAME, content: raw }], contextDir: null };
}
private async readDiskContent(stackName: string, syncEnv: boolean, relFiles: string[]): Promise<{ files: ComposeFile[]; env: string | null }> {
const fsSvc = FileSystemService.getInstance();
let compose: string;
try {
compose = await fsSvc.getStackContent(stackName);
} catch {
compose = '';
const files: ComposeFile[] = [];
for (let i = 0; i < relFiles.length; i++) {
const rel = relFiles[i];
try {
// The primary uses compose discovery (compose.yaml / docker-compose.yml);
// additional files are read at their materialized relative path.
const content = i === 0
? await fsSvc.getStackContent(stackName)
: (await fsSvc.readStackFile(stackName, rel)).content ?? '';
files.push({ path: rel, content });
} catch (e) {
// Empty-on-error is a defensible default (a prior-spec file may have
// been removed by a concurrent edit), but log it so an unexpected
// "local changes detected" can be traced to an unreadable file.
console.warn(`[GitSource] could not read ${sanitizeForLog(rel)} for ${sanitizeForLog(stackName)} diff:`, (e as Error).message);
files.push({ path: rel, content: '' });
}
}
let env: string | null = null;
if (syncEnv) {
@@ -915,7 +1116,57 @@ export class GitSourceService {
env = null;
}
}
return { compose, env };
return { files, env };
}
/**
* Write an ordered compose file set to a stack on disk: the primary to the
* root compose.yaml, each additional file to its repo-relative path. Creates
* the context dir when set, writes the env file when syncing, removes files
* that the previous applied spec materialized but the new set drops, and
* returns the deploy spec to persist.
*/
private async materialize(
stackName: string,
composeFiles: ComposeFile[],
contextDir: string | null,
syncEnv: boolean,
envContent: string | null,
prevSpec: GitSourceAppliedSpec | null,
): Promise<GitSourceAppliedSpec | null> {
const fsSvc = FileSystemService.getInstance();
const localFiles = gitSourceLocalComposeFiles(composeFiles.map(f => f.path));
await fsSvc.saveStackContent(stackName, composeFiles[0].content);
for (let i = 1; i < composeFiles.length; i++) {
await fsSvc.writeStackFile(stackName, localFiles[i], composeFiles[i].content);
}
if (contextDir) {
await fsSvc.mkdirStackPath(stackName, contextDir);
}
if (syncEnv && envContent !== null) {
await fsSvc.saveEnvContent(stackName, envContent);
}
// Stale cleanup: remove additional files the previous apply wrote that are
// no longer in the set. Re-validate each as a safe relative path and never
// touch the primary compose.yaml.
if (prevSpec) {
const keep = new Set(localFiles);
for (const old of prevSpec.files) {
if (old === PRIMARY_COMPOSE_FILENAME || keep.has(old)) continue;
if (!isValidRelativeStackPath(old) || old === '') continue;
try {
await fsSvc.deleteStackPath(stackName, old);
} catch (e) {
console.warn(`[GitSource] stale file cleanup skipped ${sanitizeForLog(old)} for ${sanitizeForLog(stackName)}:`, (e as Error).message);
}
}
}
return this.deriveAppliedSpec(composeFiles.map(f => f.path), contextDir);
}
// ─── Pull / apply ────────────────────────────────────────────────────────
@@ -949,24 +1200,25 @@ export class GitSourceService {
const fetched = await this.fetchFromGit({
repoUrl: src.repo_url,
branch: src.branch,
composePath: src.compose_path,
composePaths: src.compose_paths,
envPath: src.sync_env ? src.env_path : null,
token,
});
const validation = await this.validateCompose(fetched.composeContent, fetched.envContent);
const disk = await this.readDiskContent(stackName, src.sync_env);
const currentHash = this.hashContent(disk.compose, disk.env);
const validation = await this.validateCompose(fetched.composeFiles, fetched.envContent, src.context_dir);
const appliedFiles = src.applied_deploy_spec?.files ?? [PRIMARY_COMPOSE_FILENAME];
const disk = await this.readDiskContent(stackName, src.sync_env, appliedFiles);
const currentHash = this.hashContent(disk.files, disk.env);
const hasLocalChanges = src.last_applied_content_hash !== null
&& src.last_applied_content_hash !== currentHash;
// Store pending so a subsequent apply doesn't re-fetch. Compose files
// routinely contain secrets inlined as env interpolations or passwords,
// so encrypt the pending buffers at rest.
// so the v2 blob (ordered files + contextDir) is encrypted at rest.
db.setGitSourcePending(
stackName,
fetched.commitSha,
this.crypto.encrypt(fetched.composeContent),
this.encodePendingCompose(fetched.composeFiles, src.context_dir),
fetched.envContent !== null ? this.crypto.encrypt(fetched.envContent) : null,
);
@@ -977,9 +1229,9 @@ export class GitSourceService {
return {
commitSha: fetched.commitSha,
incomingCompose: fetched.composeContent,
incomingCompose: this.combinedComposePreview(fetched.composeFiles),
incomingEnv: fetched.envContent,
currentCompose: disk.compose,
currentCompose: this.combinedComposePreview(disk.files),
currentEnv: disk.env,
validation,
hasLocalChanges,
@@ -1025,28 +1277,29 @@ export class GitSourceService {
throw new GitSourceError('GIT_ERROR', 'Pending commit has changed since this pull was fetched. Please review the latest diff.');
}
// Pending buffers are stored encrypted; decrypt is a no-op for any
// legacy plaintext rows (isEncrypted check inside CryptoService).
const composeContent = this.crypto.decrypt(src.pending_compose_content);
// Materialize from the pending blob (its files + contextDir), never the
// live config: a config edit between pull and apply must not change what
// gets written. The v2 blob is decoded here; legacy plaintext is treated
// as a single compose.yaml.
const pending = this.decodePendingCompose(src.pending_compose_content);
const envContent = src.pending_env_content !== null
? this.crypto.decrypt(src.pending_env_content)
: null;
// Re-validate before writing.
const validation = await this.validateCompose(composeContent, envContent);
const validation = await this.validateCompose(pending.files, envContent, pending.contextDir);
if (!validation.ok) {
if (diag) console.log(`[GitSource:diag] apply validation fail stack=${stackName}`);
throw new GitSourceError('GIT_ERROR', `Compose validation failed: ${validation.error}`);
}
const fsSvc = FileSystemService.getInstance();
await fsSvc.saveStackContent(stackName, composeContent);
if (src.sync_env && envContent !== null) {
await fsSvc.saveEnvContent(stackName, envContent);
}
const appliedSpec = await this.materialize(
stackName, pending.files, pending.contextDir, src.sync_env, envContent, src.applied_deploy_spec,
);
const hash = this.hashContent(composeContent, envContent);
const hash = this.hashContent(pending.files, envContent);
db.markGitSourceApplied(stackName, commitSha, hash);
db.setGitSourceAppliedSpec(stackName, appliedSpec);
const shouldDeploy = opts.deploy ?? src.auto_deploy_on_apply;
if (diag) console.log('[GitSource:diag] apply wrote stack=%s sha=%s deploy=%s', sanitizeForLog(stackName), sanitizeForLog(commitSha.slice(0, 7)), sanitizeForLog(shouldDeploy));
@@ -1110,31 +1363,29 @@ export class GitSourceService {
const fetched = await this.fetchFromGit({
repoUrl: input.repoUrl,
branch: input.branch,
composePath: input.composePath,
composePaths: input.composePaths,
envPath: input.syncEnv ? input.envPath : null,
token: input.token,
});
// 2. Validate against the same `docker compose config` check the
// apply path uses. Reject before creating anything on disk.
const validation = await this.validateCompose(fetched.composeContent, fetched.envContent);
const validation = await this.validateCompose(fetched.composeFiles, fetched.envContent, input.contextDir);
if (!validation.ok) {
throw new GitSourceError('GIT_ERROR', `Compose validation failed: ${validation.error}`);
}
// 3. Create directory + boilerplate, then overwrite with the
// fetched content. createStack() throws if the directory
// already exists, so a name collision is caught here.
// 3. Create directory + boilerplate, then materialize the fetched
// files. createStack() throws if the directory already exists, so a
// name collision is caught here.
let stackCreated = false;
try {
await fsSvc.createStack(input.stackName);
stackCreated = true;
await fsSvc.saveStackContent(input.stackName, fetched.composeContent);
let envWritten = false;
if (input.syncEnv && fetched.envContent !== null) {
await fsSvc.saveEnvContent(input.stackName, fetched.envContent);
envWritten = true;
}
const appliedSpec = await this.materialize(
input.stackName, fetched.composeFiles, input.contextDir, input.syncEnv, fetched.envContent, null,
);
const envWritten = input.syncEnv && fetched.envContent !== null;
// 4. Insert the git-source row, then mark it applied so future
// pulls diff against the fetched commit rather than treating
@@ -1142,11 +1393,14 @@ export class GitSourceService {
const encryptedToken = input.authType === 'token' && input.token
? this.crypto.encrypt(input.token)
: null;
const hash = this.hashContent(fetched.composeFiles, fetched.envContent);
db.upsertGitSource({
stack_name: input.stackName,
repo_url: input.repoUrl,
branch: input.branch,
compose_path: input.composePath,
compose_path: input.composePaths[0],
compose_paths: input.composePaths,
context_dir: input.contextDir,
sync_env: input.syncEnv,
env_path: input.syncEnv ? input.envPath : null,
auth_type: input.authType,
@@ -1154,18 +1408,15 @@ export class GitSourceService {
auto_apply_on_webhook: input.autoApplyOnWebhook,
auto_deploy_on_apply: input.autoDeployOnApply,
last_applied_commit_sha: fetched.commitSha,
last_applied_content_hash: this.hashContent(fetched.composeContent, fetched.envContent),
last_applied_content_hash: hash,
pending_commit_sha: null,
pending_compose_content: null,
pending_env_content: null,
pending_fetched_at: null,
last_debounce_at: null,
});
db.markGitSourceApplied(
input.stackName,
fetched.commitSha,
this.hashContent(fetched.composeContent, fetched.envContent),
);
db.markGitSourceApplied(input.stackName, fetched.commitSha, hash);
db.setGitSourceAppliedSpec(input.stackName, appliedSpec);
const source = this.get(input.stackName);
if (!source) {
+75 -6
View File
@@ -105,6 +105,54 @@ export function extractImagesFromCompose(
return extractServiceImagesFromCompose(yamlContent, envVars).map(e => e.image);
}
/**
* Extract service images from a `docker compose config --format json` render.
* The render is already merged + interpolated, so no env substitution is needed.
*/
export function extractServiceImagesFromRenderedConfig(renderedJson: string): ComposeServiceImage[] {
let parsed: { services?: Record<string, { image?: unknown }> };
try {
parsed = JSON.parse(renderedJson);
} catch {
return [];
}
if (!parsed?.services || typeof parsed.services !== 'object') return [];
const out: ComposeServiceImage[] = [];
for (const [service, svc] of Object.entries(parsed.services)) {
const raw = svc?.image;
if (!raw || typeof raw !== 'string') continue;
const ref = raw.trim();
if (!ref || ref.startsWith('sha256:')) continue;
out.push({ service, image: ref });
}
return out;
}
/**
* Service-name -> image refs for a stack. For a Git stack with an applied
* multi-file / context-dir spec, this comes from the effective merged model
* (docker compose config), so a service/image declared only in an override file
* is included. Returns null for single-file / non-git stacks (and on a render
* failure) so the caller falls back to its existing single-file compose parse.
*/
export async function loadEffectiveServiceImages(nodeId: number, stackName: string): Promise<ComposeServiceImage[] | null> {
const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec;
if (!spec || spec.files.length === 0) return null;
// Lazy import to avoid a static module cycle (ComposeService is a heavy hub).
const { ComposeService } = await import('./ComposeService');
const rendered = await ComposeService.getInstance(nodeId).renderConfig(stackName);
if (!rendered.rendered) {
// The effective render failed (unset var, bad include, timeout, output cap).
// Falling back to the root-compose parse misses override-only images, so log
// the reason; without this the degradation is invisible to the operator.
console.warn(
`[ImageUpdateService] effective image render failed for "${sanitizeForLog(stackName)}" (code=${rendered.code} timedOut=${rendered.timedOut}); falling back to root-compose parse: ${sanitizeForLog(rendered.stderr)}`,
);
return null;
}
return extractServiceImagesFromRenderedConfig(rendered.rendered);
}
// ─── Service ──────────────────────────────────────────────────────────────────
export class ImageUpdateService {
@@ -348,6 +396,14 @@ export class ImageUpdateService {
// Phase 2: Parse compose files for image refs
for (const stackName of stacks) {
try {
// Multi-file / context-dir Git stacks resolve images from the
// effective merged model so override-only images are captured.
const effective = await loadEffectiveServiceImages(nodeId, stackName);
if (effective) {
for (const e of effective) stackImages.get(stackName)?.add(e.image);
continue;
}
const content = await withTimeout(fs.getStackContent(stackName), ImageUpdateService.SOCKET_TIMEOUT_MS, 'getStackContent');
// Load .env for variable resolution (best-effort)
@@ -384,13 +440,26 @@ export class ImageUpdateService {
try {
const containers = await withTimeout(docker.getAllContainers(), ImageUpdateService.SOCKET_TIMEOUT_MS, 'getAllContainers');
for (const c of containers) {
const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir'];
if (!workingDir) continue;
// Prefer the pinned project label (== stackName for Sencho-deployed
// stacks, including multi-file / context-dir ones where
// --project-directory would otherwise change the working-dir
// basename). Fall back to the working-dir basename for legacy /
// non-Sencho containers.
const project: string | undefined = c.Labels?.['com.docker.compose.project'];
let stackName: string | null = null;
if (project && stackImages.has(project)) {
stackName = project;
} else {
const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir'];
if (workingDir) {
const resolved = path.resolve(workingDir);
if (resolved === composeDir || resolved.startsWith(composeDir + path.sep)) {
stackName = path.basename(resolved);
}
}
}
if (!stackName) continue;
const resolved = path.resolve(workingDir);
if (resolved !== composeDir && !resolved.startsWith(composeDir + path.sep)) continue;
const stackName = path.basename(resolved);
const imageRef: string = c.Image ?? '';
if (!imageRef || imageRef.startsWith('sha256:')) continue;
+37 -12
View File
@@ -18,7 +18,7 @@ import { lookupContainerIp } from '../mesh/containerLookup';
import { STREAM_PENDING_DATA_MAX_BYTES } from '../pilot/protocol';
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
import { isDebugEnabled } from '../utils/debug';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { isPathWithinBase, isValidStackName, isValidRelativeStackPath } from '../utils/validation';
import { PORT as SENCHO_LISTEN_PORT } from '../helpers/constants';
import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate';
@@ -2080,18 +2080,23 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
const targetNodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
try {
const fsSvc = FileSystemService.getInstance(targetNodeId);
const filename = await fsSvc.getComposeFilename(stackName);
const baseDir = fsSvc.getBaseDir();
// path.basename strips any directory component as defense-in-depth
// on top of isValidStackName + isPathWithinBase. Recognized by
// CodeQL's path-injection model.
const composePath = path.join(baseDir, path.basename(stackName), filename);
if (!isPathWithinBase(composePath, baseDir)) return [];
const content = await fs.readFile(composePath, 'utf8');
const parsed = YAML.parse(content) as { services?: Record<string, unknown> } | null;
const services = parsed?.services && typeof parsed.services === 'object' ? parsed.services : null;
if (!services) return [];
return Object.keys(services).filter((name) => /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(name));
// For a multi-file Git stack, read every materialized compose file and
// union their service names, so a service declared only in an override
// file is still attached to the mesh. Single-file stacks read the one
// resolved compose file, byte-identical to the prior behavior.
const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec;
const relFiles = spec && spec.files.length > 0
? spec.files
: [await fsSvc.getComposeFilename(stackName)];
const names = new Set<string>();
for (const relFile of relFiles) {
if (relFile === '' || !isValidRelativeStackPath(relFile)) continue;
for (const name of await this.readComposeServiceNames(baseDir, stackName, relFile)) {
names.add(name);
}
}
return Array.from(names);
} catch (err) {
console.warn(
'[MeshService] getDeclaredStackServiceNames failed:',
@@ -2101,6 +2106,26 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
}
}
/**
* Read one compose file under a stack directory and return its declared
* service names. The stack segment uses path.basename as defense-in-depth and
* the resolved path is re-checked against the base dir; the relative file is
* validated by the caller. Returns [] when the file is missing or unparseable.
*/
private async readComposeServiceNames(baseDir: string, stackName: string, relFile: string): Promise<string[]> {
const composePath = path.join(baseDir, path.basename(stackName), relFile);
if (!isPathWithinBase(composePath, baseDir)) return [];
try {
const content = await fs.readFile(composePath, 'utf8');
const parsed = YAML.parse(content) as { services?: Record<string, unknown> } | null;
const services = parsed?.services && typeof parsed.services === 'object' ? parsed.services : null;
if (!services) return [];
return Object.keys(services).filter((name) => /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(name));
} catch {
return [];
}
}
/**
* Parse an existing mesh override file and extract the service names
* it already lists. Used by the defensive fallback so a transient
@@ -4,6 +4,7 @@ import { RegistryService } from './RegistryService';
import {
extractServiceImagesFromCompose,
loadDotEnv,
loadEffectiveServiceImages,
type ComposeServiceImage,
} from './ImageUpdateService';
import {
@@ -121,6 +122,12 @@ async function loadStackImages(
nodeId: number,
stackName: string,
): Promise<ComposeServiceImage[]> {
// A multi-file / context-dir Git stack resolves images from the effective
// merged model so override-only services are included; single-file stacks
// fall through to the root-compose parse below.
const effective = await loadEffectiveServiceImages(nodeId, stackName);
if (effective) return effective;
const fs = FileSystemService.getInstance(nodeId);
const composeContent = await fs.getStackContent(stackName);
let envVars: Record<string, string> = {};
+57
View File
@@ -0,0 +1,57 @@
import path from 'path';
import { DatabaseService } from '../services/DatabaseService';
import { NodeRegistry } from '../services/NodeRegistry';
import { isPathWithinBase, isValidRelativeStackPath } from './validation';
/**
* Build the `docker compose` global-flag prefix (`-f` per file, `-p <project>`,
* `--project-directory`) for a stack's authored multi-file deploy spec.
*
* Returns `[]` for single-file / non-git stacks (no applied spec), so every
* caller stays byte-identical to the pre-multi-file behavior: docker compose
* resolves the single root compose.yaml via auto-discovery.
*
* `applied_deploy_spec` is DB state ultimately derived from user input, and this
* prefix is spliced straight into a child-process argv, so it is treated as a
* spawn sink: every file path and the context dir is re-validated with the
* relative-path rules and resolved under the stack directory. A malformed,
* absolute, or escaping entry throws here, before any `docker compose` runs.
*
* `-p <stackName>` pins the Compose project name so container labels stay
* `com.docker.compose.project=<stackName>` even when `--project-directory`
* changes the directory basename Compose would otherwise derive the name from.
*/
export function authoredComposeFileArgs(stackName: string, nodeId?: number): string[] {
const resolvedNodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec;
if (!spec || spec.files.length === 0) return [];
const baseDir = NodeRegistry.getInstance().getComposeDir(resolvedNodeId);
const stackDir = path.resolve(baseDir, stackName);
const args: string[] = [];
for (const file of spec.files) {
if (!file || !isValidRelativeStackPath(file)) {
throw new Error(`Invalid compose file path in deploy spec for stack "${stackName}"`);
}
if (!isPathWithinBase(path.resolve(stackDir, file), stackDir)) {
throw new Error(`Compose file path escapes the stack directory for stack "${stackName}"`);
}
args.push('-f', file);
}
args.push('-p', stackName);
if (spec.contextDir) {
if (!isValidRelativeStackPath(spec.contextDir)) {
throw new Error(`Invalid context directory in deploy spec for stack "${stackName}"`);
}
const ctxAbs = path.resolve(stackDir, spec.contextDir);
if (!isPathWithinBase(ctxAbs, stackDir)) {
throw new Error(`Context directory escapes the stack directory for stack "${stackName}"`);
}
args.push('--project-directory', ctxAbs);
}
return args;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* The local filename Sencho always writes a Git source's primary compose file
* to, at the stack root. Keeping the primary at this fixed root name preserves
* stack discovery (FileSystemService.getStacks / hasComposeFile) and the editor's
* compose tab, and matches single-file behavior where the fetched file is written
* to compose.yaml regardless of its repo name.
*/
export const PRIMARY_COMPOSE_FILENAME = 'compose.yaml';
/**
* Map an ordered list of repo-relative compose paths to the ordered local
* relative filenames Sencho materializes for them under the stack directory:
* - index 0 (primary) -> compose.yaml at the stack root
* - every additional file -> its repo-relative path, preserved under the stack dir
*
* The result is the deploy-time `applied_deploy_spec.files` list, also used to
* write the files to disk. Repo paths are already validated as POSIX relative
* paths upstream (isValidGitSourcePath), so they are used as-is for additional
* files; only a leading "./" is stripped for a clean on-disk layout.
*/
export function gitSourceLocalComposeFiles(composePaths: string[]): string[] {
return composePaths.map((p, index) =>
index === 0 ? PRIMARY_COMPOSE_FILENAME : p.replace(/^\.\//, ''),
);
}