mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 03:36:55 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user