Rich Bayliss 96c68166a1
application-manager: Convert to a singleton
Change-type: patch
Signed-off-by: Rich Bayliss <rich@balena.io>
Signed-off-by: Cameron Diver <cameron@balena.io>
2020-09-14 11:23:36 +01:00

803 lines
22 KiB
TypeScript

import * as _ from 'lodash';
import { promises as fs } from 'fs';
import * as path from 'path';
import Network from './network';
import Volume from './volume';
import Service from './service';
import * as imageManager from './images';
import type { Image } from './images';
import * as applicationManager from './application-manager';
import {
CompositionStep,
generateStep,
CompositionStepAction,
} from './composition-steps';
import * as targetStateCache from '../device-state/target-state-cache';
import * as dockerUtils from '../lib/docker-utils';
import constants = require('../lib/constants');
import { getStepsFromStrategy } from './update-strategies';
import { InternalInconsistencyError, NotFoundError } from '../lib/errors';
import * as config from '../config';
import { checkTruthy, checkString } from '../lib/validation';
import { ServiceComposeConfig, DeviceMetadata } from './types/service';
import { ImageInspectInfo } from 'dockerode';
import { pathExistsOnHost } from '../lib/fs-utils';
export interface AppConstructOpts {
appId: number;
appName?: string;
commit?: string;
releaseId?: number;
source?: string;
services: Service[];
volumes: Dictionary<Volume>;
networks: Dictionary<Network>;
}
export interface UpdateState {
localMode: boolean;
availableImages: Image[];
containerIds: Dictionary<string>;
downloading: number[];
}
interface ChangingPair<T> {
current?: T;
target?: T;
}
export class App {
public appId: number;
// When setting up an application from current state, these values are not available
public appName?: string;
public commit?: string;
public releaseId?: number;
public source?: string;
// Services are stored as an array, as at any one time we could have more than one
// service for a single service ID running (for example handover)
public services: Service[];
public networks: Dictionary<Network>;
public volumes: Dictionary<Volume>;
public constructor(opts: AppConstructOpts, public isTargetState: boolean) {
this.appId = opts.appId;
this.appName = opts.appName;
this.commit = opts.commit;
this.releaseId = opts.releaseId;
this.source = opts.source;
this.services = opts.services;
this.volumes = opts.volumes;
this.networks = opts.networks;
if (this.networks.default == null && isTargetState) {
// We always want a default network
this.networks.default = Network.fromComposeObject(
'default',
opts.appId,
{},
);
}
}
public nextStepsForAppUpdate(
state: UpdateState,
target: App,
): CompositionStep[] {
// Check to see if we need to polyfill in some "new" data for legacy services
this.migrateLegacy(target);
// Check for changes in the volumes. We don't remove any volumes until we remove an
// entire app
const volumeChanges = this.compareComponents(
this.volumes,
target.volumes,
false,
);
const networkChanges = this.compareComponents(
this.networks,
target.networks,
true,
);
let steps: CompositionStep[] = [];
// Any services which have died get a remove step
for (const service of this.services) {
if (service.status === 'Dead') {
steps.push(generateStep('remove', { current: service }));
}
}
const { removePairs, installPairs, updatePairs } = this.compareServices(
this.services,
target.services,
state.containerIds,
);
for (const { current: svc } of removePairs) {
// All removes get a kill action if they're not already stopping
if (svc!.status !== 'Stopping') {
steps.push(generateStep('kill', { current: svc! }));
} else {
steps.push(generateStep('noop', {}));
}
}
// For every service which needs to be updated, update via update strategy.
const servicePairs = updatePairs.concat(installPairs);
steps = steps.concat(
servicePairs
.map((pair) =>
this.generateStepsForService(pair, {
...state,
servicePairs: installPairs.concat(updatePairs),
targetApp: target,
networkPairs: networkChanges,
volumePairs: volumeChanges,
}),
)
.filter((step) => step != null) as CompositionStep[],
);
// Generate volume steps
steps = steps.concat(
this.generateStepsForComponent(volumeChanges, servicePairs, (v, svc) =>
// TODO: Volumes are stored without the appId prepended, but networks are stored
// with it prepended. Sort out this inequality
svc.config.volumes.includes(v.name),
),
);
// Generate network steps
steps = steps.concat(
this.generateStepsForComponent(
networkChanges,
servicePairs,
(n, svc) => `${this.appId}_${n.name}` in svc.config.networks,
),
);
if (
steps.length === 0 &&
target.commit != null &&
this.commit !== target.commit
) {
// TODO: The next PR should change this to support multiapp commit values
steps.push(generateStep('updateCommit', { target: target.commit }));
}
return steps;
}
public async stepsToRemoveApp(
state: Omit<UpdateState, 'availableImages'>,
): Promise<CompositionStep[]> {
if (Object.keys(this.services).length > 0) {
return Object.values(this.services).map((service) =>
generateStep('kill', { current: service }),
);
}
if (Object.keys(this.networks).length > 0) {
return Object.values(this.networks).map((network) =>
generateStep('removeNetwork', { current: network }),
);
}
// Don't remove volumes in local mode
if (!state.localMode) {
if (Object.keys(this.volumes).length > 0) {
return Object.values(this.volumes).map((volume) =>
generateStep('removeVolume', { current: volume }),
);
}
}
return [];
}
private migrateLegacy(target: App) {
const currentServices = Object.values(this.services);
const targetServices = Object.values(target.services);
if (
currentServices.length === 1 &&
targetServices.length === 1 &&
targetServices[0].serviceName === currentServices[0].serviceName &&
checkTruthy(
currentServices[0].config.labels['io.balena.legacy-container'],
)
) {
// This is a legacy preloaded app or container, so we didn't have things like serviceId.
// We hack a few things to avoid an unnecessary restart of the preloaded app
// (but ensuring it gets updated if it actually changed)
targetServices[0].config.labels['io.balena.legacy-container'] =
currentServices[0].config.labels['io.balena.legacy-container'];
targetServices[0].config.labels['io.balena.service-id'] =
currentServices[0].config.labels['io.balena.service-id'];
targetServices[0].serviceId = currentServices[0].serviceId;
}
}
private compareComponents<T extends { isEqualConfig(target: T): boolean }>(
current: Dictionary<T>,
target: Dictionary<T>,
// Should this function issue remove steps? (we don't want to for volumes)
generateRemoves: boolean,
): Array<ChangingPair<T>> {
const currentNames = _.keys(current);
const targetNames = _.keys(target);
const outputs: Array<{ current?: T; target?: T }> = [];
if (generateRemoves) {
for (const name of _.difference(currentNames, targetNames)) {
outputs.push({ current: current[name] });
}
}
for (const name of _.difference(targetNames, currentNames)) {
outputs.push({ target: target[name] });
}
const toBeUpdated = _.filter(
_.intersection(targetNames, currentNames),
(name) => !current[name].isEqualConfig(target[name]),
);
for (const name of toBeUpdated) {
outputs.push({ current: current[name], target: target[name] });
}
return outputs;
}
private compareServices(
current: Service[],
target: Service[],
containerIds: Dictionary<string>,
): {
installPairs: Array<ChangingPair<Service>>;
removePairs: Array<ChangingPair<Service>>;
updatePairs: Array<ChangingPair<Service>>;
} {
const currentByServiceId = _.keyBy(current, 'serviceId');
const targetByServiceId = _.keyBy(target, 'serviceId');
const currentServiceIds = Object.keys(currentByServiceId).map((i) =>
parseInt(i, 10),
);
const targetServiceIds = Object.keys(targetByServiceId).map((i) =>
parseInt(i, 10),
);
const toBeRemoved = _(currentServiceIds)
.difference(targetServiceIds)
.map((id) => ({ current: currentByServiceId[id] }))
.value();
const toBeInstalled = _(targetServiceIds)
.difference(currentServiceIds)
.map((id) => ({ target: targetByServiceId[id] }))
.value();
const maybeUpdate = _.intersection(targetServiceIds, currentServiceIds);
// Build up a list of services for a given service ID, always using the latest created
// service. Any older services will have kill steps emitted
for (const serviceId of maybeUpdate) {
const currentServiceContainers = _.filter(current, { serviceId });
if (currentServiceContainers.length > 1) {
currentByServiceId[serviceId] = _.maxBy(
currentServiceContainers,
'createdAt',
)!;
// All but the latest container for the service are spurious and should
// be removed
const otherContainers = _.without(
currentServiceContainers,
currentByServiceId[serviceId],
);
for (const service of otherContainers) {
toBeRemoved.push({ current: service });
}
} else {
currentByServiceId[serviceId] = currentServiceContainers[0];
}
}
const alreadyStarted = (serviceId: number) => {
const equalExceptForRunning = currentByServiceId[
serviceId
].isEqualExceptForRunningState(
targetByServiceId[serviceId],
containerIds,
);
if (!equalExceptForRunning) {
// We need to recreate the container, as the configuration has changed
return false;
}
if (targetByServiceId[serviceId].config.running) {
// If the container is already running, and we don't need to change it
// due to the config, we know it's already been started
return true;
}
// We recently ran a start step for this container, it just hasn't
// started running yet
return (
applicationManager.containerStarted[
currentByServiceId[serviceId].containerId!
] != null
);
};
const needUpdate = maybeUpdate.filter(
(serviceId) =>
!(
currentByServiceId[serviceId].isEqual(
targetByServiceId[serviceId],
containerIds,
) && alreadyStarted(serviceId)
),
);
const toBeUpdated = needUpdate.map((serviceId) => ({
current: currentByServiceId[serviceId],
target: targetByServiceId[serviceId],
}));
return {
installPairs: toBeInstalled,
removePairs: toBeRemoved,
updatePairs: toBeUpdated,
};
}
// We also accept a changingServices list, so we can avoid outputting multiple kill
// steps for a service
// FIXME: It would make the function simpler if we could just output the steps we want,
// and the nextStepsForAppUpdate function makes sure that we're not repeating steps.
// I'll leave it like this for now as this is how it was in application-manager.js, but
// it should be changed.
private generateStepsForComponent<T extends Volume | Network>(
components: Array<ChangingPair<T>>,
changingServices: Array<ChangingPair<Service>>,
dependencyFn: (component: T, service: Service) => boolean,
): CompositionStep[] {
if (components.length === 0) {
return [];
}
let steps: CompositionStep[] = [];
const actions: {
create: CompositionStepAction;
remove: CompositionStepAction;
} =
(components[0].current ?? components[0].target) instanceof Volume
? { create: 'createVolume', remove: 'removeVolume' }
: { create: 'createNetwork', remove: 'removeNetwork' };
for (const { current, target } of components) {
// If a current exists, we're either removing it or updating the configuration. In
// both cases, we must remove the component first, so we output those steps first.
// If we do remove the component, we first need to remove any services which depend
// on the component
if (current != null) {
// Find any services which are currently running which need to be killed when we
// recreate this component
const dependencies = _.filter(this.services, (s) =>
dependencyFn(current, s),
);
if (dependencies.length > 0) {
// We emit kill steps for these services, and wait to destroy the component in
// the next state application loop
// FIXME: We should add to the changingServices array, as we could emit several
// kill steps for a service
steps = steps.concat(
dependencies.reduce(
(acc, svc) =>
acc.concat(this.generateKillStep(svc, changingServices)),
[] as CompositionStep[],
),
);
} else {
steps = steps.concat([generateStep(actions.remove, { current })]);
}
} else if (target != null) {
steps = steps.concat([generateStep(actions.create, { target })]);
}
}
return steps;
}
private generateStepsForService(
{ current, target }: ChangingPair<Service>,
context: {
localMode: boolean;
availableImages: Image[];
downloading: number[];
targetApp: App;
containerIds: Dictionary<string>;
networkPairs: Array<ChangingPair<Network>>;
volumePairs: Array<ChangingPair<Volume>>;
servicePairs: Array<ChangingPair<Service>>;
},
): Nullable<CompositionStep> {
if (current?.status === 'Stopping') {
// Theres a kill step happening already, emit a noop to ensure we stay alive while
// this happens
return generateStep('noop', {});
}
if (current?.status === 'Dead') {
// A remove step will already have been generated, so we let the state
// application loop revisit this service, once the state has settled
return;
}
let needsDownload = false;
// don't attempt to fetch images whilst in local mode, as they should be there already
if (!context.localMode) {
needsDownload = !_.some(
context.availableImages,
(image) =>
image.dockerImageId === target?.config.image ||
imageManager.isSameImage(image, { name: target?.imageName! }),
);
}
if (needsDownload && context.downloading.includes(target?.imageId!)) {
// The image needs to be downloaded, and it's currently downloading. We simply keep
// the application loop alive
return generateStep('noop', {});
}
if (target && current?.isEqualConfig(target, context.containerIds)) {
// we're only starting/stopping a service
return this.generateContainerStep(current, target);
} else if (current == null) {
// Either this is a new service, or the current one has already been killed
return this.generateFetchOrStartStep(
target!,
needsDownload,
context.networkPairs,
context.volumePairs,
context.servicePairs,
);
} else {
if (!target) {
throw new InternalInconsistencyError(
'An empty changing pair passed to generateStepsForService',
);
}
const needsSpecialKill = this.serviceHasNetworkOrVolume(
current,
context.networkPairs,
context.volumePairs,
);
let strategy =
checkString(target.config.labels['io.balena.update.strategy']) || '';
const validStrategies = [
'download-then-kill',
'kill-then-download',
'delete-then-download',
'hand-over',
];
if (!validStrategies.includes(strategy)) {
strategy = 'download-then-kill';
}
const dependenciesMetForStart = this.dependenciesMetForServiceStart(
target,
context.networkPairs,
context.volumePairs,
context.servicePairs,
);
const dependenciesMetForKill = this.dependenciesMetForServiceKill(
target,
context.targetApp,
context.availableImages,
context.localMode,
);
return getStepsFromStrategy(strategy, {
current,
target,
needsDownload,
dependenciesMetForStart,
dependenciesMetForKill,
needsSpecialKill,
});
}
}
// We return an array from this function so the caller can just concatenate the arrays
// without worrying if the step is skipped or not
private generateKillStep(
service: Service,
changingServices: Array<ChangingPair<Service>>,
): CompositionStep[] {
if (
service.status !== 'Stopping' &&
!_.some(
changingServices,
({ current }) => current?.serviceId !== service.serviceId,
)
) {
return [generateStep('kill', { current: service })];
} else {
return [];
}
}
private serviceHasNetworkOrVolume(
svc: Service,
networkPairs: Array<ChangingPair<Network>>,
volumePairs: Array<ChangingPair<Volume>>,
): boolean {
const serviceVolumes = svc.config.volumes;
for (const { current } of volumePairs) {
if (current && serviceVolumes.includes(`${this.appId}_${current.name}`)) {
return true;
}
}
const serviceNetworks = svc.config.networks;
for (const { current } of networkPairs) {
if (current && `${this.appId}_${current.name}` in serviceNetworks) {
return true;
}
}
return false;
}
private generateContainerStep(current: Service, target: Service) {
// if the services release/image don't match, then rename the container...
if (
current.releaseId !== target.releaseId ||
current.imageId !== target.imageId
) {
return generateStep('updateMetadata', { current, target });
} else if (target.config.running !== current.config.running) {
if (target.config.running) {
return generateStep('start', { target });
} else {
return generateStep('stop', { current });
}
}
}
private generateFetchOrStartStep(
target: Service,
needsDownload: boolean,
networkPairs: Array<ChangingPair<Network>>,
volumePairs: Array<ChangingPair<Volume>>,
servicePairs: Array<ChangingPair<Service>>,
): CompositionStep | undefined {
if (needsDownload) {
// We know the service name exists as it always does for targets
return generateStep('fetch', {
image: imageManager.imageFromService(target),
serviceName: target.serviceName!,
});
} else if (
this.dependenciesMetForServiceStart(
target,
networkPairs,
volumePairs,
servicePairs,
)
) {
return generateStep('start', { target });
}
}
// TODO: account for volumes-from, networks-from, links, etc
// TODO: support networks instead of only network mode
private dependenciesMetForServiceStart(
target: Service,
networkPairs: Array<ChangingPair<Network>>,
volumePairs: Array<ChangingPair<Volume>>,
servicePairs: Array<ChangingPair<Service>>,
): boolean {
// Firstly we check if a dependency is not already running (this is
// different to a dependency which is in the servicePairs below, as these
// are services which are changing). We could have a dependency which is
// starting up, but is not yet running.
const depInstallingButNotRunning = _.some(this.services, (svc) => {
if (target.dependsOn?.includes(svc.serviceName!)) {
if (!svc.config.running) {
return true;
}
}
});
if (depInstallingButNotRunning) {
return false;
}
const depedencyUnmet = _.some(target.dependsOn, (dep) =>
_.some(servicePairs, (pair) => pair.target?.serviceName === dep),
);
if (depedencyUnmet) {
return false;
}
if (
_.some(
networkPairs,
(pair) =>
`${this.appId}_${pair.target?.name}` === target.config.networkMode,
)
) {
return false;
}
if (
_.some(target.config.volumes, (volumeDefinition) => {
const [sourceName, destName] = volumeDefinition.split(':');
if (destName == null) {
// If this is not a named volume, ignore it
return false;
}
if (sourceName[0] === '/') {
// Absolute paths should also be ignored
return false;
}
return _.some(
volumePairs,
(pair) => `${target.appId}_${pair.target?.name}` === sourceName,
);
})
) {
return false;
}
// everything is ready for the service to start...
return true;
}
// Unless the update strategy requires an early kill (i.e kill-then-download,
// delete-then-download), we only want to kill a service once the images for the
// services it depends on have been downloaded, so as to minimize downtime (but not
// block the killing too much, potentially causing a daedlock)
private dependenciesMetForServiceKill(
target: Service,
targetApp: App,
availableImages: Image[],
localMode: boolean,
) {
// because we only check for an image being available, in local mode this will always
// be the case, so return true regardless. If this function ever checks anything else,
// we'll need to change the logic here
if (localMode) {
return true;
}
if (target.dependsOn != null) {
for (const dependency of target.dependsOn) {
const dependencyService = _.find(targetApp.services, {
serviceName: dependency,
});
if (
!_.some(
availableImages,
(image) =>
image.dockerImageId === dependencyService?.imageId ||
imageManager.isSameImage(image, {
name: dependencyService?.imageName!,
}),
)
) {
return false;
}
}
}
return true;
}
public static async fromTargetState(
app: targetStateCache.DatabaseApp,
): Promise<App> {
const volumes = _.mapValues(JSON.parse(app.volumes) ?? {}, (conf, name) => {
if (conf == null) {
conf = {};
}
if (conf.labels == null) {
conf.labels = {};
}
return Volume.fromComposeObject(name, app.appId, conf);
});
const networks = _.mapValues(
JSON.parse(app.networks) ?? {},
(conf, name) => {
return Network.fromComposeObject(name, app.appId, conf ?? {});
},
);
const [
opts,
supervisorApiHost,
hostPathExists,
hostnameOnHost,
] = await Promise.all([
config.get('extendedEnvOptions'),
dockerUtils
.getNetworkGateway(constants.supervisorNetworkInterface)
.catch(() => '127.0.0.1'),
(async () => ({
firmware: await pathExistsOnHost('/lib/firmware'),
modules: await pathExistsOnHost('/lib/modules'),
}))(),
(async () =>
_.trim(
await fs.readFile(
path.join(constants.rootMountPoint, '/etc/hostname'),
'utf8',
),
))(),
]);
const svcOpts = {
appName: app.name,
supervisorApiHost,
hostPathExists,
hostnameOnHost,
...opts,
};
// In the db, the services are an array, but here we switch them to an
// object so that they are consistent
const services: Service[] = await Promise.all(
(JSON.parse(app.services) ?? []).map(
async (svc: ServiceComposeConfig) => {
// Try to fill the image id if the image is downloaded
let imageInfo: ImageInspectInfo | undefined;
try {
imageInfo = await imageManager.inspectByName(svc.image);
} catch (e) {
if (!NotFoundError(e)) {
throw e;
}
}
const thisSvcOpts = {
...svcOpts,
imageInfo,
serviceName: svc.serviceName,
};
// FIXME: Typings for DeviceMetadata
return Service.fromComposeObject(
svc,
(thisSvcOpts as unknown) as DeviceMetadata,
);
},
),
);
return new App(
{
appId: app.appId,
commit: app.commit,
releaseId: app.releaseId,
appName: app.name,
source: app.source,
services,
volumes,
networks,
},
true,
);
}
}
export default App;