balena-supervisor/src/local-mode.ts
Cameron Diver aad20e2c2f Make docker module a singleton
Change-type: patch
Signed-off-by: Cameron Diver <cameron@balena.io>
2020-06-02 17:57:18 +01:00

317 lines
9.3 KiB
TypeScript

import * as Bluebird from 'bluebird';
import * as _ from 'lodash';
import * as config from './config';
import * as db from './db';
import * as constants from './lib/constants';
import { docker } from './lib/docker-utils';
import { SupervisorContainerNotFoundError } from './lib/errors';
import log from './lib/supervisor-console';
import { Logger } from './logger';
// EngineSnapshot represents a list of containers, images, volumens, and networks present on the engine.
// A snapshot is taken before entering local mode in order to perform cleanup when we exit local mode.
export class EngineSnapshot {
constructor(
public readonly containers: string[],
public readonly images: string[],
public readonly volumes: string[],
public readonly networks: string[],
) {}
public static fromJSON(json: string): EngineSnapshot {
const obj = JSON.parse(json);
return new EngineSnapshot(
obj.containers,
obj.images,
obj.volumes,
obj.networks,
);
}
// Builds a new snapshot object that contains entities present in another snapshot,
// but not present in this one.
public diff(another: EngineSnapshot): EngineSnapshot {
return new EngineSnapshot(
_.difference(this.containers, another.containers),
_.difference(this.images, another.images),
_.difference(this.volumes, another.volumes),
_.difference(this.networks, another.networks),
);
}
public toString(): string {
return (
`${this.containers.length} containers, ` +
`${this.images.length} images, ` +
`${this.volumes.length} volumes, ` +
`${this.networks.length} networks`
);
}
}
// Record in a database that stores EngineSnapshot.
export class EngineSnapshotRecord {
constructor(
public readonly snapshot: EngineSnapshot,
public readonly timestamp: Date,
) {}
}
/** Container name used to inspect own resources when container ID cannot be resolved. */
const SUPERVISOR_CONTAINER_NAME_FALLBACK = 'resin_supervisor';
/**
* This class handles any special cases necessary for switching
* modes in localMode.
*
* Currently this is needed because of inconsistencies in the way
* that the state machine handles local mode and normal operation.
* We should aim to remove these inconsistencies in the future.
*/
export class LocalModeManager {
public constructor(
public logger: Logger,
private containerId: string | undefined = constants.containerId,
) {}
// Indicates that switch from or to the local mode is not complete.
private switchInProgress: Bluebird<void> | null = null;
public async init() {
// Setup a listener to catch state changes relating to local mode
config.on('change', (changed) => {
if (changed.localMode != null) {
const local = changed.localMode || false;
// First switch the logger to it's correct state
this.logger.switchBackend(local);
this.startLocalModeChangeHandling(local);
}
});
// On startup, check if we're in unmanaged mode,
// as local mode needs to be set
let unmanagedLocalMode = false;
if (await config.get('unmanaged')) {
log.info('Starting up in unmanaged mode, activating local mode');
await config.set({ localMode: true });
unmanagedLocalMode = true;
}
const localMode =
// short circuit the next get if we know we're in local mode
unmanagedLocalMode || (await config.get('localMode'));
if (!localMode) {
// Remove any leftovers if necessary
await this.handleLocalModeStateChange(false);
}
}
public startLocalModeChangeHandling(local: boolean) {
this.switchInProgress = Bluebird.resolve(
this.handleLocalModeStateChange(local),
).finally(() => {
this.switchInProgress = null;
});
}
// Query the engine to get currently running containers and installed images.
public async collectEngineSnapshot(): Promise<EngineSnapshotRecord> {
const containersPromise = docker
.listContainers()
.then((resp) => _.map(resp, 'Id'));
const imagesPromise = docker.listImages().then((resp) => _.map(resp, 'Id'));
const volumesPromise = docker
.listVolumes()
.then((resp) => _.map(resp.Volumes, 'Name'));
const networksPromise = docker
.listNetworks()
.then((resp) => _.map(resp, 'Id'));
const [containers, images, volumes, networks] = await Bluebird.all([
containersPromise,
imagesPromise,
volumesPromise,
networksPromise,
]);
return new EngineSnapshotRecord(
new EngineSnapshot(containers, images, volumes, networks),
new Date(),
);
}
private async collectContainerResources(
nameOrId: string,
): Promise<EngineSnapshot> {
const inspectInfo = await docker.getContainer(nameOrId).inspect();
return new EngineSnapshot(
[inspectInfo.Id],
[inspectInfo.Image],
inspectInfo.Mounts.filter((m) => m.Name != null).map((m) => m.Name!),
_.map(inspectInfo.NetworkSettings.Networks, (n) => n.NetworkID),
);
}
// Determine what engine objects are linked to our own container.
private async collectOwnResources(): Promise<EngineSnapshot> {
try {
return this.collectContainerResources(
this.containerId || SUPERVISOR_CONTAINER_NAME_FALLBACK,
);
} catch (e) {
if (this.containerId !== undefined) {
// Inspect operation fails (container ID is out of sync?).
const fallback = SUPERVISOR_CONTAINER_NAME_FALLBACK;
log.warn(
'Supervisor container resources cannot be obtained by container ID. ' +
`Using '${fallback}' name instead.`,
e.message,
);
return this.collectContainerResources(fallback);
}
throw new SupervisorContainerNotFoundError(e);
}
}
private async cleanEngineSnapshots() {
await db.models('engineSnapshot').delete();
}
// Store engine snapshot data in the local database.
public async storeEngineSnapshot(record: EngineSnapshotRecord) {
const timestamp = record.timestamp.toISOString();
log.debug(
`Storing engine snapshot in the database. Timestamp: ${timestamp}`,
);
await this.cleanEngineSnapshots();
await db.models('engineSnapshot').insert({
snapshot: JSON.stringify(record.snapshot),
timestamp,
});
}
// Ensures an error is thrown id timestamp string cannot be parsed.
// Date.parse may both throw or return NaN depending on a case.
private static parseTimestamp(input: string): Date {
const ms = Date.parse(input);
if (isNaN(ms)) {
throw new Error('bad date string - got Nan parsing it');
}
return new Date(ms);
}
// Read the latest stored snapshot from the database.
public async retrieveLatestSnapshot(): Promise<EngineSnapshotRecord | null> {
const r = await db
.models('engineSnapshot')
.select()
.orderBy('rowid', 'DESC')
.first();
if (!r) {
return null;
}
try {
return new EngineSnapshotRecord(
EngineSnapshot.fromJSON(r.snapshot),
LocalModeManager.parseTimestamp(r.timestamp),
);
} catch (e) {
// Some parsing error happened. Ensure we add data details to the error description.
throw new Error(
`Cannot parse snapshot data ${JSON.stringify(r)}.` +
`Original message: [${e.message}].`,
);
}
}
private async removeLocalModeArtifacts(objects: EngineSnapshot) {
log.debug(`Going to delete the following objects: ${objects}`);
// Delete engine objects. We catch every deletion error, so that we can attempt other objects deletions.
await Bluebird.map(objects.containers, (cId) => {
return docker
.getContainer(cId)
.remove({ force: true })
.catch((e) => log.error(`Unable to delete container ${cId}`, e));
});
await Bluebird.map(objects.images, (iId) => {
return docker
.getImage(iId)
.remove({ force: true })
.catch((e) => log.error(`Unable to delete image ${iId}`, e));
});
await Bluebird.map(objects.networks, (nId) => {
return docker
.getNetwork(nId)
.remove()
.catch((e) => log.error(`Unable to delete network ${nId}`, e));
});
await Bluebird.map(objects.volumes, (vId) => {
return docker
.getVolume(vId)
.remove()
.catch((e) => log.error(`Unable to delete volume ${vId}`, e));
});
// Remove any local mode state added to the database.
await db
.models('app')
.del()
.where({ source: 'local' })
.catch((e) =>
log.error('Cannot delete local app entries in the database', e),
);
}
// Handle local mode state change.
// Input parameter is a target (new) state.
public async handleLocalModeStateChange(local: boolean) {
try {
const currentRecord = await this.collectEngineSnapshot();
if (local) {
return await this.storeEngineSnapshot(currentRecord);
}
const previousRecord = await this.retrieveLatestSnapshot();
if (!previousRecord) {
log.info('Previous engine snapshot was not stored. Skipping cleanup.');
return;
}
const supervisorResources = await this.collectOwnResources();
log.debug(`${supervisorResources} are linked to current supervisor`);
log.debug(
`Leaving local mode and cleaning up objects since ${previousRecord.timestamp.toISOString()}`,
);
await this.removeLocalModeArtifacts(
currentRecord.snapshot
.diff(previousRecord.snapshot)
.diff(supervisorResources),
);
await this.cleanEngineSnapshots();
} catch (e) {
log.error(
`Problems managing engine state on local mode switch. Local mode: ${local}.`,
e,
);
} finally {
log.debug('Handling of local mode switch is completed');
}
}
// Returns a promise to await local mode switch completion started previously.
public async switchCompletion() {
if (this.switchInProgress == null) {
return;
}
await this.switchInProgress;
}
}
export default LocalModeManager;