diff --git a/src/container-stop.test.ts b/src/container-stop.test.ts index da2906105..0a7ba5815 100644 --- a/src/container-stop.test.ts +++ b/src/container-stop.test.ts @@ -1,7 +1,7 @@ import { fastKillAgentContainer } from './container-lifecycle'; import { containerLifecycleTestHelpers } from './container-lifecycle.test-utils'; -import { stopContainers } from './container-stop'; -import { AGENT_CONTAINER_NAME } from './constants'; +import { fixSquidLogPermissionsBeforeShutdown, stopContainers } from './container-stop'; +import { AGENT_CONTAINER_NAME, SQUID_CONTAINER_NAME } from './constants'; // Mock execa module import { mockExecaFn } from './test-helpers/mock-execa.test-utils'; @@ -12,31 +12,83 @@ jest.mock('execa', () => require('./test-helpers/mock-execa.test-utils').execaMo describe('stopContainers', () => { const { getDir } = useTempDir(); + beforeEach(() => jest.clearAllMocks()); + it('should skip stopping when keepContainers is true', async () => { await stopContainers(getDir(), true); expect(mockExecaFn).not.toHaveBeenCalled(); }); - it('should run docker compose down when keepContainers is false', async () => { + it('should run pre-shutdown chmod and docker compose down when keepContainers is false', async () => { + // 1. docker exec --user root awf-squid chmod (pre-shutdown) + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + // 2. docker compose down mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); await stopContainers(getDir(), false); - expect(mockExecaFn).toHaveBeenCalledWith( + expect(mockExecaFn).toHaveBeenNthCalledWith( + 1, + 'docker', + ['exec', '--user', 'root', SQUID_CONTAINER_NAME, 'chmod', '-R', 'a+rX', '/var/log/squid'], + expect.objectContaining({ reject: false }), + ); + expect(mockExecaFn).toHaveBeenNthCalledWith( + 2, 'docker', ['compose', 'down', '-v', '-t', '1'], expect.objectContaining({ cwd: getDir(), stdout: process.stderr, stderr: 'inherit' }) ); }); + it('should still run docker compose down when pre-shutdown chmod fails', async () => { + // chmod fails (e.g. container not running) + mockExecaFn.mockRejectedValueOnce(new Error('container not found')); + // compose down succeeds + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + + await stopContainers(getDir(), false); + + expect(mockExecaFn).toHaveBeenCalledWith( + 'docker', + ['compose', 'down', '-v', '-t', '1'], + expect.anything(), + ); + }); + it('should throw error when docker compose down fails', async () => { + // chmod succeeds + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + // compose down fails mockExecaFn.mockRejectedValueOnce(new Error('Docker compose down failed')); await expect(stopContainers(getDir(), false)).rejects.toThrow('Docker compose down failed'); }); }); +describe('fixSquidLogPermissionsBeforeShutdown', () => { + beforeEach(() => jest.clearAllMocks()); + + it('runs docker exec as root to chmod squid logs', async () => { + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + + await fixSquidLogPermissionsBeforeShutdown(); + + expect(mockExecaFn).toHaveBeenCalledWith( + 'docker', + ['exec', '--user', 'root', SQUID_CONTAINER_NAME, 'chmod', '-R', 'a+rX', '/var/log/squid'], + expect.objectContaining({ reject: false }), + ); + }); + + it('does not throw when docker exec fails (container not running)', async () => { + mockExecaFn.mockRejectedValueOnce(new Error('No such container')); + + await expect(fixSquidLogPermissionsBeforeShutdown()).resolves.toBeUndefined(); + }); +}); + describe('fastKillAgentContainer', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/src/container-stop.ts b/src/container-stop.ts index c361567fb..606c6754f 100644 --- a/src/container-stop.ts +++ b/src/container-stop.ts @@ -1,6 +1,7 @@ import execa from 'execa'; import { logger } from './logger'; import { getLocalDockerEnv } from './docker-host'; +import { SQUID_CONTAINER_NAME } from './constants'; /** * Runs `docker compose down -v -t 1` with the standard AWF options. @@ -18,6 +19,41 @@ export async function runComposeDown( }); } +/** + * Fixes squid log file permissions inside the running container before shutdown. + * + * Squid writes logs as its internal proxy user (UID 13). After `docker compose + * down`, those files on the host are owned by UID 13, and the runner user + * (e.g. UID 1001) cannot chmod them without sudo. This is especially + * problematic on ARC/DinD topologies where the docker-based rootless repair in + * `fixArtifactPermissionsForRootless()` also fails because path translation is + * not applied to the bind-mount or the squid image is unavailable after compose + * down with `--pull never`. + * + * Running `chmod -R a+rX` as root inside the still-running container fixes the + * permissions on the bind-mounted log volume before it is unmounted, ensuring + * that `awf logs summary` and artifact uploads can read the files. + * + * Tolerant: silently continues if the container is not running. + */ +export async function fixSquidLogPermissionsBeforeShutdown(): Promise { + try { + const result = await execa( + 'docker', + ['exec', '--user', 'root', SQUID_CONTAINER_NAME, 'chmod', '-R', 'a+rX', '/var/log/squid'], + { env: getLocalDockerEnv(), reject: false }, + ); + if (result.exitCode !== 0) { + logger.debug( + `Pre-shutdown squid log chmod exited with code ${result.exitCode}: ${result.stderr || '(no stderr)'}`, + ); + } + } catch { + // Container not running or docker not available — not an error. + logger.debug('Pre-shutdown squid log chmod skipped (container not available)'); + } +} + /** * Stops and removes Docker Compose services */ @@ -29,6 +65,10 @@ export async function stopContainers(workDir: string, keepContainers: boolean): logger.info('Stopping containers...'); + // Fix squid log permissions before compose down so log files are readable + // after shutdown (e.g. for `awf logs summary` and artifact upload). + await fixSquidLogPermissionsBeforeShutdown(); + try { await runComposeDown(workDir); logger.success('Containers stopped successfully');