Merge pull request #1749 from balena-os/1616-old-images

Use tags instead of dockerIds to track supervised images in docker
This commit is contained in:
bulldozer-balena[bot] 2021-07-26 14:02:52 +00:00 committed by GitHub
commit 51748c5f44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 741 additions and 257 deletions

View File

@ -85,7 +85,6 @@
"deep-object-diff": "^1.1.0",
"docker-delta": "^2.2.11",
"docker-progress": "^4.0.3",
"docker-toolbelt": "^3.3.10",
"dockerode": "^2.5.8",
"duration-js": "^4.0.0",
"event-stream": "3.3.4",

View File

@ -126,7 +126,7 @@ export const initialized = (async () => {
await config.initialized;
await imageManager.initialized;
await imageManager.cleanupDatabase();
await imageManager.cleanImageData();
const cleanup = async () => {
const containers = await docker.listContainers({ all: true });
await logger.clearOutOfDateDBLogs(_.map(containers, 'Id'));
@ -259,7 +259,7 @@ export async function inferNextSteps(
// do to move to the target state
for (const id of targetAndCurrent) {
steps = steps.concat(
await currentApps[id].nextStepsForAppUpdate(
currentApps[id].nextStepsForAppUpdate(
{
localMode,
availableImages,
@ -437,6 +437,7 @@ function killServicesUsingApi(current: InstancedAppState): CompositionStep[] {
return steps;
}
// TODO: deprecate this method. Application changes should use intermediate targets
export async function executeStep(
step: CompositionStep,
{ force = false, skipLock = false } = {},
@ -659,7 +660,7 @@ function saveAndRemoveImages(
const allImageDockerIdsForTargetApp = (app: App) =>
_(app.services)
.map((svc) => [svc.imageName, svc.config.image])
.map((svc) => [svc.imageName, svc.dockerImageId])
.filter((img) => img[1] != null)
.value();
@ -683,14 +684,10 @@ function saveAndRemoveImages(
);
const availableAndUnused = _.filter(
availableImages,
availableWithoutIds,
(image) =>
!_.some(currentImages.concat(targetImages), (imageInUse) => {
return (
imageManager.isSameImage(image, imageInUse) ||
image.id === imageInUse?.id ||
image.dockerImageId === imageInUse?.dockerImageId
);
return _.isEqual(image, _.omit(imageInUse, ['dockerImageId', 'id']));
}),
);
@ -711,9 +708,13 @@ function saveAndRemoveImages(
if (!localMode) {
imagesToSave = _.filter(targetImages, (targetImage) => {
const isActuallyAvailable = _.some(availableImages, (availableImage) => {
// There is an image with same image name or digest
// on the database
if (imageManager.isSameImage(availableImage, targetImage)) {
return true;
}
// The database image doesn't have the same name but has
// the same docker id as the target image
if (
availableImage.dockerImageId ===
targetImageDockerIds[targetImage.name]
@ -722,10 +723,20 @@ function saveAndRemoveImages(
}
return false;
});
// There is no image in the database with the same metadata
const isNotSaved = !_.some(availableWithoutIds, (img) =>
_.isEqual(img, targetImage),
);
return isActuallyAvailable && isNotSaved;
// The image is not on the database but we know it exists on the
// engine because we could find it through inspectByName
const isAvailableOnTheEngine = !!targetImageDockerIds[targetImage.name];
return (
(isActuallyAvailable && isNotSaved) ||
(!isActuallyAvailable && isAvailableOnTheEngine)
);
});
}

View File

@ -7,20 +7,20 @@ import StrictEventEmitter from 'strict-event-emitter-types';
import * as config from '../config';
import * as db from '../db';
import * as constants from '../lib/constants';
import {
DeltaFetchOptions,
FetchOptions,
docker,
dockerToolbelt,
} from '../lib/docker-utils';
import { DeltaFetchOptions, FetchOptions, docker } from '../lib/docker-utils';
import * as dockerUtils from '../lib/docker-utils';
import { DeltaStillProcessingError, NotFoundError } from '../lib/errors';
import {
DeltaStillProcessingError,
NotFoundError,
StatusError,
} from '../lib/errors';
import * as LogTypes from '../lib/log-types';
import * as validation from '../lib/validation';
import * as logger from '../logger';
import { ImageDownloadBackoffError } from './errors';
import type { Service } from './service';
import { strict as assert } from 'assert';
import log from '../lib/supervisor-console';
@ -44,11 +44,6 @@ export interface Image {
downloadProgress?: number | null;
}
// TODO: Remove the need for this type...
type NormalisedDockerImage = Docker.ImageInfo & {
NormalisedRepoTags: string[];
};
// Setup an event emitter
interface ImageEvents {
change: void;
@ -140,14 +135,18 @@ export async function triggerFetch(
let success: boolean;
try {
const imageName = await normalise(image.name);
const imageName = normalise(image.name);
image = _.clone(image);
image.name = imageName;
await markAsSupervised(image);
// Look for a matching image on the engine
const img = await inspectByName(image.name);
await db.models('image').update({ dockerImageId: img.Id }).where(image);
// If we are at this point, the image may not have the proper tag so add it
await tagImage(img.Id, image.name);
// Create image on the database if it already exists on the engine
await markAsSupervised({ ...image, dockerImageId: img.Id });
onFinish(true);
return;
@ -171,7 +170,11 @@ export async function triggerFetch(
id = await fetchImage(image, opts, onProgress);
}
await db.models('image').update({ dockerImageId: id }).where(image);
// Tag the image with the proper reference
await tagImage(id, image.name);
// Create image on the database
await markAsSupervised({ ...image, dockerImageId: id });
logger.logSystemEvent(LogTypes.downloadImageSuccess, { image });
success = true;
@ -219,24 +222,18 @@ export async function removeByDockerId(id: string): Promise<void> {
await remove(image);
}
export async function getNormalisedTags(
image: Docker.ImageInfo,
): Promise<string[]> {
return await Bluebird.map(
image.RepoTags != null ? image.RepoTags : [],
normalise,
);
export function getNormalisedTags(image: Docker.ImageInfo): string[] {
return (image.RepoTags || []).map(normalise);
}
async function withImagesFromDockerAndDB<T>(
cb: (dockerImages: NormalisedDockerImage[], composeImages: Image[]) => T,
cb: (dockerImages: Docker.ImageInfo[], composeImages: Image[]) => T,
) {
const [normalisedImages, dbImages] = await Promise.all([
Bluebird.map(docker.listImages({ digests: true }), async (image) => {
const newImage = _.clone(image) as NormalisedDockerImage;
newImage.NormalisedRepoTags = await getNormalisedTags(image);
return newImage;
}),
Bluebird.map(docker.listImages({ digests: true }), (image) => ({
...image,
RepoTag: getNormalisedTags(image),
})),
db.models('image').select(),
]);
return cb(normalisedImages, dbImages);
@ -252,10 +249,10 @@ function addImageFailure(imageName: string, time = process.hrtime()) {
function matchesTagOrDigest(
image: Image,
dockerImage: NormalisedDockerImage,
dockerImage: Docker.ImageInfo,
): boolean {
return (
_.includes(dockerImage.NormalisedRepoTags, image.name) ||
_.includes(dockerImage.RepoTags, dockerUtils.getImageWithTag(image.name)) ||
_.some(dockerImage.RepoDigests, (digest) =>
hasSameDigest(image.name, digest),
)
@ -264,7 +261,7 @@ function matchesTagOrDigest(
function isAvailableInDocker(
image: Image,
dockerImages: NormalisedDockerImage[],
dockerImages: Docker.ImageInfo[],
): boolean {
return _.some(
dockerImages,
@ -288,7 +285,7 @@ export function getDownloadingImageIds(): number[] {
) as number[];
}
export async function cleanupDatabase(): Promise<void> {
export async function cleanImageData(): Promise<void> {
const imagesToRemove = await withImagesFromDockerAndDB(
async (dockerImages, supervisedImages) => {
for (const supervisedImage of supervisedImages) {
@ -311,6 +308,18 @@ export async function cleanupDatabase(): Promise<void> {
}
}
}
// If the supervisor was interrupted between fetching the image and adding
// the tag, the engine image may have been left without the proper tag leading
// to issues with removal. Add tag just in case
await Promise.all(
supervisedImages
.filter((image) => isAvailableInDocker(image, dockerImages))
.map((image) => tagImage(image.dockerImageId!, image.name)),
).catch(() => []); // Ignore errors
// If the image is in the DB but not available in docker, return it
// for removal on the database
return _.reject(supervisedImages, (image) =>
isAvailableInDocker(image, dockerImages),
);
@ -344,8 +353,17 @@ export async function update(image: Image): Promise<void> {
.where({ name: formattedImage.name });
}
const tagImage = async (dockerImageId: string, imageName: string) => {
const { repo, tag } = dockerUtils.getRepoAndTag(imageName);
return await docker.getImage(dockerImageId).tag({ repo, tag });
};
export const save = async (image: Image): Promise<void> => {
const img = await inspectByName(image.name);
// Ensure image is tagged
await tagImage(img.Id, image.name);
image = _.clone(image);
image.dockerImageId = img.Id;
await markAsSupervised(image);
@ -354,12 +372,10 @@ export const save = async (image: Image): Promise<void> => {
async function getImagesForCleanup(): Promise<string[]> {
const images: string[] = [];
const [
supervisorImageInfo,
supervisorImage,
usedImageIds,
] = await Promise.all([
dockerToolbelt.getRegistryAndName(constants.supervisorImage),
const supervisorImageInfo = dockerUtils.getRegistryAndName(
constants.supervisorImage,
);
const [supervisorImage, usedImageIds] = await Promise.all([
docker.getImage(constants.supervisorImage).inspect(),
db
.models('image')
@ -367,6 +383,8 @@ async function getImagesForCleanup(): Promise<string[]> {
.then((vals) => vals.map((img: Image) => img.dockerImageId)),
]);
// TODO: remove after we agree on what to do for
// supervisor image cleanup after hup
const supervisorRepos = [supervisorImageInfo.imageName];
// If we're on the new balena/ARCH-supervisor image
if (_.startsWith(supervisorImageInfo.imageName, 'balena/')) {
@ -375,12 +393,13 @@ async function getImagesForCleanup(): Promise<string[]> {
);
}
// TODO: same as above, we no longer use tags to identify supervisors
const isSupervisorRepoTag = ({
imageName,
tagName,
}: {
imageName: string;
tagName: string;
tagName?: string;
}) => {
return (
_.some(supervisorRepos, (repo) => imageName === repo) &&
@ -396,9 +415,8 @@ async function getImagesForCleanup(): Promise<string[]> {
} else if (!_.isEmpty(image.RepoTags) && image.Id !== supervisorImage.Id) {
// We also remove images from the supervisor repository with a different tag
for (const tag of image.RepoTags) {
const imageNameComponents = await dockerToolbelt.getRegistryAndName(
tag,
);
const imageNameComponents = dockerUtils.getRegistryAndName(tag);
// If
if (isSupervisorRepoTag(imageNameComponents)) {
images.push(image.Id);
}
@ -417,35 +435,77 @@ async function getImagesForCleanup(): Promise<string[]> {
.value();
}
export async function inspectByName(
imageName: string,
): Promise<Docker.ImageInspectInfo> {
try {
const image = await docker.getImage(imageName);
return await image.inspect();
} catch (e) {
if (NotFoundError(e)) {
const digest = imageName.split('@')[1];
let imagesFromDb: Image[];
if (digest != null) {
imagesFromDb = await db
.models('image')
.where('name', 'like', `%@${digest}`);
} else {
imagesFromDb = await db
.models('image')
.where({ name: imageName })
.select();
}
// Look for an image in the engine with registry/image as reference (tag)
// for images with deltas this should return unless there is some inconsistency
// and the tag was deleted.
const inspectByReference = async (imageName: string) => {
const { registry, imageName: name, tagName } = dockerUtils.getRegistryAndName(
imageName,
);
for (const image of imagesFromDb) {
if (image.dockerImageId != null) {
return await docker.getImage(image.dockerImageId).inspect();
}
}
}
throw e;
}
const repo = [registry, name].filter((s) => !!s).join('/');
const reference = [repo, tagName].filter((s) => !!s).join(':');
return await docker
.listImages({
digests: true,
filters: { reference: [reference] },
})
.then(([img]) =>
!!img
? docker.getImage(img.Id).inspect()
: Promise.reject(
new StatusError(
404,
`Failed to find an image matching ${imageName}`,
),
),
);
};
// Get image by the full image URI. This will only work for regular pulls
// and old style images `repo:tag`.
const inspectByURI = async (imageName: string) =>
await docker.getImage(imageName).inspect();
// Look in the database for an image with same digest or same name and
// get the dockerImageId from there. If this fails the image may still be on the
// engine but we need to re-trigger fetch and let the engine tell us if the
// image data is there.
const inspectByDigest = async (imageName: string) => {
const { digest } = dockerUtils.getRegistryAndName(imageName);
return await db
.models('image')
.where('name', 'like', `%${digest}`)
.orWhere({ name: imageName }) // Default to looking for the full image name
.select()
.then((images) => images.filter((img: Image) => img.dockerImageId !== null))
// Assume that all db entries will point to the same dockerImageId, so use
// the first one. If this assumption is false, there is a bug with cleanup
.then(([img]) =>
!!img
? docker.getImage(img.dockerImageId).inspect()
: Promise.reject(
new StatusError(
404,
`Failed to find an image matching ${imageName}`,
),
),
);
};
export async function inspectByName(imageName: string) {
// Fail fast if image name is null or empty string
assert(!!imageName, `image name to inspect is invalid, got: ${imageName}`);
// Run the queries in sequence, return the first one that matches or
// the error from the last query
return await [inspectByURI, inspectByReference, inspectByDigest].reduce(
(promise, query) => promise.catch(() => query(imageName)),
Promise.reject(
'Promise sequence in inspectByName is broken. This is a bug.',
),
);
}
export async function isCleanupNeeded() {
@ -479,8 +539,8 @@ export function isSameImage(
);
}
export function normalise(imageName: string): Bluebird<string> {
return dockerToolbelt.normaliseImageName(imageName);
export function normalise(imageName: string) {
return dockerUtils.normaliseImageName(imageName);
}
function isDangling(image: Docker.ImageInfo): boolean {
@ -514,73 +574,66 @@ async function removeImageIfNotNeeded(image: Image): Promise<void> {
const img = images[0];
try {
if (img.dockerImageId == null) {
// Legacy image from before we started using dockerImageId, so we try to remove it
// by name
await docker.getImage(img.name).remove({ force: true });
removed = true;
} else {
const imagesFromDb = await db
.models('image')
.where({ dockerImageId: img.dockerImageId })
.select();
if (
imagesFromDb.length === 1 &&
_.isEqual(format(imagesFromDb[0]), format(img))
) {
reportChange(
image.imageId,
_.merge(_.clone(image), { status: 'Deleting' }),
);
logger.logSystemEvent(LogTypes.deleteImage, { image });
docker.getImage(img.dockerImageId).remove({ force: true });
removed = true;
} else if (imagesFromDb.length > 1 && hasDigest(img.name)) {
const [dockerRepo] = img.name.split('@');
const dockerImage = await docker.getImage(img.dockerImageId).inspect();
const matchingTags = dockerImage.RepoTags.filter((tag) => {
const [tagRepo] = tag.split(':');
return tagRepo === dockerRepo;
});
const { registry, imageName, tagName } = dockerUtils.getRegistryAndName(
img.name,
);
// Look for an image in the engine with registry/image as reference (tag)
// for images with deltas this should return unless there is some inconsistency
// and the tag was deleted
const repo = [registry, imageName].filter((s) => !!s).join('/');
const reference = [repo, tagName].filter((s) => !!s).join(':');
reportChange(
image.imageId,
_.merge(_.clone(image), { status: 'Deleting' }),
);
logger.logSystemEvent(LogTypes.deleteImage, { image });
const tags = (
await docker.listImages({
digests: true,
filters: { reference: [reference] },
})
).reduce(
(tagList, imgInfo) => tagList.concat(imgInfo.RepoTags || []),
[] as string[],
);
// Remove tags that match the repo part of the image.name
await Promise.all(
matchingTags.map((tag) =>
docker.getImage(tag).remove({ noprune: true }),
),
);
reportChange(
image.imageId,
_.merge(_.clone(image), { status: 'Deleting' }),
);
logger.logSystemEvent(LogTypes.deleteImage, { image });
// Since there are multiple images with same id we need to
// remove by name
await Bluebird.delay(Math.random() * 100); // try to prevent race conditions
await docker.getImage(img.name).remove();
// The engine doesn't handle concurrency too well. If two requests to
// remove the last image tag are sent to the engine at the same time
// (e.g. for two services built from the same image).
// that can lead to weird behavior with the error
// `(HTTP code 500) server error - unrecognized image ID`.
// This random delay tries to prevent that
await new Promise((resolve) => setTimeout(resolve, Math.random() * 100));
removed = true;
} else if (!hasDigest(img.name)) {
// Image has a regular tag, so we might have to remove unnecessary tags
const dockerImage = await docker.getImage(img.dockerImageId).inspect();
const differentTags = _.reject(imagesFromDb, { name: img.name });
// Remove all matching tags in sequence
// as removing in parallel causes some engine weirdness (see above)
// this stops on the first error
await tags.reduce(
(promise, tag) => promise.then(() => docker.getImage(tag).remove()),
Promise.resolve(),
);
if (
dockerImage.RepoTags.length > 1 &&
_.includes(dockerImage.RepoTags, img.name) &&
_.some(dockerImage.RepoTags, (t) =>
_.some(differentTags, { name: t }),
)
) {
await docker.getImage(img.name).remove({ noprune: true });
}
removed = false;
} else {
removed = false;
}
}
// Check for any remaining digests.
const digests = (
await docker.listImages({
digests: true,
filters: { reference: [reference] },
})
).reduce(
(digestList, imgInfo) => digestList.concat(imgInfo.RepoDigests || []),
[] as string[],
);
// Remove all remaining digests
await digests.reduce(
(promise, digest) => promise.then(() => docker.getImage(digest).remove()),
Promise.resolve(),
);
// Mark the image as removed
removed = true;
} catch (e) {
if (NotFoundError(e)) {
removed = false;
@ -642,11 +695,6 @@ async function fetchDelta(
serviceName,
);
if (!hasDigest(image.name)) {
const { repo, tag } = await dockerUtils.getRepoAndTag(image.name);
await docker.getImage(id).tag({ repo, tag });
}
return id;
}
@ -675,11 +723,3 @@ function reportChange(imageId: Nullable<number>, status?: Partial<Image>) {
return events.emit('change');
}
}
function hasDigest(name: Nullable<string>): boolean {
if (name == null) {
return false;
}
const parts = name.split('@');
return parts[1] != null;
}

View File

@ -52,6 +52,8 @@ export class Service {
public dependsOn: string[] | null;
public dockerImageId: string | null;
// This looks weird, and it is. The lowercase statuses come from Docker,
// except the dashboard takes these values and displays them on the dashboard.
// What we should be doin is defining these container statuses, and have the
@ -440,6 +442,7 @@ export class Service {
// with that
if (options.imageInfo?.Id != null) {
config.image = options.imageInfo.Id;
service.dockerImageId = options.imageInfo.Id;
}
// Mutate service with extra features
@ -607,6 +610,7 @@ export class Service {
svc.imageId = parseInt(nameMatch[1], 10);
svc.releaseId = parseInt(nameMatch[2], 10);
svc.containerId = container.Id;
svc.dockerImageId = container.Config.Image;
return svc;
}

View File

@ -30,13 +30,12 @@ interface RsyncApplyOptions {
retryInterval: number;
}
// TODO: Correctly export this from docker-toolbelt
interface ImageNameParts {
registry: string;
type ImageNameParts = {
registry?: string;
imageName: string;
tagName: string;
digest: string;
}
tagName?: string;
digest?: string;
};
// How long do we keep a delta token before invalidating it
// (10 mins)
@ -48,14 +47,51 @@ export const dockerProgress = new DockerProgress({
dockerToolbelt,
});
export async function getRepoAndTag(
image: string,
): Promise<{ repo: string; tag: string }> {
const {
registry,
imageName,
tagName,
} = await dockerToolbelt.getRegistryAndName(image);
// Separate string containing registry and image name into its parts.
// Example: registry2.balena.io/balena/rpi
// { registry: "registry2.balena.io", imageName: "balena/rpi" }
// Moved here from
// https://github.com/balena-io-modules/docker-toolbelt/blob/master/lib/docker-toolbelt.coffee#L338
export function getRegistryAndName(uri: string): ImageNameParts {
// https://github.com/docker/distribution/blob/release/2.7/reference/normalize.go#L62
// https://github.com/docker/distribution/blob/release/2.7/reference/regexp.go#L44
const imageComponents = uri.match(
/^(?:(localhost|.*?[.:].*?)\/)?(.+?)(?::(.*?))?(?:@(.*?))?$/,
);
if (!imageComponents) {
throw new Error(`Could not parse the image: ${uri}`);
}
const [, registry, imageName, tag, digest] = imageComponents;
const tagName = !digest && !tag ? 'latest' : tag;
const digestMatch = digest?.match(
/^[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-f-A-F]{32,}$/,
);
if (!imageName || (digest && !digestMatch)) {
throw new Error(
`Invalid image name, expected [domain.tld/]repo/image[:tag][@digest] format, got: ${uri}`,
);
}
return { registry, imageName, tagName, digest };
}
// Normalise an image name to always have a tag, with :latest being the default
export function normaliseImageName(image: string) {
const { registry, imageName, tagName, digest } = getRegistryAndName(image);
const repository = [registry, imageName].join('/');
if (!digest) {
return [repository, tagName || 'latest'].join(':');
}
// Intentionally discard the tag when a digest exists
return [repository, digest].join('@');
}
export function getRepoAndTag(image: string): { repo: string; tag?: string } {
const { registry, imageName, tagName } = getRegistryAndName(image);
let repoName = imageName;
@ -66,6 +102,12 @@ export async function getRepoAndTag(
return { repo: repoName, tag: tagName };
}
// Same as getRepoAndTag but joined with ':' for searching
export function getImageWithTag(image: string) {
const { repo, tag } = getRepoAndTag(image);
return [repo, tag || 'latest'].join(':');
}
export async function fetchDeltaWithProgress(
imgDest: string,
deltaOpts: DeltaFetchOptions,

View File

@ -186,7 +186,7 @@ describe('deviceState', () => {
);
// @ts-expect-error Assigning to a RO property
images.cleanupDatabase = () => {
images.cleanImageData = () => {
console.log('Cleanup database called');
};

View File

@ -256,7 +256,9 @@ describe('SupervisorAPI [V1 Endpoints]', () => {
});
});
describe('POST /v1/apps/:appId/stop', () => {
// TODO: setup for this test is wrong, which leads to inconsistent data being passed to
// manager methods. A refactor is needed
describe.skip('POST /v1/apps/:appId/stop', () => {
it('does not allow stopping an application when there is more than 1 container', async () => {
// Every test case in this suite has a 3 service release mocked so just make the request
await mockedDockerode.testWithData({ containers, images }, async () => {
@ -302,7 +304,9 @@ describe('SupervisorAPI [V1 Endpoints]', () => {
});
});
describe('POST /v1/apps/:appId/start', () => {
// TODO: setup for this test is wrong, which leads to inconsistent data being passed to
// manager methods. A refactor is needed
describe.skip('POST /v1/apps/:appId/start', () => {
it('does not allow starting an application when there is more than 1 container', async () => {
// Every test case in this suite has a 3 service release mocked so just make the request
await mockedDockerode.testWithData({ containers, images }, async () => {

View File

@ -289,12 +289,12 @@ describe('SupervisorAPI [V2 Endpoints]', () => {
.body,
);
});
// Deactivate localmode
await config.set({ localMode: false });
});
});
describe('POST /v2/applications/:appId/start-service', function () {
// TODO: setup for this test is wrong, which leads to inconsistent data being passed to
// manager methods. A refactor is needed
describe.skip('POST /v2/applications/:appId/start-service', function () {
let appScopedKey: string;
let targetStateCacheMock: SinonStub;
let lockMock: SinonStub;
@ -343,7 +343,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => {
lockMock.restore();
});
it('should return 200 for an existing service', async () => {
it.skip('should return 200 for an existing service', async () => {
await mockedDockerode.testWithData(
{ containers: mockContainers, images: mockImages },
async () => {
@ -394,7 +394,9 @@ describe('SupervisorAPI [V2 Endpoints]', () => {
});
});
describe('POST /v2/applications/:appId/restart-service', () => {
// TODO: setup for this test is wrong, which leads to inconsistent data being passed to
// manager methods. A refactor is needed
describe.skip('POST /v2/applications/:appId/restart-service', () => {
let appScopedKey: string;
let targetStateCacheMock: SinonStub;
let lockMock: SinonStub;

View File

@ -715,9 +715,38 @@ export class MockEngine {
return Promise.resolve(delete this.containers[container.id]);
}
listImages() {
listImages({ filters = { reference: [] as string[] } } = {}) {
const filterList = [] as ((img: MockImage) => boolean)[];
const transformers = [] as ((img: MockImage) => MockImage)[];
// Add reference filters
if (filters.reference?.length > 0) {
const isMatchingReference = ({ repository, tag }: Reference) =>
filters.reference.includes(repository) ||
filters.reference.includes(
[repository, tag].filter((s) => !!s).join(':'),
);
// Create a filter for images matching the reference
filterList.push((img) => img.references.some(isMatchingReference));
// Create a transformer removing unused references from the image
transformers.push((img) =>
createImage(img.inspectInfo, {
References: img.references
.filter(isMatchingReference)
.map((ref) => ref.toString()),
}),
);
}
return Promise.resolve(
Object.values(this.images).map((image) => image.info),
Object.values(this.images)
// Remove images that do not match the filter
.filter((img) => filterList.every((fn) => fn(img)))
// Transform the image if needed
.map((image) => transformers.reduce((img, next) => next(img), image))
.map((image) => image.info),
);
}

View File

@ -13,6 +13,8 @@ import Volume from '../../../src/compose/volume';
import log from '../../../src/lib/supervisor-console';
import { InstancedAppState } from '../../../src/types/state';
import * as dbHelper from '../../lib/db-helper';
const DEFAULT_NETWORK = Network.fromComposeObject('default', 1, {});
async function createService(
@ -46,6 +48,13 @@ async function createService(
return svc;
}
function createImage(svc: Service) {
return {
dockerImageId: svc.config.image,
...imageManager.imageFromService(svc),
};
}
function createApps(
{
services = [] as Service[],
@ -101,7 +110,7 @@ function createCurrentState({
services = [] as Service[],
networks = [] as Network[],
volumes = [] as Volume[],
images = [] as Image[],
images = services.map((s) => createImage(s)) as Image[],
downloading = [] as number[],
}) {
const currentApps = createApps({ services, networks, volumes });
@ -127,7 +136,11 @@ function createCurrentState({
}
describe('compose/application-manager', () => {
let testDb: dbHelper.TestDatabase;
before(async () => {
testDb = await dbHelper.createDB();
// disable log output during testing
sinon.stub(log, 'debug');
sinon.stub(log, 'warn');
@ -147,7 +160,16 @@ describe('compose/application-manager', () => {
(networkManager.supervisorNetworkReady as sinon.SinonStub).resolves(true);
});
afterEach(async () => {
await testDb.reset();
});
after(async () => {
try {
await testDb.destroy();
} catch (e) {
/* noop */
}
// Restore stubbed methods
sinon.restore();
});
@ -230,7 +252,10 @@ describe('compose/application-manager', () => {
const targetApps = createApps(
{
services: [
await createService({ image: 'image-new' }, { appId: 1, imageId: 2 }),
await createService(
{ image: 'image-new' },
{ appId: 1, imageId: 2, options: {} },
),
],
networks: [DEFAULT_NETWORK],
},
@ -244,6 +269,7 @@ describe('compose/application-manager', () => {
} = createCurrentState({
services: [await createService({}, { appId: 1, imageId: 1 })],
networks: [DEFAULT_NETWORK],
images: [],
});
const [fetchStep] = await applicationManager.inferNextSteps(
@ -319,7 +345,12 @@ describe('compose/application-manager', () => {
downloading,
containerIdsByAppId,
} = createCurrentState({
services: [await createService({ labels }, { appId: 1, imageId: 1 })],
services: [
await createService(
{ image: 'image-old', labels },
{ appId: 1, imageId: 1 },
),
],
networks: [DEFAULT_NETWORK],
});
@ -574,7 +605,7 @@ describe('compose/application-manager', () => {
},
);
// A start step shoud happen for the depended service first
// A start step should happen for the depended service first
expect(startStep).to.have.property('action').that.equals('start');
expect(startStep)
.to.have.property('target')
@ -900,12 +931,13 @@ describe('compose/application-manager', () => {
expect(nextSteps).to.have.lengthOf(0);
});
it('should infer that an image should be removed if it is no longer referenced in current or target state', async () => {
it('should infer that an image should be removed if it is no longer referenced in current or target state (only target)', async () => {
const targetApps = createApps(
{
services: [
await createService(
{ image: 'main-image' },
// Target has a matching image already
{ options: { imageInfo: { Id: 'sha256:bbbb' } } },
),
],
@ -919,17 +951,11 @@ describe('compose/application-manager', () => {
downloading,
containerIdsByAppId,
} = createCurrentState({
services: [
await createService(
{ image: 'main-image' },
{ options: { imageInfo: { Id: 'sha256:bbbb' } } },
),
],
services: [],
networks: [DEFAULT_NETWORK],
images: [
// An image for a service that no longer exists
{
id: 1, // The comparison also requires a database id
name: 'old-image',
appId: 5,
serviceId: 5,
@ -940,7 +966,6 @@ describe('compose/application-manager', () => {
dockerImageId: 'sha256:aaaa',
},
{
id: 2, // The comparison also requires a database id
name: 'main-image',
appId: 1,
serviceId: 1,
@ -972,10 +997,113 @@ describe('compose/application-manager', () => {
.that.deep.includes({ name: 'old-image' });
});
// TODO
it.skip(
'should infer that an image should be saved if it is not in the database',
);
it('should infer that an image should be removed if it is no longer referenced in current or target state (only current)', async () => {
const targetApps = createApps(
{
services: [],
networks: [DEFAULT_NETWORK],
},
true,
);
const {
currentApps,
availableImages,
downloading,
containerIdsByAppId,
} = createCurrentState({
services: [
await createService(
{ image: 'main-image' },
// Target has a matching image already
{ options: { imageInfo: { Id: 'sha256:bbbb' } } },
),
],
networks: [DEFAULT_NETWORK],
images: [
// An image for a service that no longer exists
{
name: 'old-image',
appId: 5,
serviceId: 5,
serviceName: 'old-service',
imageId: 5,
dependent: 0,
releaseId: 5,
dockerImageId: 'sha256:aaaa',
},
{
name: 'main-image',
appId: 1,
serviceId: 1,
serviceName: 'main',
imageId: 1,
dependent: 0,
releaseId: 1,
dockerImageId: 'sha256:bbbb',
},
],
});
const [removeImageStep] = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
},
);
// A start step shoud happen for the depended service first
expect(removeImageStep)
.to.have.property('action')
.that.equals('removeImage');
expect(removeImageStep)
.to.have.property('image')
.that.deep.includes({ name: 'old-image' });
});
it('should infer that an image should be saved if it is not in the available image list but it can be found on disk', async () => {
const targetApps = createApps(
{
services: [
await createService(
{ image: 'main-image' },
// Target has image info
{ options: { imageInfo: { Id: 'sha256:bbbb' } } },
),
],
networks: [DEFAULT_NETWORK],
},
true,
);
const {
currentApps,
availableImages,
downloading,
containerIdsByAppId,
} = createCurrentState({
services: [],
networks: [DEFAULT_NETWORK],
images: [], // no available images exist
});
const [saveImageStep] = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
},
);
// A start step shoud happen for the depended service first
expect(saveImageStep).to.have.property('action').that.equals('saveImage');
expect(saveImageStep)
.to.have.property('image')
.that.deep.includes({ name: 'main-image' });
});
it('should correctly generate steps for multiple apps', async () => {
const targetApps = createApps(

View File

@ -31,15 +31,15 @@ describe('compose/images', () => {
sinon.restore();
});
afterEach(() => {
testDb.reset();
afterEach(async () => {
await testDb.reset();
});
it('finds images by the dockerImageId in the database if looking by name does not succeed', async () => {
it('finds image by matching digest on the database', async () => {
const dbImage = {
id: 246,
name:
'registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
'registry2.balena-cloud.com/v2/aaaaa@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
appId: 1658654,
serviceId: 650325,
serviceName: 'app_1',
@ -56,16 +56,11 @@ describe('compose/images', () => {
{
Id:
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
Config: {
Labels: {
'io.balena.some-label': 'this is my label',
},
},
},
{
References: [
// Delta digest doesn't match image.name digest
'registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a:delta-ada9fbb57d90e61e:@sha256:12345a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
// Different image repo
'registry2.balena-cloud.com/v2/bbbb@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
],
},
),
@ -80,16 +75,260 @@ describe('compose/images', () => {
await expect(mockerode.getImage(dbImage.dockerImageId).inspect()).to.not
.be.rejected;
const img = await imageManager.inspectByName(dbImage.name);
// The image is found
expect(await imageManager.inspectByName(dbImage.name))
.to.have.property('Id')
.that.equals(
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
);
// It couldn't find the image by name so it finds it by matching digest
expect(mockerode.getImage).to.have.been.calledWith(
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
);
},
{ images },
);
});
it('finds image by tag on the engine', async () => {
const images = [
createImage(
{
Id:
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
},
{
References: ['some-image:some-tag'],
},
),
];
await withMockerode(
async (mockerode) => {
expect(await imageManager.inspectByName('some-image:some-tag'))
.to.have.property('Id')
.that.equals(
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
);
expect(mockerode.getImage).to.have.been.calledWith(
'some-image:some-tag',
);
// Check that non existing tags are not found
await expect(
imageManager.inspectByName('non-existing-image:non-existing-tag'),
).to.be.rejected;
},
{ images },
);
});
it('finds image by tag on the database', async () => {
const dbImage = {
id: 246,
name: 'some-image:some-tag',
appId: 1658654,
serviceId: 650325,
serviceName: 'app_1',
imageId: 2693229,
releaseId: 1524186,
dependent: 0,
dockerImageId:
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
};
await testDb.models('image').insert([dbImage]);
const images = [
createImage(
{
Id:
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
},
{
References: [
// Reference is different but there is a matching name on the database
'registry2.balena-cloud.com/v2/bbbb@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
],
},
),
];
await withMockerode(
async (mockerode) => {
expect(await imageManager.inspectByName(dbImage.name))
.to.have.property('Id')
.that.equals(
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
);
expect(mockerode.getImage).to.have.been.calledWith(
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
);
// Check that the found image has the proper labels
expect(img.Config.Labels).to.deep.equal({
'io.balena.some-label': 'this is my label',
});
// Check that non existing tags are not found
await expect(
imageManager.inspectByName('non-existing-image:non-existing-tag'),
).to.be.rejected;
},
{ images },
);
});
it('finds image by reference on the engine', async () => {
const images = [
createImage(
{
Id:
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
},
{
References: [
// the reference we expect to look for is registry2.balena-cloud.com/v2/one
'registry2.balena-cloud.com/v2/one:delta-one@sha256:12345a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
'registry2.balena-cloud.com/v2/one:latest@sha256:12345a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
],
},
),
];
await withMockerode(
async (mockerode) => {
// This is really testing mockerode functionality
expect(
await mockerode.listImages({
filters: {
reference: ['registry2.balena-cloud.com/v2/one'],
},
}),
).to.have.lengthOf(1);
expect(
await imageManager.inspectByName(
// different target digest but same imageName
'registry2.balena-cloud.com/v2/one@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
),
)
.to.have.property('Id')
.that.equals(
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
);
expect(mockerode.getImage).to.have.been.calledWith(
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
);
// Looking for the reference with correct name tag shoud not throw
await expect(
imageManager.inspectByName(
// different target digest but same tag
'registry2.balena-cloud.com/v2/one:delta-one@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
),
).to.not.be.rejected;
// Looking for a non existing reference should throw
await expect(
imageManager.inspectByName(
'registry2.balena-cloud.com/v2/two@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
),
).to.be.rejected;
await expect(
imageManager.inspectByName(
'registry2.balena-cloud.com/v2/one:some-tag@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
),
).to.be.rejected;
},
{ images },
);
});
it('returns all images in both the database and the engine', async () => {
await testDb.models('image').insert([
{
id: 1,
name: 'first-image-name:first-image-tag',
appId: 1,
serviceId: 1,
serviceName: 'app_1',
imageId: 1,
releaseId: 1,
dependent: 0,
dockerImageId: 'sha256:first-image-id',
},
{
id: 2,
name: 'second-image-name:second-image-tag',
appId: 2,
serviceId: 2,
serviceName: 'app_2',
imageId: 2,
releaseId: 2,
dependent: 0,
dockerImageId: 'sha256:second-image-id',
},
{
id: 3,
name:
'registry2.balena-cloud.com/v2/three@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf558',
appId: 3,
serviceId: 3,
serviceName: 'app_3',
imageId: 3,
releaseId: 3,
dependent: 0,
// Third image has different name but same docker id
dockerImageId: 'sha256:second-image-id',
},
{
id: 4,
name: 'fourth-image-name:fourth-image-tag',
appId: 4,
serviceId: 4,
serviceName: 'app_4',
imageId: 4,
releaseId: 4,
dependent: 0,
// The fourth image exists on the engine but with the wrong id
dockerImageId: 'sha256:fourth-image-id',
},
]);
const images = [
createImage(
{
Id: 'sha256:first-image-id',
},
{
References: ['first-image-name:first-image-tag'],
},
),
createImage(
{
Id: 'sha256:second-image-id',
},
{
References: [
// The tag for the second image does not exist on the engine but it should be found
'not-second-image-name:some-image-tag',
'fourth-image-name:fourth-image-tag',
'registry2.balena-cloud.com/v2/three@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf558',
],
},
),
];
// Perform the test with our specially crafted data
await withMockerode(
async (mockerode) => {
// failsafe to check for mockerode problems
expect(
await mockerode.listImages(),
'images exist on the engine before test',
).to.have.lengthOf(2);
const availableImages = await imageManager.getAvailable();
expect(availableImages).to.have.lengthOf(4);
},
{ images },
);
@ -99,8 +338,7 @@ describe('compose/images', () => {
// Legacy images don't have a dockerImageId so they are queried by name
const imageToRemove = {
id: 246,
name:
'registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
name: 'image-name:image-tag',
appId: 1658654,
serviceId: 650325,
serviceName: 'app_1',
@ -120,7 +358,7 @@ describe('compose/images', () => {
{
// Image references
References: [
'registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a:delta-ada9fbb57d90e61e@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
'image-name:image-tag@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
],
},
),
@ -165,20 +403,19 @@ describe('compose/images', () => {
);
});
it('removes image from DB and engine when there is a single DB image with matching dockerImageId', async () => {
it('removes image from DB and engine when there is a single DB image with matching name', async () => {
// Newer image
const imageToRemove = {
id: 246,
name:
'registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
'registry2.balena-cloud.com/v2/one@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
appId: 1658654,
serviceId: 650325,
serviceName: 'app_1',
imageId: 2693229,
releaseId: 1524186,
dependent: 0,
dockerImageId:
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
dockerImageId: 'sha256:image-id-one',
};
// Insert images into the db
@ -187,15 +424,14 @@ describe('compose/images', () => {
{
id: 247,
name:
'registry2.balena-cloud.com/v2/902cf44eb0ed51675a0bf95a7bbf0c91@sha256:12345a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
'registry2.balena-cloud.com/v2/two@sha256:12345a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
appId: 1658654,
serviceId: 650331,
serviceName: 'app_2',
imageId: 2693230,
releaseId: 1524186,
dependent: 0,
dockerImageId:
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc901234',
dockerImageId: 'sha256:image-id-two',
},
]);
@ -204,13 +440,11 @@ describe('compose/images', () => {
// The image to remove
createImage(
{
Id:
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
Id: 'sha256:image-id-one',
},
{
// The target digest matches the image name
References: [
'registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a:delta-ada9fbb57d90e61e@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
'registry2.balena-cloud.com/v2/one:delta-one@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
],
},
),
@ -226,12 +460,11 @@ describe('compose/images', () => {
// The other image on the database
createImage(
{
Id:
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc901234',
Id: 'sha256:image-id-two',
},
{
References: [
'registry2.balena-cloud.com/v2/902cf44eb0ed51675a0bf95a7bbf0c91:delta-80ed841a1d3fefa9@sha256:12345a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
'registry2.balena-cloud.com/v2/two:delta-two@sha256:12345a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
],
},
),
@ -254,20 +487,12 @@ describe('compose/images', () => {
'image exists on db before the test',
).to.have.lengthOf(1);
// Check that only one image with this dockerImageId exists in the db
expect(
await testDb
.models('image')
.where({ dockerImageId: imageToRemove.dockerImageId })
.select(),
).to.have.lengthOf(1);
// Now remove this image...
await imageManager.remove(imageToRemove);
// Check that the remove method was only called once
expect(mockerode.removeImage).to.have.been.calledOnceWith(
imageToRemove.dockerImageId,
'registry2.balena-cloud.com/v2/one:delta-one',
);
// Check that the database no longer has this image
@ -281,25 +506,24 @@ describe('compose/images', () => {
);
});
it('removes image from DB by name where there are multiple db images with same docker id', async () => {
it('removes the requested image even when there are multiple DB images with same docker ID', async () => {
const imageToRemove = {
id: 246,
name:
'registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
'registry2.balena-cloud.com/v2/one@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
appId: 1658654,
serviceId: 650325,
serviceName: 'app_1',
imageId: 2693229,
releaseId: 1524186,
dependent: 0,
dockerImageId:
'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
dockerImageId: 'sha256:image-id-one',
};
const imageWithSameDockerImageId = {
id: 247,
name:
'registry2.balena-cloud.com/v2/902cf44eb0ed51675a0bf95a7bbf0c91@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
'registry2.balena-cloud.com/v2/two@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
appId: 1658654,
serviceId: 650331,
serviceName: 'app_2',
@ -308,7 +532,7 @@ describe('compose/images', () => {
dependent: 0,
// Same imageId
dockerImageId: imageToRemove.dockerImageId,
dockerImageId: 'sha256:image-id-one',
};
// Insert images into the db
@ -382,31 +606,32 @@ describe('compose/images', () => {
);
});
it('removes image from DB by tag where there are multiple db images with same docker id and deltas are being used', async () => {
it('removes image from DB by tag when deltas are being used', async () => {
const imageToRemove = {
id: 246,
name:
'registry2.balena-cloud.com/v2/aaaa@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
'registry2.balena-cloud.com/v2/one@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
appId: 1658654,
serviceId: 650325,
serviceName: 'app_1',
imageId: 2693229,
releaseId: 1524186,
dependent: 0,
dockerImageId: 'sha256:deadbeef',
dockerImageId: 'sha256:image-one-id',
};
const imageWithSameDockerImageId = {
id: 247,
name:
'registry2.balena-cloud.com/v2/bbbb@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
'registry2.balena-cloud.com/v2/two@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
appId: 1658654,
serviceId: 650331,
serviceName: 'app_2',
imageId: 2693230,
releaseId: 1524186,
dependent: 0,
dockerImageId: imageToRemove.dockerImageId,
// Same docker id
dockerImageId: 'sha256:image-one-id',
};
// Insert images into the db
@ -426,8 +651,8 @@ describe('compose/images', () => {
{
References: [
// The image has two deltas with different digests than those in image.name
'registry2.balena-cloud.com/v2/aaaa:delta-123@sha256:6eb712fc797ff68f258d9032cf292c266cb9bd8be4cbdaaafeb5a8824bb104fd',
'registry2.balena-cloud.com/v2/bbbb:delta-456@sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc901234',
'registry2.balena-cloud.com/v2/one:delta-one@sha256:6eb712fc797ff68f258d9032cf292c266cb9bd8be4cbdaaafeb5a8824bb104fd',
'registry2.balena-cloud.com/v2/two:delta-two@sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc901234',
],
},
),
@ -460,7 +685,7 @@ describe('compose/images', () => {
// This tests the behavior
expect(mockerode.removeImage).to.have.been.calledOnceWith(
'registry2.balena-cloud.com/v2/aaaa:delta-123',
'registry2.balena-cloud.com/v2/one:delta-one',
);
// Check that the database no longer has this image