-
Notifications
You must be signed in to change notification settings - Fork 15
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
Merged
+303
−1
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
411c941
feat(cli): add predownload command to pre-pull container images
Mossaka 8fb4180
test(cli): add predownloadCommand unit tests for coverage
Mossaka ef4cfca
test(cli): add handlePredownloadAction test for coverage
Mossaka b634c44
fix: address review feedback on predownload command
Mossaka File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
|
|
||
| // 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'); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.