diff --git a/src/lib/server/compose-args.ts b/src/lib/server/compose-args.ts new file mode 100644 index 0000000..5900cff --- /dev/null +++ b/src/lib/server/compose-args.ts @@ -0,0 +1,77 @@ +/** + * Operation-specific `docker compose` CLI args, split out of executeLocalCompose so the + * arg construction is unit-testable without a daemon. + * + * `--no-cache` is a `docker compose build` flag, NOT an `up` flag: pushing it onto `up` + * makes the CLI reject the whole command with "unknown flag: --no-cache" (#1479). When a + * no-cache rebuild is requested, the caller runs a separate `build` operation first (see + * shouldRunSeparateBuildStep) and then a plain `up` - so `up` omits `--build` in that case + * (the fresh image already exists). + */ +export interface ComposeOperationArgOptions { + forceRecreate?: boolean; + removeVolumes?: boolean; + build?: boolean; + noBuildCache?: boolean; + pullPolicy?: string; + serviceName?: string; +} + +export function buildComposeOperationArgs( + operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build', + options: ComposeOperationArgOptions = {} +): string[] { + const { forceRecreate, removeVolumes, build, noBuildCache, pullPolicy, serviceName } = options; + const args: string[] = []; + + switch (operation) { + case 'up': + args.push('up', '-d', '--remove-orphans'); + if (forceRecreate) args.push('--force-recreate'); + // A no-cache rebuild is handled by a separate `build` step, so `up` must not + // also carry --build (and never --no-cache, which up doesn't accept). + if (build && !noBuildCache) args.push('--build'); + if (pullPolicy) args.push('--pull', pullPolicy); + if (serviceName) args.push(serviceName); + break; + case 'down': + args.push('down', '--remove-orphans'); + if (removeVolumes) args.push('--volumes'); + break; + case 'stop': + args.push('stop'); + break; + case 'start': + args.push('start'); + break; + case 'restart': + args.push('restart'); + break; + case 'pull': + args.push('pull'); + if (serviceName) args.push(serviceName); + break; + case 'build': + args.push('build'); + if (noBuildCache) args.push('--no-cache'); + if (serviceName) args.push(serviceName); + break; + } + + return args; +} + +/** + * A no-cache rebuild needs a separate `docker compose build --no-cache` before `up`. + * Hawser's remote agent has no `build` operation (#880/#1020), so the separate step only + * runs for local/direct deployments; on Hawser a no-cache request is silently a no-op + * rather than a hard error. + */ +export function shouldRunSeparateBuildStep( + build: boolean | undefined, + noBuildCache: boolean | undefined, + connectionType: string | null | undefined +): boolean { + const isHawser = connectionType === 'hawser-standard' || connectionType === 'hawser-edge'; + return !!build && !!noBuildCache && !isHawser; +} diff --git a/tests/compose-args.test.ts b/tests/compose-args.test.ts new file mode 100644 index 0000000..6cdc2df --- /dev/null +++ b/tests/compose-args.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'bun:test'; +import { buildComposeOperationArgs, shouldRunSeparateBuildStep } from '../src/lib/server/compose-args'; + +describe('buildComposeOperationArgs', () => { + it('plain up: no --build, no --no-cache (back-compat, the common case)', () => { + const a = buildComposeOperationArgs('up', {}); + expect(a).toEqual(['up', '-d', '--remove-orphans']); + }); + + it('up with build (no no-cache): keeps --build exactly as before', () => { + const a = buildComposeOperationArgs('up', { build: true }); + expect(a).toContain('--build'); + expect(a).not.toContain('--no-cache'); + }); + + it('up never carries --no-cache, even when noBuildCache is requested (#1479)', () => { + const a = buildComposeOperationArgs('up', { build: true, noBuildCache: true }); + expect(a).not.toContain('--no-cache'); + }); + + it('up omits --build when a no-cache rebuild is requested (the separate build step handles it)', () => { + const a = buildComposeOperationArgs('up', { build: true, noBuildCache: true }); + expect(a).not.toContain('--build'); + }); + + it('up passes forceRecreate, pull policy, and a target service', () => { + const a = buildComposeOperationArgs('up', { forceRecreate: true, pullPolicy: 'always', serviceName: 'web' }); + expect(a).toEqual(['up', '-d', '--remove-orphans', '--force-recreate', '--pull', 'always', 'web']); + }); + + it('build operation carries --no-cache only when requested', () => { + expect(buildComposeOperationArgs('build', { noBuildCache: true })).toEqual(['build', '--no-cache']); + expect(buildComposeOperationArgs('build', { noBuildCache: false })).toEqual(['build']); + }); + + it('build operation scopes to a single service when given', () => { + expect(buildComposeOperationArgs('build', { noBuildCache: true, serviceName: 'web' })).toEqual(['build', '--no-cache', 'web']); + }); + + it('down / stop / start / restart / pull unchanged (back-compat)', () => { + expect(buildComposeOperationArgs('down', {})).toEqual(['down', '--remove-orphans']); + expect(buildComposeOperationArgs('down', { removeVolumes: true })).toEqual(['down', '--remove-orphans', '--volumes']); + expect(buildComposeOperationArgs('stop', {})).toEqual(['stop']); + expect(buildComposeOperationArgs('start', {})).toEqual(['start']); + expect(buildComposeOperationArgs('restart', {})).toEqual(['restart']); + expect(buildComposeOperationArgs('pull', {})).toEqual(['pull']); + expect(buildComposeOperationArgs('pull', { serviceName: 'web' })).toEqual(['pull', 'web']); + }); +}); + +describe('shouldRunSeparateBuildStep', () => { + it('runs for local socket (no env) and direct when a no-cache rebuild is requested', () => { + expect(shouldRunSeparateBuildStep(true, true, undefined)).toBe(true); + expect(shouldRunSeparateBuildStep(true, true, 'socket')).toBe(true); + expect(shouldRunSeparateBuildStep(true, true, 'direct')).toBe(true); + }); + + it('never runs for Hawser (its agent has no build op) - a no-cache request is a no-op there', () => { + expect(shouldRunSeparateBuildStep(true, true, 'hawser-standard')).toBe(false); + expect(shouldRunSeparateBuildStep(true, true, 'hawser-edge')).toBe(false); + }); + + it('does not run without both build and noBuildCache (back-compat: normal deploys unaffected)', () => { + expect(shouldRunSeparateBuildStep(false, true, 'socket')).toBe(false); + expect(shouldRunSeparateBuildStep(true, false, 'socket')).toBe(false); + expect(shouldRunSeparateBuildStep(false, false, 'socket')).toBe(false); + expect(shouldRunSeparateBuildStep(undefined, undefined, 'socket')).toBe(false); + }); +});