mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-19 13:47:54 +00:00
Improve supervisor API behavior when locks are set
This PR adds the following * Supervisor v1 API application actions now return HTTP status code 423 when locks are preventing the action to be performed. Previously this resulted in a 503 error * Supervisor API v2 service actions now returns HTTP status code 423 when locks are preventing the action to be performed. Previously, this resulted in an exception logged by the supervisor and the API query timing out * Supervisor API `/v2/applications/:appId/start-service` now does not check for a lock. Lock handling in v2 actions is now performed by each step executor * `/v1/apps/:appId/start` now queries the target state and uses that information to execute the start step (as v2 does). Previously start resulted in `cannot get appId from undefined` * Extra tests for API methods Change-type: patch Connects-to: #1523 Signed-off-by: Felipe Lalanne <felipe@balena.io>
This commit is contained in:
parent
c41e9b5e1f
commit
4cdf26f82f
@ -10,6 +10,7 @@ import * as applicationManager from '../compose/application-manager';
|
|||||||
import { generateStep } from '../compose/composition-steps';
|
import { generateStep } from '../compose/composition-steps';
|
||||||
import * as commitStore from '../compose/commit';
|
import * as commitStore from '../compose/commit';
|
||||||
import { AuthorizedRequest } from '../lib/api-keys';
|
import { AuthorizedRequest } from '../lib/api-keys';
|
||||||
|
import { getApp } from '../device-state/db-format';
|
||||||
|
|
||||||
export function createV1Api(router: express.Router) {
|
export function createV1Api(router: express.Router) {
|
||||||
router.post('/v1/restart', (req: AuthorizedRequest, res, next) => {
|
router.post('/v1/restart', (req: AuthorizedRequest, res, next) => {
|
||||||
@ -46,9 +47,8 @@ export function createV1Api(router: express.Router) {
|
|||||||
return res.status(400).send('Missing app id');
|
return res.status(400).send('Missing app id');
|
||||||
}
|
}
|
||||||
|
|
||||||
return applicationManager
|
return Promise.all([applicationManager.getCurrentApps(), getApp(appId)])
|
||||||
.getCurrentApps()
|
.then(([apps, targetApp]) => {
|
||||||
.then(function (apps) {
|
|
||||||
if (apps[appId] == null) {
|
if (apps[appId] == null) {
|
||||||
return res.status(400).send('App not found');
|
return res.status(400).send('App not found');
|
||||||
}
|
}
|
||||||
@ -70,12 +70,22 @@ export function createV1Api(router: express.Router) {
|
|||||||
return res.status(401).send('Unauthorized');
|
return res.status(401).send('Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the service from the target state (as we do in v2)
|
||||||
|
// TODO: what if we want to start a service belonging to the current app?
|
||||||
|
const targetService = _.find(targetApp.services, {
|
||||||
|
serviceName: service.serviceName,
|
||||||
|
});
|
||||||
|
|
||||||
applicationManager.setTargetVolatileForService(service.imageId, {
|
applicationManager.setTargetVolatileForService(service.imageId, {
|
||||||
running: action !== 'stop',
|
running: action !== 'stop',
|
||||||
});
|
});
|
||||||
|
|
||||||
const stopOpts = { wait: true };
|
const stopOpts = { wait: true };
|
||||||
const step = generateStep(action, { current: service, ...stopOpts });
|
const step = generateStep(action, {
|
||||||
|
current: service,
|
||||||
|
target: targetService,
|
||||||
|
...stopOpts,
|
||||||
|
});
|
||||||
|
|
||||||
return applicationManager
|
return applicationManager
|
||||||
.executeStep(step, { force })
|
.executeStep(step, { force })
|
||||||
|
@ -59,66 +59,53 @@ export function createV2Api(router: Router) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return applicationManager.lockingIfNecessary(appId, { force }, () => {
|
return Promise.all([applicationManager.getCurrentApps(), getApp(appId)])
|
||||||
return Promise.all([applicationManager.getCurrentApps(), getApp(appId)])
|
.then(([apps, targetApp]) => {
|
||||||
.then(([apps, targetApp]) => {
|
const app = apps[appId];
|
||||||
const app = apps[appId];
|
|
||||||
|
|
||||||
if (app == null) {
|
if (app == null) {
|
||||||
res.status(404).send(appNotFoundMessage);
|
res.status(404).send(appNotFoundMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Work if we have a service name or an image id
|
// Work if we have a service name or an image id
|
||||||
if (imageId == null) {
|
if (imageId == null && serviceName == null) {
|
||||||
if (serviceName == null) {
|
throw new Error(v2ServiceEndpointInputErrorMessage);
|
||||||
throw new Error(v2ServiceEndpointInputErrorMessage);
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let service: Service | undefined;
|
let service: Service | undefined;
|
||||||
let targetService: Service | undefined;
|
let targetService: Service | undefined;
|
||||||
if (imageId != null) {
|
if (imageId != null) {
|
||||||
service = _.find(app.services, (svc) => svc.imageId === imageId);
|
service = _.find(app.services, { imageId });
|
||||||
targetService = _.find(
|
targetService = _.find(targetApp.services, { imageId });
|
||||||
targetApp.services,
|
} else {
|
||||||
(svc) => svc.imageId === imageId,
|
service = _.find(app.services, { serviceName });
|
||||||
);
|
targetService = _.find(targetApp.services, { serviceName });
|
||||||
} else {
|
}
|
||||||
service = _.find(
|
if (service == null) {
|
||||||
app.services,
|
res.status(404).send(serviceNotFoundMessage);
|
||||||
(svc) => svc.serviceName === serviceName,
|
return;
|
||||||
);
|
}
|
||||||
targetService = _.find(
|
|
||||||
targetApp.services,
|
|
||||||
(svc) => svc.serviceName === serviceName,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (service == null) {
|
|
||||||
res.status(404).send(serviceNotFoundMessage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
applicationManager.setTargetVolatileForService(service.imageId!, {
|
applicationManager.setTargetVolatileForService(service.imageId!, {
|
||||||
running: action !== 'stop',
|
running: action !== 'stop',
|
||||||
|
});
|
||||||
|
return applicationManager
|
||||||
|
.executeStep(
|
||||||
|
generateStep(action, {
|
||||||
|
current: service,
|
||||||
|
target: targetService,
|
||||||
|
wait: true,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
force,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
res.status(200).send('OK');
|
||||||
});
|
});
|
||||||
return applicationManager
|
})
|
||||||
.executeStep(
|
.catch(next);
|
||||||
generateStep(action, {
|
|
||||||
current: service,
|
|
||||||
target: targetService,
|
|
||||||
wait: true,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
skipLock: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
res.status(200).send('OK');
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(next);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const createServiceActionHandler = (action: string) =>
|
const createServiceActionHandler = (action: string) =>
|
||||||
|
@ -64,6 +64,10 @@ function dispose(release: () => void): Bluebird<void> {
|
|||||||
.return();
|
.return();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to take the locks for an application. If force is set, it will remove
|
||||||
|
* all existing lockfiles before performing the operation
|
||||||
|
*/
|
||||||
export function lock(
|
export function lock(
|
||||||
appId: number | null,
|
appId: number | null,
|
||||||
{ force = false }: { force: boolean },
|
{ force = false }: { force: boolean },
|
||||||
|
@ -10,6 +10,7 @@ import blink = require('./lib/blink');
|
|||||||
import log from './lib/supervisor-console';
|
import log from './lib/supervisor-console';
|
||||||
import * as apiKeys from './lib/api-keys';
|
import * as apiKeys from './lib/api-keys';
|
||||||
import * as deviceState from './device-state';
|
import * as deviceState from './device-state';
|
||||||
|
import { UpdatesLockedError } from './lib/errors';
|
||||||
|
|
||||||
const expressLogger = morgan(
|
const expressLogger = morgan(
|
||||||
(tokens, req, res) =>
|
(tokens, req, res) =>
|
||||||
@ -126,8 +127,14 @@ export class SupervisorAPI {
|
|||||||
next(err);
|
next(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log.error(`Error on ${req.method} ${req.path}: `, err);
|
|
||||||
res.status(503).send({
|
// Return 423 Locked when locks as set
|
||||||
|
const code = err instanceof UpdatesLockedError ? 423 : 503;
|
||||||
|
if (code !== 423) {
|
||||||
|
log.error(`Error on ${req.method} ${req.path}: `, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(code).send({
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
message: messageFromError(err),
|
message: messageFromError(err),
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
|
import * as Bluebird from 'bluebird';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'chai';
|
||||||
import { stub, SinonStub } from 'sinon';
|
import { stub, SinonStub } from 'sinon';
|
||||||
import * as supertest from 'supertest';
|
import * as supertest from 'supertest';
|
||||||
@ -12,47 +13,45 @@ import SupervisorAPI from '../src/supervisor-api';
|
|||||||
import * as apiBinder from '../src/api-binder';
|
import * as apiBinder from '../src/api-binder';
|
||||||
import * as deviceState from '../src/device-state';
|
import * as deviceState from '../src/device-state';
|
||||||
import * as apiKeys from '../src/lib/api-keys';
|
import * as apiKeys from '../src/lib/api-keys';
|
||||||
|
import * as dbus from '../src//lib/dbus';
|
||||||
|
import * as updateLock from '../src/lib/update-lock';
|
||||||
|
import * as targetStateCache from '../src/device-state/target-state-cache';
|
||||||
|
|
||||||
|
import { UpdatesLockedError } from '../src/lib/errors';
|
||||||
|
|
||||||
describe('SupervisorAPI [V1 Endpoints]', () => {
|
describe('SupervisorAPI [V1 Endpoints]', () => {
|
||||||
let api: SupervisorAPI;
|
let api: SupervisorAPI;
|
||||||
let healthCheckStubs: SinonStub[];
|
let healthCheckStubs: SinonStub[];
|
||||||
|
let targetStateCacheMock: SinonStub;
|
||||||
const request = supertest(
|
const request = supertest(
|
||||||
`http://127.0.0.1:${mockedAPI.mockedOptions.listenPort}`,
|
`http://127.0.0.1:${mockedAPI.mockedOptions.listenPort}`,
|
||||||
);
|
);
|
||||||
const containers = [
|
const services = [
|
||||||
mockedAPI.mockService({
|
{ appId: 2, serviceId: 640681, serviceName: 'one' },
|
||||||
appId: 2,
|
{ appId: 2, serviceId: 640682, serviceName: 'two' },
|
||||||
serviceId: 640681,
|
{ appId: 2, serviceId: 640683, serviceName: 'three' },
|
||||||
}),
|
|
||||||
mockedAPI.mockService({
|
|
||||||
appId: 2,
|
|
||||||
serviceId: 640682,
|
|
||||||
}),
|
|
||||||
mockedAPI.mockService({
|
|
||||||
appId: 2,
|
|
||||||
serviceId: 640683,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
const images = [
|
|
||||||
mockedAPI.mockImage({
|
|
||||||
appId: 2,
|
|
||||||
serviceId: 640681,
|
|
||||||
}),
|
|
||||||
mockedAPI.mockImage({
|
|
||||||
appId: 2,
|
|
||||||
serviceId: 640682,
|
|
||||||
}),
|
|
||||||
mockedAPI.mockImage({
|
|
||||||
appId: 2,
|
|
||||||
serviceId: 640683,
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
|
const containers = services.map((service) => mockedAPI.mockService(service));
|
||||||
|
const images = services.map((service) => mockedAPI.mockImage(service));
|
||||||
|
|
||||||
|
let loggerStub: SinonStub;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Mock a 3 container release
|
// Mock a 3 container release
|
||||||
appMock.mockManagers(containers, [], []);
|
appMock.mockManagers(containers, [], []);
|
||||||
appMock.mockImages([], false, images);
|
appMock.mockImages([], false, images);
|
||||||
appMock.mockSupervisorNetwork(true);
|
appMock.mockSupervisorNetwork(true);
|
||||||
|
|
||||||
|
targetStateCacheMock.resolves({
|
||||||
|
appId: 2,
|
||||||
|
commit: 'abcdef2',
|
||||||
|
name: 'test-app2',
|
||||||
|
source: 'https://api.balena-cloud.com',
|
||||||
|
releaseId: 1232,
|
||||||
|
services: JSON.stringify(services),
|
||||||
|
networks: '{}',
|
||||||
|
volumes: '{}',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -63,6 +62,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => {
|
|||||||
before(async () => {
|
before(async () => {
|
||||||
await apiBinder.initialized;
|
await apiBinder.initialized;
|
||||||
await deviceState.initialized;
|
await deviceState.initialized;
|
||||||
|
await targetStateCache.initialized;
|
||||||
|
|
||||||
// Stub health checks so we can modify them whenever needed
|
// Stub health checks so we can modify them whenever needed
|
||||||
healthCheckStubs = [
|
healthCheckStubs = [
|
||||||
@ -80,9 +80,16 @@ describe('SupervisorAPI [V1 Endpoints]', () => {
|
|||||||
mockedAPI.mockedOptions.timeout,
|
mockedAPI.mockedOptions.timeout,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mock target state cache
|
||||||
|
targetStateCacheMock = stub(targetStateCache, 'getTargetApp');
|
||||||
|
|
||||||
// Create a scoped key
|
// Create a scoped key
|
||||||
await apiKeys.initialized;
|
await apiKeys.initialized;
|
||||||
await apiKeys.generateCloudKey();
|
await apiKeys.generateCloudKey();
|
||||||
|
|
||||||
|
// Stub logs for all API methods
|
||||||
|
loggerStub = stub(logger, 'attach');
|
||||||
|
loggerStub.resolves();
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
@ -98,20 +105,11 @@ describe('SupervisorAPI [V1 Endpoints]', () => {
|
|||||||
// Remove any test data generated
|
// Remove any test data generated
|
||||||
await mockedAPI.cleanUp();
|
await mockedAPI.cleanUp();
|
||||||
appMock.unmockAll();
|
appMock.unmockAll();
|
||||||
|
targetStateCacheMock.restore();
|
||||||
|
loggerStub.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /v1/restart', () => {
|
describe('POST /v1/restart', () => {
|
||||||
let loggerStub: SinonStub;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
loggerStub = stub(logger, 'attach');
|
|
||||||
loggerStub.resolves();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
loggerStub.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('restarts all containers in release', async () => {
|
it('restarts all containers in release', async () => {
|
||||||
// Perform the test with our mocked release
|
// Perform the test with our mocked release
|
||||||
await mockedDockerode.testWithData({ containers, images }, async () => {
|
await mockedDockerode.testWithData({ containers, images }, async () => {
|
||||||
@ -282,6 +280,59 @@ describe('SupervisorAPI [V1 Endpoints]', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /v1/apps/:appId/start', () => {
|
||||||
|
it('does not allow starting an application when there is more than 1 container', async () => {
|
||||||
|
// Every test case in this suite has a 3 service release mocked so just make the request
|
||||||
|
await request
|
||||||
|
.post('/v1/apps/2/start')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a SPECIFIC application and returns a containerId', async () => {
|
||||||
|
const service = {
|
||||||
|
serviceName: 'main',
|
||||||
|
containerId: 'abc123',
|
||||||
|
appId: 2,
|
||||||
|
serviceId: 640681,
|
||||||
|
};
|
||||||
|
// Setup single container application
|
||||||
|
const container = mockedAPI.mockService(service);
|
||||||
|
const image = mockedAPI.mockImage(service);
|
||||||
|
appMock.mockManagers([container], [], []);
|
||||||
|
appMock.mockImages([], false, [image]);
|
||||||
|
|
||||||
|
// Target state returns single service
|
||||||
|
targetStateCacheMock.resolves({
|
||||||
|
appId: 2,
|
||||||
|
commit: 'abcdef2',
|
||||||
|
name: 'test-app2',
|
||||||
|
source: 'https://api.balena-cloud.com',
|
||||||
|
releaseId: 1232,
|
||||||
|
services: JSON.stringify([service]),
|
||||||
|
volumes: '{}',
|
||||||
|
networks: '{}',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Perform the test with our mocked release
|
||||||
|
await mockedDockerode.testWithData(
|
||||||
|
{ containers: [container], images: [image] },
|
||||||
|
async () => {
|
||||||
|
await request
|
||||||
|
.post('/v1/apps/2/start')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
|
.expect(200)
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.then((response) => {
|
||||||
|
expect(response.body).to.deep.equal({ containerId: 'abc123' });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('GET /v1/device', () => {
|
describe('GET /v1/device', () => {
|
||||||
it('returns MAC address', async () => {
|
it('returns MAC address', async () => {
|
||||||
const response = await request
|
const response = await request
|
||||||
@ -294,5 +345,213 @@ describe('SupervisorAPI [V1 Endpoints]', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /v1/reboot', () => {
|
||||||
|
let rebootMock: SinonStub;
|
||||||
|
before(() => {
|
||||||
|
rebootMock = stub(dbus, 'reboot').resolves((() => void 0) as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
rebootMock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rebootMock.resetHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 202 and reboot if no locks are set', async () => {
|
||||||
|
// Setup single container application
|
||||||
|
const container = mockedAPI.mockService({
|
||||||
|
containerId: 'abc123',
|
||||||
|
appId: 2,
|
||||||
|
releaseId: 77777,
|
||||||
|
});
|
||||||
|
const image = mockedAPI.mockImage({
|
||||||
|
appId: 2,
|
||||||
|
});
|
||||||
|
appMock.mockManagers([container], [], []);
|
||||||
|
appMock.mockImages([], false, [image]);
|
||||||
|
|
||||||
|
const response = await request
|
||||||
|
.post('/v1/reboot')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
|
.expect(202);
|
||||||
|
|
||||||
|
expect(response.body).to.have.property('Data').that.is.not.empty;
|
||||||
|
expect(rebootMock).to.have.been.calledOnce;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 423 and reject the reboot if no locks are set', async () => {
|
||||||
|
stub(updateLock, 'lock').callsFake((__, opts, fn) => {
|
||||||
|
if (opts.force) {
|
||||||
|
return Bluebird.resolve(fn());
|
||||||
|
}
|
||||||
|
throw new UpdatesLockedError('Updates locked');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup single container application
|
||||||
|
const container = mockedAPI.mockService({
|
||||||
|
containerId: 'abc123',
|
||||||
|
appId: 2,
|
||||||
|
releaseId: 77777,
|
||||||
|
});
|
||||||
|
const image = mockedAPI.mockImage({
|
||||||
|
appId: 2,
|
||||||
|
});
|
||||||
|
appMock.mockManagers([container], [], []);
|
||||||
|
appMock.mockImages([], false, [image]);
|
||||||
|
|
||||||
|
const response = await request
|
||||||
|
.post('/v1/reboot')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
|
.expect(423);
|
||||||
|
|
||||||
|
expect(updateLock.lock).to.be.calledOnce;
|
||||||
|
expect(response.body).to.have.property('Error').that.is.not.empty;
|
||||||
|
expect(rebootMock).to.not.have.been.called;
|
||||||
|
|
||||||
|
(updateLock.lock as SinonStub).restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 202 and reboot if force is set to true', async () => {
|
||||||
|
stub(updateLock, 'lock').callsFake((__, opts, fn) => {
|
||||||
|
if (opts.force) {
|
||||||
|
return Bluebird.resolve(fn());
|
||||||
|
}
|
||||||
|
throw new UpdatesLockedError('Updates locked');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup single container application
|
||||||
|
const container = mockedAPI.mockService({
|
||||||
|
containerId: 'abc123',
|
||||||
|
appId: 2,
|
||||||
|
releaseId: 77777,
|
||||||
|
});
|
||||||
|
const image = mockedAPI.mockImage({
|
||||||
|
appId: 2,
|
||||||
|
});
|
||||||
|
appMock.mockManagers([container], [], []);
|
||||||
|
appMock.mockImages([], false, [image]);
|
||||||
|
|
||||||
|
const response = await request
|
||||||
|
.post('/v1/reboot')
|
||||||
|
.send({ force: true })
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
|
.expect(202);
|
||||||
|
|
||||||
|
expect(updateLock.lock).to.be.calledOnce;
|
||||||
|
expect(response.body).to.have.property('Data').that.is.not.empty;
|
||||||
|
expect(rebootMock).to.have.been.calledOnce;
|
||||||
|
|
||||||
|
(updateLock.lock as SinonStub).restore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /v1/shutdown', () => {
|
||||||
|
let shutdownMock: SinonStub;
|
||||||
|
before(() => {
|
||||||
|
shutdownMock = stub(dbus, 'shutdown').resolves((() => void 0) as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
shutdownMock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 202 and shutdown if no locks are set', async () => {
|
||||||
|
// Setup single container application
|
||||||
|
const container = mockedAPI.mockService({
|
||||||
|
containerId: 'abc123',
|
||||||
|
appId: 2,
|
||||||
|
releaseId: 77777,
|
||||||
|
});
|
||||||
|
const image = mockedAPI.mockImage({
|
||||||
|
appId: 2,
|
||||||
|
});
|
||||||
|
appMock.mockManagers([container], [], []);
|
||||||
|
appMock.mockImages([], false, [image]);
|
||||||
|
|
||||||
|
const response = await request
|
||||||
|
.post('/v1/shutdown')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
|
.expect(202);
|
||||||
|
|
||||||
|
expect(response.body).to.have.property('Data').that.is.not.empty;
|
||||||
|
expect(shutdownMock).to.have.been.calledOnce;
|
||||||
|
|
||||||
|
shutdownMock.resetHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 423 and reject the reboot if no locks are set', async () => {
|
||||||
|
stub(updateLock, 'lock').callsFake((__, opts, fn) => {
|
||||||
|
if (opts.force) {
|
||||||
|
return Bluebird.resolve(fn());
|
||||||
|
}
|
||||||
|
throw new UpdatesLockedError('Updates locked');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup single container application
|
||||||
|
const container = mockedAPI.mockService({
|
||||||
|
containerId: 'abc123',
|
||||||
|
appId: 2,
|
||||||
|
releaseId: 77777,
|
||||||
|
});
|
||||||
|
const image = mockedAPI.mockImage({
|
||||||
|
appId: 2,
|
||||||
|
});
|
||||||
|
appMock.mockManagers([container], [], []);
|
||||||
|
appMock.mockImages([], false, [image]);
|
||||||
|
|
||||||
|
const response = await request
|
||||||
|
.post('/v1/shutdown')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
|
.expect(423);
|
||||||
|
|
||||||
|
expect(updateLock.lock).to.be.calledOnce;
|
||||||
|
expect(response.body).to.have.property('Error').that.is.not.empty;
|
||||||
|
expect(shutdownMock).to.not.have.been.called;
|
||||||
|
|
||||||
|
(updateLock.lock as SinonStub).restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 202 and shutdown if force is set to true', async () => {
|
||||||
|
stub(updateLock, 'lock').callsFake((__, opts, fn) => {
|
||||||
|
if (opts.force) {
|
||||||
|
return Bluebird.resolve(fn());
|
||||||
|
}
|
||||||
|
throw new UpdatesLockedError('Updates locked');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup single container application
|
||||||
|
const container = mockedAPI.mockService({
|
||||||
|
containerId: 'abc123',
|
||||||
|
appId: 2,
|
||||||
|
releaseId: 77777,
|
||||||
|
});
|
||||||
|
const image = mockedAPI.mockImage({
|
||||||
|
appId: 2,
|
||||||
|
});
|
||||||
|
appMock.mockManagers([container], [], []);
|
||||||
|
appMock.mockImages([], false, [image]);
|
||||||
|
|
||||||
|
const response = await request
|
||||||
|
.post('/v1/shutdown')
|
||||||
|
.send({ force: true })
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${apiKeys.cloudApiKey}`)
|
||||||
|
.expect(202);
|
||||||
|
|
||||||
|
expect(updateLock.lock).to.be.calledOnce;
|
||||||
|
expect(response.body).to.have.property('Data').that.is.not.empty;
|
||||||
|
expect(shutdownMock).to.have.been.calledOnce;
|
||||||
|
|
||||||
|
(updateLock.lock as SinonStub).restore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// TODO: add tests for V1 endpoints
|
// TODO: add tests for V1 endpoints
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { expect } from 'chai';
|
import { expect } from 'chai';
|
||||||
import { stub, SinonStub } from 'sinon';
|
import { stub, SinonStub, spy, SinonSpy } from 'sinon';
|
||||||
import * as supertest from 'supertest';
|
import * as supertest from 'supertest';
|
||||||
|
import * as Bluebird from 'bluebird';
|
||||||
|
|
||||||
import sampleResponses = require('./data/device-api-responses.json');
|
import sampleResponses = require('./data/device-api-responses.json');
|
||||||
import mockedAPI = require('./lib/mocked-device-api');
|
import mockedAPI = require('./lib/mocked-device-api');
|
||||||
@ -11,15 +12,25 @@ import * as serviceManager from '../src/compose/service-manager';
|
|||||||
import * as images from '../src/compose/images';
|
import * as images from '../src/compose/images';
|
||||||
import * as apiKeys from '../src/lib/api-keys';
|
import * as apiKeys from '../src/lib/api-keys';
|
||||||
import * as config from '../src/config';
|
import * as config from '../src/config';
|
||||||
|
import * as updateLock from '../src/lib/update-lock';
|
||||||
|
import * as targetStateCache from '../src/device-state/target-state-cache';
|
||||||
|
import * as mockedDockerode from './lib/mocked-dockerode';
|
||||||
|
import * as applicationManager from '../src/compose/application-manager';
|
||||||
|
import * as logger from '../src/logger';
|
||||||
|
|
||||||
|
import { UpdatesLockedError } from '../src/lib/errors';
|
||||||
|
|
||||||
describe('SupervisorAPI [V2 Endpoints]', () => {
|
describe('SupervisorAPI [V2 Endpoints]', () => {
|
||||||
let serviceManagerMock: SinonStub;
|
let serviceManagerMock: SinonStub;
|
||||||
let imagesMock: SinonStub;
|
let imagesMock: SinonStub;
|
||||||
|
let applicationManagerSpy: SinonSpy;
|
||||||
let api: SupervisorAPI;
|
let api: SupervisorAPI;
|
||||||
const request = supertest(
|
const request = supertest(
|
||||||
`http://127.0.0.1:${mockedAPI.mockedOptions.listenPort}`,
|
`http://127.0.0.1:${mockedAPI.mockedOptions.listenPort}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let loggerStub: SinonStub;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
await apiBinder.initialized;
|
await apiBinder.initialized;
|
||||||
await deviceState.initialized;
|
await deviceState.initialized;
|
||||||
@ -39,6 +50,13 @@ describe('SupervisorAPI [V2 Endpoints]', () => {
|
|||||||
await apiKeys.generateCloudKey();
|
await apiKeys.generateCloudKey();
|
||||||
serviceManagerMock = stub(serviceManager, 'getAll').resolves([]);
|
serviceManagerMock = stub(serviceManager, 'getAll').resolves([]);
|
||||||
imagesMock = stub(images, 'getStatus').resolves([]);
|
imagesMock = stub(images, 'getStatus').resolves([]);
|
||||||
|
|
||||||
|
// We want to check the actual step that was triggered
|
||||||
|
applicationManagerSpy = spy(applicationManager, 'executeStep');
|
||||||
|
|
||||||
|
// Stub logs for all API methods
|
||||||
|
loggerStub = stub(logger, 'attach');
|
||||||
|
loggerStub.resolves();
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
@ -53,6 +71,13 @@ describe('SupervisorAPI [V2 Endpoints]', () => {
|
|||||||
await mockedAPI.cleanUp();
|
await mockedAPI.cleanUp();
|
||||||
serviceManagerMock.restore();
|
serviceManagerMock.restore();
|
||||||
imagesMock.restore();
|
imagesMock.restore();
|
||||||
|
applicationManagerSpy.restore();
|
||||||
|
loggerStub.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockedDockerode.resetHistory();
|
||||||
|
applicationManagerSpy.resetHistory();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /v2/device/vpn', () => {
|
describe('GET /v2/device/vpn', () => {
|
||||||
@ -260,5 +285,248 @@ describe('SupervisorAPI [V2 Endpoints]', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /v2/applications/:appId/start-service', function () {
|
||||||
|
let appScopedKey: string;
|
||||||
|
let targetStateCacheMock: SinonStub;
|
||||||
|
let lockMock: SinonStub;
|
||||||
|
|
||||||
|
const service = {
|
||||||
|
serviceName: 'main',
|
||||||
|
containerId: 'abc123',
|
||||||
|
appId: 1658654,
|
||||||
|
serviceId: 640681,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockContainers = [mockedAPI.mockService(service)];
|
||||||
|
const mockImages = [mockedAPI.mockImage(service)];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Setup device conditions
|
||||||
|
serviceManagerMock.resolves(mockContainers);
|
||||||
|
imagesMock.resolves(mockImages);
|
||||||
|
|
||||||
|
targetStateCacheMock.resolves({
|
||||||
|
appId: 2,
|
||||||
|
commit: 'abcdef2',
|
||||||
|
name: 'test-app2',
|
||||||
|
source: 'https://api.balena-cloud.com',
|
||||||
|
releaseId: 1232,
|
||||||
|
services: JSON.stringify([service]),
|
||||||
|
networks: '{}',
|
||||||
|
volumes: '{}',
|
||||||
|
});
|
||||||
|
|
||||||
|
lockMock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
// Create scoped key for application
|
||||||
|
appScopedKey = await apiKeys.generateScopedKey(1658654, 640681);
|
||||||
|
|
||||||
|
// Mock target state cache
|
||||||
|
targetStateCacheMock = stub(targetStateCache, 'getTargetApp');
|
||||||
|
|
||||||
|
lockMock = stub(updateLock, 'lock');
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
targetStateCacheMock.restore();
|
||||||
|
lockMock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 200 for an existing service', async () => {
|
||||||
|
await mockedDockerode.testWithData(
|
||||||
|
{ containers: mockContainers, images: mockImages },
|
||||||
|
async () => {
|
||||||
|
await request
|
||||||
|
.post(
|
||||||
|
`/v2/applications/1658654/start-service?apikey=${appScopedKey}`,
|
||||||
|
)
|
||||||
|
.send({ serviceName: 'main' })
|
||||||
|
.set('Content-type', 'application/json')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(applicationManagerSpy).to.have.been.calledOnce;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for an unknown service', async () => {
|
||||||
|
await request
|
||||||
|
.post(`/v2/applications/1658654/start-service?apikey=${appScopedKey}`)
|
||||||
|
.send({ serviceName: 'unknown' })
|
||||||
|
.set('Content-type', 'application/json')
|
||||||
|
.expect(404);
|
||||||
|
|
||||||
|
expect(applicationManagerSpy).to.not.have.been.called;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore locks and return 200', async () => {
|
||||||
|
// Turn lock on
|
||||||
|
lockMock.throws(new UpdatesLockedError('Updates locked'));
|
||||||
|
|
||||||
|
await mockedDockerode.testWithData(
|
||||||
|
{ containers: mockContainers, images: mockImages },
|
||||||
|
async () => {
|
||||||
|
await request
|
||||||
|
.post(
|
||||||
|
`/v2/applications/1658654/start-service?apikey=${appScopedKey}`,
|
||||||
|
)
|
||||||
|
.send({ serviceName: 'main' })
|
||||||
|
.set('Content-type', 'application/json')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(lockMock).to.not.have.been.called;
|
||||||
|
expect(applicationManagerSpy).to.have.been.calledOnce;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /v2/applications/:appId/restart-service', () => {
|
||||||
|
let appScopedKey: string;
|
||||||
|
let targetStateCacheMock: SinonStub;
|
||||||
|
let lockMock: SinonStub;
|
||||||
|
|
||||||
|
const service = {
|
||||||
|
serviceName: 'main',
|
||||||
|
containerId: 'abc123',
|
||||||
|
appId: 1658654,
|
||||||
|
serviceId: 640681,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockContainers = [mockedAPI.mockService(service)];
|
||||||
|
const mockImages = [mockedAPI.mockImage(service)];
|
||||||
|
const lockFake = (_: any, opts: { force: boolean }, fn: () => any) => {
|
||||||
|
if (opts.force) {
|
||||||
|
return Bluebird.resolve(fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UpdatesLockedError('Updates locked');
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Setup device conditions
|
||||||
|
serviceManagerMock.resolves(mockContainers);
|
||||||
|
imagesMock.resolves(mockImages);
|
||||||
|
|
||||||
|
targetStateCacheMock.resolves({
|
||||||
|
appId: 2,
|
||||||
|
commit: 'abcdef2',
|
||||||
|
name: 'test-app2',
|
||||||
|
source: 'https://api.balena-cloud.com',
|
||||||
|
releaseId: 1232,
|
||||||
|
services: JSON.stringify(mockContainers),
|
||||||
|
networks: '{}',
|
||||||
|
volumes: '{}',
|
||||||
|
});
|
||||||
|
|
||||||
|
lockMock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
// Create scoped key for application
|
||||||
|
appScopedKey = await apiKeys.generateScopedKey(1658654, 640681);
|
||||||
|
|
||||||
|
// Mock target state cache
|
||||||
|
targetStateCacheMock = stub(targetStateCache, 'getTargetApp');
|
||||||
|
lockMock = stub(updateLock, 'lock');
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
targetStateCacheMock.restore();
|
||||||
|
lockMock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 200 for an existing service', async () => {
|
||||||
|
await mockedDockerode.testWithData(
|
||||||
|
{ containers: mockContainers, images: mockImages },
|
||||||
|
async () => {
|
||||||
|
await request
|
||||||
|
.post(
|
||||||
|
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
|
||||||
|
)
|
||||||
|
.send({ serviceName: 'main' })
|
||||||
|
.set('Content-type', 'application/json')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(applicationManagerSpy).to.have.been.calledOnce;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for an unknown service', async () => {
|
||||||
|
await request
|
||||||
|
.post(`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`)
|
||||||
|
.send({ serviceName: 'unknown' })
|
||||||
|
.set('Content-type', 'application/json')
|
||||||
|
.expect(404);
|
||||||
|
expect(applicationManagerSpy).to.not.have.been.called;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 423 for a service with update locks', async () => {
|
||||||
|
// Turn lock on
|
||||||
|
lockMock.throws(new UpdatesLockedError('Updates locked'));
|
||||||
|
|
||||||
|
await mockedDockerode.testWithData(
|
||||||
|
{ containers: mockContainers, images: mockImages },
|
||||||
|
async () => {
|
||||||
|
await request
|
||||||
|
.post(
|
||||||
|
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
|
||||||
|
)
|
||||||
|
.send({ serviceName: 'main' })
|
||||||
|
.set('Content-type', 'application/json')
|
||||||
|
.expect(423);
|
||||||
|
|
||||||
|
expect(lockMock).to.be.calledOnce;
|
||||||
|
expect(applicationManagerSpy).to.have.been.calledOnce;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 200 for a service with update locks and force true', async () => {
|
||||||
|
// Turn lock on
|
||||||
|
lockMock.callsFake(lockFake);
|
||||||
|
|
||||||
|
await mockedDockerode.testWithData(
|
||||||
|
{ containers: mockContainers, images: mockImages },
|
||||||
|
async () => {
|
||||||
|
await request
|
||||||
|
.post(
|
||||||
|
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
|
||||||
|
)
|
||||||
|
.send({ serviceName: 'main', force: true })
|
||||||
|
.set('Content-type', 'application/json')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(lockMock).to.be.calledOnce;
|
||||||
|
expect(applicationManagerSpy).to.have.been.calledOnce;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 423 if force is explicitely set to false', async () => {
|
||||||
|
// Turn lock on
|
||||||
|
lockMock.callsFake(lockFake);
|
||||||
|
|
||||||
|
await mockedDockerode.testWithData(
|
||||||
|
{ containers: mockContainers, images: mockImages },
|
||||||
|
async () => {
|
||||||
|
await request
|
||||||
|
.post(
|
||||||
|
`/v2/applications/1658654/restart-service?apikey=${appScopedKey}`,
|
||||||
|
)
|
||||||
|
.send({ serviceName: 'main', force: false })
|
||||||
|
.set('Content-type', 'application/json')
|
||||||
|
.expect(423);
|
||||||
|
|
||||||
|
expect(lockMock).to.be.calledOnce;
|
||||||
|
expect(applicationManagerSpy).to.have.been.calledOnce;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// TODO: add tests for rest of V2 endpoints
|
// TODO: add tests for rest of V2 endpoints
|
||||||
});
|
});
|
||||||
|
@ -78,6 +78,12 @@ const mockService = (overrides?: Partial<Service>) => {
|
|||||||
extraNetworksToJoin: () => {
|
extraNetworksToJoin: () => {
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
|
isEqualConfig: (service: Service) => {
|
||||||
|
return _.isEqual(
|
||||||
|
_.pick(mockService, ['imageId', 'containerId', 'serviceId']),
|
||||||
|
_.pick(service, ['imageId', 'containerId', 'serviceId']),
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
} as Service;
|
} as Service;
|
||||||
|
Loading…
Reference in New Issue
Block a user