-
Notifications
You must be signed in to change notification settings - Fork 17
feat(cli): add predownload command to pre-pull container images #1245
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
411c941
8fb4180
ef4cfca
b634c44
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| import { resolveImages, predownloadCommand, PredownloadOptions } from './predownload'; | ||
|
|
||
| // Mock execa | ||
| jest.mock('execa', () => { | ||
| const mockExeca = jest.fn().mockResolvedValue({ stdout: '', stderr: '' }); | ||
| return { __esModule: true, default: mockExeca }; | ||
| }); | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-require-imports | ||
| const execa = require('execa').default as jest.Mock; | ||
|
|
||
| describe('predownload', () => { | ||
| describe('resolveImages', () => { | ||
| const defaults: PredownloadOptions = { | ||
| imageRegistry: 'ghcr.io/github/gh-aw-firewall', | ||
| imageTag: 'latest', | ||
| agentImage: 'default', | ||
| enableApiProxy: false, | ||
| }; | ||
|
|
||
| it('should resolve squid and default agent images', () => { | ||
| const images = resolveImages(defaults); | ||
| expect(images).toEqual([ | ||
| 'ghcr.io/github/gh-aw-firewall/squid:latest', | ||
| 'ghcr.io/github/gh-aw-firewall/agent:latest', | ||
| ]); | ||
| }); | ||
|
|
||
| it('should resolve agent-act image for act preset', () => { | ||
| const images = resolveImages({ ...defaults, agentImage: 'act' }); | ||
| expect(images).toEqual([ | ||
| 'ghcr.io/github/gh-aw-firewall/squid:latest', | ||
| 'ghcr.io/github/gh-aw-firewall/agent-act:latest', | ||
| ]); | ||
| }); | ||
|
|
||
| it('should include api-proxy when enabled', () => { | ||
| const images = resolveImages({ ...defaults, enableApiProxy: true }); | ||
| expect(images).toEqual([ | ||
| 'ghcr.io/github/gh-aw-firewall/squid:latest', | ||
| 'ghcr.io/github/gh-aw-firewall/agent:latest', | ||
| 'ghcr.io/github/gh-aw-firewall/api-proxy:latest', | ||
| ]); | ||
| }); | ||
|
|
||
| it('should use custom registry and tag', () => { | ||
| const images = resolveImages({ | ||
| ...defaults, | ||
| imageRegistry: 'my-registry.io/awf', | ||
| imageTag: 'v1.0.0', | ||
| }); | ||
| expect(images).toEqual([ | ||
| 'my-registry.io/awf/squid:v1.0.0', | ||
| 'my-registry.io/awf/agent:v1.0.0', | ||
| ]); | ||
| }); | ||
|
|
||
| it('should use custom agent image as-is', () => { | ||
| const images = resolveImages({ ...defaults, agentImage: 'ubuntu:22.04' }); | ||
| expect(images).toEqual([ | ||
| 'ghcr.io/github/gh-aw-firewall/squid:latest', | ||
| 'ubuntu:22.04', | ||
| ]); | ||
| }); | ||
|
|
||
| it('should reject custom image starting with dash', () => { | ||
| expect(() => resolveImages({ ...defaults, agentImage: '--help' })).toThrow( | ||
| 'must not start with "-"', | ||
| ); | ||
| }); | ||
|
|
||
| it('should reject custom image containing whitespace', () => { | ||
| expect(() => resolveImages({ ...defaults, agentImage: 'ubuntu 22.04' })).toThrow( | ||
| 'must not contain whitespace', | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('predownloadCommand', () => { | ||
| const defaults: PredownloadOptions = { | ||
| imageRegistry: 'ghcr.io/github/gh-aw-firewall', | ||
| imageTag: 'latest', | ||
| agentImage: 'default', | ||
| enableApiProxy: false, | ||
| }; | ||
|
|
||
| beforeEach(() => { | ||
| execa.mockReset(); | ||
| execa.mockResolvedValue({ stdout: '', stderr: '' }); | ||
| }); | ||
|
|
||
| it('should pull all resolved images', async () => { | ||
| await predownloadCommand(defaults); | ||
|
|
||
| expect(execa).toHaveBeenCalledTimes(2); | ||
| expect(execa).toHaveBeenCalledWith( | ||
| 'docker', | ||
| ['pull', 'ghcr.io/github/gh-aw-firewall/squid:latest'], | ||
| { stdio: 'inherit' }, | ||
| ); | ||
| expect(execa).toHaveBeenCalledWith( | ||
| 'docker', | ||
| ['pull', 'ghcr.io/github/gh-aw-firewall/agent:latest'], | ||
| { stdio: 'inherit' }, | ||
| ); | ||
| }); | ||
|
|
||
| it('should pull api-proxy when enabled', async () => { | ||
| await predownloadCommand({ ...defaults, enableApiProxy: true }); | ||
|
|
||
| expect(execa).toHaveBeenCalledTimes(3); | ||
| expect(execa).toHaveBeenCalledWith( | ||
| 'docker', | ||
| ['pull', 'ghcr.io/github/gh-aw-firewall/api-proxy:latest'], | ||
| { stdio: 'inherit' }, | ||
| ); | ||
| }); | ||
|
|
||
| it('should throw with exitCode 1 when a pull fails', async () => { | ||
| execa | ||
| .mockResolvedValueOnce({ stdout: '', stderr: '' }) | ||
| .mockRejectedValueOnce(new Error('pull failed')); | ||
|
|
||
| try { | ||
| await predownloadCommand(defaults); | ||
| fail('Expected predownloadCommand to throw'); | ||
| } catch (error) { | ||
| expect((error as Error).message).toBe('1 of 2 image(s) failed to pull'); | ||
| expect((error as Error & { exitCode?: number }).exitCode).toBe(1); | ||
| } | ||
| }); | ||
|
|
||
| it('should continue pulling remaining images after a failure', async () => { | ||
| execa.mockRejectedValueOnce(new Error('pull failed')).mockResolvedValueOnce({ stdout: '', stderr: '' }); | ||
|
|
||
| await expect(predownloadCommand(defaults)).rejects.toThrow( | ||
| '1 of 2 image(s) failed to pull', | ||
| ); | ||
|
|
||
| // Both images should have been attempted | ||
| expect(execa).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| it('should handle non-Error rejection', async () => { | ||
| execa.mockRejectedValueOnce('string error').mockResolvedValueOnce({ stdout: '', stderr: '' }); | ||
|
|
||
| try { | ||
| await predownloadCommand(defaults); | ||
| fail('Expected predownloadCommand to throw'); | ||
| } catch (error) { | ||
| expect((error as Error).message).toBe('1 of 2 image(s) failed to pull'); | ||
| expect((error as Error & { exitCode?: number }).exitCode).toBe(1); | ||
| } | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| import execa from 'execa'; | ||
| import { logger } from '../logger'; | ||
|
|
||
| export interface PredownloadOptions { | ||
| imageRegistry: string; | ||
| imageTag: string; | ||
| agentImage: string; | ||
| enableApiProxy: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Validates a custom Docker image reference. | ||
| * Rejects values that could be interpreted as Docker CLI flags or contain whitespace. | ||
| */ | ||
| function validateImageReference(image: string): void { | ||
| if (image.startsWith('-')) { | ||
| throw new Error(`Invalid image reference "${image}": must not start with "-"`); | ||
| } | ||
| if (/\s/.test(image)) { | ||
| throw new Error(`Invalid image reference "${image}": must not contain whitespace`); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Resolves the list of image references to pull based on the given options. | ||
| */ | ||
| export function resolveImages(options: PredownloadOptions): string[] { | ||
| const { imageRegistry, imageTag, agentImage, enableApiProxy } = options; | ||
| const images: string[] = []; | ||
|
|
||
| // Always pull squid | ||
| images.push(`${imageRegistry}/squid:${imageTag}`); | ||
|
|
||
| // Pull agent image based on preset | ||
| const isPreset = agentImage === 'default' || agentImage === 'act'; | ||
| if (isPreset) { | ||
| const imageName = agentImage === 'act' ? 'agent-act' : 'agent'; | ||
| images.push(`${imageRegistry}/${imageName}:${imageTag}`); | ||
| } else { | ||
| // Custom image - validate and pull as-is | ||
| validateImageReference(agentImage); | ||
| images.push(agentImage); | ||
| } | ||
|
Comment on lines
+39
to
+43
|
||
|
|
||
| // Optionally pull api-proxy | ||
| if (enableApiProxy) { | ||
| images.push(`${imageRegistry}/api-proxy:${imageTag}`); | ||
| } | ||
|
|
||
| return images; | ||
| } | ||
|
|
||
| /** | ||
| * Pre-download Docker images for offline use or faster startup. | ||
| */ | ||
| export async function predownloadCommand(options: PredownloadOptions): Promise<void> { | ||
| const images = resolveImages(options); | ||
|
|
||
| logger.info(`Pre-downloading ${images.length} image(s)...`); | ||
|
|
||
| let failed = 0; | ||
| for (const image of images) { | ||
| logger.info(`Pulling ${image}...`); | ||
| try { | ||
| await execa('docker', ['pull', image], { stdio: 'inherit' }); | ||
| logger.info(`Successfully pulled ${image}`); | ||
| } catch (error) { | ||
| logger.error(`Failed to pull ${image}: ${error instanceof Error ? error.message : error}`); | ||
| failed++; | ||
| } | ||
| } | ||
|
|
||
| if (failed > 0) { | ||
| const message = `${failed} of ${images.length} image(s) failed to pull`; | ||
| logger.error(message); | ||
| const error: Error & { exitCode?: number } = new Error(message); | ||
| error.exitCode = 1; | ||
| throw error; | ||
| } | ||
|
|
||
| logger.info(`All ${images.length} image(s) pre-downloaded successfully`); | ||
| logger.info('You can now use --skip-pull to skip pulling images at runtime'); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resolveImagesre-implements the same preset-to-image mapping logic that already exists ingenerateDockerCompose(e.g., default→agent, act→agent-act). This duplication is likely to drift as image naming/registry/tag logic evolves (including api-proxy behavior). Consider extracting a shared image-resolution helper used by bothgenerateDockerComposeand this command so the pulled images always match what runtime would use.