diff --git a/src/compose/volume-manager.ts b/src/compose/volume-manager.ts index 2f8939d7..960d726d 100644 --- a/src/compose/volume-manager.ts +++ b/src/compose/volume-manager.ts @@ -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 { - 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, diff --git a/src/device-state/legacy.ts b/src/device-state/legacy.ts new file mode 100644 index 00000000..dd279812 --- /dev/null +++ b/src/device-state/legacy.ts @@ -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, +): TargetApp & { uuid: string } { + const environment: Dictionary = {}; + 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; + return { apps, config: deviceConfig } as AppsJsonFormat; +} + +type AppsJsonV2 = { + config: { + [varName: string]: string; + }; + apps: TargetAppsV2; + pinDevice?: boolean; +}; + +export async function fromV2AppsJson( + appsJson: AppsJsonV2, +): Promise { + const { config: conf, apps, pinDevice } = appsJson; + + const v3apps = await fromV2TargetApps(apps); + return { config: conf, apps: v3apps, ...(pinDevice && { pinDevice }) }; +} diff --git a/src/device-state/preload.ts b/src/device-state/preload.ts index 69929b70..e062d014 100644 --- a/src/device-state/preload.ts +++ b/src/device-state/preload.ts @@ -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 { 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 { (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 diff --git a/src/lib/legacy.ts b/src/lib/legacy.ts new file mode 100644 index 00000000..1fc8d5e2 --- /dev/null +++ b/src/lib/legacy.ts @@ -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 { + 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 { + 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 }), {}) + ); +} diff --git a/src/lib/migration.ts b/src/lib/migration.ts index 8e097798..9a138199 100644 --- a/src/lib/migration.ts +++ b/src/lib/migration.ts @@ -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, -): TargetApp & { uuid: string } { - const environment: Dictionary = {}; - 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; - 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 { - 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, diff --git a/src/supervisor.ts b/src/supervisor.ts index 63d4aa43..5e3cd219 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -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';