Setup environment for dbus tests

Change-type: patch
This commit is contained in:
Felipe Lalanne 2022-09-29 17:52:29 -03:00 committed by pipex
parent 97ec2a4151
commit 819e184095
13 changed files with 402 additions and 174 deletions

View File

@ -22,4 +22,5 @@ testfs:
# when restoring the filesystem
cleanup:
- /data/database.sqlite
- /data/apps.json.preloaded
- /mnt/root/tmp/balena-supervisor/**/*.lock

View File

@ -1,6 +1,13 @@
version: '2.3'
services:
dbus-services:
environment:
DEVELOPMENT: 1
volumes:
- './test/lib/dbus/systemd.ts:/usr/src/app/systemd.ts'
- './test/lib/dbus/login.ts:/usr/src/app/login.ts'
sut:
command: sleep infinity
volumes:

View File

@ -10,8 +10,23 @@ services:
command: sleep infinity
dbus:
build:
context: ./test/lib/dbus/
image: balenablocks/dbus
environment:
DBUS_CONFIG: session.conf
DBUS_ADDRESS: unix:path=/run/dbus/system_bus_socket
volumes:
- dbus:/run/dbus
# Fake system service to listen for supervisor
# requests
dbus-services:
build: ./test/lib/dbus
depends_on:
- dbus
volumes:
- dbus:/run/dbus
environment:
DBUS_SYSTEM_BUS_ADDRESS: unix:path=/run/dbus/system_bus_socket
docker:
image: docker:dind
@ -28,15 +43,18 @@ services:
target: test
args:
# Change this if testing in another architecture
ARCH: amd64
ARCH: ${ARCH:-amd64}
depends_on:
- balena-supervisor
- docker
- dbus
- dbus-services
volumes:
- dbus:/run/dbus
# Set required supervisor configuration variables here
environment:
DOCKER_HOST: tcp://docker:2375
DBUS_SYSTEM_BUS_ADDRESS: tcp:host=dbus,port=6667,family=ipv4
DBUS_SYSTEM_BUS_ADDRESS: unix:path=/run/dbus/system_bus_socket
# Required by migrations
CONFIG_MOUNT_POINT: /mnt/root/mnt/boot/config.json
# Read by constants to setup `bootMountpoint`
@ -48,3 +66,10 @@ services:
tmpfs:
- /data
- /mnt/root
volumes:
dbus:
driver_opts:
# Use tmpfs to avoid files remaining between runs
type: tmpfs
device: tmpfs

View File

