Add update lock check to PATCH /v1/device/host-config

This is necessary with the changes as of balenaOS 2.82.6, which watches config.json
and will restart balena-hostname and some other services automatically on file change.

Change-type: patch
Relates-to: #1876
Signed-off-by: Christina Wang <christina@balena.io>
This commit is contained in:
Christina Wang 2022-02-09 03:05:40 +00:00 committed by Christina Wang
parent 4f446103f4
commit 5f1a77da25
4 changed files with 94 additions and 20 deletions

View File

@ -548,16 +548,16 @@ $ curl -X POST --header "Content-Type:application/json" \
> **Note:** on devices with supervisor version lower than 7.22.0, replace all `BALENA_` variables with `RESIN_`, e.g. `RESIN_SUPERVISOR_ADDRESS` instead of `BALENA_SUPERVISOR_ADDRESS`.
This endpoint allows setting some configuration values for the host OS. Currently it supports
proxy and hostname configuration.
This endpoint allows setting some configuration values for the host OS. Currently it supports proxy and hostname configuration.
For proxy configuration, balenaOS 2.0.7 and higher provides a transparent proxy redirector (redsocks) that makes all connections be routed to a SOCKS or HTTP proxy. This endpoint allows services to modify these proxy settings at runtime.
#### Request body
Is a JSON object with several optional fields. Proxy and hostname configuration go under a "network" key. If "proxy" or "hostname" are not present (undefined), those values will not be modified, so that a request can modify hostname
without changing proxy settings and viceversa.
A JSON object with several optional fields. Proxy and hostname configuration go under a `network` key. If `proxy` or `hostname` are not present (undefined), those values will not be modified, so that a request can modify hostname without changing proxy settings and vice versa.
By default, with [balenaOS 2.82.6](https://github.com/balena-os/meta-balena/blob/master/CHANGELOG.md#v2826) or newer, host config PATCH requests will cause a balenaEngine restart. Therefore, with Supervisor v12.11.34 and newer, the PATCH request will respect the presence of update locks. Specify the `force` boolean (`false` by default) property in the request body to ignore this.
```json
{
@ -570,7 +570,8 @@ without changing proxy settings and viceversa.
"password": "password",
"noProxy": [ "152.10.30.4", "253.1.1.0/16" ]
},
"hostname": "mynewhostname"
"hostname": "mynewhostname",
"force": true
}
}
```
@ -581,10 +582,10 @@ guaranteed to work, especially if they block connections that the balena service
Keep in mind that, even if transparent proxy redirection will take effect immediately after the API call (i.e. all new connections will go through the proxy), open connections will not be closed. So, if for example, the device has managed to connect to the balenaCloud VPN without the proxy, it will stay connected directly without trying to reconnect through the proxy, unless the connection breaks - any reconnection attempts will then go through the proxy. To force *all* connections to go through the proxy, the best way is to reboot the device (see the /v1/reboot endpoint). In most networks were no connections to the Internet can be made if not through a proxy, this should not be necessary (as there will be no open connections before configuring the proxy settings).
The "noProxy" setting for the proxy is an optional array of IP addresses/subnets that should not be routed through the
The `noProxy` setting for the proxy is an optional array of IP addresses/subnets that should not be routed through the
proxy. Keep in mind that local/reserved subnets are already [excluded by balenaOS automatically](https://github.com/balena-os/meta-balena/blob/master/meta-balena-common/recipes-connectivity/balena-proxy-config/balena-proxy-config/balena-proxy-config).
If either "proxy" or "hostname" are null or empty values (i.e. `{}` for proxy or an empty string for hostname), they will be cleared to their default values (i.e. not using a proxy, and a hostname equal to the first 7 characters of the device's uuid, respectively).
If either `proxy` or `hostname` are null or empty values (i.e. `{}` for proxy or an empty string for hostname), they will be cleared to their default values (i.e. not using a proxy, and a hostname equal to the first 7 characters of the device's uuid, respectively).
#### Examples:
From an app container:

View File

@ -147,10 +147,19 @@ function createDeviceStateRouter() {
const uuid = await config.get('uuid');
req.body.network.hostname = uuid?.slice(0, 7);
}
await hostConfig.patch(req.body);
const lockOverride = await config.get('lockOverride');
await hostConfig.patch(
req.body,
validation.checkTruthy(req.body.force) || lockOverride,
);
res.status(200).send('OK');
} catch (err) {
// TODO: We should be able to throw err if it's UpdatesLockedError
// and the error middleware will handle it, but this doesn't work in
// the test environment. Fix this when fixing API tests.
if (err instanceof UpdatesLockedError) {
return res.status(423).send(err?.message ?? err);
}
res.status(503).send(err?.message ?? err ?? 'Unknown error');
}
});

View File

@ -7,8 +7,9 @@ import * as path from 'path';
import * as config from './config';
import * as constants from './lib/constants';
import * as dbus from './lib/dbus';
import { ENOENT } from './lib/errors';
import { ENOENT, InternalInconsistencyError } from './lib/errors';
import { writeFileAtomic, mkdirp, unlinkAll } from './lib/fs-utils';
import * as updateLock from './lib/update-lock';
const redsocksHeader = stripIndent`
base {
@ -214,15 +215,22 @@ export function get(): Bluebird<HostConfig> {
});
}
export function patch(conf: HostConfig): Bluebird<void> {
const promises: Array<Promise<void>> = [];
if (conf != null && conf.network != null) {
if (conf.network.proxy != null) {
promises.push(setProxy(conf.network.proxy));
}
if (conf.network.hostname != null) {
promises.push(setHostname(conf.network.hostname));
}
export async function patch(conf: HostConfig, force: boolean): Promise<void> {
const appId = await config.get('applicationId');
if (!appId) {
throw new InternalInconsistencyError('Could not find an appId');
}
return Bluebird.all(promises).return();
return updateLock.lock(appId, { force }, () => {
const promises: Array<Promise<void>> = [];
if (conf != null && conf.network != null) {
if (conf.network.proxy != null) {
promises.push(setProxy(conf.network.proxy));
}
if (conf.network.hostname != null) {
promises.push(setHostname(conf.network.hostname));
}
}
return Bluebird.all(promises).return();
});
}

View File

@ -900,6 +900,62 @@ describe('SupervisorAPI [V1 Endpoints]', () => {
restartServiceSpy.restore();
});
it('prevents patch if update locks are present', async () => {
stub(updateLock, 'lock').callsFake((__, opts, fn) => {
if (opts.force) {
return Bluebird.resolve(fn());
}
throw new UpdatesLockedError('Updates locked');
});
await request
.patch('/v1/device/host-config')
.send({ network: { hostname: 'foobaz' } })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(423);
expect(updateLock.lock).to.be.calledOnce;
(updateLock.lock as SinonStub).restore();
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.then((response) => {
expect(response.body.network.hostname).to.deep.equal(
'foobardevice',
);
});
});
it('allows patch while update locks are present if force is in req.body', async () => {
stub(updateLock, 'lock').callsFake((__, opts, fn) => {
if (opts.force) {
return Bluebird.resolve(fn());
}
throw new UpdatesLockedError('Updates locked');
});
await request
.patch('/v1/device/host-config')
.send({ network: { hostname: 'foobaz' }, force: true })
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.expect(200);
expect(updateLock.lock).to.be.calledOnce;
(updateLock.lock as SinonStub).restore();
await request
.get('/v1/device/host-config')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
.then((response) => {
expect(response.body.network.hostname).to.deep.equal('foobaz');
});
});
it('updates the hostname with provided string if string is not empty', async () => {
// stub servicePartOf to throw exceptions for the new service names
stub(dbus, 'servicePartOf').callsFake(