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: 60 additions & 0 deletions src/artifact-preservation-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
jest.mock('execa', () => require('./test-helpers/mock-execa.test-utils').execaMockFactory());

jest.mock('./artifact-permissions', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const real = jest.requireActual<typeof import('./artifact-permissions')>('./artifact-permissions');
return {
...real,
fixArtifactPermissionsForRootless: jest.fn(real.fixArtifactPermissionsForRootless),
};
});

// Wrap the fs methods we need to control with jest.fn() so individual tests
// can inject one-shot errors without using jest.spyOn (which fails on non-
// configurable properties in Jest's module sandbox).
Expand All @@ -24,6 +33,7 @@ jest.mock('fs', () => {
return {
...real,
copyFileSync: jest.fn(real.copyFileSync),
rmSync: jest.fn(real.rmSync),
renameSync: jest.fn(real.renameSync),
mkdirSync: jest.fn(real.mkdirSync),
};
Expand All @@ -32,16 +42,21 @@ jest.mock('fs', () => {
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { fixArtifactPermissionsForRootless } from './artifact-permissions';
import { mockExecaSync } from './test-helpers/mock-execa.test-utils';
import {
preserveIptablesAudit,
preserveCleanupArtifacts,
removeWorkDirectories,
} from './artifact-preservation';

// Cast mocked methods for convenient mockImplementationOnce usage.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const realFs = jest.requireActual<typeof import('fs')>('fs');
const mockCopyFileSync = fs.copyFileSync as jest.MockedFunction<typeof fs.copyFileSync>;
const mockFixArtifactPermissionsForRootless =
fixArtifactPermissionsForRootless as jest.MockedFunction<typeof fixArtifactPermissionsForRootless>;
const mockRmSync = fs.rmSync as jest.MockedFunction<typeof fs.rmSync>;
const mockRenameSync = fs.renameSync as jest.MockedFunction<typeof fs.renameSync>;
const mockMkdirSync = fs.mkdirSync as jest.MockedFunction<typeof fs.mkdirSync>;

Expand All @@ -56,6 +71,7 @@ describe('artifact-preservation – error paths', () => {
mockExecaSync.mockReturnValue({ stdout: '', stderr: '', exitCode: 0 });
// Re-apply real implementations in case a test replaced them with mockImplementation/mockImplementationOnce.
mockCopyFileSync.mockImplementation(realFs.copyFileSync);
mockRmSync.mockImplementation(realFs.rmSync);
mockRenameSync.mockImplementation(realFs.renameSync);
mockMkdirSync.mockImplementation(realFs.mkdirSync);
});
Expand Down Expand Up @@ -317,4 +333,48 @@ describe('artifact-preservation – error paths', () => {
}
});
});

describe('removeWorkDirectories', () => {
it('repairs chroot-home permissions and retries removal after EACCES', () => {
const workDir = makeTempDir();
const chrootHomeDir = `${workDir}-chroot-home`;
let chrootHomeRemovalAttempts = 0;

try {
realFs.mkdirSync(chrootHomeDir);
realFs.writeFileSync(path.join(chrootHomeDir, '.aws-config'), 'config');

mockRmSync.mockImplementation((target, options) => {
if (target === chrootHomeDir) {
chrootHomeRemovalAttempts += 1;
if (chrootHomeRemovalAttempts === 1) {
const error = Object.assign(new Error('permission denied'), { code: 'EACCES' });
throw error;
}
}
return realFs.rmSync(target, options);
});

expect(() => removeWorkDirectories(workDir, {
dockerHostPathPrefix: '/host',
imageRegistry: 'ghcr.io/github/gh-aw-firewall',
imageTag: 'latest',
agentImage: 'act',
})).not.toThrow();

expect(mockFixArtifactPermissionsForRootless).toHaveBeenCalledWith(
[chrootHomeDir],
'/host',
'ghcr.io/github/gh-aw-firewall',
'latest',
'act',
);
expect(chrootHomeRemovalAttempts).toBe(2);
expect(realFs.existsSync(chrootHomeDir)).toBe(false);
} finally {
realFs.rmSync(workDir, { recursive: true, force: true });
realFs.rmSync(chrootHomeDir, { recursive: true, force: true });
}
});
});
});
32 changes: 30 additions & 2 deletions src/artifact-preservation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
export function preserveIptablesAudit(workDir: string, auditDir?: string): void {
const iptablesAuditSrc = path.join(workDir, 'init-signal', 'iptables-audit.txt');
const targetAuditDir = auditDir || path.join(workDir, 'audit');
if (fs.existsSync(iptablesAuditSrc) && fs.existsSync(targetAuditDir)) {

Check warning on line 16 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 16 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 16 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 16 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 16 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 16 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0
try {
fs.copyFileSync(iptablesAuditSrc, path.join(targetAuditDir, 'iptables-audit.txt'));
fs.chmodSync(path.join(targetAuditDir, 'iptables-audit.txt'), 0o644);

Check warning on line 19 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found chmodSync from package "fs" with non literal argument at index 0

Check warning on line 19 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found chmodSync from package "fs" with non literal argument at index 0

Check warning on line 19 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found chmodSync from package "fs" with non literal argument at index 0
logger.debug('Copied iptables audit state to audit directory');
} catch (error) {
logger.debug('Could not copy iptables audit file:', error);
Expand Down Expand Up @@ -55,7 +55,7 @@
}: PreserveDirectoryOptions): void {
if (runtimeDir) {
const targetDir = runtimeSubdir ? path.join(runtimeDir, runtimeSubdir) : runtimeDir;
if (!runtimeDirMustExist || fs.existsSync(targetDir)) {

Check warning on line 58 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 58 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 58 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0
try {
execa.sync('chmod', ['-R', 'a+rX', targetDir]);
logger.info(`${availableLabel} available at: ${targetDir}`);
Expand All @@ -68,9 +68,9 @@

const sourceDir = path.join(workDir, workSubdir);
const destinationDir = path.join(os.tmpdir(), `${destinationBaseName}-${timestamp}`);
if (fs.existsSync(sourceDir) && fs.readdirSync(sourceDir).length > 0) {

Check warning on line 71 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found readdirSync from package "fs" with non literal argument at index 0

Check warning on line 71 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 71 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found readdirSync from package "fs" with non literal argument at index 0

Check warning on line 71 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 71 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found readdirSync from package "fs" with non literal argument at index 0

Check warning on line 71 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0
try {
fs.renameSync(sourceDir, destinationDir);

Check warning on line 73 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found renameSync from package "fs" with non literal argument at index 0,1

Check warning on line 73 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found renameSync from package "fs" with non literal argument at index 0,1

Check warning on line 73 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found renameSync from package "fs" with non literal argument at index 0,1
if (chmodPreservedDir) {
execa.sync('chmod', ['-R', 'a+rX', destinationDir]);
}
Expand Down Expand Up @@ -220,11 +220,39 @@
);
}

export function removeWorkDirectories(workDir: string): void {
type RemoveWorkDirectoriesOptions = Pick<
PreserveCleanupArtifactsOptions,
'dockerHostPathPrefix' | 'imageRegistry' | 'imageTag' | 'agentImage'
>;

export function removeWorkDirectories(workDir: string, options: RemoveWorkDirectoriesOptions = {}): void {
fs.rmSync(workDir, { recursive: true, force: true });

const chrootHomeDir = `${workDir}-chroot-home`;
if (fs.existsSync(chrootHomeDir)) {
fs.rmSync(chrootHomeDir, { recursive: true, force: true });
try {
fs.rmSync(chrootHomeDir, { recursive: true, force: true });
} catch (error: unknown) {
// In rootless Docker, files created inside the container may be owned by
// remapped UIDs that the host process cannot delete. Fix permissions via
// a privileged container, then retry removal.
if (error && typeof error === 'object' && 'code' in error && error.code === 'EACCES') {
logger.debug('Chroot home removal failed with EACCES; attempting rootless permission repair');
Comment on lines +236 to +240
fixArtifactPermissionsForRootless(
[chrootHomeDir],
options.dockerHostPathPrefix,
options.imageRegistry,
options.imageTag,
options.agentImage,
);
try {
fs.rmSync(chrootHomeDir, { recursive: true, force: true });
} catch (retryError) {
logger.warn('Failed to remove chroot home directory after permission repair:', retryError);
}
} else {
logger.warn('Failed to remove chroot home directory:', error);
}
}
}
}
7 changes: 6 additions & 1 deletion src/container-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ export async function cleanup(
await unmountSslTmpfs(sslDir);
}

removeWorkDirectories(workDir);
removeWorkDirectories(workDir, {
dockerHostPathPrefix,
imageRegistry,
imageTag,
agentImage,
});
logger.debug('Temporary files cleaned up');
} catch (error) {
logger.warn('Failed to clean up temporary files:', error);
Expand Down
Loading