@ -1,134 +1,61 @@
import { expect } from 'chai';
import * as sinon from 'sinon';
import { StatusCodeError, UpdatesLockedError } from '~/lib/errors';
import * as dockerUtils from '~/lib/docker-utils';
import * as config from '~/src/config';
import * as imageManager from '~/src/compose/images';
import { ConfigTxt } from '~/src/config/backends/config-txt';
import * as deviceState from '~/src/device-state';
import * as deviceConfig from '~/src/device-config';
import { loadTargetFromFile, appsJsonBackup } from '~/src/device-state/preload';
import Service from '~/src/compose/service';
import { intialiseContractRequirements } from '~/lib/contracts';
import * as updateLock from '~/lib/update-lock';
import { UpdatesLockedError } from '~/lib/errors';
import * as fsUtils from '~/lib/fs-utils';
import * as updateLock from '~/lib/update-lock';
import * as config from '~/src/config';
import * as deviceState from '~/src/device-state';
import { appsJsonBackup, loadTargetFromFile } from '~/src/device-state/preload';
import { TargetState } from '~/src/types';
import { promises as fs } from 'fs';
import { intialiseContractRequirements } from '~/lib/contracts';
import * as dbHelper from '~/test-lib/db-helper';
import log from '~/lib/supervisor-console';
const mockedInitialConfig = {
RESIN_SUPERVISOR_CONNECTIVITY_CHECK: 'true',
RESIN_SUPERVISOR_DELTA: 'false',
RESIN_SUPERVISOR_DELTA_APPLY_TIMEOUT: '0',
RESIN_SUPERVISOR_DELTA_REQUEST_TIMEOUT: '30000',
RESIN_SUPERVISOR_DELTA_RETRY_COUNT: '30',
RESIN_SUPERVISOR_DELTA_RETRY_INTERVAL: '10000',
RESIN_SUPERVISOR_DELTA_VERSION: '2',
RESIN_SUPERVISOR_INSTANT_UPDATE_TRIGGER: 'true',
RESIN_SUPERVISOR_LOCAL_MODE: 'false',
RESIN_SUPERVISOR_LOG_CONTROL: 'true',
RESIN_SUPERVISOR_OVERRIDE_LOCK: 'false',
RESIN_SUPERVISOR_POLL_INTERVAL: '60000',
RESIN_SUPERVISOR_VPN_CONTROL: 'true',
};
import { testfs } from 'mocha-pod';
import { createDockerImage } from '~/test-lib/docker-helper';
import * as Docker from 'dockerode';
describe('device-state', () => {
const originalImagesSave = imageManager.save;
const originalImagesInspect = imageManager.inspectByName;
const originalGetCurrent = deviceConfig.getCurrent;
let testDb: dbHelper.TestDatabase;
const docker = new Docker();
before(async () => {
testDb = await dbHelper.createDB();
await config.initialized();
// Prevent side effects from changes in config
sinon.stub(config, 'on');
// Set the device uuid
await config.set({ uuid: 'local' });
await deviceState.initialized();
// disable log output during testing
sinon.stub(log, 'debug');
sinon.stub(log, 'warn');
sinon.stub(log, 'info');
sinon.stub(log, 'event');
sinon.stub(log, 'success');
// TODO: all these stubs are internal implementation details of
// deviceState, we should refactor deviceState to use dependency
// injection instead of initializing everything in memory
sinon.stub(Service as any, 'extendEnvVars').callsFake((env: any) => {
env['ADDITIONAL_ENV_VAR'] = 'foo';
return env;
});
intialiseContractRequirements({
supervisorVersion: '11.0.0',
deviceType: 'intel-nuc',
});
sinon
.stub(dockerUtils, 'getNetworkGateway')
.returns(Promise.resolve('172.17.0.1'));
// @ts-expect-error Assigning to a RO property
imageManager.cleanImageData = () => {
console.log('Cleanup database called');
};
// @ts-expect-error Assigning to a RO property
imageManager.save = () => Promise.resolve();
// @ts-expect-error Assigning to a RO property
imageManager.inspectByName = () => {
const err: StatusCodeError = new Error();
err.statusCode = 404;
return Promise.reject(err);
};
// @ts-expect-error Assigning to a RO property
deviceConfig.configBackend = new ConfigTxt();
// @ts-expect-error Assigning to a RO property
deviceConfig.getCurrent = async () => mockedInitialConfig;
});
after(async () => {
(Service as any).extendEnvVars.restore();
(dockerUtils.getNetworkGateway as sinon.SinonStub).restore();
// @ts-expect-error Assigning to a RO property
imageManager.save = originalImagesSave;
// @ts-expect-error Assigning to a RO property
imageManager.inspectByName = originalImagesInspect;
// @ts-expect-error Assigning to a RO property
deviceConfig.getCurrent = originalGetCurrent;
try {
await testDb.destroy();
} catch {
/* noop */
}
sinon.restore();
});
afterEach(async () => {
await testDb.reset();
await docker.pruneImages({ filters: { dangling: { false: true } } });
});
it('loads a target state from an apps.json file and saves it as target state, then returns it', async () => {
const appsJson = process.env.ROOT_MOUNTPOINT + '/apps.json';
const localFs = await testfs(
{ '/data/apps.json': testfs.from('test/data/apps.json') },
{ cleanup: ['/data/apps.json.preloaded'] },
).enable();
// The image needs to exist before the test
const dockerImageId = await createDockerImage(
'registry2.resin.io/superapp/abcdef:latest',
['io.balena.testing=1'],
docker,
);
const appsJson = '/data/apps.json';
await expect(
fs.access(appsJson),
'apps.json exists before loading the target',
).to.not.be.rejected;
await loadTargetFromFile(appsJson);
const targetState = await deviceState.getTarget();
expect(await fsUtils.exists(appsJsonBackup(appsJson))).to.be.true;
// console.log('TARGET', JSON.stringify(targetState, null, 2));
await expect(
fs.access(appsJsonBackup(appsJson)),
'apps.json.preloaded is created after loading the target',
).to.not.be.rejected;
expect(targetState)
.to.have.property('local')
.that.has.property('config')
@ -145,58 +72,31 @@ describe('device-state', () => {
expect(app.services[0])
.to.have.property('config')
.that.has.property('image')
.that.equals('registry2.resin.io/superapp/abcdef:latest');
expect(app.services[0].config)
.to.have.property('labels')
.that.has.property('io.balena.something')
.that.equals('bar');
expect(app).to.have.property('appName').that.equals('superapp');
expect(app).to.have.property('services').that.is.an('array').with.length(1);
expect(app.services[0])
.to.have.property('config')
.that.has.property('image')
.that.equals('registry2.resin.io/superapp/abcdef:latest');
expect(app.services[0].config)
.to.have.property('labels')
.that.has.property('io.balena.something')
.that.equals('bar');
expect(app).to.have.property('appName').that.equals('superapp');
expect(app).to.have.property('services').that.is.an('array').with.length(1);
expect(app.services[0])
.to.have.property('config')
.that.has.property('image')
.that.equals('registry2.resin.io/superapp/abcdef:latest');
expect(app.services[0].config)
.to.have.property('labels')
.that.has.property('io.balena.something')
.that.equals('bar');
expect(app).to.have.property('appName').that.equals('superapp');
expect(app).to.have.property('services').that.is.an('array').with.length(1);
expect(app.services[0])
.to.have.property('config')
.that.has.property('image')
.that.equals('registry2.resin.io/superapp/abcdef:latest');
expect(app.services[0].config)
.to.have.property('labels')
.that.has.property('io.balena.something')
.that.equals('bar');
expect(app).to.have.property('appName').that.equals('superapp');
expect(app).to.have.property('services').that.is.an('array').with.length(1);
expect(app.services[0])
.to.have.property('config')
.that.has.property('image')
.that.equals('registry2.resin.io/superapp/abcdef:latest');
.that.equals(dockerImageId);
expect(app.services[0].config)
.to.have.property('labels')
.that.has.property('io.balena.something')
.that.equals('bar');
// Restore renamed apps.json
await fsUtils.safeRename(appsJsonBackup(appsJson), appsJson);
// Remove the image
await docker.getImage(dockerImageId).remove();
await localFs.restore();
});
it('stores info for pinning a device after loading an apps.json with a pinDevice field', async () => {
const appsJson = process.env.ROOT_MOUNTPOINT + '/apps-pin.json';
const localFs = await testfs(
{ '/data/apps.json': testfs.from('test/data/apps-pin.json') },
{ cleanup: ['/data/apps.json.preloaded'] },
).enable();
// The image needs to exist before the test
const dockerImageId = await createDockerImage(
'registry2.resin.io/superapp/abcdef:latest',
['io.balena.testing=1'],
docker,
);
const appsJson = '/data/apps.json';
await loadTargetFromFile(appsJson);
const pinned = await config.get('pinDevice');
@ -204,12 +104,12 @@ describe('device-state', () => {
expect(pinned).to.have.property('commit').that.equals('abcdef');
expect(await fsUtils.exists(appsJsonBackup(appsJson))).to.be.true;
// Restore renamed apps.json
await fsUtils.safeRename(appsJsonBackup(appsJson), appsJson);
// Remove the image
await docker.getImage(dockerImageId).remove();
await localFs.restore();
});
it('emits a change event when a new state is reported', (done) => {
// TODO: where is the test on this test?
deviceState.once('change', done);
deviceState.reportCurrentState({ someStateDiff: 'someValue' } as any);
});

1
test/lib/dbus/.npmrc Normal file
View File

@ -0,0 +1 @@
package-lock=false

View File

@ -1,10 +1,10 @@
FROM ubuntu:20.04
FROM node:16-alpine
# Install Systemd
RUN apt-get update && apt-get install -y --no-install-recommends \
dbus \
&& rm -rf /var/lib/apt/lists/*
RUN apk add --update python3 dbus-dev make g++ libgcc
COPY dbus.conf /etc/dbus-1/session.d/
WORKDIR /usr/src/app
COPY package.json *.ts tsconfig.json entry.sh ./
ENTRYPOINT ["dbus-run-session", "sleep", "infinity"]
RUN npm install && npm run build
CMD ["./entry.sh"]

View File

@ -1,9 +0,0 @@
<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<listen>tcp:host=localhost,bind=*,port=6667,family=ipv4</listen>
<listen>unix:tmpdir=/tmp</listen>
<auth>ANONYMOUS</auth>
<allow_anonymous/>
</busconfig>

13
test/lib/dbus/entry.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/sh
if [ "${DEVELOPMENT}" = "1" ]; then
# Use nodemon in development mode
npx nodemon -w systemd.ts systemd.ts &
npx nodemon -w login.ts login.ts
else
# Launch services in separate processes. node-dbus for some
# reason blocks when trying to register multiple services
# on the same process
node systemd.js &
node login.js
fi

66
test/lib/dbus/login.ts Normal file
View File

@ -0,0 +1,66 @@
import { createSystemInterface } from './utils';
// Create login interface
const login = createSystemInterface(
'org.freedesktop.login1',
'/org/freedesktop/login1',
'org.freedesktop.login1.Manager',
);
type SystemState = { status: 'ready' | 'rebooting' | 'off' };
const systemState: SystemState = { status: 'ready' };
login.addMethod(
'Reboot',
{ in: [{ type: 'b', name: 'interactive' }] } as any,
function (_interactive, callback) {
// Wait a bit before changing the runtime state
setTimeout(() => {
console.log('Rebooting');
systemState.status = 'rebooting';
}, 500);
callback(null);
},
);
login.addMethod(
'PowerOff',
{ in: [{ type: 'b', name: 'interactive' }] } as any,
function (_interactive, callback) {
// Wait a bit before changing the runtime state
setTimeout(() => {
console.log('Powering off');
systemState.status = 'off';
}, 500);
callback(null);
},
);
// This is not a real login interface method, but it will help for
// testing
login.addMethod(
'PowerOn',
{ in: [{ type: 'b', name: 'interactive' }] } as any,
function (_interactive, callback) {
// Wait a bit before changing the runtime state
setTimeout(() => {
console.log('Starting up');
systemState.status = 'ready';
}, 500);
callback(null);
},
);
// This is not a real login interface method, but it will help for
// testing
login.addMethod(
'GetState',
{ out: { type: 's', name: 'state' } } as any,
function (callback: any) {
callback(null, systemState.status);
},
);
login.update();

View File

@ -0,0 +1,20 @@
{
"name": "dbus",
"version": "0.1.0",
"description": "OS dbus service spoofing",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc"
},
"author": "",
"license": "Apache-2.0",
"dependencies": {
"dbus": "^1.0.7"
},
"devDependencies": {
"@types/dbus": "^1.0.3",
"nodemon": "^2.0.20",
"ts-node": "^10.9.1",
"typescript": "^4.8.4"
}
}

173
test/lib/dbus/systemd.ts Normal file
View File

@ -0,0 +1,173 @@
import * as DBus from 'dbus';
import { createSystemInterface } from './utils';
const systemdService = DBus.registerService(
'system',
'org.freedesktop.systemd1',
);
// Create the systemd
const systemd = createSystemInterface(
systemdService,
'/org/freedesktop/systemd1',
'org.freedesktop.systemd1.Manager',
);
type Unit = {
running: boolean;
path: string;
partOf?: string;
};
// Maintain the state of created units in memory
const units: { [key: string]: Unit } = {};
function createUnit(name: string, path: string, partOf?: string) {
// Each unit needs an object and a properties interface
const obj = systemdService.createObject(path);
const iface = obj.createInterface('org.freedesktop.DBus.Properties');
units[name] = { running: false, path, partOf };
// org.freedesktop.DBus.Properties needs a Get method to get the
// unit properties
iface.addMethod(
'Get',
{
in: [
{ type: 's', name: 'interface_name' },
{ type: 's', name: 'property_name' },
],
out: { type: 'v' },
} as any,
function (interfaceName, propertyName, callback: any) {
if (interfaceName !== 'org.freedesktop.systemd1.Unit') {
callback(`Unkown interface: ${interfaceName}`);
}
switch (propertyName) {
case 'ActiveState':
callback(null, units[name].running ? 'active' : 'inactive');
break;
case 'PartOf':
callback(partOf ?? 'none');
break;
default:
callback(`Unknown property: ${propertyName}`);
}
},
);
iface.update();
}
systemd.addMethod(
'StopUnit',
{
in: [
{ type: 's', name: 'unit_name' },
{ type: 's', name: 'mode' },
],
out: { type: 'o' },
} as any,
function (unitName, _mode, callback: any) {
if (!units[unitName]) {
callback(`Unit not found: ${unitName}`);
return;
}
// Wait a bit before changing the runtime state
setTimeout(() => {
units[unitName] = { ...units[unitName], running: false };
}, 500);
callback(
null,
`/org/freedesktop/systemd1/job/${String(
Math.floor(Math.random() * 10000),
)}`,
);
},
);
systemd.addMethod(
'StartUnit',
{
in: [
{ type: 's', name: 'unit_name' },
{ type: 's', name: 'mode' },
],
out: { type: 'o' },
} as any,
function (unitName, _mode, callback: any) {
if (!units[unitName]) {
callback(`Unit not found: ${unitName}`);
return;
}
// Wait a bit before changing the runtime state
setTimeout(() => {
units[unitName] = { ...units[unitName], running: true };
}, 500);
callback(
null,
// Make up a job number
`/org/freedesktop/systemd1/job/${String(
Math.floor(Math.random() * 10000),
)}`,
);
},
);
systemd.addMethod(
'RestartUnit',
{
in: [
{ type: 's', name: 'unit_name' },
{ type: 's', name: 'mode' },
],
out: { type: 'o' },
} as any,
function (unitName, _mode, callback: any) {
if (!units[unitName]) {
callback(`Unit not found: ${unitName}`);
return;
}
// Wait a bit before changing the runtime state
setTimeout(() => {
units[unitName] = { ...units[unitName], running: false };
}, 500);
setTimeout(() => {
units[unitName] = { ...units[unitName], running: true };
}, 1000);
callback(
null,
`/org/freedesktop/systemd1/job/${String(
Math.floor(Math.random() * 10000),
)}`,
);
},
);
systemd.addMethod(
'GetUnit',
{ in: [{ type: 's', name: 'unit_name' }], out: { type: 'o' } } as any,
function (unitName, callback) {
if (!units[unitName]) {
callback(`Unit not found: ${unitName}`);
return;
}
const { path } = units[unitName];
callback(null, path);
},
);
// Simulate OS units
createUnit('openvpn.service', '/org/freedesktop/systemd1/unit/openvpn');
createUnit('avahi.socket', '/org/freedesktop/systemd1/unit/avahi');
systemd.update();

View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"module": "commonjs",
"noUnusedParameters": true,
"noUnusedLocals": true,
"removeComments": true,
"sourceMap": true,
"strict": true,
"target": "es2019",
"declaration": true,
"skipLibCheck": true
},
"include": ["*.ts"]
}

17
test/lib/dbus/utils.ts Normal file
View File

@ -0,0 +1,17 @@
import * as DBus from 'dbus';
export function createSystemInterface(
svc: DBus.DBusService | string,
objName: string,
ifaceName: string,
) {
const service = ((s: DBus.DBusService | string) => {
if (typeof s === 'string') {
return DBus.registerService('system', s);
}
return s;
})(svc);
const obj = service.createObject(objName);
return obj.createInterface(ifaceName);
}