Skip to content

Commit

Permalink
Merge pull request #98 from dependabot/brrygrdn/cleanup-old-images
Browse files Browse the repository at this point in the history
Cleanup old image versions
  • Loading branch information
Barry Gordon authored and GitHub committed Mar 3, 2022
2 parents 253310b + 308510f commit 749c000
Show file tree
Hide file tree
Showing 10 changed files with 315 additions and 20 deletions.
69 changes: 69 additions & 0 deletions __tests__/cleanup-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import * as core from '@actions/core'
import Docker from 'dockerode'
import {ImageService} from '../src/image-service'
import {integration, delay} from './helpers'
import {run, cleanupOldImageVersions} from '../src/cleanup'

integration('run', () => {
beforeEach(async () => {
jest.spyOn(core, 'error').mockImplementation(jest.fn())
})

test('it does not log any errors interacting with Docker by default', async () => {
await run()

expect(core.error).not.toHaveBeenCalled()
})
})

integration('cleanupOldImageVersions', () => {
// We use this GitHub-hosted hello world example as a small stand-in for this test
// in order to avoid hitting the rate limit on pulling containers from docker.io
// since this test needs to remove and pull containers per run.
const testImage = 'ghcr.io/github/hello-docker'
const docker = new Docker()
const imageOptions = {
filters: {
reference: [testImage]
}
}

const currentImage = `${testImage}@sha256:f32f4412fa4b6c7ece72cb85ae652751f11ac0d075c1131df09bb24f46b2f4e3`
const oldImage = `${testImage}@sha256:8cfee63309567569d3d7d0edc05fcf8be8f9f5130f0564dacea4cfe82a9db4b7`

async function clearTestImages(): Promise<void> {
const testImages = await docker.listImages(imageOptions)

for (const image of testImages) {
await docker.getImage(image.Id).remove()
}
}

beforeAll(async () => {
// Remove any existing alpine images from other tests
await clearTestImages()
}, 10000)

beforeEach(async () => {
await ImageService.fetchImage(currentImage)
await ImageService.fetchImage(oldImage)
}, 20000)

afterEach(async () => {
await clearTestImages()
}, 10000)

test('it removes unused versions of the given image', async () => {
const imageCount = (await docker.listImages(imageOptions)).length
expect(imageCount).toEqual(2)

await cleanupOldImageVersions(docker, currentImage)
// The Docker API seems to ack the removal before it is carried out, so let's wait briefly to ensure
// the verification query doesn't race the deletion
await delay(200)

const remainingImages = await docker.listImages(imageOptions)
expect(remainingImages.length).toEqual(1)
expect(remainingImages[0].RepoDigests?.includes(currentImage))
})
})
15 changes: 0 additions & 15 deletions __tests__/cleanup.test.ts

This file was deleted.

77 changes: 76 additions & 1 deletion __tests__/docker-tags.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import {UPDATER_IMAGE_NAME, PROXY_IMAGE_NAME} from '../src/docker-tags'
import {
UPDATER_IMAGE_NAME,
PROXY_IMAGE_NAME,
repositoryName
} from '../src/docker-tags'
import {getImageName} from '../src/update-containers'

