Add app uuid as metadata to new volumes

We cannot modify older volumes but newly created volumes will contain
app uuid as metadata so they can be migrated at some point in the
future.
This commit is contained in:
Felipe Lalanne 2021-08-25 21:50:34 +00:00
parent 0b19dee511
commit 0835b29874
8 changed files with 194 additions and 90 deletions

View File

@ -726,7 +726,7 @@ export class App {
if (conf.labels == null) {
conf.labels = {};
}
return Volume.fromComposeObject(name, app.appId, conf);
return Volume.fromComposeObject(name, app.appId, app.uuid, conf);
});
const networks = _.mapValues(

View File

@ -78,11 +78,19 @@ export async function remove(volume: Volume) {
}
export async function createFromPath(
{ name, appId }: VolumeNameOpts,
{ name, appId, appUuid }: VolumeNameOpts & { appUuid?: string },
config: Partial<VolumeConfig>,
oldPath: string,
): Promise<Volume> {
const volume = Volume.fromComposeObject(name, appId, config);
const volume = Volume.fromComposeObject(
name,
appId,
// We may not have a uuid here, but we need one to create a volume
// from a compose object. We pass uuid as undefined here so that we will
// fallback to id comparison for apps
appUuid as any,
config,
);
await create(volume);
const inspect = await docker

View File

@ -1,5 +1,4 @@
import * as Docker from 'dockerode';
import assign = require('lodash/assign');
import isEqual = require('lodash/isEqual');
import omitBy = require('lodash/omitBy');
@ -27,6 +26,7 @@ export class Volume {
private constructor(
public name: string,
public appId: number,
public appUuid: string,
public config: VolumeConfig,
) {}
@ -40,26 +40,34 @@ export class Volume {
// Detect the name and appId from the inspect data
const { name, appId } = this.deconstructDockerName(inspect.Name);
const appUuid = config.labels['io.balena.app-uuid'];
return new Volume(name, appId, config);
return new Volume(name, appId, appUuid, config);
}
public static fromComposeObject(
name: string,
appId: number,
config: Partial<ComposeVolumeConfig>,
appUuid: string,
config = {} as Partial<ComposeVolumeConfig>,
) {
const filledConfig: VolumeConfig = {
driverOpts: config.driver_opts || {},
driver: config.driver || 'local',
labels: ComposeUtils.normalizeLabels(config.labels || {}),
labels: {
// We only need to assign the labels here, as when we
// get it from the daemon, they should already be there
...ComposeUtils.normalizeLabels(config.labels || {}),
...constants.defaultVolumeLabels,
// the app uuid will always be in the target state, the
// only reason this is done this way is to be compatible
// with loading a volume from backup (see lib/migration)
...(appUuid && { 'io.balena.app-uuid': appUuid }),
},
};
// We only need to assign the labels here, as when we
// get it from the daemon, they should already be there
assign(filledConfig.labels, constants.defaultVolumeLabels);
return new Volume(name, appId, filledConfig);
return new Volume(name, appId, appUuid, filledConfig);
}
public toComposeObject(): ComposeVolumeConfig {
@ -141,7 +149,14 @@ export class Volume {
// TODO: Export these to a constant
return omitBy(
labels,
(_v, k) => k === 'io.resin.supervised' || k === 'io.balena.supervised',
(_v, k) =>
k === 'io.resin.supervised' ||
k === 'io.balena.supervised' ||
// TODO: we need to omit the app-uuid label
// in the comparison or else the supervisor will try to recreate
// the volume, which won't fail but won't have any effect on the volume
// either, leading to a supervisor target state apply loop
k === 'io.balena.app-uuid',
);
}
}

View File

@ -31,8 +31,9 @@ const defaultLegacyVolume = () => 'resin-data';
/**
* Creates a docker volume from the legacy data directory
*/
export async function createVolumeFromLegacyData(
async function createVolumeFromLegacyData(
appId: number,
appUuid: string,
): Promise<Volume | void> {
const name = defaultLegacyVolume();
const legacyPath = path.join(
@ -42,7 +43,11 @@ export async function createVolumeFromLegacyData(
);
try {
return await volumeManager.createFromPath({ name, appId }, {}, legacyPath);
return await volumeManager.createFromPath(
{ name, appId, appUuid },
{},
legacyPath,
);
} catch (e) {
logger.logSystemMessage(
`Warning: could not migrate legacy /data volume: ${e.message}`,
@ -128,7 +133,7 @@ export async function normaliseLegacyDatabase() {
// 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 appUuid = 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
@ -168,7 +173,7 @@ export async function normaliseLegacyDatabase() {
await trx('image').insert({
name: imageUrl,
appId: app.appId,
appUuid: uuid,
appUuid,
serviceId,
serviceName: service.serviceName,
imageId: image.id,
@ -188,7 +193,7 @@ export async function normaliseLegacyDatabase() {
services: JSON.stringify([
Object.assign(service, {
appId: app.appId,
appUuid: uuid,
appUuid,
image: imageUrl,
serviceId,
imageId: image.id,
@ -196,7 +201,7 @@ export async function normaliseLegacyDatabase() {
commit: app.commit,
}),
]),
uuid,
uuid: appUuid,
releaseId: release.id,
class: 'fleet',
});
@ -215,8 +220,9 @@ export async function normaliseLegacyDatabase() {
await applicationManager.initialized;
const targetApps = await applicationManager.getTargetApps();
for (const app of Object.values(targetApps)) {
await createVolumeFromLegacyData(app.id);
for (const appUuid of Object.keys(targetApps)) {
const app = targetApps[appUuid];
await createVolumeFromLegacyData(app.id, appUuid);
}
await config.set({

View File

@ -138,7 +138,7 @@ describe('compose/app', () => {
// Setup current and target apps
const current = createApp();
const target = createApp({
volumes: [Volume.fromComposeObject('test-volume', 1, {})],
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
isTarget: true,
});
@ -156,8 +156,8 @@ describe('compose/app', () => {
const current = createApp();
const target = createApp({
volumes: [
Volume.fromComposeObject('test-volume', 1, {}),
Volume.fromComposeObject('test-volume-2', 1, {}),
Volume.fromComposeObject('test-volume', 1, 'deadbeef'),
Volume.fromComposeObject('test-volume-2', 1, 'deadbeef'),
],
isTarget: true,
});
@ -186,12 +186,12 @@ describe('compose/app', () => {
it('should not infer a volume remove step when the app is still referenced', () => {
const current = createApp({
volumes: [
Volume.fromComposeObject('test-volume', 1, {}),
Volume.fromComposeObject('test-volume-2', 1, {}),
Volume.fromComposeObject('test-volume', 1, 'deadbeef'),
Volume.fromComposeObject('test-volume-2', 1, 'deadbeef'),
],
});
const target = createApp({
volumes: [Volume.fromComposeObject('test-volume-2', 1, {})],
volumes: [Volume.fromComposeObject('test-volume-2', 1, 'deadbeef')],
isTarget: true,
});
@ -201,11 +201,11 @@ describe('compose/app', () => {
it('should correctly infer volume recreation steps', () => {
const current = createApp({
volumes: [Volume.fromComposeObject('test-volume', 1, {})],
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
});
const target = createApp({
volumes: [
Volume.fromComposeObject('test-volume', 1, {
Volume.fromComposeObject('test-volume', 1, 'deadbeef', {
labels: { test: 'test' },
}),
],
@ -221,8 +221,12 @@ describe('compose/app', () => {
const [removalStep] = expectSteps('removeVolume', stepsForRemoval);
expect(removalStep)
.to.have.property('current')
.that.has.property('config')
.that.deep.includes({ labels: { 'io.balena.supervised': 'true' } });
.that.has.property('name')
.that.equals('test-volume');
expect(removalStep)
.to.have.property('current')
.that.has.property('appId')
.that.equals(1);
// we are assuming that after the execution steps the current state of the
// app will look like this
@ -241,7 +245,11 @@ describe('compose/app', () => {
.to.have.property('target')
.that.has.property('config')
.that.deep.includes({
labels: { 'io.balena.supervised': 'true', test: 'test' },
labels: {
'io.balena.supervised': 'true',
'io.balena.app-uuid': 'deadbeef',
test: 'test',
},
});
});
@ -252,7 +260,7 @@ describe('compose/app', () => {
composition: { volumes: ['test-volume:/data'] },
}),
],
volumes: [Volume.fromComposeObject('test-volume', 1, {})],
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
});
const target = createApp({
services: [
@ -261,7 +269,7 @@ describe('compose/app', () => {
}),
],
volumes: [
Volume.fromComposeObject('test-volume', 1, {
Volume.fromComposeObject('test-volume', 1, 'deadbeef', {
labels: { test: 'test' },
}),
],
@ -279,7 +287,7 @@ describe('compose/app', () => {
it('should correctly infer to remove an app volumes when the app is being removed', async () => {
const current = createApp({
volumes: [Volume.fromComposeObject('test-volume', 1, {})],
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
});
const steps = await current.stepsToRemoveApp(defaultContext);
@ -297,12 +305,12 @@ describe('compose/app', () => {
service.status = 'Stopping';
const current = createApp({
services: [service],
volumes: [Volume.fromComposeObject('test-volume', 1, {})],
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
});
const target = createApp({
services: [service],
volumes: [
Volume.fromComposeObject('test-volume', 1, {
Volume.fromComposeObject('test-volume', 1, 'deadbeef', {
labels: { test: 'test' },
}),
],
@ -318,7 +326,11 @@ describe('compose/app', () => {
image: 'test-image',
composition: { volumes: ['db-volume:/data'] },
});
const volume = Volume.fromComposeObject('db-volume', service.appId, {});
const volume = Volume.fromComposeObject(
'db-volume',
service.appId,
'deadbeef',
);
const contextWithImages = {
...defaultContext,
...{

View File

@ -866,7 +866,7 @@ describe('compose/application-manager', () => {
} = createCurrentState({
services: [],
networks: [DEFAULT_NETWORK],
volumes: [Volume.fromComposeObject('test-volume', 1, {})],
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
});
const steps = await applicationManager.inferNextSteps(
@ -893,7 +893,7 @@ describe('compose/application-manager', () => {
services: [],
networks: [],
// Volume with different id
volumes: [Volume.fromComposeObject('test-volume', 2, {})],
volumes: [Volume.fromComposeObject('test-volume', 2, 'deadbeef')],
});
const steps = await applicationManager.inferNextSteps(

View File

@ -34,8 +34,11 @@ describe('compose/volume-manager', () => {
}),
createVolume({
Name: Volume.generateDockerName(1, 'mysql'),
// Recently created volumes contain io.balena.supervised label
Labels: { 'io.balena.supervised': '1' },
// Recently created volumes contain io.balena.supervised label and app-uuid
Labels: {
'io.balena.supervised': '1',
'io.balena.app-uuid': 'deadbeef',
},
}),
createVolume({
Name: Volume.generateDockerName(1, 'backend'),
@ -56,6 +59,7 @@ describe('compose/volume-manager', () => {
await expect(volumeManager.getAll()).to.eventually.deep.equal([
{
appId: 1,
appUuid: undefined,
config: {
driver: 'local',
driverOpts: {},
@ -67,17 +71,20 @@ describe('compose/volume-manager', () => {
},
{
appId: 1,
appUuid: 'deadbeef',
config: {
driver: 'local',
driverOpts: {},
labels: {
'io.balena.supervised': '1',
'io.balena.app-uuid': 'deadbeef',
},
},
name: 'mysql',
},
{
appId: 1,
appUuid: undefined,
config: {
driver: 'local',
driverOpts: {},
@ -126,6 +133,7 @@ describe('compose/volume-manager', () => {
).to.eventually.deep.equal([
{
appId: 111,
appUuid: undefined,
config: {
driver: 'local',
driverOpts: {},
@ -152,7 +160,7 @@ describe('compose/volume-manager', () => {
).to.be.rejected;
// Volume to create
const volume = Volume.fromComposeObject('main', 111, {});
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
sinon.spy(volume, 'create');
// Create volume
@ -177,7 +185,7 @@ describe('compose/volume-manager', () => {
await withMockerode(
async () => {
// Create compose object for volume already set up in mock engine
const volume = Volume.fromComposeObject('main', 111, {});
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
sinon.spy(volume, 'create');
// Create volume
@ -206,7 +214,7 @@ describe('compose/volume-manager', () => {
await withMockerode(
async (mockerode) => {
// Volume to remove
const volume = Volume.fromComposeObject('main', 111, {});
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
sinon.spy(volume, 'remove');
// Remove volume
@ -234,7 +242,7 @@ describe('compose/volume-manager', () => {
await withMockerode(
async (mockerode) => {
// Volume to remove
const volume = Volume.fromComposeObject('main', 111, {});
const volume = Volume.fromComposeObject('main', 111, 'deadbeef', {});
sinon.spy(volume, 'remove');
// Remove volume

View File

@ -9,28 +9,40 @@ import { createVolume, withMockerode } from '../../lib/mockerode';
describe('compose/volume', () => {
describe('creating a volume from a compose object', () => {
it('should use proper defaults when no compose configuration is provided', () => {
const volume = Volume.fromComposeObject('my_volume', 1234, {});
const volume = Volume.fromComposeObject(
'my_volume',
1234,
'deadbeef',
{},
);
expect(volume.name).to.equal('my_volume');
expect(volume.appId).to.equal(1234);
expect(volume.appUuid).to.equal('deadbeef');
expect(volume.config).to.deep.equal({
driver: 'local',
driverOpts: {},
labels: {
'io.balena.supervised': 'true',
'io.balena.app-uuid': 'deadbeef',
},
});
});
it('should correctly parse compose volumes without an explicit driver', () => {
const volume = Volume.fromComposeObject('one_volume', 1032480, {
driver_opts: {
opt1: 'test',
const volume = Volume.fromComposeObject(
'one_volume',
1032480,
'deadbeef',
{
driver_opts: {
opt1: 'test',
},
labels: {
'my-label': 'test-label',
},
},
labels: {
'my-label': 'test-label',
},
});
);
expect(volume).to.have.property('appId').that.equals(1032480);
expect(volume).to.have.property('name').that.equals('one_volume');
@ -39,6 +51,7 @@ describe('compose/volume', () => {
.that.has.property('labels')
.that.deep.equals({
'io.balena.supervised': 'true',
'io.balena.app-uuid': 'deadbeef',
'my-label': 'test-label',
});
expect(volume)
@ -54,15 +67,20 @@ describe('compose/volume', () => {
});
it('should correctly parse compose volumes with an explicit driver', () => {
const volume = Volume.fromComposeObject('one_volume', 1032480, {
driver: 'other',
driver_opts: {
opt1: 'test',
const volume = Volume.fromComposeObject(
'one_volume',
1032480,
'deadbeef',
{
driver: 'other',
driver_opts: {
opt1: 'test',
},
labels: {
'my-label': 'test-label',
},
},
labels: {
'my-label': 'test-label',
},
});
);
expect(volume).to.have.property('appId').that.equals(1032480);
expect(volume).to.have.property('name').that.equals('one_volume');
@ -71,6 +89,7 @@ describe('compose/volume', () => {
.that.has.property('labels')
.that.deep.equals({
'io.balena.supervised': 'true',
'io.balena.app-uuid': 'deadbeef',
'my-label': 'test-label',
});
expect(volume)
@ -119,6 +138,7 @@ describe('compose/volume', () => {
Driver: 'local',
Labels: {
'io.balena.supervised': 'true',
'io.balena.app-uuid': 'deadbeef',
},
Mountpoint: '/var/lib/docker/volumes/1032480_one_volume/_data',
Name: '1032480_one_volume',
@ -128,11 +148,13 @@ describe('compose/volume', () => {
expect(volume).to.have.property('appId').that.equals(1032480);
expect(volume).to.have.property('name').that.equals('one_volume');
expect(volume).to.have.property('appUuid').that.equals('deadbeef');
expect(volume)
.to.have.property('config')
.that.has.property('labels')
.that.deep.equals({
'io.balena.supervised': 'true',
'io.balena.app-uuid': 'deadbeef',
});
expect(volume)
.to.have.property('config')
@ -160,7 +182,11 @@ describe('compose/volume', () => {
it('should use defaults to create the volume when no options are given', async () => {
await withMockerode(async (mockerode) => {
const volume = Volume.fromComposeObject('one_volume', 1032480, {});
const volume = Volume.fromComposeObject(
'one_volume',
1032480,
'deadbeef',
);
await volume.create();
@ -169,6 +195,7 @@ describe('compose/volume', () => {
Driver: 'local',
Labels: {
'io.balena.supervised': 'true',
'io.balena.app-uuid': 'deadbeef',
},
DriverOpts: {},
});
@ -177,14 +204,19 @@ describe('compose/volume', () => {
it('should pass configuration options to the engine', async () => {
await withMockerode(async (mockerode) => {
const volume = Volume.fromComposeObject('one_volume', 1032480, {
driver_opts: {
opt1: 'test',
const volume = Volume.fromComposeObject(
'one_volume',
1032480,
'deadbeef',
{
driver_opts: {
opt1: 'test',
},
labels: {
'my-label': 'test-label',
},
},
labels: {
'my-label': 'test-label',
},
});
);
await volume.create();
@ -194,6 +226,7 @@ describe('compose/volume', () => {
Labels: {
'my-label': 'test-label',
'io.balena.supervised': 'true',
'io.balena.app-uuid': 'deadbeef',
},
DriverOpts: {
opt1: 'test',
@ -208,7 +241,11 @@ describe('compose/volume', () => {
it('should log successful volume creation to the cloud', async () => {
await withMockerode(async (mockerode) => {
const volume = Volume.fromComposeObject('one_volume', 1032480, {});
const volume = Volume.fromComposeObject(
'one_volume',
1032480,
'deadbeef',
);
await volume.create();
@ -223,8 +260,8 @@ describe('compose/volume', () => {
describe('comparing volume configuration', () => {
it('should ignore name and supervisor labels in the comparison', () => {
expect(
Volume.fromComposeObject('aaa', 1234, {}).isEqualConfig(
Volume.fromComposeObject('bbb', 4567, {
Volume.fromComposeObject('aaa', 1234, 'deadbeef').isEqualConfig(
Volume.fromComposeObject('bbb', 4567, 'deadbeef', {
driver: 'local',
driver_opts: {},
}),
@ -232,13 +269,30 @@ describe('compose/volume', () => {
).to.be.true;
expect(
Volume.fromComposeObject('aaa', 1234, {}).isEqualConfig(
Volume.fromComposeObject('bbb', 4567, {}),
Volume.fromComposeObject('aaa', 1234, 'deadbeef').isEqualConfig(
Volume.fromComposeObject('bbb', 4567, 'deadc0de'),
),
).to.be.true;
expect(
Volume.fromComposeObject('aaa', 1234, {}).isEqualConfig(
Volume.fromComposeObject('aaa', 1234, 'deadbeef').isEqualConfig(
Volume.fromDockerVolume({
Name: '1234_aaa',
Driver: 'local',
Labels: {
'io.balena.supervised': 'true',
'io.balena.app-uuid': 'deadbeef',
},
Options: {},
Mountpoint: '/var/lib/docker/volumes/1032480_one_volume/_data',
Scope: 'local',
}),
),
).to.be.true;
// the app-uuid should be omitted from the comparison
expect(
Volume.fromComposeObject('aaa', 1234, 'deadbeef').isEqualConfig(
Volume.fromDockerVolume({
Name: '1234_aaa',
Driver: 'local',
@ -253,7 +307,7 @@ describe('compose/volume', () => {
).to.be.true;
expect(
Volume.fromComposeObject('aaa', 1234, {}).isEqualConfig(
Volume.fromComposeObject('aaa', 1234, null as any).isEqualConfig(
Volume.fromDockerVolume({
Name: '4567_bbb',
Driver: 'local',
@ -268,13 +322,14 @@ describe('compose/volume', () => {
).to.be.true;
expect(
Volume.fromComposeObject('aaa', 1234, {}).isEqualConfig(
Volume.fromComposeObject('aaa', 1234, 'deadbeef').isEqualConfig(
Volume.fromDockerVolume({
Name: '1234_aaa',
Driver: 'local',
Labels: {
'some.other.label': '123',
'io.balena.supervised': 'true',
'io.balena.app-uuid': 'deadbeef',
},
Options: {},
Mountpoint: '/var/lib/docker/volumes/1032480_one_volume/_data',
@ -286,8 +341,8 @@ describe('compose/volume', () => {
it('should compare based on driver configuration and options', () => {
expect(
Volume.fromComposeObject('aaa', 1234, {}).isEqualConfig(
Volume.fromComposeObject('aaa', 1234, {
Volume.fromComposeObject('aaa', 1234, 'deadbeef').isEqualConfig(
Volume.fromComposeObject('aaa', 1234, 'deadbeef', {
driver: 'other',
driver_opts: {},
}),
@ -295,10 +350,10 @@ describe('compose/volume', () => {
).to.be.false;
expect(
Volume.fromComposeObject('aaa', 1234, {
Volume.fromComposeObject('aaa', 1234, 'deadbeef', {
driver: 'other',
}).isEqualConfig(
Volume.fromComposeObject('aaa', 1234, {
Volume.fromComposeObject('aaa', 1234, 'deadbeef', {
driver: 'other',
driver_opts: {},
}),
@ -306,15 +361,15 @@ describe('compose/volume', () => {
).to.be.true;
expect(
Volume.fromComposeObject('aaa', 1234, {}).isEqualConfig(
Volume.fromComposeObject('aaa', 1234, {
Volume.fromComposeObject('aaa', 1234, 'deadbeef', {}).isEqualConfig(
Volume.fromComposeObject('aaa', 1234, 'deadbeef', {
driver_opts: { opt: '123' },
}),
),
).to.be.false;
expect(
Volume.fromComposeObject('aaa', 1234, {
Volume.fromComposeObject('aaa', 1234, 'deadbeef', {
driver: 'other',
labels: { 'some.other.label': '123' },
driver_opts: { 'some-opt': '123' },
@ -334,7 +389,7 @@ describe('compose/volume', () => {
).to.be.false;
expect(
Volume.fromComposeObject('aaa', 1234, {
Volume.fromComposeObject('aaa', 1234, 'deadbeef', {
driver: 'other',
labels: { 'some.other.label': '123' },
driver_opts: { 'some-opt': '123' },
@ -375,7 +430,7 @@ describe('compose/volume', () => {
await withMockerode(
async (mockerode) => {
const volume = Volume.fromComposeObject('aaa', 1234, {});
const volume = Volume.fromComposeObject('aaa', 1234, 'deadbeef');
// Check engine state before (this is really to test that mockerode is doing its job)
expect((await mockerode.listVolumes()).Volumes).to.have.lengthOf(1);
@ -402,7 +457,7 @@ describe('compose/volume', () => {
await withMockerode(
async (mockerode) => {
const volume = Volume.fromComposeObject('aaa', 1234, {});
const volume = Volume.fromComposeObject('aaa', 1234, 'deadbeef');
// Check engine state before
expect((await mockerode.listVolumes()).Volumes).to.have.lengthOf(1);
@ -430,7 +485,7 @@ describe('compose/volume', () => {
});
await withMockerode(
async (mockerode) => {
const volume = Volume.fromComposeObject('aaa', 1234, {});
const volume = Volume.fromComposeObject('aaa', 1234, 'deadbeef');
// Check engine state before
expect((await mockerode.listVolumes()).Volumes).to.have.lengthOf(1);
@ -456,7 +511,7 @@ describe('compose/volume', () => {
});
await withMockerode(
async (mockerode) => {
const volume = Volume.fromComposeObject('aaa', 1234, {});
const volume = Volume.fromComposeObject('aaa', 1234, 'deadbeef');
// Stub the mockerode method to fail
mockerode.removeVolume.rejects('Something bad happened');