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
+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');
});
});