mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-04-18 08:10:04 +00:00
Add compose support for volumes defined with long syntax
balena-compose already supports this, and with this PR, Supervisor can have the option of using HostConfig.Mounts for internal bind mounts such as ones added by feature labels. This will be handled in a future PR. The only blocker to having users use long syntax is adding this feature to target state. This PR does not add that feature. Relates-to: https://github.com/balena-os/balena-supervisor/pull/1780 Relates-to: https://github.com/balena-os/balena-engine/issues/220 Relates-to: #1933 Change-type: patch Signed-off-by: Christina Wang <christina@balena.io>
This commit is contained in:
parent
713d39a85e
commit
0a9c7282e8
@ -65,7 +65,7 @@ export function sanitiseComposeConfig(
|
||||
): ServiceComposeConfig {
|
||||
const filtered: string[] = [];
|
||||
const toReturn = _.pickBy(composeConfig, (_v, k) => {
|
||||
const included = _.includes(supportedComposeFields, k);
|
||||
const included = supportedComposeFields.includes(k);
|
||||
if (!included) {
|
||||
filtered.push(k);
|
||||
}
|
||||
|
@ -4,26 +4,34 @@ import Duration = require('duration-js');
|
||||
import * as _ from 'lodash';
|
||||
import * as path from 'path';
|
||||
|
||||
import * as conversions from '../lib/conversions';
|
||||
import { checkInt } from '../lib/validation';
|
||||
import { InternalInconsistencyError } from '../lib/errors';
|
||||
import { DockerPortOptions, PortMap } from './ports';
|
||||
import {
|
||||
ConfigMap,
|
||||
DeviceMetadata,
|
||||
DockerDevice,
|
||||
ServiceComposeConfig,
|
||||
ServiceConfig,
|
||||
ServiceConfigArrayField,
|
||||
} from './types/service';
|
||||
import * as ComposeUtils from './utils';
|
||||
|
||||
import * as updateLock from '../lib/update-lock';
|
||||
import { sanitiseComposeConfig } from './sanitise';
|
||||
import { getPathOnHost } from '../lib/fs-utils';
|
||||
|
||||
import log from '../lib/supervisor-console';
|
||||
import * as conversions from '../lib/conversions';
|
||||
import { checkInt } from '../lib/validation';
|
||||
import { InternalInconsistencyError } from '../lib/errors';
|
||||
|
||||
import { EnvVarObject } from '../types';
|
||||
import {
|
||||
ServiceConfig,
|
||||
ServiceConfigArrayField,
|
||||
ServiceComposeConfig,
|
||||
ConfigMap,
|
||||
DeviceMetadata,
|
||||
DockerDevice,
|
||||
ShortMount,
|
||||
ShortBind,
|
||||
ShortAnonymousVolume,
|
||||
ShortNamedVolume,
|
||||
LongDefinition,
|
||||
LongTmpfs,
|
||||
LongBind,
|
||||
LongAnonymousVolume,
|
||||
LongNamedVolume,
|
||||
} from './types/service';
|
||||
|
||||
const SERVICE_NETWORK_MODE_REGEX = /service:\s*(.+)/;
|
||||
const CONTAINER_NETWORK_MODE_REGEX = /container:\s*(.+)/;
|
||||
@ -528,10 +536,20 @@ export class Service {
|
||||
}
|
||||
expose = _.uniq(expose);
|
||||
|
||||
const tmpfs: string[] = [];
|
||||
_.each((container.HostConfig as any).Tmpfs, (_v, key) => {
|
||||
tmpfs.push(key);
|
||||
});
|
||||
const tmpfs: string[] = Object.keys(container.HostConfig?.Tmpfs || {});
|
||||
|
||||
const binds: string[] = _.uniq(
|
||||
([] as string[]).concat(
|
||||
container.HostConfig.Binds || [],
|
||||
Object.keys(container.Config?.Volumes || {}),
|
||||
),
|
||||
);
|
||||
|
||||
const mounts: LongDefinition[] = (container.HostConfig?.Mounts || []).map(
|
||||
ComposeUtils.dockerMountToServiceMount,
|
||||
);
|
||||
|
||||
const volumes: ServiceConfig['volumes'] = [...binds, ...mounts];
|
||||
|
||||
// We cannot use || for this value, as the empty string is a
|
||||
// valid restart policy but will equate to null in an OR
|
||||
@ -558,10 +576,7 @@ export class Service {
|
||||
hostname,
|
||||
command: container.Config.Cmd || '',
|
||||
entrypoint: container.Config.Entrypoint || '',
|
||||
volumes: _.concat(
|
||||
container.HostConfig.Binds || [],
|
||||
_.keys(container.Config.Volumes || {}),
|
||||
),
|
||||
volumes,
|
||||
image: container.Config.Image,
|
||||
environment: Service.omitDeviceNameVars(
|
||||
conversions.envArrayToObject(container.Config.Env || []),
|
||||
@ -667,13 +682,13 @@ export class Service {
|
||||
deviceName: string;
|
||||
containerIds: Dictionary<string>;
|
||||
}): Dockerode.ContainerCreateOptions {
|
||||
const { binds, volumes } = this.getBindsAndVolumes();
|
||||
const { binds, mounts, volumes } = this.getBindsMountsAndVolumes();
|
||||
const { exposedPorts, portBindings } = this.generateExposeAndPorts();
|
||||
|
||||
const tmpFs: Dictionary<''> = {};
|
||||
_.each(this.config.tmpfs, (tmp) => {
|
||||
tmpFs[tmp] = '';
|
||||
});
|
||||
const tmpFs: Dictionary<''> = this.config.tmpfs.reduce(
|
||||
(dict, tmp) => ({ ...dict, [tmp]: '' }),
|
||||
{},
|
||||
);
|
||||
|
||||
const mainNetwork = _.pickBy(
|
||||
this.config.networks,
|
||||
@ -724,6 +739,7 @@ export class Service {
|
||||
CapAdd: this.config.capAdd,
|
||||
CapDrop: this.config.capDrop,
|
||||
Binds: binds,
|
||||
Mounts: mounts,
|
||||
CgroupParent: this.config.cgroupParent,
|
||||
Devices: this.config.devices,
|
||||
DeviceRequests: this.config.deviceRequests,
|
||||
@ -923,21 +939,26 @@ export class Service {
|
||||
);
|
||||
}
|
||||
|
||||
private getBindsAndVolumes(): {
|
||||
private getBindsMountsAndVolumes(): {
|
||||
binds: string[];
|
||||
mounts: Dockerode.MountSettings[];
|
||||
volumes: { [volName: string]: {} };
|
||||
} {
|
||||
const binds: string[] = [];
|
||||
const mounts: Dockerode.MountSettings[] = [];
|
||||
const volumes: { [volName: string]: {} } = {};
|
||||
_.each(this.config.volumes, (volume) => {
|
||||
if (_.includes(volume, ':')) {
|
||||
binds.push(volume);
|
||||
} else {
|
||||
volumes[volume] = {};
|
||||
}
|
||||
});
|
||||
|
||||
return { binds, volumes };
|
||||
for (const volume of this.config.volumes) {
|
||||
if (LongDefinition.is(volume)) {
|
||||
// Volumes with the long syntax are translated into Docker-accepted configs
|
||||
mounts.push(ComposeUtils.serviceMountToDockerMount(volume));
|
||||
} else {
|
||||
// Volumes with the string short syntax are acceptable as Docker configs as-is
|
||||
ShortMount.is(volume) ? binds.push(volume) : (volumes[volume] = {});
|
||||
}
|
||||
}
|
||||
|
||||
return { binds, mounts, volumes };
|
||||
}
|
||||
|
||||
private generateExposeAndPorts(): DockerPortOptions {
|
||||
@ -1029,17 +1050,17 @@ export class Service {
|
||||
|
||||
public hasVolume(volumeName: string) {
|
||||
return this.config.volumes.some((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
|
||||
let source: string;
|
||||
|
||||
if (LongNamedVolume.is(volumeDefinition)) {
|
||||
source = volumeDefinition.source;
|
||||
} else if (ShortNamedVolume.is(volumeDefinition)) {
|
||||
[source] = volumeDefinition.split(':');
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
return `${this.appId}_${volumeName}` === sourceName;
|
||||
return `${this.appId}_${volumeName}` === source;
|
||||
});
|
||||
}
|
||||
|
||||
@ -1124,26 +1145,43 @@ export class Service {
|
||||
): ServiceConfig['volumes'] {
|
||||
let volumes: ServiceConfig['volumes'] = [];
|
||||
|
||||
_.each(composeVolumes, (volume) => {
|
||||
const isBind = _.includes(volume, ':');
|
||||
if (isBind) {
|
||||
const [bindSource, bindDest, mode] = volume.split(':');
|
||||
if (!path.isAbsolute(bindSource)) {
|
||||
// namespace our volumes by appId
|
||||
let volumeDef = `${appId}_${bindSource.trim()}:${bindDest.trim()}`;
|
||||
if (mode != null) {
|
||||
volumeDef = `${volumeDef}:${mode.trim()}`;
|
||||
}
|
||||
volumes.push(volumeDef);
|
||||
} else {
|
||||
log.warn(`Ignoring invalid bind mount ${volume}`);
|
||||
}
|
||||
} else {
|
||||
// Push anonymous volume. Anonymous volumes can only be
|
||||
// set by using the `VOLUME` instruction inside a dockerfile
|
||||
// namespace our volumes by appId
|
||||
const namespaceVolume = (volumeSource: string) =>
|
||||
`${appId}_${volumeSource.trim()}`;
|
||||
|
||||
for (const volume of composeVolumes || []) {
|
||||
const isString = typeof volume === 'string';
|
||||
// Bind mounts are not allowed
|
||||
if (LongBind.is(volume) || ShortBind.is(volume)) {
|
||||
log.warn(
|
||||
`Ignoring invalid bind mount ${
|
||||
isString ? volume : JSON.stringify(volume)
|
||||
}`,
|
||||
);
|
||||
} else if (
|
||||
LongTmpfs.is(volume) ||
|
||||
LongAnonymousVolume.is(volume) ||
|
||||
ShortAnonymousVolume.is(volume)
|
||||
) {
|
||||
volumes.push(volume);
|
||||
} else if (LongNamedVolume.is(volume)) {
|
||||
volume.source = namespaceVolume(volume.source);
|
||||
volumes.push(volume);
|
||||
} else if (ShortNamedVolume.is(volume)) {
|
||||
const [source, target, mode] = (volume as string).split(':');
|
||||
let volumeDef = `${namespaceVolume(source)}:${target.trim()}`;
|
||||
if (mode != null) {
|
||||
volumeDef = `${volumeDef}:${mode.trim()}`;
|
||||
}
|
||||
volumes.push(volumeDef);
|
||||
} else {
|
||||
log.warn(
|
||||
`Ignoring invalid compose volume definition ${
|
||||
isString ? volume : JSON.stringify(volume)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Now add the default and image binds
|
||||
volumes = volumes.concat(Service.defaultBinds(appId, serviceName));
|
||||
|
@ -1,4 +1,6 @@
|
||||
import * as Dockerode from 'dockerode';
|
||||
import * as t from 'io-ts';
|
||||
import { isAbsolute } from 'path';
|
||||
|
||||
import { PortMap } from '../ports';
|
||||
|
||||
@ -28,6 +30,135 @@ export interface ServiceNetworkDictionary {
|
||||
};
|
||||
}
|
||||
|
||||
// Any short syntax volume definition
|
||||
const ShortDefinition = t.string;
|
||||
export type ShortDefinition = t.TypeOf<typeof ShortDefinition>;
|
||||
|
||||
/**
|
||||
* Brands are io-ts utilities to refine a base type into something more specific.
|
||||
* For example, ShortMount's base type is a string, but ShortMount only matches strings with a colon.
|
||||
*/
|
||||
// Short syntax volumes or binds with a colon, indicating a host to container mapping
|
||||
interface ShortMountBrand {
|
||||
readonly ShortMount: unique symbol;
|
||||
}
|
||||
export const ShortMount = t.brand(
|
||||
ShortDefinition,
|
||||
(s): s is t.Branded<string, ShortMountBrand> => s.includes(':'),
|
||||
'ShortMount',
|
||||
);
|
||||
type ShortMount = t.TypeOf<typeof ShortMount>;
|
||||
|
||||
// Short syntax volumes with a colon and an absolute host path
|
||||
interface ShortBindBrand {
|
||||
readonly ShortBind: unique symbol;
|
||||
}
|
||||
export const ShortBind = t.brand(
|
||||
ShortMount,
|
||||
(s): s is t.Branded<ShortMount, ShortBindBrand> => {
|
||||
const [source] = s.split(':');
|
||||
return isAbsolute(source);
|
||||
},
|
||||
'ShortBind',
|
||||
);
|
||||
|
||||
// Anonymous short syntax volumes (no colon)
|
||||
interface ShortAnonymousVolumeBrand {
|
||||
readonly ShortAnonymousVolume: unique symbol;
|
||||
}
|
||||
export const ShortAnonymousVolume = t.brand(
|
||||
ShortDefinition,
|
||||
(s): s is t.Branded<string, ShortAnonymousVolumeBrand> => !ShortMount.is(s),
|
||||
'ShortAnonymousVolume',
|
||||
);
|
||||
|
||||
// Named short syntax volumes
|
||||
interface ShortNamedVolumeBrand {
|
||||
readonly ShortNamedVolume: unique symbol;
|
||||
}
|
||||
export const ShortNamedVolume = t.brand(
|
||||
ShortMount,
|
||||
(s): s is t.Branded<ShortMount, ShortNamedVolumeBrand> =>
|
||||
ShortMount.is(s) && !ShortBind.is(s),
|
||||
'ShortNamedVolume',
|
||||
);
|
||||
|
||||
// https://docs.docker.com/compose/compose-file/compose-file-v2/#volumes
|
||||
const LongRequired = t.type({
|
||||
type: t.keyof({ volume: null, bind: null, tmpfs: null }),
|
||||
target: t.string,
|
||||
});
|
||||
// LongOptional will not restrict definable options based on
|
||||
// the required `type` property, similar to docker-compose's behavior
|
||||
const LongOptional = t.partial({
|
||||
readOnly: t.boolean,
|
||||
volume: t.type({ nocopy: t.boolean }),
|
||||
bind: t.type({ propagation: t.string }),
|
||||
tmpfs: t.type({ size: t.number }),
|
||||
});
|
||||
const LongBase = t.intersection([LongRequired, LongOptional]);
|
||||
type LongBase = t.TypeOf<typeof LongBase>;
|
||||
const LongWithSource = t.intersection([
|
||||
LongRequired,
|
||||
LongOptional,
|
||||
t.type({ source: t.string }),
|
||||
]);
|
||||
type LongWithSource = t.TypeOf<typeof LongWithSource>;
|
||||
|
||||
// 'source' is optional for volumes. Volumes without source are interpreted as anonymous volumes
|
||||
interface LongAnonymousVolumeBrand {
|
||||
readonly LongAnonymousVolume: unique symbol;
|
||||
}
|
||||
export const LongAnonymousVolume = t.brand(
|
||||
LongBase,
|
||||
(l): l is t.Branded<LongBase, LongAnonymousVolumeBrand> =>
|
||||
l.type === 'volume' && !('source' in l),
|
||||
'LongAnonymousVolume',
|
||||
);
|
||||
|
||||
interface LongNamedVolumeBrand {
|
||||
readonly LongNamedVolume: unique symbol;
|
||||
}
|
||||
export const LongNamedVolume = t.brand(
|
||||
LongWithSource,
|
||||
(l): l is t.Branded<LongWithSource, LongNamedVolumeBrand> =>
|
||||
l.type === 'volume' && !isAbsolute(l.source),
|
||||
'LongNamedVolume',
|
||||
);
|
||||
|
||||
// 'source' is required for binds
|
||||
interface LongBindBrand {
|
||||
readonly LongBind: unique symbol;
|
||||
}
|
||||
export const LongBind = t.brand(
|
||||
LongWithSource,
|
||||
(l): l is t.Branded<LongWithSource, LongBindBrand> =>
|
||||
l.type === 'bind' && isAbsolute(l.source),
|
||||
'LongBind',
|
||||
);
|
||||
|
||||
// 'source' is disallowed for tmpfs
|
||||
interface LongTmpfsBrand {
|
||||
readonly LongTmpfs: unique symbol;
|
||||
}
|
||||
export const LongTmpfs = t.brand(
|
||||
LongBase,
|
||||
(l): l is t.Branded<LongBase, LongTmpfsBrand> =>
|
||||
l.type === 'tmpfs' && !('source' in l),
|
||||
'LongTmpfs',
|
||||
);
|
||||
|
||||
// Any long syntax volume definition
|
||||
export const LongDefinition = t.union([
|
||||
LongAnonymousVolume,
|
||||
LongNamedVolume,
|
||||
LongBind,
|
||||
LongTmpfs,
|
||||
]);
|
||||
export type LongDefinition = t.TypeOf<typeof LongDefinition>;
|
||||
|
||||
type ServiceVolumeConfig = ShortDefinition | LongDefinition;
|
||||
|
||||
// This is the config directly from the compose file (after running it
|
||||
// through _.camelCase)
|
||||
export interface ServiceComposeConfig {
|
||||
@ -68,7 +199,7 @@ export interface ServiceComposeConfig {
|
||||
[ulimitName: string]: number | { soft: number; hard: number };
|
||||
};
|
||||
usernsMode?: string;
|
||||
volumes?: string[];
|
||||
volumes?: ServiceVolumeConfig[];
|
||||
restart?: string;
|
||||
cpuShares?: number;
|
||||
cpuQuota?: number;
|
||||
@ -133,7 +264,7 @@ export interface ServiceConfig {
|
||||
[ulimitName: string]: { soft: number; hard: number };
|
||||
};
|
||||
usernsMode: string;
|
||||
volumes: string[];
|
||||
volumes: ServiceVolumeConfig[];
|
||||
restart: string;
|
||||
cpuShares: number;
|
||||
cpuQuota: number;
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
ServiceComposeConfig,
|
||||
ServiceConfig,
|
||||
ServiceHealthcheck,
|
||||
LongDefinition,
|
||||
} from './types/service';
|
||||
|
||||
import log from '../lib/supervisor-console';
|
||||
@ -602,3 +603,81 @@ export function compareArrayFields<T extends Dictionary<unknown>>(
|
||||
return { equal, difference };
|
||||
}
|
||||
}
|
||||
|
||||
export function serviceMountToDockerMount(
|
||||
serviceMount: LongDefinition,
|
||||
): Dockerode.MountSettings {
|
||||
const { target, type, readOnly } = serviceMount;
|
||||
|
||||
const mount: Partial<Dockerode.MountSettings> = {
|
||||
Type: type,
|
||||
Target: target,
|
||||
};
|
||||
|
||||
// Add optional mount settings
|
||||
if ('source' in serviceMount) {
|
||||
mount.Source = serviceMount.source;
|
||||
}
|
||||
if (readOnly) {
|
||||
mount.ReadOnly = readOnly;
|
||||
}
|
||||
if ('bind' in serviceMount && 'propagation' in serviceMount.bind!) {
|
||||
mount.BindOptions = {
|
||||
Propagation: serviceMount.bind!.propagation as Dockerode.MountPropagation,
|
||||
};
|
||||
}
|
||||
// Although Dockerode.MountSettings type includes some additional options
|
||||
// under VolumeOptions and TmpfsOptions, compose does not allow setting
|
||||
// those additional options with volume long syntax.
|
||||
// Therefore we need to typecast here to satisfy the TS compiler.
|
||||
if ('volume' in serviceMount && 'nocopy' in serviceMount.volume!) {
|
||||
mount.VolumeOptions = {
|
||||
NoCopy: serviceMount.volume!.nocopy,
|
||||
} as Dockerode.MountSettings['VolumeOptions'];
|
||||
}
|
||||
if ('tmpfs' in serviceMount && 'size' in serviceMount.tmpfs!) {
|
||||
mount.TmpfsOptions = {
|
||||
SizeBytes: serviceMount.tmpfs!.size,
|
||||
} as Dockerode.MountSettings['TmpfsOptions'];
|
||||
}
|
||||
|
||||
return mount as Dockerode.MountSettings;
|
||||
}
|
||||
|
||||
export function dockerMountToServiceMount(
|
||||
dockerMount: Dockerode.MountSettings,
|
||||
): LongDefinition {
|
||||
const {
|
||||
Source,
|
||||
Target,
|
||||
Type,
|
||||
ReadOnly,
|
||||
BindOptions,
|
||||
VolumeOptions,
|
||||
TmpfsOptions,
|
||||
} = dockerMount;
|
||||
|
||||
const mount: any = {
|
||||
type: Type,
|
||||
target: Target,
|
||||
};
|
||||
|
||||
// Add optional mount settings
|
||||
if (Source) {
|
||||
mount.source = Source;
|
||||
}
|
||||
if (ReadOnly) {
|
||||
mount.readOnly = ReadOnly;
|
||||
}
|
||||
if (BindOptions?.Propagation) {
|
||||
mount.bind = { propagation: BindOptions.Propagation };
|
||||
}
|
||||
if (VolumeOptions?.NoCopy) {
|
||||
mount.volume = { nocopy: VolumeOptions.NoCopy };
|
||||
}
|
||||
if (TmpfsOptions?.SizeBytes) {
|
||||
mount.tmpfs = { size: TmpfsOptions.SizeBytes };
|
||||
}
|
||||
|
||||
return mount as LongDefinition;
|
||||
}
|
||||
|
@ -375,7 +375,6 @@ describe('LocalModeManager', () => {
|
||||
it('stores snapshot and retrieves from the db', async () => {
|
||||
await localMode.storeEngineSnapshot(recordSample);
|
||||
const retrieved = await localMode.retrieveLatestSnapshot();
|
||||
console.log(retrieved);
|
||||
expect(retrieved).to.be.deep.equal(recordSample);
|
||||
});
|
||||
|
||||
|
@ -6,10 +6,7 @@ import { createContainer } from '../../lib/mockerode';
|
||||
|
||||
import Service from '../../../src/compose/service';
|
||||
import Volume from '../../../src/compose/volume';
|
||||
import {
|
||||
ServiceComposeConfig,
|
||||
ServiceConfig,
|
||||
} from '../../../src/compose/types/service';
|
||||
import * as ServiceT from '../../../src/compose/types/service';
|
||||
import * as constants from '../../../src/lib/constants';
|
||||
import * as apiKeys from '../../../src/lib/api-keys';
|
||||
|
||||
@ -424,7 +421,7 @@ describe('compose/service', () => {
|
||||
describe('Configuring service networks', () => {
|
||||
it('should correctly convert networks from compose to docker format', async () => {
|
||||
const makeComposeServiceWithNetwork = async (
|
||||
networks: ServiceComposeConfig['networks'],
|
||||
networks: ServiceT.ServiceComposeConfig['networks'],
|
||||
) =>
|
||||
await Service.fromComposeObject(
|
||||
{
|
||||
@ -877,7 +874,7 @@ describe('compose/service', () => {
|
||||
});
|
||||
|
||||
describe('Creating service instances from docker configuration', () => {
|
||||
const omitConfigForComparison = (config: ServiceConfig) =>
|
||||
const omitConfigForComparison = (config: ServiceT.ServiceConfig) =>
|
||||
_.omit(config, ['running', 'networks']);
|
||||
|
||||
it('should be equivalent to loading from compose config for simple services', async () => {
|
||||
@ -976,7 +973,7 @@ describe('compose/service', () => {
|
||||
});
|
||||
|
||||
describe('Network mode service:', () => {
|
||||
const omitConfigForComparison = (config: ServiceConfig) =>
|
||||
const omitConfigForComparison = (config: ServiceT.ServiceConfig) =>
|
||||
_.omit(config, ['running', 'networks']);
|
||||
|
||||
it('should correctly add a depends_on entry for the service', async () => {
|
||||
@ -1123,4 +1120,333 @@ describe('compose/service', () => {
|
||||
.that.deep.equals(['seccomp=unconfined']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Long syntax volume configuration', () => {
|
||||
it('should generate a docker container config from a compose object (compose -> Service, Service -> container)', async () => {
|
||||
/**
|
||||
* compose -> Service (fromComposeObject)
|
||||
*/
|
||||
const appId = 5;
|
||||
const serviceName = 'main';
|
||||
const longSyntaxVolumes = [
|
||||
// Long named volume
|
||||
{
|
||||
type: 'volume',
|
||||
source: 'another',
|
||||
target: '/another',
|
||||
readOnly: true,
|
||||
},
|
||||
// Long anonymous volume
|
||||
{
|
||||
type: 'volume',
|
||||
target: '/yet/another',
|
||||
},
|
||||
{ type: 'bind', source: '/mnt/data', target: '/data' },
|
||||
{ type: 'tmpfs', target: '/home/tmp' },
|
||||
];
|
||||
const service = await Service.fromComposeObject(
|
||||
{
|
||||
appId,
|
||||
serviceName,
|
||||
releaseId: 4,
|
||||
serviceId: 3,
|
||||
imageId: 2,
|
||||
composition: {
|
||||
volumes: [
|
||||
'myvolume:/myvolume',
|
||||
'readonly:/readonly:ro',
|
||||
'/home/mybind:/mybind',
|
||||
'anonymous_volume',
|
||||
...longSyntaxVolumes,
|
||||
],
|
||||
tmpfs: ['/var/tmp'],
|
||||
},
|
||||
},
|
||||
{ appName: 'bar' } as any,
|
||||
);
|
||||
|
||||
// Only tmpfs from composition should be added to config.tmpfs
|
||||
expect(service.config.tmpfs).to.deep.equal(['/var/tmp']);
|
||||
// config.volumes should include all long syntax and short syntax mounts, excluding binds
|
||||
expect(service.config.volumes).to.deep.include.members([
|
||||
`${appId}_myvolume:/myvolume`,
|
||||
`${appId}_readonly:/readonly:ro`,
|
||||
'anonymous_volume',
|
||||
...longSyntaxVolumes.filter(({ type }) => type !== 'bind'),
|
||||
`/tmp/balena-supervisor/services/${appId}/${serviceName}:/tmp/resin`,
|
||||
`/tmp/balena-supervisor/services/${appId}/${serviceName}:/tmp/balena`,
|
||||
]);
|
||||
// bind mounts are not allowed
|
||||
expect(service.config.volumes).to.not.deep.include.members([
|
||||
'/home/mybind:/mybind',
|
||||
...longSyntaxVolumes.filter(({ type }) => type === 'bind'),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Service -> container (toDockerContainer)
|
||||
*/
|
||||
// Inject bind mounts (as feature labels would)
|
||||
// Bind mounts added under feature labels use the short syntax (for now)
|
||||
service.config.volumes.push('/var/log/journal:/var/log/journal:ro');
|
||||
service.config.volumes.push(
|
||||
`${constants.dockerSocket}:${constants.containerDockerSocket}`,
|
||||
);
|
||||
|
||||
const ctn = service.toDockerContainer({
|
||||
deviceName: 'thicc_nucc',
|
||||
} as any);
|
||||
// Only long syntax volumes should be listed under HostConfig.Mounts.
|
||||
// This includes any tmpfs volumes defined using long syntax, consistent
|
||||
// with docker-compose's behavior.
|
||||
expect(ctn.HostConfig)
|
||||
.to.have.property('Mounts')
|
||||
.that.deep.includes.members([
|
||||
{
|
||||
Type: 'volume',
|
||||
Source: `${appId}_another`, // Should be namespaced by appId
|
||||
Target: '/another',
|
||||
ReadOnly: true,
|
||||
},
|
||||
{ Type: 'tmpfs', Target: '/home/tmp' },
|
||||
]);
|
||||
|
||||
// bind mounts should be filtered out
|
||||
expect(ctn.HostConfig)
|
||||
.to.have.property('Mounts')
|
||||
.that.does.not.deep.include.members([
|
||||
{ Type: 'bind', Source: '/mnt/data', Target: '/data' },
|
||||
]);
|
||||
|
||||
// Short syntax volumes should be configured as HostConfig.Binds
|
||||
expect(ctn.HostConfig)
|
||||
.to.have.property('Binds')
|
||||
.that.includes.members([
|
||||
`${appId}_myvolume:/myvolume`,
|
||||
`${appId}_readonly:/readonly:ro`,
|
||||
`/tmp/balena-supervisor/services/${appId}/${serviceName}:/tmp/resin`,
|
||||
`/tmp/balena-supervisor/services/${appId}/${serviceName}:/tmp/balena`,
|
||||
'/var/log/journal:/var/log/journal:ro',
|
||||
`${constants.dockerSocket}:${constants.containerDockerSocket}`,
|
||||
]);
|
||||
|
||||
// Tmpfs volumes defined through compose's service.tmpfs are under HostConfig.Tmpfs.
|
||||
// Otherwise tmpfs volumes defined through compose's service.volumes as type: 'tmpfs' are under HostConfig.Mounts.
|
||||
expect(ctn.HostConfig).to.have.property('Tmpfs').that.deep.equals({
|
||||
'/var/tmp': '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate a service instance from a docker container (container -> Service)', async () => {
|
||||
const appId = 6;
|
||||
const mockContainer = createContainer({
|
||||
Id: 'deadbeef',
|
||||
Name: 'main_123_456_789',
|
||||
HostConfig: {
|
||||
Binds: [
|
||||
`${appId}_test:/test`,
|
||||
`${appId}_test2:/test2:ro`,
|
||||
'/proc:/proc',
|
||||
'/etc/machine-id:/etc/machine-id:ro',
|
||||
],
|
||||
Tmpfs: {
|
||||
'/var/tmp1': '',
|
||||
},
|
||||
Mounts: [
|
||||
{
|
||||
Type: 'volume',
|
||||
Source: '6_test3',
|
||||
Target: '/test3',
|
||||
},
|
||||
{
|
||||
Type: 'tmpfs',
|
||||
Target: '/var/tmp2',
|
||||
} as any,
|
||||
// Dockerode typings require a Source field but tmpfs doesn't require Source
|
||||
],
|
||||
},
|
||||
Config: {
|
||||
Volumes: {
|
||||
'/var/lib/volume': {},
|
||||
},
|
||||
Labels: {
|
||||
'io.balena.app-id': `${appId}`,
|
||||
'io.balena.architecture': 'amd64',
|
||||
'io.balena.service-id': '123',
|
||||
'io.balena.service-name': 'main',
|
||||
'io.balena.supervised': 'true',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = Service.fromDockerContainer(mockContainer.inspectInfo);
|
||||
// service.volumes should combine:
|
||||
// - HostConfig.Binds
|
||||
// - 'volume'|'tmpfs' types from HostConfig.Mounts
|
||||
// - Config.Volumes
|
||||
expect(service.config)
|
||||
.to.have.property('volumes')
|
||||
.that.deep.includes.members([
|
||||
`${appId}_test:/test`,
|
||||
`${appId}_test2:/test2:ro`,
|
||||
'/proc:/proc',
|
||||
'/etc/machine-id:/etc/machine-id:ro',
|
||||
'/var/lib/volume',
|
||||
{
|
||||
type: 'volume',
|
||||
source: '6_test3',
|
||||
target: '/test3',
|
||||
},
|
||||
{
|
||||
type: 'tmpfs',
|
||||
target: '/var/tmp2',
|
||||
},
|
||||
]);
|
||||
|
||||
// service.tmpfs should only include HostConfig.Tmpfs,
|
||||
// 'tmpfs' types defined with long syntax belong to HostConfig.Mounts
|
||||
// and therefore are added to service.config.volumes.
|
||||
expect(service.config)
|
||||
.to.have.property('tmpfs')
|
||||
.that.deep.equals(['/var/tmp1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Service volume types', () => {
|
||||
it('should correctly identify short syntax volumes', () => {
|
||||
// Short binds
|
||||
['/one:/one', '/two:/two:ro', '/three:/three:rw'].forEach((b) => {
|
||||
expect(ServiceT.ShortMount.is(b)).to.be.true;
|
||||
expect(ServiceT.ShortBind.is(b)).to.be.true;
|
||||
expect(ServiceT.ShortAnonymousVolume.is(b)).to.be.false;
|
||||
expect(ServiceT.ShortNamedVolume.is(b)).to.be.false;
|
||||
});
|
||||
// Short anonymous volumes
|
||||
['volume', 'another_volume'].forEach((v) => {
|
||||
expect(ServiceT.ShortMount.is(v)).to.be.false;
|
||||
expect(ServiceT.ShortBind.is(v)).to.be.false;
|
||||
expect(ServiceT.ShortAnonymousVolume.is(v)).to.be.true;
|
||||
expect(ServiceT.ShortNamedVolume.is(v)).to.be.false;
|
||||
});
|
||||
// Short named volumes
|
||||
[
|
||||
'another_one:/another/one',
|
||||
'yet_another:/yet/another:ro',
|
||||
'final:/final:rw',
|
||||
].forEach((v) => {
|
||||
expect(ServiceT.ShortMount.is(v)).to.be.true;
|
||||
expect(ServiceT.ShortBind.is(v)).to.be.false;
|
||||
expect(ServiceT.ShortAnonymousVolume.is(v)).to.be.false;
|
||||
expect(ServiceT.ShortNamedVolume.is(v)).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly identify long syntax volumes', () => {
|
||||
// For all the following examples where optionals are defined, only the option with key equal to the type
|
||||
// will be applied to the resulting volume, but the other options shouldn't cause the type check to return false.
|
||||
// For example, for a definition with { type: volume }, only { volume: { nocopy: boolean }} option will apply.
|
||||
const longAnonymousVols = [
|
||||
{ type: 'volume', target: '/one' },
|
||||
{ type: 'volume', target: '/two', readOnly: true },
|
||||
{ type: 'volume', target: '/three', volume: { nocopy: true } },
|
||||
{ type: 'volume', target: '/four', bind: { propagation: 'slave' } },
|
||||
{ type: 'volume', target: '/five', tmpfs: { size: 200 } },
|
||||
];
|
||||
longAnonymousVols.forEach((v) => {
|
||||
expect(ServiceT.LongAnonymousVolume.is(v)).to.be.true;
|
||||
expect(ServiceT.LongNamedVolume.is(v)).to.be.false;
|
||||
expect(ServiceT.LongBind.is(v)).to.be.false;
|
||||
expect(ServiceT.LongTmpfs.is(v)).to.be.false;
|
||||
});
|
||||
|
||||
const longNamedVols = [
|
||||
{ type: 'volume', source: 'one', target: '/one' },
|
||||
{ type: 'volume', source: 'two', target: '/two', readOnly: false },
|
||||
{
|
||||
type: 'volume',
|
||||
source: 'three',
|
||||
target: '/three',
|
||||
volume: { nocopy: false },
|
||||
},
|
||||
{
|
||||
type: 'volume',
|
||||
source: 'four',
|
||||
target: '/four',
|
||||
bind: { propagation: 'slave' },
|
||||
},
|
||||
{
|
||||
type: 'volume',
|
||||
source: 'five',
|
||||
target: '/five',
|
||||
tmpfs: { size: 200 },
|
||||
},
|
||||
];
|
||||
longNamedVols.forEach((v) => {
|
||||
expect(ServiceT.LongAnonymousVolume.is(v)).to.be.false;
|
||||
expect(ServiceT.LongNamedVolume.is(v)).to.be.true;
|
||||
expect(ServiceT.LongBind.is(v)).to.be.false;
|
||||
expect(ServiceT.LongTmpfs.is(v)).to.be.false;
|
||||
});
|
||||
|
||||
const longBinds = [
|
||||
{ type: 'bind', source: '/one', target: '/one' },
|
||||
{ type: 'bind', source: '/two', target: '/two', readOnly: true },
|
||||
{
|
||||
type: 'bind',
|
||||
source: '/three',
|
||||
target: '/three',
|
||||
volume: { nocopy: false },
|
||||
},
|
||||
{
|
||||
type: 'bind',
|
||||
source: '/four',
|
||||
target: '/four',
|
||||
bind: { propagation: 'slave' },
|
||||
},
|
||||
{
|
||||
type: 'bind',
|
||||
source: '/five',
|
||||
target: '/five',
|
||||
tmpfs: { size: 200 },
|
||||
},
|
||||
];
|
||||
longBinds.forEach((v) => {
|
||||
expect(ServiceT.LongAnonymousVolume.is(v)).to.be.false;
|
||||
expect(ServiceT.LongNamedVolume.is(v)).to.be.false;
|
||||
expect(ServiceT.LongBind.is(v)).to.be.true;
|
||||
expect(ServiceT.LongTmpfs.is(v)).to.be.false;
|
||||
});
|
||||
|
||||
const longTmpfs = [
|
||||
{ type: 'tmpfs', target: '/var/tmp' },
|
||||
{ type: 'tmpfs', target: '/var/tmp2', readOnly: false },
|
||||
{ type: 'tmpfs', target: '/var/tmp3', volume: { nocopy: false } },
|
||||
{ type: 'tmpfs', target: '/var/tmp4', bind: { propagation: 'slave' } },
|
||||
{ type: 'tmpfs', target: '/var/tmp4', tmpfs: { size: 200 } },
|
||||
];
|
||||
longTmpfs.forEach((v) => {
|
||||
expect(ServiceT.LongAnonymousVolume.is(v)).to.be.false;
|
||||
expect(ServiceT.LongNamedVolume.is(v)).to.be.false;
|
||||
expect(ServiceT.LongBind.is(v)).to.be.false;
|
||||
expect(ServiceT.LongTmpfs.is(v)).to.be.true;
|
||||
});
|
||||
|
||||
// All of the following volume definitions are not allowed by docker-compose
|
||||
const invalids = [
|
||||
// bind without source
|
||||
{ type: 'bind', target: '/test' },
|
||||
// bind with source that's not an absolute path
|
||||
{ type: 'bind', source: 'not_a_bind', target: '/bind' },
|
||||
// tmpfs with source
|
||||
{ type: 'tmpfs', source: '/var/tmp', target: '/home/tmp' },
|
||||
// Other types besides volume, tmpfs, or bind
|
||||
{ type: 'invalid', source: 'test', target: '/test2' },
|
||||
];
|
||||
invalids.forEach((v) => {
|
||||
expect(ServiceT.LongAnonymousVolume.is(v)).to.be.false;
|
||||
expect(ServiceT.LongNamedVolume.is(v)).to.be.false;
|
||||
expect(ServiceT.LongBind.is(v)).to.be.false;
|
||||
expect(ServiceT.LongTmpfs.is(v)).to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user