mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-02-20 17:52:51 +00:00
Merge pull request #1529 from balena-io/1523-start-lock
Improve supervisor API behavior when locks are set
This commit is contained in:
commit
d5f996d217
@ -10,6 +10,7 @@ import * as applicationManager from '../compose/application-manager';
|
||||
import { generateStep } from '../compose/composition-steps';
|
||||
import * as commitStore from '../compose/commit';
|
||||
import { AuthorizedRequest } from '../lib/api-keys';
|
||||
import { getApp } from '../device-state/db-format';
|
||||
|
||||
export function createV1Api(router: express.Router) {
|
||||
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 applicationManager
|
||||
.getCurrentApps()
|
||||
.then(function (apps) {
|
||||
return Promise.all([applicationManager.getCurrentApps(), getApp(appId)])
|
||||
.then(([apps, targetApp]) => {
|
||||
if (apps[appId] == null) {
|
||||
return res.status(400).send('App not found');
|
||||
}
|
||||
@ -70,12 +70,22 @@ export function createV1Api(router: express.Router) {
|
||||
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, {
|
||||
running: action !== 'stop',
|
||||
});
|
||||
|
||||
const stopOpts = { wait: true };
|
||||
const step = generateStep(action, { current: service, ...stopOpts });
|
||||
const step = generateStep(action, {
|
||||
current: service,
|
||||
target: targetService,
|
||||
...stopOpts,
|
||||
});
|
||||
|
||||
return applicationManager
|
||||
.executeStep(step, { force })
|
||||
|
@ -59,66 +59,53 @@ export function createV2Api(router: Router) {
|
||||
return;
|
||||
}
|
||||
|
||||
return applicationManager.lockingIfNecessary(appId, { force }, () => {
|
||||
return Promise.all([applicationManager.getCurrentApps(), getApp(appId)])
|
||||
.then(([apps, targetApp]) => {
|
||||
const app = apps[appId];
|
||||
return Promise.all([applicationManager.getCurrentApps(), getApp(appId)])
|
||||
.then(([apps, targetApp]) => {
|
||||
const app = apps[appId];
|
||||
|
||||
if (app == null) {
|
||||
res.status(404).send(appNotFoundMessage);
|
||||
return;
|
||||
}
|
||||
if (app == null) {
|
||||
res.status(404).send(appNotFoundMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Work if we have a service name or an image id
|
||||
if (imageId == null) {
|
||||
if (serviceName == null) {
|
||||
throw new Error(v2ServiceEndpointInputErrorMessage);
|
||||
}
|
||||
}
|
||||
// Work if we have a service name or an image id
|
||||
if (imageId == null && serviceName == null) {
|
||||
throw new Error(v2ServiceEndpointInputErrorMessage);
|
||||
}
|
||||
|
||||
let service: Service | undefined;
|
||||
let targetService: Service | undefined;
|
||||
if (imageId != null) {
|
||||
service = _.find(app.services, (svc) => svc.imageId === imageId);
|
||||
targetService = _.find(
|
||||
targetApp.services,
|
||||
(svc) => svc.imageId === imageId,
|
||||
);
|
||||
} else {
|
||||
service = _.find(
|
||||
app.services,
|
||||
(svc) => svc.serviceName === serviceName,
|
||||
);
|
||||
targetService = _.find(
|
||||
targetApp.services,
|
||||
(svc) => svc.serviceName === serviceName,
|
||||
);
|
||||
}
|
||||
if (service == null) {
|
||||
res.status(404).send(serviceNotFoundMessage);
|
||||
return;
|
||||
}
|
||||
let service: Service | undefined;
|
||||
let targetService: Service | undefined;
|
||||
if (imageId != null) {
|
||||
service = _.find(app.services, { imageId });
|
||||
targetService = _.find(targetApp.services, { imageId });
|
||||
} else {
|
||||
service = _.find(app.services, { serviceName });
|
||||
targetService = _.find(targetApp.services, { serviceName });
|
||||
}
|
||||
if (service == null) {
|
||||
res.status(404).send(serviceNotFoundMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
applicationManager.setTargetVolatileForService(service.imageId!, {
|
||||
running: action !== 'stop',
|
||||
applicationManager.setTargetVolatileForService(service.imageId!, {
|
||||
running: action !== 'stop',
|
||||
});
|
||||
return applicationManager
|
||||
.executeStep(
|
||||
generateStep(action, {
|
||||
current: service,
|
||||
target: targetService,
|
||||
wait: true,
|
||||
}),
|
||||
{
|
||||
force,
|
||||
},
|
||||
)
|
||||
.then(() => {
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
return applicationManager
|
||||
.executeStep(
|
||||
generateStep(action, {
|
||||
current: service,
|
||||
target: targetService,
|
||||
wait: true,
|
||||
}),
|
||||
{
|
||||
skipLock: true,
|
||||
},
|
||||
)
|
||||
.then(() => {
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
};
|
||||
|
||||
const createServiceActionHandler = (action: string) =>
|
||||
|
@ -64,6 +64,10 @@ function dispose(release: () => void): Bluebird<void> {
|
||||
.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(
|
||||
appId: number | null,
|
||||
{ force = false }: { force: boolean },
|
||||
|
@ -10,6 +10,7 @@ import blink = require('./lib/blink');
|
||||
import log from './lib/supervisor-console';
|
||||
import * as apiKeys from './lib/api-keys';
|
||||
import * as deviceState from './device-state';
|
||||
import { UpdatesLockedError } from './lib/errors';
|
||||
|
||||
const expressLogger = morgan(
|
||||
(tokens, req, res) =>
|
||||
@ -126,8 +127,14 @@ export class SupervisorAPI {
|
||||
next(err);
|
||||
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',
|
||||
message: messageFromError(err),
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
import * as _ from 'lodash';
|
||||
import * as Bluebird from 'bluebird';
|
||||
import { expect } from 'chai';
|
||||
import { stub, SinonStub } from 'sinon';
|
||||
import * as supertest from 'supertest';
|
||||
@ -12,47 +13,45 @@ import SupervisorAPI from '../src/supervisor-api';
|
||||
import * as apiBinder from '../src/api-binder';
|
||||
import * as deviceState from '../src/device-state';
|
||||
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]', () => {
|
||||
let api: SupervisorAPI;
|
||||
let healthCheckStubs: SinonStub[];
|
||||
let targetStateCacheMock: SinonStub;
|
||||
const request = supertest(
|
||||
`http://127.0.0.1:${mockedAPI.mockedOptions.listenPort}`,
|
||||
);
|
||||
const containers = [
|
||||
mockedAPI.mockService({
|
||||
appId: 2,
|
||||
serviceId: 640681,
|
||||
}),
|
||||
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 services = [
|
||||
{ appId: 2, serviceId: 640681, serviceName: 'one' },
|
||||
{ appId: 2, serviceId: 640682, serviceName: 'two' },
|
||||
{ appId: 2, serviceId: 640683, serviceName: 'three' },
|
||||
];
|
||||
const containers = services.map((service) => mockedAPI.mockService(service));
|
||||
const images = services.map((service) => mockedAPI.mockImage(service));
|
||||
|
||||
let loggerStub: SinonStub;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock a 3 container release
|
||||
appMock.mockManagers(containers, [], []);
|
||||
appMock.mockImages([], false, images);
|
||||
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(() => {
|
||||
@ -63,6 +62,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => {
|
||||
before(async () => {
|
||||
await apiBinder.initialized;
|
||||
await deviceState.initialized;
|
||||
await targetStateCache.initialized;
|
||||
|
||||
// Stub health checks so we can modify them whenever needed
|
||||
healthCheckStubs = [
|
||||
@ -80,9 +80,16 @@ describe('SupervisorAPI [V1 Endpoints]', () => {
|
||||
mockedAPI.mockedOptions.timeout,
|
||||
);
|
||||
|
||||
// Mock target state cache
|
||||
targetStateCacheMock = stub(targetStateCache, 'getTargetApp');
|
||||
|
||||
// Create a scoped key
|
||||
await apiKeys.initialized;
|
||||
await apiKeys.generateCloudKey();
|
||||
|
||||
// Stub logs for all API methods
|
||||
loggerStub = stub(logger, 'attach');
|
||||
loggerStub.resolves();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
@ -98,20 +105,11 @@ describe('SupervisorAPI [V1 Endpoints]', () => {
|
||||
// Remove any test data generated
|
||||
await mockedAPI.cleanUp();
|
||||
appMock.unmockAll();
|
||||
targetStateCacheMock.restore();
|
||||
loggerStub.restore();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// Perform the test with our mocked release
|
||||
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', () => {
|
||||
it('returns MAC address', async () => {
|
||||
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
|
||||
});
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { expect } from 'chai';
|
||||
import { stub, SinonStub } from 'sinon';
|
||||
import { stub, SinonStub, spy, SinonSpy } from 'sinon';
|
||||
import * as supertest from 'supertest';
|
||||
import * as Bluebird from 'bluebird';
|
||||
|
||||
import sampleResponses = require('./data/device-api-responses.json');
|
||||
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 apiKeys from '../src/lib/api-keys';
|
||||
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]', () => {
|
||||
let serviceManagerMock: SinonStub;
|
||||
let imagesMock: SinonStub;
|
||||
let applicationManagerSpy: SinonSpy;
|
||||
let api: SupervisorAPI;
|
||||
const request = supertest(
|
||||
`http://127.0.0.1:${mockedAPI.mockedOptions.listenPort}`,
|
||||
);
|
||||
|
||||
let loggerStub: SinonStub;
|
||||
|
||||
before(async () => {
|
||||
await apiBinder.initialized;
|
||||
await deviceState.initialized;
|
||||
@ -39,6 +50,13 @@ describe('SupervisorAPI [V2 Endpoints]', () => {
|
||||
await apiKeys.generateCloudKey();
|
||||
serviceManagerMock = stub(serviceManager, 'getAll').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 () => {
|
||||
@ -53,6 +71,13 @@ describe('SupervisorAPI [V2 Endpoints]', () => {
|
||||
await mockedAPI.cleanUp();
|
||||
serviceManagerMock.restore();
|
||||
imagesMock.restore();
|
||||
applicationManagerSpy.restore();
|
||||
loggerStub.restore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockedDockerode.resetHistory();
|
||||
applicationManagerSpy.resetHistory();
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
|
@ -78,6 +78,12 @@ const mockService = (overrides?: Partial<Service>) => {
|
||||
extraNetworksToJoin: () => {
|
||||
return [];
|
||||
},
|
||||
isEqualConfig: (service: Service) => {
|
||||
return _.isEqual(
|
||||
_.pick(mockService, ['imageId', 'containerId', 'serviceId']),
|
||||
_.pick(service, ['imageId', 'containerId', 'serviceId']),
|
||||
);
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
} as Service;
|
||||
|
Loading…
x
Reference in New Issue
Block a user