Clean up migration from legacy target state format

Creates `lib/legacy.ts` and `device-state/legacy.ts` to deal with
migration from legacy target states (single container and v2) for all
apps and for apps.json respectively
This commit is contained in:
Felipe Lalanne 2021-08-23 18:48:48 +00:00
parent 7425d1110b
commit 1edd060143
6 changed files with 467 additions and 388 deletions

View File

@ -7,7 +7,6 @@ import { NotFoundError, InternalInconsistencyError } from '../lib/errors';
import { safeRename } from '../lib/fs-utils';
import { docker } from '../lib/docker-utils';
import * as LogTypes from '../lib/log-types';
import { defaultLegacyVolume } from '../lib/migration';
import log from '../lib/supervisor-console';
import * as logger from '../logger';
import { ResourceRecreationAttemptError } from './errors';
@ -78,25 +77,6 @@ export async function remove(volume: Volume) {
await volume.remove();
}
export async function createFromLegacy(appId: number): Promise<Volume | void> {
const name = defaultLegacyVolume();
const legacyPath = Path.join(
constants.rootMountPoint,
'mnt/data/resin-data',
appId.toString(),
);
try {
return await createFromPath({ name, appId }, {}, legacyPath);
} catch (e) {
logger.logSystemMessage(
`Warning: could not migrate legacy /data volume: ${e.message}`,
{ error: e },
'Volume migration error',
);
}
}
export async function createFromPath(
{ name, appId }: VolumeNameOpts,
config: Partial<VolumeConfig>,

116
src/device-state/legacy.ts Normal file
View File

@ -0,0 +1,116 @@
import * as _ from 'lodash';
import { fromV2TargetApps, TargetAppsV2 } from '../lib/legacy';
import { AppsJsonFormat, TargetApp, TargetRelease } from '../types';
/**
* Converts a single app from single container format into
* multi-container, multi-app format (v3)
*
* This function doesn't pull ids from the cloud, but uses dummy values,
* letting the normaliseLegacyDatabase() method perform the normalization
*/
function singleToMulticontainerApp(
app: Dictionary<any>,
): TargetApp & { uuid: string } {
const environment: Dictionary<string> = {};
for (const key in app.env) {
if (!/^RESIN_/.test(key)) {
environment[key] = app.env[key];
}
}
const { appId } = app;
const release: TargetRelease = {
id: 1,
networks: {},
volumes: {},
services: {},
};
const conf = app.config != null ? app.config : {};
const newApp: TargetApp & { uuid: string } = {
id: appId,
uuid: 'user-app',
name: app.name,
class: 'fleet',
releases: {
[app.commit]: release,
},
};
const defaultVolume = exports.defaultLegacyVolume();
release.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';
release.services = {
main: {
id: 1,
image_id: 1,
image: app.imageId,
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,
running: true,
composition: {
restart: restartPolicy,
privileged: true,
networkMode: 'host',
volumes: [`${defaultVolume}:/data`],
},
},
};
return newApp;
}
/**
* Converts an apps.json from single container to multi-app (v3) format.
*/
export function fromLegacyAppsJson(appsArray: any[]): AppsJsonFormat {
const deviceConfig = _.reduce(
appsArray,
(conf, app) => {
return _.merge({}, conf, app.config);
},
{},
);
const apps = _.keyBy(
_.map(appsArray, singleToMulticontainerApp),
'uuid',
) as Dictionary<TargetApp>;
return { apps, config: deviceConfig } as AppsJsonFormat;
}
type AppsJsonV2 = {
config: {
[varName: string]: string;
};
apps: TargetAppsV2;
pinDevice?: boolean;
};
export async function fromV2AppsJson(
appsJson: AppsJsonV2,
): Promise<AppsJsonFormat> {
const { config: conf, apps, pinDevice } = appsJson;
const v3apps = await fromV2TargetApps(apps);
return { config: conf, apps: v3apps, ...(pinDevice && { pinDevice }) };
}

View File

@ -16,7 +16,7 @@ import {
} from '../lib/errors';
import log from '../lib/supervisor-console';
import { convertLegacyAppsJson, convertV2toV3AppsJson } from '../lib/migration';
import { fromLegacyAppsJson, fromV2AppsJson } from './legacy';
import { AppsJsonFormat } from '../types/state';
import * as fsUtils from '../lib/fs-utils';
import { isLeft } from 'fp-ts/lib/Either';
@ -42,7 +42,7 @@ export async function loadTargetFromFile(appsPath: string): Promise<boolean> {
if (_.isArray(stateFromFile)) {
log.debug('Detected a legacy apps.json, converting...');
stateFromFile = convertLegacyAppsJson(stateFromFile as any[]);
stateFromFile = fromLegacyAppsJson(stateFromFile as any[]);
}
// if apps.json apps are keyed by numeric ids, then convert to v3 target state
@ -51,7 +51,7 @@ export async function loadTargetFromFile(appsPath: string): Promise<boolean> {
(appId) => !isNaN(parseInt(appId, 10)),
)
) {
stateFromFile = await convertV2toV3AppsJson(stateFromFile as any);
stateFromFile = await fromV2AppsJson(stateFromFile as any);
}
// Check that transformed apps.json has the correct format

345
src/lib/legacy.ts Normal file
View File

@ -0,0 +1,345 @@
import * as _ from 'lodash';
import * as path from 'path';
import * as apiBinder from '../api-binder';
import * as config from '../config';
import * as db from '../db';
import * as volumeManager from '../compose/volume-manager';
import * as serviceManager from '../compose/service-manager';
import * as deviceState from '../device-state';
import * as applicationManager from '../compose/application-manager';
import {
DatabaseParseError,
NotFoundError,
InternalInconsistencyError,
} from '../lib/errors';
import * as constants from '../lib/constants';
import { docker } from '../lib/docker-utils';
import { log } from '../lib/supervisor-console';
import Volume from '../compose/volume';
import * as logger from '../logger';
import type {
DatabaseApp,
DatabaseService,
} from '../device-state/target-state-cache';
import { TargetApp, TargetApps } from '../types';
const defaultLegacyVolume = () => 'resin-data';
/**
* Creates a docker volume from the legacy data directory
*/
export async function createVolumeFromLegacyData(
appId: number,
): Promise<Volume | void> {
const name = defaultLegacyVolume();
const legacyPath = path.join(
constants.rootMountPoint,
'mnt/data/resin-data',
appId.toString(),
);
try {
return await volumeManager.createFromPath({ name, appId }, {}, legacyPath);
} catch (e) {
logger.logSystemMessage(
`Warning: could not migrate legacy /data volume: ${e.message}`,
{ error: e },
'Volume migration error',
);
}
}
/**
* Gets proper database ids from the cloud for the app and app services
*/
export async function normaliseLegacyDatabase() {
await apiBinder.initialized;
await deviceState.initialized;
if (apiBinder.balenaApi == null) {
throw new InternalInconsistencyError(
'API binder is not initialized correctly',
);
}
// When legacy apps are present, we kill their containers and migrate their /data to a named volume
log.info('Migrating ids for legacy app...');
const apps: DatabaseApp[] = await db.models('app').select();
if (apps.length === 0) {
log.debug('No app to migrate');
return;
}
for (const app of apps) {
let services: DatabaseService[];
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`);
const releases = await apiBinder.balenaApi.get({
resource: 'release',
options: {
$filter: {
belongs_to__application: app.appId,
commit: app.commit,
status: 'success',
},
$expand: {
contains__image: {
$expand: 'image',
},
belongs_to__application: {
$select: ['uuid'],
},
},
},
});
if (releases.length === 0) {
log.warn(
`No compatible releases found in API, removing ${app.appId} from target state`,
);
await db.models('app').where({ appId: app.appId }).del();
}
// We need to get the app.uuid, release.id, serviceId, image.id and updated imageUrl
const release = releases[0];
const uuid = release.belongs_to__application[0].uuid;
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(
`Found a release with releaseId ${release.id}, imageId ${image.id}, serviceId ${serviceId}\nImage location is ${imageUrl}`,
);
const imageFromDocker = await docker
.getImage(service.image)
.inspect()
.catch((error) => {
if (error instanceof NotFoundError) {
return;
}
throw error;
});
const imagesFromDatabase = await db
.models('image')
.where({ name: service.image })
.select();
await db.transaction(async (trx: db.Transaction) => {
try {
if (imagesFromDatabase.length > 0) {
log.debug('Deleting existing image entry in db');
await trx('image').where({ name: service.image }).del();
} 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,
appUuid: uuid,
serviceId,
serviceName: service.serviceName,
imageId: image.id,
releaseId: release.id,
commit: app.commit,
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, {
appId: app.appId,
appUuid: uuid,
image: imageUrl,
serviceId,
imageId: image.id,
releaseId: release.id,
commit: app.commit,
}),
]),
uuid,
releaseId: release.id,
class: 'fleet',
});
log.debug('Updating app entry in db');
log.success('Successfully migrated legacy application');
await trx('app').update(app).where({ appId: app.appId });
}
});
}
log.debug('Killing legacy containers');
await serviceManager.killAllLegacy();
log.debug('Migrating legacy app volumes');
await applicationManager.initialized;
const targetApps = await applicationManager.getTargetApps();
for (const app of Object.values(targetApps)) {
await createVolumeFromLegacyData(app.id);
}
await config.set({
legacyAppsPresent: false,
});
}
export type TargetAppsV2 = {
[id: string]: {
name: string;
commit: string;
releaseId: number;
services: { [id: string]: any };
volumes: { [name: string]: any };
networks: { [name: string]: any };
};
};
/**
* Convert v2 to v3 target apps. If local is false
* it will query the API to get the app uuid
*/
export async function fromV2TargetApps(
apps: TargetAppsV2,
local = false,
): Promise<TargetApps> {
await apiBinder.initialized;
await deviceState.initialized;
if (apiBinder.balenaApi == null) {
throw new InternalInconsistencyError(
'API binder is not initialized correctly',
);
}
const { balenaApi } = apiBinder;
const getUUIDFromAPI = async (appId: number) => {
const appDetails = await balenaApi.get({
resource: 'application',
id: appId,
options: {
$select: ['uuid'],
},
});
if (!appDetails || !appDetails.uuid) {
throw new InternalInconsistencyError(
`No app with id ${appId} found on the API.`,
);
}
return appDetails.uuid;
};
return (
(
await Promise.all(
Object.keys(apps).map(
async (id): Promise<[string, TargetApp]> => {
const appId = parseInt(id, 10);
const app = apps[appId];
// If local mode just use id as uuid
const uuid = local ? id : await getUUIDFromAPI(appId);
const releases = app.commit
? {
[app.commit]: {
id: app.releaseId,
services: Object.keys(app.services)
.map((serviceId) => {
const {
imageId,
serviceName,
image,
environment,
labels,
running,
serviceId: _serviceId,
contract,
...composition
} = app.services[serviceId];
return [
serviceName,
{
id: serviceId,
image_id: imageId,
image,
environment,
labels,
running,
contract,
composition,
},
];
})
.reduce(
(res, [serviceName, svc]) => ({
...res,
[serviceName]: svc,
}),
{},
),
volumes: app.volumes,
networks: app.networks,
},
}
: {};
return [
uuid,
{
id: appId,
name: app.name,
class: 'fleet',
releases,
} as TargetApp,
];
},
),
)
)
// Key by uuid
.reduce((res, [uuid, app]) => ({ ...res, [uuid]: app }), {})
);
}

View File

@ -6,376 +6,14 @@ import * as rimraf from 'rimraf';
const rimrafAsync = Bluebird.promisify(rimraf);
import * as apiBinder from '../api-binder';
import * as config from '../config';
import * as db from '../db';
import * as volumeManager from '../compose/volume-manager';
import * as serviceManager from '../compose/service-manager';
import * as deviceState from '../device-state';
import * as applicationManager from '../compose/application-manager';
import * as constants from '../lib/constants';
import {
BackupError,
DatabaseParseError,
NotFoundError,
InternalInconsistencyError,
} from '../lib/errors';
import { docker } from '../lib/docker-utils';
import { BackupError, NotFoundError } from '../lib/errors';
import { exec, pathExistsOnHost, mkdirp } from '../lib/fs-utils';
import { log } from '../lib/supervisor-console';
import {
AppsJsonFormat,
TargetApp,
TargetState,
TargetRelease,
} from '../types';
import type {
DatabaseApp,
DatabaseService,
} from '../device-state/target-state-cache';
export const defaultLegacyVolume = () => 'resin-data';
function singleToMulticontainerApp(
app: Dictionary<any>,
): TargetApp & { uuid: string } {
const environment: Dictionary<string> = {};
for (const key in app.env) {
if (!/^RESIN_/.test(key)) {
environment[key] = app.env[key];
}
}
const { appId } = app;
const release: TargetRelease = {
id: 1,
networks: {},
volumes: {},
services: {},
};
const conf = app.config != null ? app.config : {};
const newApp: TargetApp & { uuid: string } = {
id: appId,
uuid: 'user-app',
name: app.name,
class: 'fleet',
releases: {
[app.commit]: release,
},
};
const defaultVolume = exports.defaultLegacyVolume();
release.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';
release.services = {
main: {
id: 1,
image_id: 1,
image: app.imageId,
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,
running: true,
composition: {
restart: restartPolicy,
privileged: true,
networkMode: 'host',
volumes: [`${defaultVolume}:/data`],
},
},
};
return newApp;
}
export function convertLegacyAppsJson(appsArray: any[]): AppsJsonFormat {
const deviceConfig = _.reduce(
appsArray,
(conf, app) => {
return _.merge({}, conf, app.config);
},
{},
);
const apps = _.keyBy(
_.map(appsArray, singleToMulticontainerApp),
'uuid',
) as Dictionary<TargetApp>;
return { apps, config: deviceConfig } as AppsJsonFormat;
}
export async function normaliseLegacyDatabase() {
await apiBinder.initialized;
await deviceState.initialized;
if (apiBinder.balenaApi == null) {
throw new InternalInconsistencyError(
'API binder is not initialized correctly',
);
}
// When legacy apps are present, we kill their containers and migrate their /data to a named volume
log.info('Migrating ids for legacy app...');
const apps: DatabaseApp[] = await db.models('app').select();
if (apps.length === 0) {
log.debug('No app to migrate');
return;
}
for (const app of apps) {
let services: DatabaseService[];
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`);
const releases = await apiBinder.balenaApi.get({
resource: 'release',
options: {
$filter: {
belongs_to__application: app.appId,
commit: app.commit,
status: 'success',
},
$expand: {
contains__image: {
$expand: 'image',
},
belongs_to__application: {
$select: ['uuid'],
},
},
},
});
if (releases.length === 0) {
log.warn(
`No compatible releases found in API, removing ${app.appId} from target state`,
);
await db.models('app').where({ appId: app.appId }).del();
}
// We need to get the app.uuid, release.id, serviceId, image.id and updated imageUrl
const release = releases[0];
const uuid = release.belongs_to__application[0].uuid;
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(
`Found a release with releaseId ${release.id}, imageId ${image.id}, serviceId ${serviceId}\nImage location is ${imageUrl}`,
);
const imageFromDocker = await docker
.getImage(service.image)
.inspect()
.catch((error) => {
if (error instanceof NotFoundError) {
return;
}
throw error;
});
const imagesFromDatabase = await db
.models('image')
.where({ name: service.image })
.select();
await db.transaction(async (trx: db.Transaction) => {
try {
if (imagesFromDatabase.length > 0) {
log.debug('Deleting existing image entry in db');
await trx('image').where({ name: service.image }).del();
} 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,
appUuid: uuid,
serviceId,
serviceName: service.serviceName,
imageId: image.id,
releaseId: release.id,
commit: app.commit,
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, {
appId: app.appId,
appUuid: uuid,
image: imageUrl,
serviceId,
imageId: image.id,
releaseId: release.id,
commit: app.commit,
}),
]),
uuid,
class: 'fleet',
releaseId: release.id,
});
log.debug('Updating app entry in db');
log.success('Successfully migrated legacy application');
await trx('app').update(app).where({ appId: app.appId });
}
});
}
log.debug('Killing legacy containers');
await serviceManager.killAllLegacy();
log.debug('Migrating legacy app volumes');
await applicationManager.initialized;
const targetApps = await applicationManager.getTargetApps();
for (const app of Object.values(targetApps)) {
await volumeManager.createFromLegacy(app.id);
}
await config.set({
legacyAppsPresent: false,
});
}
type AppsJsonV2 = {
config: {
[varName: string]: string;
};
apps: {
[id: string]: {
name: string;
commit: string;
releaseId: number;
services: { [id: string]: any };
volumes: { [name: string]: any };
networks: { [name: string]: any };
};
};
pinDevice?: boolean;
};
export async function convertV2toV3AppsJson(
appsJson: AppsJsonV2,
): Promise<AppsJsonFormat> {
const { config: conf, apps, pinDevice } = appsJson;
await apiBinder.initialized;
await deviceState.initialized;
if (apiBinder.balenaApi == null) {
throw new InternalInconsistencyError(
'API binder is not initialized correctly',
);
}
const { balenaApi } = apiBinder;
const v3apps = (
await Promise.all(
Object.keys(apps).map(
async (id): Promise<[string, TargetApp]> => {
const appId = parseInt(id, 10);
const app = apps[appId];
const appDetails = await balenaApi.get({
resource: 'application',
id: appId,
options: {
$select: ['uuid'],
},
});
if (!appDetails || appDetails.length === 0) {
throw new InternalInconsistencyError(
`No app with id ${appId} found on the API. Skipping apps.json migration`,
);
}
const { uuid } = appDetails[0];
const releases = app.commit
? {
[app.commit]: {
id: app.releaseId,
services: app.services,
volumes: app.volumes,
networks: app.networks,
},
}
: {};
return [
uuid,
{
id: appId,
name: app.name,
class: 'fleet',
releases,
} as TargetApp,
];
},
),
)
)
// Key by uuid
.reduce((res, [uuid, app]) => ({ ...res, [uuid]: app }), {});
return { config: conf, apps: v3apps, ...(pinDevice && { pinDevice }) };
}
import { TargetState } from '../types';
export async function loadBackupFromMigration(
targetState: TargetState,

View File

@ -4,7 +4,7 @@ import * as config from './config';
import * as deviceState from './device-state';
import * as eventTracker from './event-tracker';
import { intialiseContractRequirements } from './lib/contracts';
import { normaliseLegacyDatabase } from './lib/migration';
import { normaliseLegacyDatabase } from './lib/legacy';
import * as osRelease from './lib/os-release';
import * as logger from './logger';
import SupervisorAPI from './supervisor-api';