diff --git a/Dockerfile.template b/Dockerfile.template index 6a4931fc..3bf84fe4 100644 --- a/Dockerfile.template +++ b/Dockerfile.template @@ -1,12 +1,12 @@ ARG ARCH=%%BALENA_ARCH%% ARG FATRW_VERSION=0.2.9 -ARG NODE="nodejs<18" -ARG NPM="npm<9" +ARG NODE="nodejs<19" +ARG NPM="npm<10" ################################################### # Build the supervisor dependencies ################################################### -FROM alpine:3.16 as build-base +FROM alpine:3.18 as build-base ARG ARCH ARG NODE @@ -18,16 +18,15 @@ ARG FATRW_LOCATION="https://github.com/balena-os/fatrw/releases/download/v${FATR WORKDIR /usr/src/app RUN apk add --update --no-cache \ - g++ \ - make \ + build-base \ python3 \ curl \ $NODE \ $NPM \ libuv \ sqlite-dev \ - dbus-dev && \ - npm install -g npm@8 + cargo \ + rust COPY package*.json ./ @@ -114,7 +113,7 @@ ARG ARCH # We want to use as close to the final image when running tests # but we need npm so we install it here again -RUN apk add --update --no-cache $NPM && npm install -g npm@8 +RUN apk add --update --no-cache $NPM WORKDIR /usr/src/app @@ -170,8 +169,8 @@ RUN npm run build # Run the production install here, to avoid the npm dependency on # the later stage RUN npm ci \ - --production \ - --no-optional \ + --omit=dev \ + --omit=optional \ --unsafe-perm \ --build-from-source \ --sqlite=/usr/lib \ diff --git a/README.md b/README.md index 420ebd27..81005240 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,18 @@ Here's a few guidelines to make the process easier for everyone involved. - Commits should be squashed as much as makes sense. - Commits should be signed-off (`git commit -s`) -## Setup + +## Developing the supervisor + +### Requirements + +These are the system requirements for developing and testing the balenaSupervisor on a local machine + +- [Node.js](https://nodejs.org/en) v18 or latest +- [Rust](https://www.rust-lang.org/) v1.64 or latest for installing the [@balena/systemd](https://www.npmjs.com/package/@balena/systemd) NPM package. +- If developing on an architecture not supported by default by [node-sqlite3](https://github.com/TryGhost/node-sqlite3#prebuilt-binaries), a C++ compiler and linker are also required, plus the libsqlite development headers. + +### Setup To get the codebase setup on your development machine follow these steps. For running the supervisor on a device see [Developing the supervisor](#developing-the-supervisor) or [Using balenaOS-in-container](#using-balenaos-in-container). @@ -53,11 +64,11 @@ npm ci We explicitly use `npm ci` over `npm install` to ensure the correct package versions are installed. More documentation for this can be found [here](https://docs.npmjs.com/cli/ci) on the npm cli docs. -You're now ready to start developing. If you get stuck at some point please reference the [troubleshooting](#troubleshooting) section before creating an issue. +You're now ready to start developing. -## Developing the supervisor +### Running your code -By far the most convenient way to develop the supervisor is +By far the most convenient way to test your supervisor code is to download a development image of balenaOS from the dashboard, and run it on a device you have to hand. You can then use the local network to sync changes using @@ -69,7 +80,7 @@ a supervisor locally, using [balenaOS-in-container](https://github.com/balena-os/balenaos-in-container). These steps are detailed below. -### Sync +#### Sync Example: @@ -101,7 +112,7 @@ and sync any relevant file changes to the running supervisor container. It will then decide if the container should be restarted, or let nodemon handle the changes. -### Using balenaOS-in-container +#### Using balenaOS-in-container This process will allow you to run a development instance of the supervisor on your local computer. It is not recommended for production scenarios, but allows someone developing on the supervisor to test changes quickly. The supervisor is run inside a balenaOS instance running in a container, so effectively it's a Docker-in-Docker instance (or more precisely, [balenaEngine](https://github.com/resin-os/balena-engine)-in-Docker). @@ -117,7 +128,7 @@ $ npm run sync -- d19baeb.local -a amd64 > ts-node --project tsconfig.json sync/sync.ts "d19baeb.local" ``` -## Developing using a production image or device +### Developing using a production image or device A production balena image does not have an open docker socket, required for livepush to work. In this situation, [balena tunnel](https://www.balena.io/docs/reference/balena-cli/#tunnel-deviceorfleet) @@ -152,7 +163,7 @@ root@d19baeb.local npm run sync -- 127.0.0.1 -a amd64 ``` -## Building +### Building The supervisor is built automatically by the CI system, but a docker image can be also be built locally using the [balena CLI](https://www.balena.io/docs/reference/balena-cli/#build-source). @@ -174,7 +185,7 @@ For instance to build for raspberrypi4: balena build -d raspberrypi4-64 -A aarch64 ``` -## Testing +### Testing The codebase splits the test suite into unit and integration tests. While unit tests can be run in the local development machine, integration tests require a containerized environment with the right dependencies to be setup. @@ -235,30 +246,7 @@ npm run test:unit -- -g "(GET|POST|PUT|DELETE)" The `--grep` option, when specified, will trigger mocha to only run tests matching the given pattern which is internally compiled to a RegExp. -## Troubleshooting - -Make sure you are running at least: - -```sh -node -v # >= 12.16.2 -npm -v # >= 6.14.4 -git --version # >= 2.13.0 -``` - -Also, ensure you're installing dependencies with `npm ci` as this will perform a clean install and guarantee the module versions specified are downloaded rather then installed which might attempt to upgrade! - -If you have upgraded system packages and find that your tests are failing to initialize with docker network errors, a reboot may resolve this. See [this issue](https://github.com/moby/moby/issues/34575) for details. - -### DBus - -When developing on macOS you may need to install DBus on the development host. - -1. `brew install dbus` -2. `npm ci` - -On Debian-based systems, `sudo apt install libdbus-1-dev` would be the equivalent. - -#### Downgrading versions +## Downgrading versions The Supervisor will always be forwards compatible so you can just simply run newer versions. If there is data that must be normalized to a new schema such as the naming of engine resources, values in the sqlite database, etc then the new version will automatically take care of that either via [migrations](/src/migrations) or at runtime when the value is queried. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 29c703d3..cdc3c60f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,13 +1,6 @@ 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: diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 88cc7b99..fbe966e2 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -12,41 +12,30 @@ services: # Use bridge networking for the tests environment: DOCKER_HOST: tcp://docker:2375 - DBUS_SYSTEM_BUS_ADDRESS: unix:path=/run/dbus/system_bus_socket + DBUS_SYSTEM_BUS_ADDRESS: unix:path=/shared/dbus/system_bus_socket # Required to skip device mounting in test env TEST: 1 depends_on: - docker - - dbus - - dbus-services + - mock-systemd volumes: - - dbus:/run/dbus + - dbus:/shared/dbus - ./test/data/root:/mnt/root - ./test/data/root/mnt/boot:/mnt/boot - ./test/lib/wait-for-it.sh:/wait-for-it.sh tmpfs: - /data # sqlite3 database - dbus: - image: balenablocks/dbus - stop_grace_period: 3s - environment: - DBUS_CONFIG: session.conf - DBUS_ADDRESS: unix:path=/run/dbus/system_bus_socket + # The service setup + mock-systemd: + image: ghcr.io/balena-os/mock-systemd-bus + # Necessary to run systemd in a container + privileged: true volumes: - - dbus:/run/dbus - - # Fake system service to listen for supervisor - # requests - dbus-services: - build: ./test/lib/dbus - stop_grace_period: 3s - depends_on: - - dbus - volumes: - - dbus:/run/dbus + - dbus:/shared/dbus environment: - DBUS_SYSTEM_BUS_ADDRESS: unix:path=/run/dbus/system_bus_socket + DBUS_SYSTEM_BUS_ADDRESS: unix:path=/shared/dbus/system_bus_socket + MOCK_SYSTEMD_UNITS: openvpn.service avahi.socket docker: image: docker:dind @@ -77,15 +66,14 @@ services: depends_on: - balena-supervisor-sut - docker - - dbus - - dbus-services + - mock-systemd stop_grace_period: 3s volumes: - - dbus:/run/dbus + - dbus:/shared/dbus # Set required supervisor configuration variables here environment: DOCKER_HOST: tcp://docker:2375 - DBUS_SYSTEM_BUS_ADDRESS: unix:path=/run/dbus/system_bus_socket + DBUS_SYSTEM_BUS_ADDRESS: unix:path=/shared/dbus/system_bus_socket BALENA_SUPERVISOR_ADDRESS: http://balena-supervisor-sut:48484 # Required by migrations CONFIG_MOUNT_POINT: /mnt/boot/config.json diff --git a/package-lock.json b/package-lock.json index aced4a5b..c1e45c98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@balena/happy-eyeballs": "0.0.6", - "dbus": "^1.0.7", + "@balena/systemd": "^0.4.1", "got": "^12.5.3", "mdns-resolver": "^1.0.0", "semver": "^7.3.2", @@ -28,7 +28,6 @@ "@types/chai-things": "0.0.35", "@types/common-tags": "^1.8.1", "@types/copy-webpack-plugin": "^10.1.0", - "@types/dbus": "^1.0.3", "@types/dockerode": "^2.5.34", "@types/event-stream": "^3.3.34", "@types/express": "^4.17.14", @@ -37,7 +36,7 @@ "@types/mocha": "^8.2.3", "@types/mock-fs": "^4.13.1", "@types/morgan": "^1.9.3", - "@types/node": "^16.11.63", + "@types/node": "^18.11.7", "@types/request": "^2.48.8", "@types/rewire": "^2.5.28", "@types/rimraf": "^2.0.5", @@ -115,8 +114,8 @@ "yargs": "^15.4.1" }, "engines": { - "node": "^16.17.0", - "npm": "^8.15.0" + "node": ">=16.17.0", + "npm": ">=8.15.0" } }, "node_modules/@babel/code-frame": { @@ -875,6 +874,12 @@ "node": ">=0.10.0" } }, + "node_modules/@balena/systemd": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@balena/systemd/-/systemd-0.4.1.tgz", + "integrity": "sha512-N4/kiixRHPqw9ZLdQSMEzyYm7rJrpnYW2lPcLb2bHrRoHznWPoz1UlmMOYfvf1iNgpEuSd0x9ARSWI1P6J358Q==", + "hasInstallScript": true + }, "node_modules/@dabh/diagnostics": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.2.tgz", @@ -1268,12 +1273,6 @@ "copy-webpack-plugin": "*" } }, - "node_modules/@types/dbus": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/dbus/-/dbus-1.0.3.tgz", - "integrity": "sha512-DY8++cs1e4d7zPuNibJOhRmaEWkSIMheGo3govF2LwHREi4yN/OJbAX79V9hDJnjUqxI9BrHoBIsfekh9LX/Hg==", - "dev": true - }, "node_modules/@types/debug": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.5.tgz", @@ -1485,9 +1484,9 @@ } }, "node_modules/@types/node": { - "version": "16.11.63", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.63.tgz", - "integrity": "sha512-3OxnrEQLBz8EIIaHpg3CibmTAEGkDBcHY4fL5cnBwg2vd2yvHrUDGWxK+MlYPeXWWIoJJW79dGtU+oeBr6166Q==", + "version": "18.11.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.7.tgz", + "integrity": "sha512-LhFTglglr63mNXUSRYD8A+ZAIu5sFqNJ4Y2fPuY7UlrySJH87rRRlhtVmMHplmfk5WkoJGmDjE9oiTfyX94CpQ==", "dev": true }, "node_modules/@types/object-hash": { @@ -3705,21 +3704,6 @@ "node": ">=0.10" } }, - "node_modules/dbus": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/dbus/-/dbus-1.0.7.tgz", - "integrity": "sha512-qba6/ajLoqzCy3Kl3aFgLXLP4TTf0qfgNjib1qoCJG/8HbSs0lDvxkz4nJU63CURZVzxvpK/VpQpT40KA8Kr3A==", - "hasInstallScript": true, - "os": [ - "!win32" - ], - "dependencies": { - "nan": "^2.14.0" - }, - "engines": { - "node": ">= 0.12.0" - } - }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -8905,11 +8889,6 @@ "thenify-all": "^1.0.0" } }, - "node_modules/nan": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", - "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==" - }, "node_modules/nanoid": { "version": "3.1.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz", @@ -14186,6 +14165,11 @@ } } }, + "@balena/systemd": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@balena/systemd/-/systemd-0.4.1.tgz", + "integrity": "sha512-N4/kiixRHPqw9ZLdQSMEzyYm7rJrpnYW2lPcLb2bHrRoHznWPoz1UlmMOYfvf1iNgpEuSd0x9ARSWI1P6J358Q==" + }, "@dabh/diagnostics": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.2.tgz", @@ -14517,12 +14501,6 @@ "copy-webpack-plugin": "*" } }, - "@types/dbus": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/dbus/-/dbus-1.0.3.tgz", - "integrity": "sha512-DY8++cs1e4d7zPuNibJOhRmaEWkSIMheGo3govF2LwHREi4yN/OJbAX79V9hDJnjUqxI9BrHoBIsfekh9LX/Hg==", - "dev": true - }, "@types/debug": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.5.tgz", @@ -14734,9 +14712,9 @@ } }, "@types/node": { - "version": "16.11.63", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.63.tgz", - "integrity": "sha512-3OxnrEQLBz8EIIaHpg3CibmTAEGkDBcHY4fL5cnBwg2vd2yvHrUDGWxK+MlYPeXWWIoJJW79dGtU+oeBr6166Q==", + "version": "18.11.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.7.tgz", + "integrity": "sha512-LhFTglglr63mNXUSRYD8A+ZAIu5sFqNJ4Y2fPuY7UlrySJH87rRRlhtVmMHplmfk5WkoJGmDjE9oiTfyX94CpQ==", "dev": true }, "@types/object-hash": { @@ -16577,14 +16555,6 @@ "assert-plus": "^1.0.0" } }, - "dbus": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/dbus/-/dbus-1.0.7.tgz", - "integrity": "sha512-qba6/ajLoqzCy3Kl3aFgLXLP4TTf0qfgNjib1qoCJG/8HbSs0lDvxkz4nJU63CURZVzxvpK/VpQpT40KA8Kr3A==", - "requires": { - "nan": "^2.14.0" - } - }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -20629,11 +20599,6 @@ "thenify-all": "^1.0.0" } }, - "nan": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", - "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==" - }, "nanoid": { "version": "3.1.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz", diff --git a/package.json b/package.json index e9e9da61..f03593e4 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "private": true, "dependencies": { "@balena/happy-eyeballs": "0.0.6", - "dbus": "^1.0.7", + "@balena/systemd": "^0.4.1", "got": "^12.5.3", "mdns-resolver": "^1.0.0", "semver": "^7.3.2", @@ -40,8 +40,8 @@ "systeminformation": "^5.6.10" }, "engines": { - "node": "^16.17.0", - "npm": "^8.15.0" + "node": ">=16.17.0", + "npm": ">=8.15.0" }, "devDependencies": { "@balena/contrato": "^0.6.0", @@ -54,7 +54,6 @@ "@types/chai-things": "0.0.35", "@types/common-tags": "^1.8.1", "@types/copy-webpack-plugin": "^10.1.0", - "@types/dbus": "^1.0.3", "@types/dockerode": "^2.5.34", "@types/event-stream": "^3.3.34", "@types/express": "^4.17.14", @@ -63,7 +62,7 @@ "@types/mocha": "^8.2.3", "@types/mock-fs": "^4.13.1", "@types/morgan": "^1.9.3", - "@types/node": "^16.11.63", + "@types/node": "^18.11.7", "@types/request": "^2.48.8", "@types/rewire": "^2.5.28", "@types/rimraf": "^2.0.5", diff --git a/src/lib/dbus.ts b/src/lib/dbus.ts index 9fe1dee5..b08e13bb 100644 --- a/src/lib/dbus.ts +++ b/src/lib/dbus.ts @@ -1,69 +1,21 @@ -import { getBus, Error as DBusError } from 'dbus'; -import { promisify } from 'util'; -import { TypedError } from 'typed-error'; -import * as _ from 'lodash'; - import log from './supervisor-console'; -import DBus = require('dbus'); - -export class DbusError extends TypedError {} - -let bus: DBus.DBusConnection; -let getInterfaceAsync: ( - serviceName: string, - objectPath: string, - ifaceName: string, -) => Promise>; - -export const initialized = _.once(async () => { - bus = getBus('system'); - getInterfaceAsync = promisify(bus.getInterface.bind(bus)); -}); - -async function getSystemdInterface() { - await initialized(); - try { - return await getInterfaceAsync( - 'org.freedesktop.systemd1', - '/org/freedesktop/systemd1', - 'org.freedesktop.systemd1.Manager', - ); - } catch (e) { - throw new DbusError(e as DBusError); - } -} - -async function getLoginManagerInterface() { - await initialized(); - try { - return await getInterfaceAsync( - 'org.freedesktop.login1', - '/org/freedesktop/login1', - 'org.freedesktop.login1.Manager', - ); - } catch (e) { - throw new DbusError(e as DBusError); - } -} +import { singleton, ServiceManager, LoginManager } from '@balena/systemd'; +import { setTimeout } from 'timers/promises'; async function startUnit(unitName: string) { - const systemd = await getSystemdInterface(); + const bus = await singleton(); + const systemd = new ServiceManager(bus); + const unit = systemd.getUnit(unitName); log.debug(`Starting systemd unit: ${unitName}`); - try { - systemd.StartUnit(unitName, 'fail'); - } catch (e) { - throw new DbusError(e as DBusError); - } + await unit.start('fail'); } export async function restartService(serviceName: string) { - const systemd = await getSystemdInterface(); + const bus = await singleton(); + const systemd = new ServiceManager(bus); + const unit = systemd.getUnit(`${serviceName}.service`); log.debug(`Restarting systemd service: ${serviceName}`); - try { - systemd.RestartUnit(`${serviceName}.service`, 'fail'); - } catch (e) { - throw new DbusError(e as DBusError); - } + await unit.restart('fail'); } export async function startService(serviceName: string) { @@ -75,13 +27,11 @@ export async function startSocket(socketName: string) { } async function stopUnit(unitName: string) { - const systemd = await getSystemdInterface(); + const bus = await singleton(); + const systemd = new ServiceManager(bus); + const unit = systemd.getUnit(unitName); log.debug(`Stopping systemd unit: ${unitName}`); - try { - systemd.StopUnit(unitName, 'fail'); - } catch (e) { - throw new DbusError(e as DBusError); - } + await unit.stop('fail'); } export async function stopService(serviceName: string) { @@ -92,60 +42,44 @@ export async function stopSocket(socketName: string) { return stopUnit(`${socketName}.socket`); } -export const reboot = async () => - setTimeout(async () => { - try { - const logind = await getLoginManagerInterface(); - logind.Reboot(false); - } catch (e) { - log.error(`Unable to reboot: ${e}`); - } - }, 1000); - -export const shutdown = async () => - setTimeout(async () => { - try { - const logind = await getLoginManagerInterface(); - logind.PowerOff(false); - } catch (e) { - log.error(`Unable to shutdown: ${e}`); - } - }, 1000); - -async function getUnitProperty( - unitName: string, - property: string, -): Promise { - const systemd = await getSystemdInterface(); - return new Promise((resolve, reject) => { - systemd.GetUnit(unitName, async (err: Error, unitPath: string) => { - if (err) { - return reject(err); - } - const iface = await getInterfaceAsync( - 'org.freedesktop.systemd1', - unitPath, - 'org.freedesktop.DBus.Properties', - ); - - iface.Get( - 'org.freedesktop.systemd1.Unit', - property, - (e: Error, value: string) => { - if (e) { - return reject(new DbusError(e)); - } - resolve(value); - }, - ); - }); - }); +export async function reboot() { + // No idea why this timeout is here, my guess + // is that it is to allow the API reboot endpoint to be able + // to send a response before the event happens + await setTimeout(1000); + const bus = await singleton(); + const logind = new LoginManager(bus); + try { + await logind.reboot(); + } catch (e) { + log.error(`Unable to reboot: ${e}`); + } } -export function serviceActiveState(serviceName: string) { - return getUnitProperty(`${serviceName}.service`, 'ActiveState'); +export async function shutdown() { + // No idea why this timeout is here, my guess + // is that it is to allow the API shutdown endpoint to be able + // to send a response before the event happens + await setTimeout(1000); + const bus = await singleton(); + const logind = new LoginManager(bus); + try { + await logind.powerOff(); + } catch (e) { + log.error(`Unable to shutdown: ${e}`); + } } -export function servicePartOf(serviceName: string) { - return getUnitProperty(`${serviceName}.service`, 'PartOf'); +export async function serviceActiveState(serviceName: string) { + const bus = await singleton(); + const systemd = new ServiceManager(bus); + const unit = systemd.getUnit(`${serviceName}.service`); + return await unit.activeState; +} + +export async function servicePartOf(serviceName: string) { + const bus = await singleton(); + const systemd = new ServiceManager(bus); + const unit = systemd.getUnit(`${serviceName}.service`); + return await unit.partOf; } diff --git a/test/integration/device-api/actions.spec.ts b/test/integration/device-api/actions.spec.ts index 21469575..13ec6c20 100644 --- a/test/integration/device-api/actions.spec.ts +++ b/test/integration/device-api/actions.spec.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { stub, SinonStub, spy, SinonSpy } from 'sinon'; +import { stub, SinonStub } from 'sinon'; import * as Docker from 'dockerode'; import * as request from 'supertest'; import { setTimeout } from 'timers/promises'; @@ -10,9 +10,41 @@ import * as hostConfig from '~/src/host-config'; import * as deviceApi from '~/src/device-api'; import * as actions from '~/src/device-api/actions'; import * as TargetState from '~/src/device-state/target-state'; -import * as dbus from '~/lib/dbus'; import { cleanupDocker } from '~/test-lib/docker-helper'; +import { exec } from '~/src/lib/fs-utils'; + +export async function dbusSend( + dest: string, + path: string, + message: string, + ...contents: string[] +) { + const { stdout, stderr } = await exec( + [ + 'dbus-send', + '--system', + `--dest=${dest}`, + '--print-reply', + path, + message, + ...contents, + ].join(' '), + { encoding: 'utf8' }, + ); + + if (stderr) { + throw new Error(stderr); + } + + // Remove first line, trim each line, and join them back together + return stdout + .split(/\r?\n/) + .slice(1) + .map((s) => s.trim()) + .join(''); +} + describe('regenerates API keys', () => { // Stub external dependency - current state report should be tested separately. // API key related methods are tested in api-keys.spec.ts. @@ -692,24 +724,34 @@ describe('manages application lifecycle', () => { }); describe('reboots or shuts down device', () => { - before(async () => { - spy(dbus, 'reboot'); - spy(dbus, 'shutdown'); - }); - - after(() => { - (dbus.reboot as SinonSpy).restore(); - (dbus.shutdown as SinonSpy).restore(); - }); - it('reboots device', async () => { await actions.executeDeviceAction({ action: 'reboot' }); - expect(dbus.reboot as SinonSpy).to.have.been.called; + // The reboot method delays the call by one second + await setTimeout(1500); + await expect( + dbusSend( + 'org.freedesktop.login1', + '/org/freedesktop/login1', + 'org.freedesktop.DBus.Properties.Get', + 'string:org.freedesktop.login1.Manager', + 'string:MockState', + ), + ).to.eventually.equal('variant string "rebooting"'); }); it('shuts down device', async () => { await actions.executeDeviceAction({ action: 'shutdown' }); - expect(dbus.shutdown as SinonSpy).to.have.been.called; + // The shutdown method delays the call by one second + await setTimeout(1500); + await expect( + dbusSend( + 'org.freedesktop.login1', + '/org/freedesktop/login1', + 'org.freedesktop.DBus.Properties.Get', + 'string:org.freedesktop.login1.Manager', + 'string:MockState', + ), + ).to.eventually.equal('variant string "off"'); }); }); diff --git a/test/integration/host-config.spec.ts b/test/integration/host-config.spec.ts index 3b481b4b..929aeab0 100644 --- a/test/integration/host-config.spec.ts +++ b/test/integration/host-config.spec.ts @@ -68,7 +68,7 @@ describe('host-config', () => { beforeEach(async () => { await tFs.enable(); // Stub external dependencies - stub(dbus, 'servicePartOf').resolves(''); + stub(dbus, 'servicePartOf').resolves([]); stub(dbus, 'restartService').resolves(); }); @@ -153,7 +153,7 @@ describe('host-config', () => { }); it('skips restarting proxy services when part of redsocks-conf.target', async () => { - (dbus.servicePartOf as SinonStub).resolves('redsocks-conf.target'); + (dbus.servicePartOf as SinonStub).resolves(['redsocks-conf.target']); await hostConfig.patch({ network: { proxy: { diff --git a/test/lib/dbus/.npmrc b/test/lib/dbus/.npmrc deleted file mode 100644 index 43c97e71..00000000 --- a/test/lib/dbus/.npmrc +++ /dev/null @@ -1 +0,0 @@ -package-lock=false diff --git a/test/lib/dbus/Dockerfile b/test/lib/dbus/Dockerfile deleted file mode 100644 index 216cd497..00000000 --- a/test/lib/dbus/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM node:16-alpine - -RUN apk add --update python3 dbus-dev make g++ libgcc - -WORKDIR /usr/src/app -COPY package.json *.ts tsconfig.json entry.sh ./ - -RUN npm install && npm run build - -CMD ["./entry.sh"] diff --git a/test/lib/dbus/entry.sh b/test/lib/dbus/entry.sh deleted file mode 100755 index 2c3819c1..00000000 --- a/test/lib/dbus/entry.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/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 diff --git a/test/lib/dbus/login.ts b/test/lib/dbus/login.ts deleted file mode 100644 index b23baf8b..00000000 --- a/test/lib/dbus/login.ts +++ /dev/null @@ -1,66 +0,0 @@ -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(); diff --git a/test/lib/dbus/package.json b/test/lib/dbus/package.json deleted file mode 100644 index 27052203..00000000 --- a/test/lib/dbus/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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" - } -} diff --git a/test/lib/dbus/systemd.ts b/test/lib/dbus/systemd.ts deleted file mode 100644 index b12f93e4..00000000 --- a/test/lib/dbus/systemd.ts +++ /dev/null @@ -1,173 +0,0 @@ -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(); diff --git a/test/lib/dbus/tsconfig.json b/test/lib/dbus/tsconfig.json deleted file mode 100644 index f6532c51..00000000 --- a/test/lib/dbus/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "noUnusedParameters": true, - "noUnusedLocals": true, - "removeComments": true, - "sourceMap": true, - "strict": true, - "target": "es2019", - "declaration": true, - "skipLibCheck": true - }, - "include": ["*.ts"] -} diff --git a/test/lib/dbus/utils.ts b/test/lib/dbus/utils.ts deleted file mode 100644 index e7714620..00000000 --- a/test/lib/dbus/utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -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); -} diff --git a/test/lib/legacy-mocha-hooks.ts b/test/lib/legacy-mocha-hooks.ts deleted file mode 100644 index 72946c68..00000000 --- a/test/lib/legacy-mocha-hooks.ts +++ /dev/null @@ -1,70 +0,0 @@ -// TODO: Remove this file when all legacy tests have migrated to unit/integration. - -import { stub, SinonStub } from 'sinon'; -import * as dbus from 'dbus'; -import { Error as DBusError, DBusInterface } from 'dbus'; -import { initialized } from '~/lib/dbus'; - -let getBusStub: SinonStub; - -export const mochaHooks = { - async beforeAll() { - getBusStub = stub(dbus, 'getBus').returns({ - getInterface: ( - serviceName: string, - _objectPath: string, - _interfaceName: string, - interfaceCb: (err: null | DBusError, iface: DBusInterface) => void, - ) => { - if (/systemd/.test(serviceName)) { - interfaceCb(null, { - StartUnit: () => { - // noop - }, - RestartUnit: () => { - // noop - }, - StopUnit: () => { - // noop - }, - EnableUnitFiles: () => { - // noop - }, - DisableUnitFiles: () => { - // noop - }, - GetUnit: ( - _unitName: string, - getUnitCb: (err: null | Error, unitPath: string) => void, - ) => { - getUnitCb(null, 'this is the unit path'); - }, - Get: ( - _unitName: string, - _property: string, - getCb: (err: null | Error, value: unknown) => void, - ) => { - getCb(null, 'this is the value'); - }, - } as any); - } else { - interfaceCb(null, { - Reboot: () => { - // noop - }, - PowerOff: () => { - // noop - }, - } as any); - } - }, - } as dbus.DBusConnection); - - // Initialize dbus module before any tests are run so any further tests - // that interface with lib/dbus use the stubbed busses above. - await initialized(); - }, - afterAll() { - getBusStub.restore(); - }, -}; diff --git a/tsconfig.json b/tsconfig.json index 28e5bc66..28682559 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "baseUrl": "./", - "target": "ES2021", + "target": "ES2022", "module": "commonjs", "moduleResolution": "Node16", "strict": true, diff --git a/webpack.config.js b/webpack.config.js index 9df2e95a..0603c84a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -19,9 +19,9 @@ var externalModules = [ 'oracledb', 'pg-query-stream', 'tedious', - 'dbus', /mssql\/.*/, 'osx-temperature-sensor', + '@balena/systemd', ]; let requiredModules = [];