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 | 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 { 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 { 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 { 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 { 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;