2019-11-09 18:38:41 +00:00
|
|
|
import * as Bluebird from 'bluebird';
|
2018-12-12 15:13:33 +00:00
|
|
|
import * as _ from 'lodash';
|
2019-11-09 18:38:41 +00:00
|
|
|
import * as mkdirp from 'mkdirp';
|
|
|
|
import { child_process, fs } from 'mz';
|
|
|
|
import * as path from 'path';
|
|
|
|
import * as rimraf from 'rimraf';
|
|
|
|
|
|
|
|
const mkdirpAsync = Bluebird.promisify(mkdirp);
|
|
|
|
const rimrafAsync = Bluebird.promisify(rimraf);
|
2018-12-12 15:13:33 +00:00
|
|
|
|
2020-07-21 16:25:47 +01:00
|
|
|
import * as apiBinder from '../api-binder';
|
2020-06-02 14:29:05 +01:00
|
|
|
import * as config from '../config';
|
2020-05-28 18:15:33 +01:00
|
|
|
import * as db from '../db';
|
2020-06-11 11:43:03 +01:00
|
|
|
import * as volumeManager from '../compose/volume-manager';
|
2020-06-15 11:31:26 +01:00
|
|
|
import * as serviceManager from '../compose/service-manager';
|
2020-07-21 16:25:47 +01:00
|
|
|
import * as deviceState from '../device-state';
|
2020-08-13 13:25:39 +01:00
|
|
|
import * as applicationManager from '../compose/application-manager';
|
2019-11-09 18:38:41 +00:00
|
|
|
import * as constants from '../lib/constants';
|
2020-07-21 16:25:47 +01:00
|
|
|
import {
|
|
|
|
BackupError,
|
|
|
|
DatabaseParseError,
|
|
|
|
NotFoundError,
|
|
|
|
InternalInconsistencyError,
|
|
|
|
} from '../lib/errors';
|
2020-06-02 17:56:58 +01:00
|
|
|
import { docker } from '../lib/docker-utils';
|
2019-11-09 18:38:41 +00:00
|
|
|
import { pathExistsOnHost } from '../lib/fs-utils';
|
2019-11-06 18:45:53 +00:00
|
|
|
import { log } from '../lib/supervisor-console';
|
2020-06-08 10:33:19 +01:00
|
|
|
import type {
|
2019-11-06 18:45:53 +00:00
|
|
|
AppsJsonFormat,
|
|
|
|
TargetApplication,
|
2019-11-09 18:38:41 +00:00
|
|
|
TargetState,
|
2019-11-06 18:45:53 +00:00
|
|
|
} from '../types/state';
|
2020-06-08 10:33:19 +01:00
|
|
|
import type { DatabaseApp } from '../device-state/target-state-cache';
|
2018-12-12 15:13:33 +00:00
|
|
|
|
|
|
|
export const defaultLegacyVolume = () => 'resin-data';
|
|
|
|
|
2019-11-06 16:52:28 +00:00
|
|
|
export function singleToMulticontainerApp(
|
|
|
|
app: Dictionary<any>,
|
|
|
|
): TargetApplication & { appId: string } {
|
2018-12-12 15:13:33 +00:00
|
|
|
const environment: Dictionary<string> = {};
|
|
|
|
for (const key in app.env) {
|
|
|
|
if (!/^RESIN_/.test(key)) {
|
|
|
|
environment[key] = app.env[key];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const { appId } = app;
|
|
|
|
const conf = app.config != null ? app.config : {};
|
2019-11-06 16:52:28 +00:00
|
|
|
const newApp: TargetApplication & { appId: string } = {
|
|
|
|
appId: appId.toString(),
|
2018-12-12 15:13:33 +00:00
|
|
|
commit: app.commit,
|
|
|
|
name: app.name,
|
|
|
|
releaseId: 1,
|
|
|
|
networks: {},
|
|
|
|
volumes: {},
|
2019-11-06 16:52:28 +00:00
|
|
|
services: {},
|
|
|
|
};
|
2018-12-12 15:13:33 +00:00
|
|
|
const defaultVolume = exports.defaultLegacyVolume();
|
|
|
|
newApp.volumes[defaultVolume] = {};
|
|
|
|
const updateStrategy =
|
|
|
|
conf['RESIN_SUPERVISOR_UPDATE_STRATEGY'] != null
|
|
|
|
? conf['RESIN_SUPERVISOR_UPDATE_STRATEGY']
|
|
|
|
: 'download-then-kill';
|
|
|
|
const handoverTimeout =
|
|
|
|
conf['RESIN_SUPERVISOR_HANDOVER_TIMEOUT'] != null
|
|
|
|
? conf['RESIN_SUPERVISOR_HANDOVER_TIMEOUT']
|
|
|
|
: '';
|
|
|
|
const restartPolicy =
|
|
|
|
conf['RESIN_APP_RESTART_POLICY'] != null
|
|
|
|
? conf['RESIN_APP_RESTART_POLICY']
|
|
|
|
: 'always';
|
|
|
|
newApp.services = {
|
|
|
|
// Disable the next line, as this *has* to be a string
|
|
|
|
// tslint:disable-next-line
|
|
|
|
'1': {
|
|
|
|
appId,
|
|
|
|
serviceName: 'main',
|
|
|
|
imageId: 1,
|
|
|
|
commit: app.commit,
|
|
|
|
releaseId: 1,
|
|
|
|
image: app.imageId,
|
|
|
|
privileged: true,
|
|
|
|
networkMode: 'host',
|
|
|
|
volumes: [`${defaultVolume}:/data`],
|
|
|
|
labels: {
|
|
|
|
'io.resin.features.kernel-modules': '1',
|
|
|
|
'io.resin.features.firmware': '1',
|
|
|
|
'io.resin.features.dbus': '1',
|
|
|
|
'io.resin.features.supervisor-api': '1',
|
|
|
|
'io.resin.features.resin-api': '1',
|
|
|
|
'io.resin.update.strategy': updateStrategy,
|
|
|
|
'io.resin.update.handover-timeout': handoverTimeout,
|
|
|
|
'io.resin.legacy-container': '1',
|
|
|
|
},
|
|
|
|
environment,
|
|
|
|
restart: restartPolicy,
|
|
|
|
running: true,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
return newApp;
|
|
|
|
}
|
2019-11-06 16:52:28 +00:00
|
|
|
|
|
|
|
export function convertLegacyAppsJson(appsArray: any[]): AppsJsonFormat {
|
|
|
|
const deviceConfig = _.reduce(
|
|
|
|
appsArray,
|
|
|
|
(conf, app) => {
|
|
|
|
return _.merge({}, conf, app.config);
|
|
|
|
},
|
|
|
|
{},
|
|
|
|
);
|
|
|
|
|
|
|
|
const apps = _.keyBy(_.map(appsArray, singleToMulticontainerApp), 'appId');
|
|
|
|
return { apps, config: deviceConfig } as AppsJsonFormat;
|
|
|
|
}
|
2019-11-06 18:45:53 +00:00
|
|
|
|
2020-07-21 16:25:47 +01:00
|
|
|
export async function normaliseLegacyDatabase() {
|
|
|
|
await apiBinder.initialized;
|
|
|
|
await deviceState.initialized;
|
|
|
|
|
|
|
|
if (apiBinder.balenaApi == null) {
|
|
|
|
throw new InternalInconsistencyError(
|
|
|
|
'API binder is not initialized correctly',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2019-11-06 18:45:53 +00:00
|
|
|
// When legacy apps are present, we kill their containers and migrate their /data to a named volume
|
|
|
|
log.info('Migrating ids for legacy app...');
|
|
|
|
|
2020-06-08 10:33:19 +01:00
|
|
|
const apps: DatabaseApp[] = await db.models('app').select();
|
2019-11-06 18:45:53 +00:00
|
|
|
|
|
|
|
if (apps.length === 0) {
|
|
|
|
log.debug('No app to migrate');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const app of apps) {
|
|
|
|
let services: Array<TargetApplication['services']['']>;
|
|
|
|
|
|
|
|
try {
|
|
|
|
services = JSON.parse(app.services);
|
|
|
|
} catch (e) {
|
|
|
|
throw new DatabaseParseError(e);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check there's a main service, with legacy-container set
|
|
|
|
if (services.length !== 1) {
|
|
|
|
log.debug("App doesn't have a single service, ignoring");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const service = services[0];
|
|
|
|
if (
|
|
|
|
!service.labels['io.resin.legacy-container'] &&
|
|
|
|
!service.labels['io.balena.legacy-container']
|
|
|
|
) {
|
|
|
|
log.debug('Service is not marked as legacy, ignoring');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
log.debug(`Getting release ${app.commit} for app ${app.appId} from API`);
|
2020-07-21 16:25:47 +01:00
|
|
|
const releases = await apiBinder.balenaApi.get({
|
2019-11-06 18:45:53 +00:00
|
|
|
resource: 'release',
|
|
|
|
options: {
|
|
|
|
$filter: {
|
|
|
|
belongs_to__application: app.appId,
|
|
|
|
commit: app.commit,
|
|
|
|
status: 'success',
|
|
|
|
},
|
|
|
|
$expand: {
|
|
|
|
contains__image: {
|
|
|
|
$expand: 'image',
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
2020-08-18 18:25:06 +01:00
|
|
|
});
|
2019-11-06 18:45:53 +00:00
|
|
|
|
|
|
|
if (releases.length === 0) {
|
|
|
|
log.warn(
|
2019-11-10 20:32:14 +00:00
|
|
|
`No compatible releases found in API, removing ${app.appId} from target state`,
|
2019-11-06 18:45:53 +00:00
|
|
|
);
|
2020-05-15 12:01:51 +01:00
|
|
|
await db.models('app').where({ appId: app.appId }).del();
|
2019-11-06 18:45:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// We need to get the release.id, serviceId, image.id and updated imageUrl
|
|
|
|
const release = releases[0];
|
|
|
|
const image = release.contains__image[0].image[0];
|
|
|
|
const serviceId = image.is_a_build_of__service.__id;
|
|
|
|
const imageUrl = !image.content_hash
|
|
|
|
? image.is_stored_at__image_location
|
|
|
|
: `${image.is_stored_at__image_location}@${image.content_hash}`;
|
|
|
|
|
|
|
|
log.debug(
|
2019-11-10 20:32:14 +00:00
|
|
|
`Found a release with releaseId ${release.id}, imageId ${image.id}, serviceId ${serviceId}\nImage location is ${imageUrl}`,
|
2019-11-06 18:45:53 +00:00
|
|
|
);
|
|
|
|
|
2020-06-02 17:56:58 +01:00
|
|
|
const imageFromDocker = await docker
|
2019-11-06 18:45:53 +00:00
|
|
|
.getImage(service.image)
|
|
|
|
.inspect()
|
2020-05-15 12:01:51 +01:00
|
|
|
.catch((error) => {
|
2019-11-09 18:38:41 +00:00
|
|
|
if (error instanceof NotFoundError) {
|
2019-11-06 18:45:53 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2019-11-09 18:38:41 +00:00
|
|
|
throw error;
|
2019-11-06 18:45:53 +00:00
|
|
|
});
|
|
|
|
const imagesFromDatabase = await db
|
|
|
|
.models('image')
|
|
|
|
.where({ name: service.image })
|
|
|
|
.select();
|
|
|
|
|
2020-05-28 18:15:33 +01:00
|
|
|
await db.transaction(async (trx: db.Transaction) => {
|
2019-11-06 18:45:53 +00:00
|
|
|
try {
|
|
|
|
if (imagesFromDatabase.length > 0) {
|
|
|
|
log.debug('Deleting existing image entry in db');
|
2020-05-15 12:01:51 +01:00
|
|
|
await trx('image').where({ name: service.image }).del();
|
2019-11-06 18:45:53 +00:00
|
|
|
} else {
|
|
|
|
log.debug('No image in db to delete');
|
|
|
|
}
|
|
|
|
} finally {
|
|
|
|
if (imageFromDocker != null) {
|
|
|
|
log.debug('Inserting fixed image entry in db');
|
|
|
|
await trx('image').insert({
|
|
|
|
name: imageUrl,
|
|
|
|
appId: app.appId,
|
|
|
|
serviceId,
|
|
|
|
serviceName: service.serviceName,
|
|
|
|
imageId: image.id,
|
|
|
|
releaseId: release.id,
|
|
|
|
dependent: 0,
|
|
|
|
dockerImageId: imageFromDocker.Id,
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
log.debug('Image is not downloaded, so not saving it to db');
|
|
|
|
}
|
|
|
|
|
|
|
|
delete service.labels['io.resin.legacy-container'];
|
|
|
|
delete service.labels['io.balena.legacy-container'];
|
|
|
|
|
|
|
|
Object.assign(app, {
|
|
|
|
services: JSON.stringify([
|
|
|
|
Object.assign(service, {
|
|
|
|
image: imageUrl,
|
|
|
|
serviceID: serviceId,
|
|
|
|
imageId: image.id,
|
|
|
|
releaseId: release.id,
|
|
|
|
}),
|
|
|
|
]),
|
|
|
|
releaseId: release.id,
|
|
|
|
});
|
|
|
|
|
|
|
|
log.debug('Updating app entry in db');
|
|
|
|
log.success('Successfully migrated legacy application');
|
2020-05-15 12:01:51 +01:00
|
|
|
await trx('app').update(app).where({ appId: app.appId });
|
2019-11-06 18:45:53 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
log.debug('Killing legacy containers');
|
2020-06-15 11:31:26 +01:00
|
|
|
await serviceManager.killAllLegacy();
|
2019-11-06 18:45:53 +00:00
|
|
|
log.debug('Migrating legacy app volumes');
|
|
|
|
|
2020-08-13 13:25:39 +01:00
|
|
|
await applicationManager.initialized;
|
|
|
|
const targetApps = await applicationManager.getTargetApps();
|
2019-11-06 18:45:53 +00:00
|
|
|
|
|
|
|
for (const appId of _.keys(targetApps)) {
|
2020-06-11 11:43:03 +01:00
|
|
|
await volumeManager.createFromLegacy(parseInt(appId, 10));
|
2019-11-06 18:45:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
await config.set({
|
|
|
|
legacyAppsPresent: false,
|
|
|
|
});
|
|
|
|
}
|
2019-11-09 18:38:41 +00:00
|
|
|
|
|
|
|
export async function loadBackupFromMigration(
|
|
|
|
targetState: TargetState,
|
|
|
|
retryDelay: number,
|
|
|
|
): Promise<void> {
|
|
|
|
try {
|
|
|
|
const exists = await pathExistsOnHost(
|
|
|
|
path.join('mnt/data', constants.migrationBackupFile),
|
|
|
|
);
|
|
|
|
if (!exists) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
log.info('Migration backup detected');
|
|
|
|
|
|
|
|
await deviceState.setTarget(targetState);
|
|
|
|
|
|
|
|
// multi-app warning!
|
|
|
|
const appId = parseInt(_.keys(targetState.local?.apps)[0], 10);
|
|
|
|
|
|
|
|
if (isNaN(appId)) {
|
|
|
|
throw new BackupError('No appId in target state');
|
|
|
|
}
|
|
|
|
|
|
|
|
const volumes = targetState.local?.apps?.[appId].volumes;
|
|
|
|
|
|
|
|
const backupPath = path.join(constants.rootMountPoint, 'mnt/data/backup');
|
|
|
|
// We clear this path in case it exists from an incomplete run of this function
|
|
|
|
await rimrafAsync(backupPath);
|
|
|
|
await mkdirpAsync(backupPath);
|
|
|
|
await child_process.exec(`tar -xzf backup.tgz -C ${backupPath}`, {
|
|
|
|
cwd: path.join(constants.rootMountPoint, 'mnt/data'),
|
|
|
|
});
|
|
|
|
|
|
|
|
for (const volumeName of await fs.readdir(backupPath)) {
|
|
|
|
const statInfo = await fs.stat(path.join(backupPath, volumeName));
|
|
|
|
|
|
|
|
if (!statInfo.isDirectory()) {
|
|
|
|
throw new BackupError(
|
|
|
|
`Invalid backup: ${volumeName} is not a directory`,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (volumes[volumeName] != null) {
|
|
|
|
log.debug(`Creating volume ${volumeName} from backup`);
|
|
|
|
// If the volume exists (from a previous incomplete run of this restoreBackup), we delete it first
|
2020-06-11 11:43:03 +01:00
|
|
|
await volumeManager
|
2019-11-09 18:38:41 +00:00
|
|
|
.get({ appId, name: volumeName })
|
2020-05-15 12:01:51 +01:00
|
|
|
.then((volume) => {
|
2019-11-09 18:38:41 +00:00
|
|
|
return volume.remove();
|
|
|
|
})
|
2020-05-15 12:01:51 +01:00
|
|
|
.catch((error) => {
|
2019-11-09 18:38:41 +00:00
|
|
|
if (error instanceof NotFoundError) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
throw error;
|
|
|
|
});
|
|
|
|
|
2020-06-11 11:43:03 +01:00
|
|
|
await volumeManager.createFromPath(
|
2019-11-09 18:38:41 +00:00
|
|
|
{ appId, name: volumeName },
|
|
|
|
volumes[volumeName],
|
|
|
|
path.join(backupPath, volumeName),
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
throw new BackupError(
|
|
|
|
`Invalid backup: ${volumeName} is present in backup but not in target state`,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
await rimrafAsync(backupPath);
|
|
|
|
await rimrafAsync(
|
|
|
|
path.join(
|
|
|
|
constants.rootMountPoint,
|
|
|
|
'mnt/data',
|
|
|
|
constants.migrationBackupFile,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
} catch (err) {
|
|
|
|
log.error(`Error restoring migration backup, retrying: ${err}`);
|
|
|
|
|
|
|
|
await Bluebird.delay(retryDelay);
|
2020-07-21 16:25:47 +01:00
|
|
|
return loadBackupFromMigration(targetState, retryDelay);
|
2019-11-09 18:38:41 +00:00
|
|
|
}
|
|
|
|
}
|