Skip to content

Commit 3185587

Browse files
CopilotlpcoxCopilot
authored
feat: collect diagnostic logs on container startup failure (#1941)
* Initial plan * feat: collect diagnostic logs on container startup failure Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/76309f9a-1e18-4e50-848e-0057b10dc6a1 * fix: signal containersStarted on partial startup failure Address review feedback: - Call onContainersStarted() in the startContainers catch block so the caller's cleanup runs stopContainers() even on partial startup, preventing orphaned containers/networks. - Use .rejects.toBe() instead of .rejects.toThrow() in tests to verify exact error identity (rethrow, not wrap). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Landon Cox <landon.cox@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a9a7293 commit 3185587

3 files changed

Lines changed: 84 additions & 3 deletions

File tree

src/cli-workflow.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,4 +310,67 @@ describe('runMainWorkflow', () => {
310310

311311
await expect(runMainWorkflow(configWithDiagnostics, dependencies, { logger, performCleanup: jest.fn() })).resolves.toBe(1);
312312
});
313+
314+
it('calls collectDiagnosticLogs on startContainers failure when diagnosticLogs is enabled', async () => {
315+
const startError = new Error('Squid container is unhealthy');
316+
const collectDiagnosticLogs = jest.fn().mockResolvedValue(undefined);
317+
const configWithDiagnostics: WrapperConfig = {
318+
...baseConfig,
319+
diagnosticLogs: true,
320+
};
321+
const dependencies: WorkflowDependencies = {
322+
ensureFirewallNetwork: jest.fn().mockResolvedValue({ squidIp: '172.30.0.10' }),
323+
setupHostIptables: jest.fn().mockResolvedValue(undefined),
324+
writeConfigs: jest.fn().mockResolvedValue(undefined),
325+
startContainers: jest.fn().mockRejectedValue(startError),
326+
runAgentCommand: jest.fn(),
327+
collectDiagnosticLogs,
328+
};
329+
const logger = createLogger();
330+
331+
await expect(runMainWorkflow(configWithDiagnostics, dependencies, { logger, performCleanup: jest.fn() })).rejects.toBe(startError);
332+
333+
expect(collectDiagnosticLogs).toHaveBeenCalledWith(configWithDiagnostics.workDir);
334+
expect(dependencies.runAgentCommand).not.toHaveBeenCalled();
335+
});
336+
337+
it('does not call collectDiagnosticLogs on startContainers failure when diagnosticLogs is disabled', async () => {
338+
const startError = new Error('Squid container is unhealthy');
339+
const collectDiagnosticLogs = jest.fn().mockResolvedValue(undefined);
340+
const dependencies: WorkflowDependencies = {
341+
ensureFirewallNetwork: jest.fn().mockResolvedValue({ squidIp: '172.30.0.10' }),
342+
setupHostIptables: jest.fn().mockResolvedValue(undefined),
343+
writeConfigs: jest.fn().mockResolvedValue(undefined),
344+
startContainers: jest.fn().mockRejectedValue(startError),
345+
runAgentCommand: jest.fn(),
346+
collectDiagnosticLogs,
347+
};
348+
const logger = createLogger();
349+
350+
await expect(runMainWorkflow(baseConfig, dependencies, { logger, performCleanup: jest.fn() })).rejects.toBe(startError);
351+
352+
expect(collectDiagnosticLogs).not.toHaveBeenCalled();
353+
});
354+
355+
it('rethrows startContainers error after collecting diagnostics', async () => {
356+
const startError = new Error('docker compose failed');
357+
const configWithDiagnostics: WrapperConfig = {
358+
...baseConfig,
359+
diagnosticLogs: true,
360+
};
361+
const performCleanup = jest.fn().mockResolvedValue(undefined);
362+
const dependencies: WorkflowDependencies = {
363+
ensureFirewallNetwork: jest.fn().mockResolvedValue({ squidIp: '172.30.0.10' }),
364+
setupHostIptables: jest.fn().mockResolvedValue(undefined),
365+
writeConfigs: jest.fn().mockResolvedValue(undefined),
366+
startContainers: jest.fn().mockRejectedValue(startError),
367+
runAgentCommand: jest.fn(),
368+
collectDiagnosticLogs: jest.fn().mockResolvedValue(undefined),
369+
};
370+
const logger = createLogger();
371+
372+
await expect(runMainWorkflow(configWithDiagnostics, dependencies, { logger, performCleanup })).rejects.toBe(startError);
373+
// performCleanup should NOT be called — that is the caller's (cli.ts) responsibility
374+
expect(performCleanup).not.toHaveBeenCalled();
375+
});
313376
});

src/cli-workflow.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,25 @@ export async function runMainWorkflow(
7171
await dependencies.writeConfigs(config);
7272

7373
// Step 2: Start containers
74-
await dependencies.startContainers(config.workDir, config.allowedDomains, config.proxyLogsDir, config.skipPull);
74+
try {
75+
await dependencies.startContainers(config.workDir, config.allowedDomains, config.proxyLogsDir, config.skipPull);
76+
} catch (startError) {
77+
// Signal that containers may have been partially created so the caller's
78+
// cleanup (stopContainers / docker compose down -v) will tear them down
79+
// instead of leaving orphaned containers and networks.
80+
onContainersStarted?.();
81+
82+
// Collect diagnostics for startup failures before containers are torn down.
83+
// Must happen before performCleanup() / stopContainers() destroys them.
84+
if (config.diagnosticLogs && dependencies.collectDiagnosticLogs) {
85+
try {
86+
await dependencies.collectDiagnosticLogs(config.workDir);
87+
} catch (diagError) {
88+
logger.warn('Failed to collect diagnostic logs; continuing with cleanup.', diagError);
89+
}
90+
}
91+
throw startError;
92+
}
7593
onContainersStarted?.();
7694

7795
// Step 3: Wait for agent to complete

src/docker-manager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2521,9 +2521,9 @@ export async function collectDiagnosticLogs(workDir: string): Promise<void> {
25212521
];
25222522

25232523
for (const container of containers) {
2524-
// Collect stdout+stderr from docker logs
2524+
// Collect stdout+stderr from docker logs (last 200 lines to keep files manageable)
25252525
try {
2526-
const result = await execa('docker', ['logs', container], { reject: false });
2526+
const result = await execa('docker', ['logs', '--tail', '200', container], { reject: false });
25272527
if (result.exitCode === 0) {
25282528
const combined = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
25292529
if (combined) {

0 commit comments

Comments
 (0)