describe('Docker tags', () => {
Expand All @@ -17,4 +21,75 @@ describe('Docker tags', () => {

expect(PROXY_IMAGE_NAME).toEqual(getImageName('Dockerfile.proxy'))
})

test('repositoryName returns the image name minus the tagged version or reference for our real values', () => {
expect(repositoryName(UPDATER_IMAGE_NAME)).toMatch(
'docker.pkg.github.com/dependabot/dependabot-updater'
)

expect(repositoryName(PROXY_IMAGE_NAME)).toMatch(
'docker.pkg.github.com/github/dependabot-update-job-proxy'
)
})

test('repositoryName handles named tags', () => {
// We currently use pinned SHA references instead of tags, but we should account for both notations
// to avoid any surprises
expect(
repositoryName('docker.pkg.github.com/dependabot/dependabot-updater:v1')
).toMatch('docker.pkg.github.com/dependabot/dependabot-updater')

expect(
repositoryName('docker.pkg.github.com/dependabot/dependabot-updater:v1')
).toMatch('docker.pkg.github.com/dependabot/dependabot-updater')
})

test('repositoryName handles ghcr.io images', () => {
expect(
repositoryName('ghcr.io/dependabot/dependabot-core:0.175.0')
).toMatch('ghcr.io/dependabot/dependabot-core')
})

test('repositoryName handles other images', () => {
// A GitHub-hosted image isn't an implicit requirement of the function
expect(repositoryName('hello_world:latest')).toMatch('hello_world')

expect(repositoryName('127.0.0.1:443/hello_world')).toMatch(
'127.0.0.1:443/hello_world'
)

expect(repositoryName('127.0.0.1:443/hello_world:443')).toMatch(
'127.0.0.1:443/hello_world'
)

expect(
repositoryName(
'127.0.0.1:443/hello_world@sha256:3d6c07043f4f2baf32047634a00a6581cf1124f12a30dcc859ab128f24333a3a'
)
).toMatch('127.0.0.1:443/hello_world')
})

test('repositoryName handles garbage inputs', () => {
expect(() => {
repositoryName('this is just some random nonsense')
}).toThrow('invalid image name')

expect(() => {
repositoryName('this-is-just-some-random-nonsense-with-an-@-in-it')
}).toThrow('invalid image name')

expect(() => {
repositoryName('this-is-just-some-random-nonsense-with-an-@sha256-in-it')
}).toThrow('invalid image name')
})

test('repositoryName handles garbage inputs that look like tags', () => {
expect(
repositoryName('this-is-just-some-random-nonsense-but-looks-like-a-tag')
).toMatch('this-is-just-some-random-nonsense-but-looks-like-a-tag')

expect(
repositoryName('this-is-just-some-random-nonsense-with-a:in-it')
).toMatch('this-is-just-some-random-nonsense-with-a')
})
})
4 changes: 4 additions & 0 deletions __tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,7 @@ export const eventFixturePath = (fixtureName: string): string => {
export const integration = process.env.SKIP_INTEGRATION_TESTS
? describe.skip
: describe

export async function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
85 changes: 84 additions & 1 deletion dist/cleanup/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/cleanup/index.js.map

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion dist/main/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/main/index.js.map

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions src/cleanup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import * as core from '@actions/core'
import Docker from 'dockerode'
import {
UPDATER_IMAGE_NAME,
PROXY_IMAGE_NAME,
repositoryName
} from './docker-tags'

// This method performs housekeeping checks to remove Docker artifacts
// which were left behind by old versions of the action or any jobs
Expand All @@ -14,9 +19,59 @@ export async function run(cutoff = '24h'): Promise<void> {
await docker.pruneNetworks({filters: untilFilter})
core.info(`Pruning containers older than ${cutoff}`)
await docker.pruneContainers({filters: untilFilter})
await cleanupOldImageVersions(docker, UPDATER_IMAGE_NAME)
await cleanupOldImageVersions(docker, PROXY_IMAGE_NAME)
} catch (error) {
core.error(`Error cleaning up: ${error.message}`)
}
}

export async function cleanupOldImageVersions(
docker: Docker,
imageName: string
): Promise<void> {
const repo = repositoryName(imageName)
const options = {
filters: {
reference: [repo]
}
}

core.info(`Cleaning up images for ${repo}`)

docker.listImages(options, async function (err, imageInfoList) {
if (imageInfoList && imageInfoList.length > 0) {
for (const imageInfo of imageInfoList) {
// The given imageName is expected to be a digest, however to avoid any surprises in future
// we fail over to check for a match on tags as well.
//
// This means we won't remove any image which matches an imageName of either of these notations:
// - dependabot/image@sha256:$REF (current implementation)
// - dependabot/image:v1
//
// Without checking imageInfo.RepoTags for a match, we would actually remove the latter even if
// this was the active version.
if (
imageInfo.RepoDigests?.includes(imageName) ||
imageInfo.RepoTags?.includes(imageName)
) {
core.info(`Skipping current image ${imageInfo.Id}`)
continue
}

core.info(`Removing image ${imageInfo.Id}`)
try {
await docker.getImage(imageInfo.Id).remove()
} catch (error) {
if (error.statusCode === 409) {
core.info(
`Unable to remove ${imageInfo.Id} as it is currently in use`
)
}
}
}
}
})
}

run()
Loading

0 comments on commit 749c000

Please sign in to comment.