Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 56 additions & 4 deletions src/container-stop.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);

Comment on lines +23 to 28
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();
Expand Down
40 changes: 40 additions & 0 deletions src/container-stop.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<void> {
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
*/
Expand All @@ -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');
Expand Down
Loading