Make images module a singleton

Change-type: patch
Signed-off-by: Cameron Diver <cameron@balena.io>
This commit is contained in:
Cameron Diver 2020-06-09 14:43:45 +01:00
parent d3fae47c8a
commit 2b3dc2fbce
11 changed files with 648 additions and 636 deletions

View File

@ -6,7 +6,7 @@ import Knex = require('knex');
import { ServiceAction } from './device-api/common'; import { ServiceAction } from './device-api/common';
import { DeviceStatus, InstancedAppState } from './types/state'; import { DeviceStatus, InstancedAppState } from './types/state';
import ImageManager, { Image } from './compose/images'; import type { Image } from './compose/images';
import ServiceManager from './compose/service-manager'; import ServiceManager from './compose/service-manager';
import DeviceState from './device-state'; import DeviceState from './device-state';
@ -51,7 +51,6 @@ class ApplicationManager extends EventEmitter {
public services: ServiceManager; public services: ServiceManager;
public volumes: VolumeManager; public volumes: VolumeManager;
public networks: NetworkManager; public networks: NetworkManager;
public images: ImageManager;
public proxyvisor: any; public proxyvisor: any;
public timeSpentFetching: number; public timeSpentFetching: number;

View File

@ -28,7 +28,7 @@ import { TargetStateAccessor } from './device-state/target-state-cache';
import { ServiceManager } from './compose/service-manager'; import { ServiceManager } from './compose/service-manager';
import { Service } from './compose/service'; import { Service } from './compose/service';
import { Images } from './compose/images'; import * as Images from './compose/images';
import { NetworkManager } from './compose/network-manager'; import { NetworkManager } from './compose/network-manager';
import { Network } from './compose/network'; import { Network } from './compose/network';
import { VolumeManager } from './compose/volume-manager'; import { VolumeManager } from './compose/volume-manager';
@ -172,12 +172,11 @@ export class ApplicationManager extends EventEmitter {
this.reportOptionalContainers = this.reportOptionalContainers.bind(this); this.reportOptionalContainers = this.reportOptionalContainers.bind(this);
this.deviceState = deviceState; this.deviceState = deviceState;
this.apiBinder = apiBinder; this.apiBinder = apiBinder;
this.images = new Images();
this.services = new ServiceManager(); this.services = new ServiceManager();
this.networks = new NetworkManager(); this.networks = new NetworkManager();
this.volumes = new VolumeManager(); this.volumes = new VolumeManager();
this.proxyvisor = new Proxyvisor({ this.proxyvisor = new Proxyvisor({
images: this.images,
applications: this, applications: this,
}); });
this.localModeManager = new LocalModeManager(); this.localModeManager = new LocalModeManager();
@ -188,19 +187,12 @@ export class ApplicationManager extends EventEmitter {
this.targetStateWrapper = new TargetStateAccessor(this); this.targetStateWrapper = new TargetStateAccessor(this);
config.on('change', (changedConfig) => {
if (changedConfig.appUpdatePollInterval) {
this.images.appUpdatePollInterval = changedConfig.appUpdatePollInterval;
}
});
this.actionExecutors = compositionSteps.getExecutors({ this.actionExecutors = compositionSteps.getExecutors({
lockFn: this._lockingIfNecessary, lockFn: this._lockingIfNecessary,
services: this.services, services: this.services,
networks: this.networks, networks: this.networks,
volumes: this.volumes, volumes: this.volumes,
applications: this, applications: this,
images: this.images,
callbacks: { callbacks: {
containerStarted: (id) => { containerStarted: (id) => {
this._containerStarted[id] = true; this._containerStarted[id] = true;
@ -225,7 +217,7 @@ export class ApplicationManager extends EventEmitter {
this.proxyvisor.validActions, this.proxyvisor.validActions,
); );
this.router = createApplicationManagerRouter(this); this.router = createApplicationManagerRouter(this);
this.images.on('change', this.reportCurrentState); Images.on('change', this.reportCurrentState);
this.services.on('change', this.reportCurrentState); this.services.on('change', this.reportCurrentState);
} }
@ -234,12 +226,8 @@ export class ApplicationManager extends EventEmitter {
} }
init() { init() {
return config return Images.initialized
.get('appUpdatePollInterval') .then(() => Images.cleanupDatabase())
.then((interval) => {
this.images.appUpdatePollInterval = interval;
return this.images.cleanupDatabase();
})
.then(() => { .then(() => {
const cleanup = () => { const cleanup = () => {
return docker.listContainers({ all: true }).then((containers) => { return docker.listContainers({ all: true }).then((containers) => {
@ -271,7 +259,7 @@ export class ApplicationManager extends EventEmitter {
getStatus() { getStatus() {
return Promise.join( return Promise.join(
this.services.getStatus(), this.services.getStatus(),
this.images.getStatus(), Images.getStatus(),
config.get('currentCommit'), config.get('currentCommit'),
function (services, images, currentCommit) { function (services, images, currentCommit) {
const apps = {}; const apps = {};
@ -1006,7 +994,7 @@ export class ApplicationManager extends EventEmitter {
return service; return service;
}); });
return Promise.map(services, (service) => { return Promise.map(services, (service) => {
service.image = this.images.normalise(service.image); service.image = Images.normalise(service.image);
return Promise.props(service); return Promise.props(service);
}).then(function ($services) { }).then(function ($services) {
const dbApp = { const dbApp = {
@ -1026,7 +1014,7 @@ export class ApplicationManager extends EventEmitter {
createTargetService(service, opts) { createTargetService(service, opts) {
// The image class now returns a native promise, so wrap // The image class now returns a native promise, so wrap
// this in a bluebird promise until we convert this to typescript // this in a bluebird promise until we convert this to typescript
return Promise.resolve(this.images.inspectByName(service.image)) return Promise.resolve(Images.inspectByName(service.image))
.catchReturn(NotFoundError, undefined) .catchReturn(NotFoundError, undefined)
.then(function (imageInfo) { .then(function (imageInfo) {
const serviceOpts = { const serviceOpts = {
@ -1589,9 +1577,9 @@ export class ApplicationManager extends EventEmitter {
return config.get('localMode').then((localMode) => { return config.get('localMode').then((localMode) => {
return Promise.props({ return Promise.props({
cleanupNeeded: this.images.isCleanupNeeded(), cleanupNeeded: Images.isCleanupNeeded(),
availableImages: this.images.getAvailable(), availableImages: Images.getAvailable(),
downloading: this.images.getDownloadingImageIds(), downloading: Images.getDownloadingImageIds(),
supervisorNetworkReady: this.networks.supervisorNetworkReady(), supervisorNetworkReady: this.networks.supervisorNetworkReady(),
delta: config.get('delta'), delta: config.get('delta'),
containerIds: Promise.props(containerIdsByAppId), containerIds: Promise.props(containerIdsByAppId),

View File

@ -3,7 +3,8 @@ import * as _ from 'lodash';
import * as config from '../config'; import * as config from '../config';
import { ApplicationManager } from '../application-manager'; import { ApplicationManager } from '../application-manager';
import Images, { Image } from './images'; import type { Image } from './images';
import * as images from './images';
import Network from './network'; import Network from './network';
import Service from './service'; import Service from './service';
import ServiceManager from './service-manager'; import ServiceManager from './service-manager';
@ -139,7 +140,6 @@ export function getExecutors(app: {
networks: NetworkManager; networks: NetworkManager;
volumes: VolumeManager; volumes: VolumeManager;
applications: ApplicationManager; applications: ApplicationManager;
images: Images;
callbacks: CompositionCallbacks; callbacks: CompositionCallbacks;
}) { }) {
const executors: Executors<CompositionStepAction> = { const executors: Executors<CompositionStepAction> = {
@ -171,7 +171,7 @@ export function getExecutors(app: {
await app.services.kill(step.current); await app.services.kill(step.current);
app.callbacks.containerKilled(step.current.containerId); app.callbacks.containerKilled(step.current.containerId);
if (_.get(step, ['options', 'removeImage'])) { if (_.get(step, ['options', 'removeImage'])) {
await app.images.removeByDockerId(step.current.config.image); await images.removeByDockerId(step.current.config.image);
} }
}, },
); );
@ -241,7 +241,7 @@ export function getExecutors(app: {
app.callbacks.fetchStart(); app.callbacks.fetchStart();
const [fetchOpts, availableImages] = await Promise.all([ const [fetchOpts, availableImages] = await Promise.all([
config.get('fetchOptions'), config.get('fetchOptions'),
app.images.getAvailable(), images.getAvailable(),
]); ]);
const opts = { const opts = {
@ -249,7 +249,7 @@ export function getExecutors(app: {
...fetchOpts, ...fetchOpts,
}; };
await app.images.triggerFetch( await images.triggerFetch(
step.image, step.image,
opts, opts,
async (success) => { async (success) => {
@ -269,15 +269,15 @@ export function getExecutors(app: {
); );
}, },
removeImage: async (step) => { removeImage: async (step) => {
await app.images.remove(step.image); await images.remove(step.image);
}, },
saveImage: async (step) => { saveImage: async (step) => {
await app.images.save(step.image); await images.save(step.image);
}, },
cleanup: async () => { cleanup: async () => {
const localMode = await config.get('localMode'); const localMode = await config.get('localMode');
if (!localMode) { if (!localMode) {
await app.images.cleanup(); await images.cleanup();
} }
}, },
createNetwork: async (step) => { createNetwork: async (step) => {

View File

@ -4,6 +4,7 @@ import { EventEmitter } from 'events';
import * as _ from 'lodash'; import * as _ from 'lodash';
import StrictEventEmitter from 'strict-event-emitter-types'; import StrictEventEmitter from 'strict-event-emitter-types';
import * as config from '../config';
import * as db from '../db'; import * as db from '../db';
import * as constants from '../lib/constants'; import * as constants from '../lib/constants';
import { import {
@ -21,12 +22,6 @@ import { ImageDownloadBackoffError } from './errors';
import log from '../lib/supervisor-console'; import log from '../lib/supervisor-console';
interface ImageEvents {
change: void;
}
type ImageEventEmitter = StrictEventEmitter<EventEmitter, ImageEvents>;
interface FetchProgressEvent { interface FetchProgressEvent {
percentage: number; percentage: number;
} }
@ -52,37 +47,61 @@ type NormalisedDockerImage = Docker.ImageInfo & {
NormalisedRepoTags: string[]; NormalisedRepoTags: string[];
}; };
export class Images extends (EventEmitter as new () => ImageEventEmitter) { // Setup an event emitter
public appUpdatePollInterval: number; interface ImageEvents {
change: void;
}
class ImageEventEmitter extends (EventEmitter as new () => StrictEventEmitter<
EventEmitter,
ImageEvents
>) {}
const events = new ImageEventEmitter();
private imageFetchFailures: Dictionary<number> = {}; export const on: typeof events['on'] = events.on.bind(events);
private imageFetchLastFailureTime: Dictionary< export const once: typeof events['once'] = events.once.bind(events);
ReturnType<typeof process.hrtime> export const removeListener: typeof events['removeListener'] = events.removeListener.bind(
> = {}; events,
private imageCleanupFailures: Dictionary<number> = {}; );
// A store of volatile state for images (e.g. download progress), indexed by imageId export const removeAllListeners: typeof events['removeAllListeners'] = events.removeAllListeners.bind(
private volatileState: { [imageId: number]: Image } = {}; events,
);
public constructor() { const imageFetchFailures: Dictionary<number> = {};
super(); const imageFetchLastFailureTime: Dictionary<ReturnType<
typeof process.hrtime
>> = {};
const imageCleanupFailures: Dictionary<number> = {};
// A store of volatile state for images (e.g. download progress), indexed by imageId
const volatileState: { [imageId: number]: Image } = {};
let appUpdatePollInterval: number;
export const initialized = (async () => {
await config.initialized;
appUpdatePollInterval = await config.get('appUpdatePollInterval');
config.on('change', (vals) => {
if (vals.appUpdatePollInterval != null) {
appUpdatePollInterval = vals.appUpdatePollInterval;
} }
});
})();
public async triggerFetch( export async function triggerFetch(
image: Image, image: Image,
opts: FetchOptions, opts: FetchOptions,
onFinish = _.noop, onFinish = _.noop,
serviceName: string, serviceName: string,
): Promise<null> { ): Promise<void> {
if (this.imageFetchFailures[image.name] != null) { if (imageFetchFailures[image.name] != null) {
// If we are retrying a pull within the backoff time of the last failure, // If we are retrying a pull within the backoff time of the last failure,
// we need to throw an error, which will be caught in the device-state // we need to throw an error, which will be caught in the device-state
// engine, and ensure that we wait a bit lnger // engine, and ensure that we wait a bit lnger
const minDelay = Math.min( const minDelay = Math.min(
2 ** this.imageFetchFailures[image.name] * constants.backoffIncrement, 2 ** imageFetchFailures[image.name] * constants.backoffIncrement,
this.appUpdatePollInterval, appUpdatePollInterval,
); );
const timeSinceLastError = process.hrtime( const timeSinceLastError = process.hrtime(
this.imageFetchLastFailureTime[image.name], imageFetchLastFailureTime[image.name],
); );
const timeSinceLastErrorMs = const timeSinceLastErrorMs =
timeSinceLastError[0] * 1000 + timeSinceLastError[1] / 1e6; timeSinceLastError[0] * 1000 + timeSinceLastError[1] / 1e6;
@ -93,8 +112,8 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
const onProgress = (progress: FetchProgressEvent) => { const onProgress = (progress: FetchProgressEvent) => {
// Only report the percentage if we haven't finished fetching // Only report the percentage if we haven't finished fetching
if (this.volatileState[image.imageId] != null) { if (volatileState[image.imageId] != null) {
this.reportChange(image.imageId, { reportChange(image.imageId, {
downloadProgress: progress.percentage, downloadProgress: progress.percentage,
}); });
} }
@ -102,25 +121,25 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
let success: boolean; let success: boolean;
try { try {
const imageName = await this.normalise(image.name); const imageName = await normalise(image.name);
image = _.clone(image); image = _.clone(image);
image.name = imageName; image.name = imageName;
await this.markAsSupervised(image); await markAsSupervised(image);
const img = await this.inspectByName(image.name); const img = await inspectByName(image.name);
await db.models('image').update({ dockerImageId: img.Id }).where(image); await db.models('image').update({ dockerImageId: img.Id }).where(image);
onFinish(true); onFinish(true);
return null; return;
} catch (e) { } catch (e) {
if (!NotFoundError(e)) { if (!NotFoundError(e)) {
if (!(e instanceof ImageDownloadBackoffError)) { if (!(e instanceof ImageDownloadBackoffError)) {
this.addImageFailure(image.name); addImageFailure(image.name);
} }
throw e; throw e;
} }
this.reportChange( reportChange(
image.imageId, image.imageId,
_.merge(_.clone(image), { status: 'Downloading', downloadProgress: 0 }), _.merge(_.clone(image), { status: 'Downloading', downloadProgress: 0 }),
); );
@ -128,17 +147,17 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
try { try {
let id; let id;
if (opts.delta && (opts as DeltaFetchOptions).deltaSource != null) { if (opts.delta && (opts as DeltaFetchOptions).deltaSource != null) {
id = await this.fetchDelta(image, opts, onProgress, serviceName); id = await fetchDelta(image, opts, onProgress, serviceName);
} else { } else {
id = await this.fetchImage(image, opts, onProgress); id = await fetchImage(image, opts, onProgress);
} }
await db.models('image').update({ dockerImageId: id }).where(image); await db.models('image').update({ dockerImageId: id }).where(image);
logger.logSystemEvent(LogTypes.downloadImageSuccess, { image }); logger.logSystemEvent(LogTypes.downloadImageSuccess, { image });
success = true; success = true;
delete this.imageFetchFailures[image.name]; delete imageFetchFailures[image.name];
delete this.imageFetchLastFailureTime[image.name]; delete imageFetchLastFailureTime[image.name];
} catch (err) { } catch (err) {
if (err instanceof DeltaStillProcessingError) { if (err instanceof DeltaStillProcessingError) {
// If this is a delta image pull, and the delta still hasn't finished generating, // If this is a delta image pull, and the delta still hasn't finished generating,
@ -146,7 +165,7 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
// processing // processing
logger.logSystemEvent(LogTypes.deltaStillProcessingError, {}); logger.logSystemEvent(LogTypes.deltaStillProcessingError, {});
} else { } else {
this.addImageFailure(image.name); addImageFailure(image.name);
logger.logSystemEvent(LogTypes.downloadImageError, { logger.logSystemEvent(LogTypes.downloadImageError, {
image, image,
error: err, error: err,
@ -156,14 +175,13 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
} }
} }
this.reportChange(image.imageId); reportChange(image.imageId);
onFinish(success); onFinish(success);
return null; }
}
public async remove(image: Image): Promise<void> { export async function remove(image: Image): Promise<void> {
try { try {
await this.removeImageIfNotNeeded(image); await removeImageIfNotNeeded(image);
} catch (e) { } catch (e) {
logger.logSystemEvent(LogTypes.deleteImageError, { logger.logSystemEvent(LogTypes.deleteImageError, {
image, image,
@ -171,94 +189,93 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
}); });
throw e; throw e;
} }
} }
public async getByDockerId(id: string): Promise<Image> { export function getByDockerId(id: string): Promise<Image> {
return await db.models('image').where({ dockerImageId: id }).first(); return db.models('image').where({ dockerImageId: id }).first();
} }
public async removeByDockerId(id: string): Promise<void> { export async function removeByDockerId(id: string): Promise<void> {
const image = await this.getByDockerId(id); const image = await getByDockerId(id);
await this.remove(image); await remove(image);
} }
private async getNormalisedTags(image: Docker.ImageInfo): Promise<string[]> { export async function getNormalisedTags(
image: Docker.ImageInfo,
): Promise<string[]> {
return await Bluebird.map( return await Bluebird.map(
image.RepoTags != null ? image.RepoTags : [], image.RepoTags != null ? image.RepoTags : [],
this.normalise.bind(this), normalise,
); );
} }
private async withImagesFromDockerAndDB<T>( async function withImagesFromDockerAndDB<T>(
cb: (dockerImages: NormalisedDockerImage[], composeImages: Image[]) => T, cb: (dockerImages: NormalisedDockerImage[], composeImages: Image[]) => T,
) { ) {
const [normalisedImages, dbImages] = await Promise.all([ const [normalisedImages, dbImages] = await Promise.all([
Bluebird.map(docker.listImages({ digests: true }), async (image) => { Bluebird.map(docker.listImages({ digests: true }), async (image) => {
const newImage = _.clone(image) as NormalisedDockerImage; const newImage = _.clone(image) as NormalisedDockerImage;
newImage.NormalisedRepoTags = await this.getNormalisedTags(image); newImage.NormalisedRepoTags = await getNormalisedTags(image);
return newImage; return newImage;
}), }),
db.models('image').select(), db.models('image').select(),
]); ]);
return cb(normalisedImages, dbImages); return cb(normalisedImages, dbImages);
} }
private addImageFailure(imageName: string, time = process.hrtime()) { function addImageFailure(imageName: string, time = process.hrtime()) {
this.imageFetchLastFailureTime[imageName] = time; imageFetchLastFailureTime[imageName] = time;
this.imageFetchFailures[imageName] = imageFetchFailures[imageName] =
this.imageFetchFailures[imageName] != null imageFetchFailures[imageName] != null
? this.imageFetchFailures[imageName] + 1 ? imageFetchFailures[imageName] + 1
: 1; : 1;
} }
private matchesTagOrDigest( function matchesTagOrDigest(
image: Image, image: Image,
dockerImage: NormalisedDockerImage, dockerImage: NormalisedDockerImage,
): boolean { ): boolean {
return ( return (
_.includes(dockerImage.NormalisedRepoTags, image.name) || _.includes(dockerImage.NormalisedRepoTags, image.name) ||
_.some(dockerImage.RepoDigests, (digest) => _.some(dockerImage.RepoDigests, (digest) =>
Images.hasSameDigest(image.name, digest), hasSameDigest(image.name, digest),
) )
); );
} }
private isAvailableInDocker( function isAvailableInDocker(
image: Image, image: Image,
dockerImages: NormalisedDockerImage[], dockerImages: NormalisedDockerImage[],
): boolean { ): boolean {
return _.some( return _.some(
dockerImages, dockerImages,
(dockerImage) => (dockerImage) =>
this.matchesTagOrDigest(image, dockerImage) || matchesTagOrDigest(image, dockerImage) ||
image.dockerImageId === dockerImage.Id, image.dockerImageId === dockerImage.Id,
); );
} }
public async getAvailable(): Promise<Image[]> { export async function getAvailable(): Promise<Image[]> {
const images = await this.withImagesFromDockerAndDB( return withImagesFromDockerAndDB((dockerImages, supervisedImages) =>
(dockerImages, supervisedImages) =>
_.filter(supervisedImages, (image) => _.filter(supervisedImages, (image) =>
this.isAvailableInDocker(image, dockerImages), isAvailableInDocker(image, dockerImages),
), ),
); );
}
return images; // TODO: Why does this need a Bluebird.try?
} export function getDownloadingImageIds() {
// TODO: Why does this need a Bluebird.try?
public getDownloadingImageIds() {
return Bluebird.try(() => return Bluebird.try(() =>
_(this.volatileState) _(volatileState)
.pickBy({ status: 'Downloading' }) .pickBy({ status: 'Downloading' })
.keys() .keys()
.map(validation.checkInt) .map(validation.checkInt)
.value(), .value(),
); );
} }
public async cleanupDatabase(): Promise<void> { export async function cleanupDatabase(): Promise<void> {
const imagesToRemove = await this.withImagesFromDockerAndDB( const imagesToRemove = await withImagesFromDockerAndDB(
async (dockerImages, supervisedImages) => { async (dockerImages, supervisedImages) => {
for (const supervisedImage of supervisedImages) { for (const supervisedImage of supervisedImages) {
// If the supervisor was interrupted between fetching an image and storing its id, // If the supervisor was interrupted between fetching an image and storing its id,
@ -266,7 +283,7 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
if (supervisedImage.dockerImageId == null) { if (supervisedImage.dockerImageId == null) {
const id = _.get( const id = _.get(
_.find(dockerImages, (dockerImage) => _.find(dockerImages, (dockerImage) =>
this.matchesTagOrDigest(supervisedImage, dockerImage), matchesTagOrDigest(supervisedImage, dockerImage),
), ),
'Id', 'Id',
); );
@ -281,46 +298,46 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
} }
} }
return _.reject(supervisedImages, (image) => return _.reject(supervisedImages, (image) =>
this.isAvailableInDocker(image, dockerImages), isAvailableInDocker(image, dockerImages),
); );
}, },
); );
const ids = _(imagesToRemove).map('id').compact().value(); const ids = _(imagesToRemove).map('id').compact().value();
await db.models('image').del().whereIn('id', ids); await db.models('image').del().whereIn('id', ids);
} }
public async getStatus() { export const getStatus = async () => {
const images = await this.getAvailable(); const images = await getAvailable();
for (const image of images) { for (const image of images) {
image.status = 'Downloaded'; image.status = 'Downloaded';
image.downloadProgress = null; image.downloadProgress = null;
} }
const status = _.clone(this.volatileState); const status = _.clone(volatileState);
for (const image of images) { for (const image of images) {
if (status[image.imageId] == null) { if (status[image.imageId] == null) {
status[image.imageId] = image; status[image.imageId] = image;
} }
} }
return _.values(status); return _.values(status);
} };
public async update(image: Image): Promise<void> { export async function update(image: Image): Promise<void> {
const formattedImage = this.format(image); const formattedImage = format(image);
await db await db
.models('image') .models('image')
.update(formattedImage) .update(formattedImage)
.where({ name: formattedImage.name }); .where({ name: formattedImage.name });
} }
public async save(image: Image): Promise<void> { export const save = async (image: Image): Promise<void> => {
const img = await this.inspectByName(image.name); const img = await inspectByName(image.name);
image = _.clone(image); image = _.clone(image);
image.dockerImageId = img.Id; image.dockerImageId = img.Id;
await this.markAsSupervised(image); await markAsSupervised(image);
} };
private async getImagesForCleanup(): Promise<string[]> { async function getImagesForCleanup(): Promise<string[]> {
const images: string[] = []; const images: string[] = [];
const [ const [
@ -360,12 +377,9 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
const dockerImages = await docker.listImages({ digests: true }); const dockerImages = await docker.listImages({ digests: true });
for (const image of dockerImages) { for (const image of dockerImages) {
// Cleanup should remove truly dangling images (i.e dangling and with no digests) // Cleanup should remove truly dangling images (i.e dangling and with no digests)
if (Images.isDangling(image) && !_.includes(usedImageIds, image.Id)) { if (isDangling(image) && !_.includes(usedImageIds, image.Id)) {
images.push(image.Id); images.push(image.Id);
} else if ( } else if (!_.isEmpty(image.RepoTags) && image.Id !== supervisorImage.Id) {
!_.isEmpty(image.RepoTags) &&
image.Id !== supervisorImage.Id
) {
// We also remove images from the supervisor repository with a different tag // We also remove images from the supervisor repository with a different tag
for (const tag of image.RepoTags) { for (const tag of image.RepoTags) {
const imageNameComponents = await dockerToolbelt.getRegistryAndName( const imageNameComponents = await dockerToolbelt.getRegistryAndName(
@ -382,16 +396,16 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
.uniq() .uniq()
.filter( .filter(
(image) => (image) =>
this.imageCleanupFailures[image] == null || imageCleanupFailures[image] == null ||
Date.now() - this.imageCleanupFailures[image] > Date.now() - imageCleanupFailures[image] >
constants.imageCleanupErrorIgnoreTimeout, constants.imageCleanupErrorIgnoreTimeout,
) )
.value(); .value();
} }
public async inspectByName( export async function inspectByName(
imageName: string, imageName: string,
): Promise<Docker.ImageInspectInfo> { ): Promise<Docker.ImageInspectInfo> {
try { try {
return await docker.getImage(imageName).inspect(); return await docker.getImage(imageName).inspect();
} catch (e) { } catch (e) {
@ -417,63 +431,60 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
} }
throw e; throw e;
} }
} }
public async isCleanupNeeded() { export async function isCleanupNeeded() {
return !_.isEmpty(await this.getImagesForCleanup()); return !_.isEmpty(await getImagesForCleanup());
} }
public async cleanup() { export async function cleanup() {
const images = await this.getImagesForCleanup(); const images = await getImagesForCleanup();
for (const image of images) { for (const image of images) {
log.debug(`Cleaning up ${image}`); log.debug(`Cleaning up ${image}`);
try { try {
await docker.getImage(image).remove({ force: true }); await docker.getImage(image).remove({ force: true });
delete this.imageCleanupFailures[image]; delete imageCleanupFailures[image];
} catch (e) { } catch (e) {
logger.logSystemMessage( logger.logSystemMessage(
`Error cleaning up ${image}: ${e.message} - will ignore for 1 hour`, `Error cleaning up ${image}: ${e.message} - will ignore for 1 hour`,
{ error: e }, { error: e },
'Image cleanup error', 'Image cleanup error',
); );
this.imageCleanupFailures[image] = Date.now(); imageCleanupFailures[image] = Date.now();
}
} }
} }
}
public static isSameImage( export function isSameImage(
image1: Pick<Image, 'name'>, image1: Pick<Image, 'name'>,
image2: Pick<Image, 'name'>, image2: Pick<Image, 'name'>,
): boolean { ): boolean {
return ( return image1.name === image2.name || hasSameDigest(image1.name, image2.name);
image1.name === image2.name || }
Images.hasSameDigest(image1.name, image2.name)
);
}
public normalise(imageName: string): Bluebird<string> { export function normalise(imageName: string): Bluebird<string> {
return dockerToolbelt.normaliseImageName(imageName); return dockerToolbelt.normaliseImageName(imageName);
} }
private static isDangling(image: Docker.ImageInfo): boolean { function isDangling(image: Docker.ImageInfo): boolean {
return ( return (
(_.isEmpty(image.RepoTags) || (_.isEmpty(image.RepoTags) ||
_.isEqual(image.RepoTags, ['<none>:<none>'])) && _.isEqual(image.RepoTags, ['<none>:<none>'])) &&
(_.isEmpty(image.RepoDigests) || (_.isEmpty(image.RepoDigests) ||
_.isEqual(image.RepoDigests, ['<none>@<none>'])) _.isEqual(image.RepoDigests, ['<none>@<none>']))
); );
} }
private static hasSameDigest( function hasSameDigest(
name1: Nullable<string>, name1: Nullable<string>,
name2: Nullable<string>, name2: Nullable<string>,
): boolean { ): boolean {
const hash1 = name1 != null ? name1.split('@')[1] : null; const hash1 = name1 != null ? name1.split('@')[1] : null;
const hash2 = name2 != null ? name2.split('@')[1] : null; const hash2 = name2 != null ? name2.split('@')[1] : null;
return hash1 != null && hash1 === hash2; return hash1 != null && hash1 === hash2;
} }
private async removeImageIfNotNeeded(image: Image): Promise<void> { async function removeImageIfNotNeeded(image: Image): Promise<void> {
let removed: boolean; let removed: boolean;
// We first fetch the image from the DB to ensure it exists, // We first fetch the image from the DB to ensure it exists,
@ -498,20 +509,18 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
.select(); .select();
if ( if (
imagesFromDb.length === 1 && imagesFromDb.length === 1 &&
_.isEqual(this.format(imagesFromDb[0]), this.format(img)) _.isEqual(format(imagesFromDb[0]), format(img))
) { ) {
this.reportChange( reportChange(
image.imageId, image.imageId,
_.merge(_.clone(image), { status: 'Deleting' }), _.merge(_.clone(image), { status: 'Deleting' }),
); );
logger.logSystemEvent(LogTypes.deleteImage, { image }); logger.logSystemEvent(LogTypes.deleteImage, { image });
docker.getImage(img.dockerImageId).remove({ force: true }); docker.getImage(img.dockerImageId).remove({ force: true });
removed = true; removed = true;
} else if (!Images.hasDigest(img.name)) { } else if (!hasDigest(img.name)) {
// Image has a regular tag, so we might have to remove unnecessary tags // Image has a regular tag, so we might have to remove unnecessary tags
const dockerImage = await docker const dockerImage = await docker.getImage(img.dockerImageId).inspect();
.getImage(img.dockerImageId)
.inspect();
const differentTags = _.reject(imagesFromDb, { name: img.name }); const differentTags = _.reject(imagesFromDb, { name: img.name });
if ( if (
@ -535,7 +544,7 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
throw e; throw e;
} }
} finally { } finally {
this.reportChange(image.imageId); reportChange(image.imageId);
} }
await db.models('image').del().where({ id: img.id }); await db.models('image').del().where({ id: img.id });
@ -543,10 +552,10 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
if (removed) { if (removed) {
logger.logSystemEvent(LogTypes.deleteImageSuccess, { image }); logger.logSystemEvent(LogTypes.deleteImageSuccess, { image });
} }
} }
private async markAsSupervised(image: Image): Promise<void> { async function markAsSupervised(image: Image): Promise<void> {
const formattedImage = this.format(image); const formattedImage = format(image);
await db.upsertModel( await db.upsertModel(
'image', 'image',
formattedImage, formattedImage,
@ -554,9 +563,9 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
// and currently acts like an "insert if not exists" // and currently acts like an "insert if not exists"
formattedImage, formattedImage,
); );
} }
private format(image: Image): Omit<Image, 'id'> { function format(image: Image): Omit<Image, 'id'> {
return _(image) return _(image)
.defaults({ .defaults({
serviceId: null, serviceId: null,
@ -568,18 +577,18 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
}) })
.omit('id') .omit('id')
.value(); .value();
} }
private async fetchDelta( async function fetchDelta(
image: Image, image: Image,
opts: FetchOptions, opts: FetchOptions,
onProgress: (evt: FetchProgressEvent) => void, onProgress: (evt: FetchProgressEvent) => void,
serviceName: string, serviceName: string,
): Promise<string> { ): Promise<string> {
logger.logSystemEvent(LogTypes.downloadImageDelta, { image }); logger.logSystemEvent(LogTypes.downloadImageDelta, { image });
const deltaOpts = (opts as unknown) as DeltaFetchOptions; const deltaOpts = (opts as unknown) as DeltaFetchOptions;
const srcImage = await this.inspectByName(deltaOpts.deltaSource); const srcImage = await inspectByName(deltaOpts.deltaSource);
deltaOpts.deltaSourceId = srcImage.Id; deltaOpts.deltaSourceId = srcImage.Id;
const id = await dockerUtils.fetchDeltaWithProgress( const id = await dockerUtils.fetchDeltaWithProgress(
@ -589,47 +598,44 @@ export class Images extends (EventEmitter as new () => ImageEventEmitter) {
serviceName, serviceName,
); );
if (!Images.hasDigest(image.name)) { if (!hasDigest(image.name)) {
const { repo, tag } = await dockerUtils.getRepoAndTag(image.name); const { repo, tag } = await dockerUtils.getRepoAndTag(image.name);
await docker.getImage(id).tag({ repo, tag }); await docker.getImage(id).tag({ repo, tag });
} }
return id; return id;
} }
private fetchImage( function fetchImage(
image: Image, image: Image,
opts: FetchOptions, opts: FetchOptions,
onProgress: (evt: FetchProgressEvent) => void, onProgress: (evt: FetchProgressEvent) => void,
): Promise<string> { ): Promise<string> {
logger.logSystemEvent(LogTypes.downloadImage, { image }); logger.logSystemEvent(LogTypes.downloadImage, { image });
return dockerUtils.fetchImageWithProgress(image.name, opts, onProgress); return dockerUtils.fetchImageWithProgress(image.name, opts, onProgress);
} }
// TODO: find out if imageId can actually be null // TODO: find out if imageId can actually be null
private reportChange(imageId: Nullable<number>, status?: Partial<Image>) { function reportChange(imageId: Nullable<number>, status?: Partial<Image>) {
if (imageId == null) { if (imageId == null) {
return; return;
} }
if (status != null) { if (status != null) {
if (this.volatileState[imageId] == null) { if (volatileState[imageId] == null) {
this.volatileState[imageId] = { imageId } as Image; volatileState[imageId] = { imageId } as Image;
}
_.merge(this.volatileState[imageId], status);
return this.emit('change');
} else if (this.volatileState[imageId] != null) {
delete this.volatileState[imageId];
return this.emit('change');
} }
_.merge(volatileState[imageId], status);
return events.emit('change');
} else if (volatileState[imageId] != null) {
delete volatileState[imageId];
return events.emit('change');
} }
}
private static hasDigest(name: Nullable<string>): boolean { function hasDigest(name: Nullable<string>): boolean {
if (name == null) { if (name == null) {
return false; return false;
} }
const parts = name.split('@'); const parts = name.split('@');
return parts[1] != null; return parts[1] != null;
}
} }
export default Images;

View File

@ -8,6 +8,7 @@ import Volume from '../compose/volume';
import * as config from '../config'; import * as config from '../config';
import * as db from '../db'; import * as db from '../db';
import * as logger from '../logger'; import * as logger from '../logger';
import * as images from '../compose/images';
import { spawnJournalctl } from '../lib/journald'; import { spawnJournalctl } from '../lib/journald';
import { import {
appNotFoundMessage, appNotFoundMessage,
@ -152,11 +153,11 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
// maybe refactor this code // maybe refactor this code
Bluebird.join( Bluebird.join(
applications.services.getStatus(), applications.services.getStatus(),
applications.images.getStatus(), images.getStatus(),
db.models('app').select(['appId', 'commit', 'name']), db.models('app').select(['appId', 'commit', 'name']),
( (
services, services,
images, imgs,
apps: Array<{ appId: string; commit: string; name: string }>, apps: Array<{ appId: string; commit: string; name: string }>,
) => { ) => {
// Create an object which is keyed my application name // Create an object which is keyed my application name
@ -187,7 +188,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
appNameById[appId] = app.name; appNameById[appId] = app.name;
}); });
images.forEach((img) => { imgs.forEach((img) => {
const appName = appNameById[img.appId]; const appName = appNameById[img.appId];
if (appName == null) { if (appName == null) {
log.warn( log.warn(
@ -406,7 +407,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
let downloadProgressTotal = 0; let downloadProgressTotal = 0;
let downloads = 0; let downloads = 0;
const imagesStates = (await applications.images.getStatus()).map((img) => { const imagesStates = (await images.getStatus()).map((img) => {
if (img.downloadProgress != null) { if (img.downloadProgress != null) {
downloadProgressTotal += img.downloadProgress; downloadProgressTotal += img.downloadProgress;
downloads += 1; downloads += 1;

View File

@ -5,6 +5,7 @@ import { Image } from '../compose/images';
import DeviceState from '../device-state'; import DeviceState from '../device-state';
import * as config from '../config'; import * as config from '../config';
import * as eventTracker from '../event-tracker'; import * as eventTracker from '../event-tracker';
import * as images from '../compose/images';
import constants = require('../lib/constants'); import constants = require('../lib/constants');
import { AppsJsonParseError, EISDIR, ENOENT } from '../lib/errors'; import { AppsJsonParseError, EISDIR, ENOENT } from '../lib/errors';
@ -47,7 +48,7 @@ export async function loadTargetFromFile(
return; return;
} }
const images: Image[] = []; const imgs: Image[] = [];
const appIds = _.keys(preloadState.apps); const appIds = _.keys(preloadState.apps);
for (const appId of appIds) { for (const appId of appIds) {
const app = preloadState.apps[appId]; const app = preloadState.apps[appId];
@ -67,14 +68,14 @@ export async function loadTargetFromFile(
releaseId: app.releaseId, releaseId: app.releaseId,
appId, appId,
}; };
images.push(deviceState.applications.imageForService(svc)); imgs.push(deviceState.applications.imageForService(svc));
} }
} }
for (const image of images) { for (const image of imgs) {
const name = await deviceState.applications.images.normalise(image.name); const name = await images.normalise(image.name);
image.name = name; image.name = name;
await deviceState.applications.images.save(image); await images.save(image);
} }
const deviceConf = await deviceState.deviceConfig.getCurrent(); const deviceConf = await deviceState.deviceConfig.getCurrent();

View File

@ -14,6 +14,7 @@ import * as mkdirp from 'mkdirp';
import * as bodyParser from 'body-parser'; import * as bodyParser from 'body-parser';
import * as url from 'url'; import * as url from 'url';
import { normalise } from './compose/images';
import { log } from './lib/supervisor-console'; import { log } from './lib/supervisor-console';
import * as db from './db'; import * as db from './db';
import * as config from './config'; import * as config from './config';
@ -346,7 +347,7 @@ const createProxyvisorRouter = function (proxyvisor) {
}; };
export class Proxyvisor { export class Proxyvisor {
constructor({ images, applications }) { constructor({ applications }) {
this.bindToAPI = this.bindToAPI.bind(this); this.bindToAPI = this.bindToAPI.bind(this);
this.executeStepAction = this.executeStepAction.bind(this); this.executeStepAction = this.executeStepAction.bind(this);
this.getCurrentStates = this.getCurrentStates.bind(this); this.getCurrentStates = this.getCurrentStates.bind(this);
@ -362,7 +363,6 @@ export class Proxyvisor {
this.sendUpdate = this.sendUpdate.bind(this); this.sendUpdate = this.sendUpdate.bind(this);
this.sendDeleteHook = this.sendDeleteHook.bind(this); this.sendDeleteHook = this.sendDeleteHook.bind(this);
this.sendUpdates = this.sendUpdates.bind(this); this.sendUpdates = this.sendUpdates.bind(this);
this.images = images;
this.applications = applications; this.applications = applications;
this.acknowledgedState = {}; this.acknowledgedState = {};
this.lastRequestForDevice = {}; this.lastRequestForDevice = {};
@ -536,7 +536,7 @@ export class Proxyvisor {
normaliseDependentAppForDB(app) { normaliseDependentAppForDB(app) {
let image; let image;
if (app.image != null) { if (app.image != null) {
image = this.images.normalise(app.image); image = normalise(app.image);
} else { } else {
image = null; image = null;
} }

View File

@ -4,10 +4,12 @@ import * as _ from 'lodash';
import { SinonSpy, SinonStub, spy, stub } from 'sinon'; import { SinonSpy, SinonStub, spy, stub } from 'sinon';
import chai = require('./lib/chai-config'); import chai = require('./lib/chai-config');
import { StatusCodeError } from '../src/lib/errors';
import prepare = require('./lib/prepare'); import prepare = require('./lib/prepare');
import Log from '../src/lib/supervisor-console'; import Log from '../src/lib/supervisor-console';
import * as dockerUtils from '../src/lib/docker-utils'; import * as dockerUtils from '../src/lib/docker-utils';
import * as config from '../src/config'; import * as config from '../src/config';
import * as images from '../src/compose/images';
import { RPiConfigBackend } from '../src/config/backend'; import { RPiConfigBackend } from '../src/config/backend';
import DeviceState from '../src/device-state'; import DeviceState from '../src/device-state';
import { loadTargetFromFile } from '../src/device-state/preload'; import { loadTargetFromFile } from '../src/device-state/preload';
@ -209,6 +211,8 @@ const testTargetInvalid = {
describe('deviceState', () => { describe('deviceState', () => {
let deviceState: DeviceState; let deviceState: DeviceState;
const originalImagesSave = images.save;
const originalImagesInspect = images.inspectByName;
before(async () => { before(async () => {
await prepare(); await prepare();
@ -230,11 +234,15 @@ describe('deviceState', () => {
Promise.resolve('172.17.0.1'), Promise.resolve('172.17.0.1'),
); );
stub(deviceState.applications.images, 'inspectByName').callsFake(() => { // @ts-expect-error Assigning to a RO property
const err: any = new Error(); images.save = () => Promise.resolve();
// @ts-expect-error Assigning to a RO property
images.inspectByName = () => {
const err: StatusCodeError = new Error();
err.statusCode = 404; err.statusCode = 404;
return Promise.reject(err); return Promise.reject(err);
}); };
(deviceState as any).deviceConfig.configBackend = new RPiConfigBackend(); (deviceState as any).deviceConfig.configBackend = new RPiConfigBackend();
}); });
@ -242,12 +250,14 @@ describe('deviceState', () => {
after(() => { after(() => {
(Service as any).extendEnvVars.restore(); (Service as any).extendEnvVars.restore();
(dockerUtils.getNetworkGateway as sinon.SinonStub).restore(); (dockerUtils.getNetworkGateway as sinon.SinonStub).restore();
(deviceState.applications.images
.inspectByName as sinon.SinonStub).restore(); // @ts-expect-error Assigning to a RO property
images.save = originalImagesSave;
// @ts-expect-error Assigning to a RO property
images.inspectByName = originalImagesInspect;
}); });
it('loads a target state from an apps.json file and saves it as target state, then returns it', async () => { it('loads a target state from an apps.json file and saves it as target state, then returns it', async () => {
stub(deviceState.applications.images, 'save').returns(Promise.resolve());
stub(deviceState.deviceConfig, 'getCurrent').returns( stub(deviceState.deviceConfig, 'getCurrent').returns(
Promise.resolve(mockedInitialConfig), Promise.resolve(mockedInitialConfig),
); );
@ -272,13 +282,11 @@ describe('deviceState', () => {
JSON.parse(JSON.stringify(testTarget)), JSON.parse(JSON.stringify(testTarget)),
); );
} finally { } finally {
(deviceState.applications.images.save as sinon.SinonStub).restore();
(deviceState.deviceConfig.getCurrent as sinon.SinonStub).restore(); (deviceState.deviceConfig.getCurrent as sinon.SinonStub).restore();
} }
}); });
it('stores info for pinning a device after loading an apps.json with a pinDevice field', async () => { it('stores info for pinning a device after loading an apps.json with a pinDevice field', async () => {
stub(deviceState.applications.images, 'save').returns(Promise.resolve());
stub(deviceState.deviceConfig, 'getCurrent').returns( stub(deviceState.deviceConfig, 'getCurrent').returns(
Promise.resolve(mockedInitialConfig), Promise.resolve(mockedInitialConfig),
); );
@ -286,7 +294,6 @@ describe('deviceState', () => {
process.env.ROOT_MOUNTPOINT + '/apps-pin.json', process.env.ROOT_MOUNTPOINT + '/apps-pin.json',
deviceState, deviceState,
); );
(deviceState as any).applications.images.save.restore();
(deviceState as any).deviceConfig.getCurrent.restore(); (deviceState as any).deviceConfig.getCurrent.restore();
const pinned = await config.get('pinDevice'); const pinned = await config.get('pinDevice');
@ -306,8 +313,7 @@ describe('deviceState', () => {
const services: Service[] = []; const services: Service[] = [];
for (const service of testTarget.local.apps['1234'].services) { for (const service of testTarget.local.apps['1234'].services) {
const imageName = await (deviceState.applications const imageName = await images.normalise(service.image);
.images as any).normalise(service.image);
service.image = imageName; service.image = imageName;
(service as any).imageName = imageName; (service as any).imageName = imageName;
services.push( services.push(

View File

@ -8,6 +8,7 @@ import Service from '../src/compose/service';
import Volume from '../src/compose/volume'; import Volume from '../src/compose/volume';
import DeviceState from '../src/device-state'; import DeviceState from '../src/device-state';
import * as dockerUtils from '../src/lib/docker-utils'; import * as dockerUtils from '../src/lib/docker-utils';
import * as images from '../src/compose/images';
import chai = require('./lib/chai-config'); import chai = require('./lib/chai-config');
import prepare = require('./lib/prepare'); import prepare = require('./lib/prepare');
@ -123,14 +124,17 @@ const dependentDBFormat = {
}; };
describe('ApplicationManager', function () { describe('ApplicationManager', function () {
const originalInspectByName = images.inspectByName;
before(async function () { before(async function () {
await prepare(); await prepare();
this.deviceState = new DeviceState({ this.deviceState = new DeviceState({
apiBinder: null as any, apiBinder: null as any,
}); });
this.applications = this.deviceState.applications; this.applications = this.deviceState.applications;
stub(this.applications.images, 'inspectByName').callsFake((_imageName) =>
Bluebird.Promise.resolve({ // @ts-expect-error assigning to a RO property
images.inspectByName = () =>
Promise.resolve({
Config: { Config: {
Cmd: ['someCommand'], Cmd: ['someCommand'],
Entrypoint: ['theEntrypoint'], Entrypoint: ['theEntrypoint'],
@ -138,8 +142,8 @@ describe('ApplicationManager', function () {
Labels: {}, Labels: {},
Volumes: [], Volumes: [],
}, },
}), });
);
stub(dockerUtils, 'getNetworkGateway').returns( stub(dockerUtils, 'getNetworkGateway').returns(
Bluebird.Promise.resolve('172.17.0.1'), Bluebird.Promise.resolve('172.17.0.1'),
); );
@ -223,7 +227,8 @@ describe('ApplicationManager', function () {
); );
after(function () { after(function () {
this.applications.images.inspectByName.restore(); // @ts-expect-error Assigning to a RO property
images.inspectByName = originalInspectByName;
// @ts-expect-error restore on non-stubbed type // @ts-expect-error restore on non-stubbed type
dockerUtils.getNetworkGateway.restore(); dockerUtils.getNetworkGateway.restore();
// @ts-expect-error restore on non-stubbed type // @ts-expect-error restore on non-stubbed type

View File

@ -5,6 +5,7 @@ import * as supertest from 'supertest';
import APIBinder from '../src/api-binder'; import APIBinder from '../src/api-binder';
import DeviceState from '../src/device-state'; import DeviceState from '../src/device-state';
import Log from '../src/lib/supervisor-console'; import Log from '../src/lib/supervisor-console';
import * as images from '../src/compose/images';
import SupervisorAPI from '../src/supervisor-api'; import SupervisorAPI from '../src/supervisor-api';
import sampleResponses = require('./data/device-api-responses.json'); import sampleResponses = require('./data/device-api-responses.json');
import mockedAPI = require('./lib/mocked-device-api'); import mockedAPI = require('./lib/mocked-device-api');
@ -21,6 +22,7 @@ describe('SupervisorAPI', () => {
let api: SupervisorAPI; let api: SupervisorAPI;
let healthCheckStubs: SinonStub[]; let healthCheckStubs: SinonStub[];
const request = supertest(`http://127.0.0.1:${mockedOptions.listenPort}`); const request = supertest(`http://127.0.0.1:${mockedOptions.listenPort}`);
const originalGetStatus = images.getStatus;
before(async () => { before(async () => {
// Stub health checks so we can modify them whenever needed // Stub health checks so we can modify them whenever needed
@ -31,6 +33,10 @@ describe('SupervisorAPI', () => {
// The mockedAPI contains stubs that might create unexpected results // The mockedAPI contains stubs that might create unexpected results
// See the module to know what has been stubbed // See the module to know what has been stubbed
api = await mockedAPI.create(); api = await mockedAPI.create();
// @ts-expect-error assigning to a RO property
images.getStatus = () => Promise.resolve([]);
// Start test API // Start test API
return api.listen( return api.listen(
ALLOWED_INTERFACES, ALLOWED_INTERFACES,
@ -51,6 +57,9 @@ describe('SupervisorAPI', () => {
healthCheckStubs.forEach((hc) => hc.restore); healthCheckStubs.forEach((hc) => hc.restore);
// Remove any test data generated // Remove any test data generated
await mockedAPI.cleanUp(); await mockedAPI.cleanUp();
// @ts-expect-error assigning to a RO property
images.getStatus = originalGetStatus;
}); });
describe('/ping', () => { describe('/ping', () => {

View File

@ -3,7 +3,6 @@ import { fs } from 'mz';
import { stub } from 'sinon'; import { stub } from 'sinon';
import { ApplicationManager } from '../../src/application-manager'; import { ApplicationManager } from '../../src/application-manager';
import { Images } from '../../src/compose/images';
import { NetworkManager } from '../../src/compose/network-manager'; import { NetworkManager } from '../../src/compose/network-manager';
import { ServiceManager } from '../../src/compose/service-manager'; import { ServiceManager } from '../../src/compose/service-manager';
import { VolumeManager } from '../../src/compose/volume-manager'; import { VolumeManager } from '../../src/compose/volume-manager';
@ -136,7 +135,6 @@ function buildRoutes(appManager: ApplicationManager): Router {
function setupStubs() { function setupStubs() {
stub(ServiceManager.prototype, 'getStatus').resolves(STUBBED_VALUES.services); stub(ServiceManager.prototype, 'getStatus').resolves(STUBBED_VALUES.services);
stub(Images.prototype, 'getStatus').resolves(STUBBED_VALUES.images);
stub(NetworkManager.prototype, 'getAllByAppId').resolves( stub(NetworkManager.prototype, 'getAllByAppId').resolves(
STUBBED_VALUES.networks, STUBBED_VALUES.networks,
); );
@ -147,7 +145,6 @@ function setupStubs() {
function restoreStubs() { function restoreStubs() {
(ServiceManager.prototype as any).getStatus.restore(); (ServiceManager.prototype as any).getStatus.restore();
(Images.prototype as any).getStatus.restore();
(NetworkManager.prototype as any).getAllByAppId.restore(); (NetworkManager.prototype as any).getAllByAppId.restore();
(VolumeManager.prototype as any).getAllByAppId.restore(); (VolumeManager.prototype as any).getAllByAppId.restore();
} }