mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
fix(deploy): preserve compose.override.yml when Mesh is enabled (#1420)
When a single-file stack is opted into Sencho Mesh, the deploy builds an explicit `docker compose -f <base> -f <mesh override>` list. Passing any explicit -f disables Compose's automatic discovery of compose.override.yml (and the docker-compose.override variants), so a user's hand-authored override was silently dropped from the effective deploy once Mesh was on. Resolve the user's override file (first existing variant, with the same stack-name and symlink-containment guards as the base compose file) and insert it between the base and the mesh override, so it layers exactly as Compose's implicit discovery would, with the mesh override still taking precedence. A transient read failure during the lookup degrades to "no override" rather than failing the deploy; a stack-name or containment-guard rejection still aborts. Multi-file Git-source stacks and non-mesh deploys are unaffected.
This commit is contained in:
@@ -15,6 +15,7 @@ const {
|
||||
mockContainerInspect, mockContainerLogs,
|
||||
mockGetRegistries, mockResolveDockerConfig,
|
||||
mockBackupStackFiles, mockRestoreStackFiles,
|
||||
mockGetComposeFilename, mockGetOverrideFilename, mockEnsureStackOverride,
|
||||
mockMkdtempSync, mockWriteFileSync, mockUnlinkSync, mockRmdirSync,
|
||||
mockGetGlobalSettings, mockPruneDanglingImages,
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -28,6 +29,9 @@ const {
|
||||
mockResolveDockerConfig: vi.fn().mockResolvedValue({ config: { auths: {} }, warnings: [] }),
|
||||
mockBackupStackFiles: vi.fn().mockResolvedValue(undefined),
|
||||
mockRestoreStackFiles: vi.fn().mockResolvedValue(undefined),
|
||||
mockGetComposeFilename: vi.fn().mockResolvedValue('compose.yaml'),
|
||||
mockGetOverrideFilename: vi.fn().mockResolvedValue(null),
|
||||
mockEnsureStackOverride: vi.fn().mockResolvedValue(null),
|
||||
mockMkdtempSync: vi.fn().mockReturnValue('/tmp/sencho-docker-test'),
|
||||
mockWriteFileSync: vi.fn(),
|
||||
mockUnlinkSync: vi.fn(),
|
||||
@@ -100,6 +104,8 @@ vi.mock('../services/FileSystemService', () => ({
|
||||
getInstance: () => ({
|
||||
backupStackFiles: mockBackupStackFiles,
|
||||
restoreStackFiles: mockRestoreStackFiles,
|
||||
getComposeFilename: mockGetComposeFilename,
|
||||
getOverrideFilename: mockGetOverrideFilename,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -109,11 +115,12 @@ vi.mock('../services/LogFormatter', () => ({
|
||||
}));
|
||||
|
||||
// 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.
|
||||
// resolves the (optional) mesh override. The hoisted mock defaults to "no override"
|
||||
// so a single-file stack yields plain `docker compose <action>` args deterministically;
|
||||
// individual tests set a path to exercise the mesh-injection branch.
|
||||
vi.mock('../services/MeshService', () => ({
|
||||
MeshService: {
|
||||
getInstance: () => ({ ensureStackOverride: vi.fn().mockResolvedValue(null) }),
|
||||
getInstance: () => ({ ensureStackOverride: mockEnsureStackOverride }),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -181,6 +188,13 @@ function createMockWs(): MockWebSocket {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// clearAllMocks() clears call records but not implementations, so a mockResolvedValue
|
||||
// set by one test persists into the next. Re-assert the safe "no mesh override, no user
|
||||
// override, base = compose.yaml" baseline here so a stray override from an earlier test
|
||||
// cannot leak forward and add phantom -f flags.
|
||||
mockGetComposeFilename.mockResolvedValue('compose.yaml');
|
||||
mockGetOverrideFilename.mockResolvedValue(null);
|
||||
mockEnsureStackOverride.mockResolvedValue(null);
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
@@ -376,6 +390,142 @@ describe('ComposeService - runCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── authoredComposeArgs: mesh + user override ──────────────────────────
|
||||
|
||||
describe('ComposeService - authoredComposeArgs mesh override', () => {
|
||||
const MESH_OVERRIDE = '/app/data/mesh/overrides/1/my-stack.override.yml';
|
||||
|
||||
it('preserves a user compose.override.yml between the base and the mesh override', async () => {
|
||||
// Single-file stack opted into mesh, with a hand-authored override on disk.
|
||||
mockEnsureStackOverride.mockResolvedValue(MESH_OVERRIDE);
|
||||
mockGetComposeFilename.mockResolvedValue('compose.yaml');
|
||||
mockGetOverrideFilename.mockResolvedValue('compose.override.yml');
|
||||
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const promise = svc.runCommand('my-stack', 'restart');
|
||||
await waitForSpawn();
|
||||
proc.emit('close', 0);
|
||||
await promise;
|
||||
|
||||
// The user override sits between the base and the mesh override as a bare basename
|
||||
// (resolved against the stack-dir cwd); only the mesh override is an absolute path.
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['compose', '-f', 'compose.yaml', '-f', 'compose.override.yml', '-f', MESH_OVERRIDE, 'restart'],
|
||||
expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
|
||||
);
|
||||
});
|
||||
|
||||
it('emits base + mesh override only when no user override exists', async () => {
|
||||
mockEnsureStackOverride.mockResolvedValue(MESH_OVERRIDE);
|
||||
mockGetComposeFilename.mockResolvedValue('compose.yaml');
|
||||
mockGetOverrideFilename.mockResolvedValue(null);
|
||||
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const promise = svc.runCommand('my-stack', 'restart');
|
||||
await waitForSpawn();
|
||||
proc.emit('close', 0);
|
||||
await promise;
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['compose', '-f', 'compose.yaml', '-f', MESH_OVERRIDE, 'restart'],
|
||||
expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not look up or emit a user override when mesh is disabled', async () => {
|
||||
// A user override on disk must not introduce -f flags for a non-mesh stack;
|
||||
// implicit compose discovery already resolves it when no -f is passed.
|
||||
mockEnsureStackOverride.mockResolvedValue(null);
|
||||
mockGetOverrideFilename.mockResolvedValue('compose.override.yml');
|
||||
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const promise = svc.runCommand('my-stack', 'restart');
|
||||
await waitForSpawn();
|
||||
proc.emit('close', 0);
|
||||
await promise;
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['compose', 'restart'],
|
||||
expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
|
||||
);
|
||||
// The override lookup is gated inside the mesh branch.
|
||||
expect(mockGetOverrideFilename).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops the user override and still deploys when the lookup throws', async () => {
|
||||
// A present override that cannot be resolved (e.g. EACCES) must not crash the
|
||||
// deploy: the mesh override still applies and the deploy proceeds without the
|
||||
// user override, with a warning logged.
|
||||
mockEnsureStackOverride.mockResolvedValue(MESH_OVERRIDE);
|
||||
mockGetComposeFilename.mockResolvedValue('compose.yaml');
|
||||
mockGetOverrideFilename.mockRejectedValue(Object.assign(new Error('EACCES'), { code: 'EACCES' }));
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const promise = svc.runCommand('my-stack', 'restart');
|
||||
await waitForSpawn();
|
||||
proc.emit('close', 0);
|
||||
await promise;
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['compose', '-f', 'compose.yaml', '-f', MESH_OVERRIDE, 'restart'],
|
||||
expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('could not resolve user compose override'),
|
||||
expect.anything()
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('aborts the deploy when the override lookup hits a containment-guard rejection', async () => {
|
||||
// A symlink-escape (or invalid-name) rejection from the override lookup is a hard
|
||||
// error: it must propagate and abort the deploy, never degrade to "no override".
|
||||
mockEnsureStackOverride.mockResolvedValue(MESH_OVERRIDE);
|
||||
mockGetComposeFilename.mockResolvedValue('compose.yaml');
|
||||
mockGetOverrideFilename.mockRejectedValue(Object.assign(new Error('symlink escape'), { code: 'SYMLINK_ESCAPE' }));
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
await expect(svc.runCommand('my-stack', 'restart')).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' });
|
||||
// The error is thrown while building the args, before docker is ever spawned.
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leak sanity: a default single-file stack still emits no -f flags', async () => {
|
||||
// Proves the override-setting tests above do not leak through the shared mocks.
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const promise = svc.runCommand('my-stack', 'restart');
|
||||
await waitForSpawn();
|
||||
proc.emit('close', 0);
|
||||
await promise;
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['compose', 'restart'],
|
||||
expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── deployStack ────────────────────────────────────────────────────────
|
||||
|
||||
describe('ComposeService - deployStack', () => {
|
||||
|
||||
Reference in New Issue
Block a user